diff --git a/CLAUDE.md b/CLAUDE.md index d8f4b63..9448275 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,17 +18,27 @@ npm install npm start install # interactive: choose packages, installs them, backs up conflicting files, symlinks into $HOME npm start update # pacman -Syu + yay -Syu, then re-links (stow --restow) any new files npm start remove # removes symlinks (stow -D), restores the most recent backup (does NOT uninstall packages) -npx tsc --noEmit # typecheck (no test suite or lint script exists) +npx tsc --noEmit # typecheck +npm test # runs test/ via Node's built-in test runner (node:test) through tsx +./docker/test.sh "npm test" # run the full suite, including stow-dependent tests skipped on a bare host ``` There is no build step — `tsx` runs the TypeScript directly. +## Testing + +`test/*.test.ts` uses Node's built-in `node:test` + `node:assert` (via `tsx --test`) — no test framework dependency was added. `test/gpu.test.ts` and `test/packages.test.ts` are pure unit tests. `test/stow.test.ts` exercises the real `stow` binary and real filesystem calls against a throwaway `$HOME` (a `mkdtempSync` dir, swapped in via `process.env.HOME` before `stow.ts` is dynamically imported, since `os.homedir()` reads `$HOME` and the module's path constants are computed at import time); each case is skipped when `stow` isn't installed, which is the case on a bare host — run `./docker/test.sh "npm test"` to execute those for real inside a container that has `stow`. + +`docker/test.sh` (see `docker/Dockerfile`) spins up a disposable Arch container with `stow`/`nodejs`/`npm`/`git`/`sudo` preinstalled and the repo copied to a writable path inside it, so `npm install`, Stow symlinks, and backups never touch the host. Run it with no args for an interactive shell, or pass a command string to run non-interactively. + ## Architecture - `src/packages.ts` — the package model. Each entry has a `tier` (`required` installs with no prompt; `recommended` is offered via checkbox), an optional `group` for mutually-exclusive alternatives (e.g. `waybar` vs `ags` both have `group: "bar"`, only one gets picked via a select prompt), and an optional `dir` when the Stow directory name differs from the package name (e.g. package `rofi-wayland` → dir `rofi`). `toDirs()` converts chosen package names to their Stow directory names. -- `src/stow.ts` — all filesystem/process side effects live here: bootstrapping `yay` and oh-my-zsh/zinit if missing, installing packages via `yay`, backing up real (non-symlink) files that would collide with a Stow target into `~/.dotfiles-backup//`, and wrapping `stow`/`--restow`/`-D` (always invoked with an explicit `-d ` so behavior doesn't depend on cwd). Also reads/writes `~/.config/dotfiles/state.json`, which records which package dirs were chosen at install time — `update` and `remove` act on this list rather than re-prompting. +- `src/gpu.ts` — `detectGpuVendors()` shells out to `lspci` and matches VGA/3D controller lines against NVIDIA/AMD/Intel keywords (word-boundary regexes — a naive `/amd|ati/i` used to false-positive on the "ati" inside "Corporation"); `driverPackagesFor()` maps detected vendors to their Arch driver packages. `install.ts` shows every detected vendor's packages in a pre-checked confirm prompt (installing all of them on hybrid/Optimus laptops), and if NVIDIA is kept, `stow.ts`'s `writeNvidiaEnvConfig()` writes `~/.config/hypr/conf.d/nvidia.lua` (machine-generated, not stowed) with the Wayland env vars Hyprland's NVIDIA docs recommend. +- `src/stow.ts` — all filesystem/process side effects live here: bootstrapping `yay` and oh-my-zsh/zinit if missing, installing packages via `yay`, enabling the `sddm` systemd service (`ensureSddmEnabled`), backing up real (non-symlink) files that would collide with a Stow target into `~/.dotfiles-backup//`, and wrapping `stow`/`--restow`/`-D` (always invoked with an explicit `-d ` so behavior doesn't depend on cwd). Also reads/writes `~/.config/dotfiles/state.json`, which records which package dirs were chosen at install time — `update` and `remove` act on this list rather than re-prompting. - `src/install.ts`, `src/update.ts`, `src/remove.ts` — the three subcommands, each a thin sequence of calls into `stow.ts`, using `@clack/prompts` for interactive selection in `install.ts`. - `src/cli.ts` — dispatches `install|update|remove` via Node's built-in `node:util.parseArgs`. +- `packages/hyprland/.config/hypr/hyprland.lua` — Hyprland's config, in its **current Lua format** (`hl.config`/`hl.bind`/`hl.monitor`/`hl.on`/`hl.env`), not the old `hyprland.conf` ini/hyprlang syntax deprecated since Hyprland 0.55. Easy detail to get wrong when editing — check `wiki.hypr.land` (or the raw `hyprland-wiki` GitHub markdown) for current syntax rather than assuming the old ini style. ### Adding a new app diff --git a/README.md b/README.md index 76f2a4e..ac8acda 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,13 @@ Which packages/dirs were chosen at install time is recorded in `~/.config/dotfiles/state.json`, so `update`/`remove` know what to act on without re-prompting. +`install` also installs SDDM and enables its systemd service (so Hyprland +actually starts at next boot), and auto-detects installed GPU(s) via `lspci` +to propose NVIDIA/AMD/Intel driver packages (confirm/override before they're +installed). If NVIDIA is kept, the required Wayland env vars are written to +`~/.config/hypr/conf.d/nvidia.lua` (machine-generated, not stowed) and +sourced from `hyprland.lua`. + ## Wallpaper Default wallpaper by rahremix (via github.com/PsychedelicPalimpsest/dotfiles). diff --git a/package.json b/package.json index 5f2e957..fb0813e 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "private": true, "type": "module", "scripts": { - "start": "tsx src/cli.ts" + "start": "tsx src/cli.ts", + "test": "tsx --test \"test/**/*.test.ts\"" }, "dependencies": { "@clack/prompts": "^0.9.0" diff --git a/packages/hyprland/.config/hypr/hyprland.conf b/packages/hyprland/.config/hypr/hyprland.conf deleted file mode 100644 index ee55876..0000000 --- a/packages/hyprland/.config/hypr/hyprland.conf +++ /dev/null @@ -1,22 +0,0 @@ -monitor=,preferred,auto,1 - -exec-once = waybar -exec-once = mako -exec-once = hyprpaper - -$mainMod = SUPER - -bind = $mainMod, RETURN, exec, kitty -bind = $mainMod, D, exec, wofi --show drun -bind = $mainMod, Q, killactive -bind = $mainMod, M, exit - -input { - kb_layout = us -} - -general { - gaps_in = 4 - gaps_out = 8 - border_size = 2 -} diff --git a/packages/hyprland/.config/hypr/hyprland.lua b/packages/hyprland/.config/hypr/hyprland.lua new file mode 100644 index 0000000..5969166 --- /dev/null +++ b/packages/hyprland/.config/hypr/hyprland.lua @@ -0,0 +1,22 @@ +hl.monitor({ output = "", mode = "preferred", position = "auto", scale = 1 }) + +hl.on("hyprland.start", function() + hl.exec_cmd("waybar") + hl.exec_cmd("mako") + hl.exec_cmd("hyprpaper") +end) + +local mainMod = "SUPER" + +hl.bind(mainMod .. " + T", hl.dsp.exec_cmd("kitty")) +hl.bind(mainMod .. " + D", hl.dsp.exec_cmd("wofi --show drun")) +hl.bind(mainMod .. " + Q", hl.dsp.window.close()) +hl.bind(mainMod .. " + M", hl.dsp.exit()) + +hl.config({ + input = { kb_layout = "us" }, + general = { gaps_in = 4, gaps_out = 8, border_size = 2 }, +}) + +-- optional, machine-generated, only present when install detected an NVIDIA GPU +pcall(require, "conf.d.nvidia") diff --git a/packages/hyprpaper/.config/hypr/hyprpaper.conf b/packages/hyprpaper/.config/hypr/hyprpaper.conf index 07e7c7c..301b622 100644 --- a/packages/hyprpaper/.config/hypr/hyprpaper.conf +++ b/packages/hyprpaper/.config/hypr/hyprpaper.conf @@ -1,3 +1,7 @@ splash = false -preload = ~/.config/hypr/wallpapers/wallpaper.png -wallpaper = ,~/.config/hypr/wallpapers/wallpaper.png + +wallpaper { + monitor = + path = ~/.config/hypr/wallpapers/wallpaper.png + fit_mode = cover +} diff --git a/src/gpu.ts b/src/gpu.ts new file mode 100644 index 0000000..485ab55 --- /dev/null +++ b/src/gpu.ts @@ -0,0 +1,42 @@ +import { execFileSync } from "node:child_process"; + +export type GpuVendor = "nvidia" | "amd" | "intel"; + +// ponytail: card-generation nuance (e.g. Nouveau for pre-Turing NVIDIA cards) isn't +// handled — add if an unsupported-card report comes in. +const DRIVER_PACKAGES: Record = { + nvidia: ["nvidia-open-dkms", "nvidia-utils", "egl-wayland"], + amd: ["mesa", "vulkan-radeon", "libva-mesa-driver"], + intel: ["mesa", "vulkan-intel", "intel-media-driver"], +}; + +const VENDOR_PATTERNS: [RegExp, GpuVendor][] = [ + [/nvidia/i, "nvidia"], + [/\b(amd|ati)\b/i, "amd"], + [/intel/i, "intel"], +]; + +export function detectGpuVendors(): GpuVendor[] { + let output: string; + try { + output = execFileSync("lspci", [], { encoding: "utf8" }); + } catch { + return []; + } + + const controllerLines = output + .split("\n") + .filter((line) => /VGA compatible controller|3D controller/i.test(line)); + + const found = new Set(); + for (const line of controllerLines) { + for (const [pattern, vendor] of VENDOR_PATTERNS) { + if (pattern.test(line)) found.add(vendor); + } + } + return [...found]; +} + +export function driverPackagesFor(vendors: GpuVendor[]): string[] { + return vendors.flatMap((vendor) => DRIVER_PACKAGES[vendor]); +} diff --git a/src/install.ts b/src/install.ts index 9880cb4..6ff7e4f 100644 --- a/src/install.ts +++ b/src/install.ts @@ -1,11 +1,14 @@ import * as p from "@clack/prompts"; import { groups, packages, toDirs } from "./packages.js"; +import { detectGpuVendors, driverPackagesFor } from "./gpu.js"; import { backupConflicts, + ensureSddmEnabled, ensureYay, ensureZsh, installPackages, stow, + writeNvidiaEnvConfig, writeState, } from "./stow.js"; @@ -36,10 +39,33 @@ export async function install() { if (p.isCancel(picks)) return p.cancel("Install cancelled."); for (const name of picks as string[]) chosen.add(name); + const gpuVendors = detectGpuVendors(); + const gpuOptions = gpuVendors.flatMap((vendor) => + driverPackagesFor([vendor]).map((pkg) => ({ value: pkg, label: `${vendor}: ${pkg}` })), + ); + let gpuPicks: string[] = []; + if (gpuOptions.length > 0) { + const picked = await p.multiselect({ + message: "Detected GPU(s) — confirm driver packages to install", + options: gpuOptions, + initialValues: gpuOptions.map((o) => o.value), + required: false, + }); + if (p.isCancel(picked)) return p.cancel("Install cancelled."); + gpuPicks = picked as string[]; + for (const name of gpuPicks) chosen.add(name); + } + installPackages([...chosen]); + ensureSddmEnabled(); + if (chosen.has("zsh")) ensureZsh(); + if (gpuVendors.includes("nvidia") && driverPackagesFor(["nvidia"]).some((pkg) => gpuPicks.includes(pkg))) { + writeNvidiaEnvConfig(); + } + const dirs = toDirs([...chosen]); const backupDir = backupConflicts(dirs); if (backupDir) p.log.info(`Backed up conflicting files to ${backupDir}`); diff --git a/src/packages.ts b/src/packages.ts index 1faf3d4..2d92e11 100644 --- a/src/packages.ts +++ b/src/packages.ts @@ -10,6 +10,8 @@ export type Pkg = { export const packages: Pkg[] = [ { name: "hyprland", tier: "required" }, { name: "xdg-desktop-portal-hyprland", tier: "required" }, + { name: "sddm", tier: "required" }, + { name: "pciutils", tier: "required" }, { name: "waybar", tier: "recommended", group: "bar" }, { name: "ags", tier: "recommended", group: "bar", aur: true }, { name: "wofi", tier: "recommended", group: "launcher" }, diff --git a/src/stow.ts b/src/stow.ts index b562d88..531c835 100644 --- a/src/stow.ts +++ b/src/stow.ts @@ -63,6 +63,24 @@ export function ensureZsh() { } } +export function ensureSddmEnabled() { + try { + execFileSync("systemctl", ["is-enabled", "--quiet", "sddm"]); + } catch { + run("sudo", ["systemctl", "enable", "sddm.service"]); + } +} + +// Machine-generated, not stowed — only written when install detects an NVIDIA GPU. +export function writeNvidiaEnvConfig() { + const dir = join(homedir(), ".config", "hypr", "conf.d"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "nvidia.lua"), + 'hl.env("LIBVA_DRIVER_NAME", "nvidia")\nhl.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")\n', + ); +} + // Move real (non-symlink) files that would collide with a stow target out of the way. export function backupConflicts(dirs: string[]): string | null { const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); diff --git a/test/gpu.test.ts b/test/gpu.test.ts new file mode 100644 index 0000000..a9c7a51 --- /dev/null +++ b/test/gpu.test.ts @@ -0,0 +1,89 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { detectGpuVendors, driverPackagesFor, type GpuVendor } from "../src/gpu.js"; + +function withFakeLspci(output: string, fn: () => void) { + const dir = mkdtempSync(join(tmpdir(), "fake-lspci-")); + writeFileSync(join(dir, "lspci"), `#!/bin/sh\ncat <<'EOF'\n${output}\nEOF\n`, { mode: 0o755 }); + const originalPath = process.env.PATH; + process.env.PATH = `${dir}:${originalPath}`; + try { + fn(); + } finally { + process.env.PATH = originalPath; + } +} + +test("detects an NVIDIA + Intel machine", () => { + withFakeLspci( + `00:02.0 VGA compatible controller: Intel Corporation blah (rev 0a) +01:00.0 VGA compatible controller: NVIDIA Corporation GA104 [GeForce RTX 3070] (rev a1) +01:00.1 Audio device: NVIDIA Corporation GA104 High Definition Audio Controller (rev a1)`, + () => { + assert.deepStrictEqual(detectGpuVendors().sort(), ["intel", "nvidia"]); + }, + ); +}); + +test("detects an Intel + NVIDIA hybrid/Optimus laptop", () => { + withFakeLspci( + `00:02.0 VGA compatible controller: Intel Corporation TigerLake-LP GT2 [Iris Xe Graphics] +01:00.0 3D controller: NVIDIA Corporation GA107M [GeForce RTX 3050 Mobile]`, + () => { + assert.deepStrictEqual(detectGpuVendors().sort(), ["intel", "nvidia"]); + }, + ); +}); + +test("detects an AMD-only machine without false-positiving on 'Corporation'", () => { + // Regression test: /amd|ati/i used to match the "ati" inside "Corporation", + // flagging every vendor line (including Intel/NVIDIA ones) as AMD. + withFakeLspci( + `03:00.0 VGA compatible controller: Advanced Micro Devices, Inc. [AMD/ATI] Navi 23 [Radeon RX 6600]`, + () => { + assert.deepStrictEqual(detectGpuVendors(), ["amd"]); + }, + ); +}); + +test("does not false-positive AMD on an NVIDIA-only 'Corporation' line", () => { + withFakeLspci( + `01:00.0 VGA compatible controller: NVIDIA Corporation GA104 [GeForce RTX 3070]`, + () => { + assert.deepStrictEqual(detectGpuVendors(), ["nvidia"]); + }, + ); +}); + +test("returns no vendors when lspci reports no controllers", () => { + withFakeLspci("", () => { + assert.deepStrictEqual(detectGpuVendors(), []); + }); +}); + +test("returns no vendors when lspci is missing entirely", () => { + const originalPath = process.env.PATH; + process.env.PATH = "/nonexistent"; + try { + assert.deepStrictEqual(detectGpuVendors(), []); + } finally { + process.env.PATH = originalPath; + } +}); + +test("driverPackagesFor maps each vendor to its package list", () => { + assert.deepStrictEqual(driverPackagesFor(["amd"]), ["mesa", "vulkan-radeon", "libva-mesa-driver"]); + assert.deepStrictEqual(driverPackagesFor([]), []); + const vendors: GpuVendor[] = ["nvidia", "intel"]; + assert.deepStrictEqual(driverPackagesFor(vendors), [ + "nvidia-open-dkms", + "nvidia-utils", + "egl-wayland", + "mesa", + "vulkan-intel", + "intel-media-driver", + ]); +}); diff --git a/test/packages.test.ts b/test/packages.test.ts new file mode 100644 index 0000000..e118e43 --- /dev/null +++ b/test/packages.test.ts @@ -0,0 +1,27 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { groups, packages, toDirs } from "../src/packages.js"; + +test("toDirs maps a package name to its dir when set", () => { + assert.deepStrictEqual(toDirs(["rofi-wayland"]), ["rofi"]); +}); + +test("toDirs falls back to the package name when no dir is set", () => { + assert.deepStrictEqual(toDirs(["waybar", "kitty"]), ["waybar", "kitty"]); +}); + +test("toDirs falls back to the raw name for unknown packages", () => { + assert.deepStrictEqual(toDirs(["not-a-real-package"]), ["not-a-real-package"]); +}); + +test("groups contains exactly the distinct group values declared on packages, no duplicates", () => { + const expected = [...new Set(packages.filter((p) => p.group).map((p) => p.group!))]; + assert.deepStrictEqual(groups, expected); + assert.strictEqual(new Set(groups).size, groups.length, "groups should have no duplicates"); +}); + +test("every grouped package's group appears in the groups list", () => { + for (const pkg of packages.filter((p) => p.group)) { + assert.ok(groups.includes(pkg.group!), `${pkg.name}'s group "${pkg.group}" missing from groups`); + } +}); diff --git a/test/stow.test.ts b/test/stow.test.ts new file mode 100644 index 0000000..979e600 --- /dev/null +++ b/test/stow.test.ts @@ -0,0 +1,87 @@ +// Integration-style tests for src/stow.ts. These exercise the real `stow` binary and +// real filesystem calls, so each test: +// - runs against a throwaway $HOME (a fresh mkdtempSync dir), never the real one +// - is skipped when `stow` isn't installed (confirmed missing on a bare host) — +// run the full suite for real via `./docker/test.sh "npm test"` +import { test, after } from "node:test"; +import assert from "node:assert"; +import { execFileSync } from "node:child_process"; +import { existsSync, lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +function hasStow(): boolean { + try { + execFileSync("which", ["stow"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +const skip = !hasStow(); + +// os.homedir() reads $HOME on POSIX, so setting this before importing stow.js +// (whose module-level consts are computed from homedir() at import time) sandboxes +// every path the module touches, with no changes to production code. +const fakeHome = mkdtempSync(join(tmpdir(), "dotfiles-test-home-")); +process.env.HOME = fakeHome; +after(() => rmSync(fakeHome, { recursive: true, force: true })); + +const { + backupConflicts, + restoreLatestBackup, + restow, + stow, + unstow, + writeState, + readState, +} = skip ? ({} as any) : await import("../src/stow.js"); + +const dirs = ["hyprland", "waybar", "kitty", "hyprpaper"]; +const hyprlandTarget = join(fakeHome, ".config", "hypr", "hyprland.lua"); +const wallpaperTarget = join(fakeHome, ".config", "hypr", "wallpapers", "wallpaper.png"); + +test("seeds a pre-existing conflicting file", { skip }, () => { + mkdirSync(join(fakeHome, ".config", "hypr"), { recursive: true }); + writeFileSync(hyprlandTarget, "old config\n"); + assert.strictEqual(readFileSync(hyprlandTarget, "utf8").trim(), "old config"); +}); + +test("backupConflicts moves the pre-existing file out of the way", { skip }, () => { + const backupDir = backupConflicts(dirs); + assert.ok(backupDir, "expected a backup dir since a conflict existed"); + assert.ok(!existsSync(hyprlandTarget), "conflicting file should have been moved out of the way"); +}); + +test("stow symlinks the chosen packages into $HOME", { skip }, () => { + stow(dirs); + assert.ok(lstatSync(hyprlandTarget).isSymbolicLink(), "hyprland.lua should now be a symlink"); + assert.ok(existsSync(wallpaperTarget), "wallpaper.png should be reachable through the stowed dir"); +}); + +test("writeState/readState round-trip the chosen dirs", { skip }, () => { + writeState({ dirs }); + assert.deepStrictEqual(readState().dirs, dirs); +}); + +test("restow keeps the symlinks intact", { skip }, () => { + restow(dirs); + assert.ok(lstatSync(hyprlandTarget).isSymbolicLink(), "restow should keep the symlink"); + assert.ok(existsSync(wallpaperTarget), "restow should keep wallpaper reachable"); +}); + +test("unstow removes the symlinks", { skip }, () => { + unstow(dirs); + assert.ok( + !existsSync(hyprlandTarget) || !lstatSync(hyprlandTarget).isSymbolicLink(), + "unstow should remove the symlink", + ); + assert.ok(!existsSync(wallpaperTarget), "unstow should remove the wallpaper symlink"); +}); + +test("restoreLatestBackup brings back the original file content", { skip }, () => { + const restored = restoreLatestBackup(); + assert.ok(restored, "expected a backup to restore"); + assert.strictEqual(readFileSync(hyprlandTarget, "utf8").trim(), "old config", "original content should be restored"); +}); diff --git a/tsconfig.json b/tsconfig.json index 2502c8f..353183c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,5 +7,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["src"] + "include": ["src", "test"] }