From 3ee00f2de352872192e0a9ef859f4c0610d9d7bf Mon Sep 17 00:00:00 2001 From: Haylan Date: Mon, 6 Jul 2026 00:01:07 +0200 Subject: [PATCH] Refactor installation process and enhance configuration management; add locale detection and autostart support --- CLAUDE.md | 2 +- packages/hyprland/.config/hypr/hyprland.lua | 13 +- .../.config/hypr/hyprlauncher.conf | 15 +++ packages/rofi/.config/rofi/config.rasi | 5 - packages/wofi/.config/wofi/config | 5 - src/install.ts | 118 ++++++++++++------ src/locale.ts | 18 +++ src/packages.ts | 3 +- src/stow.ts | 70 +++++++++-- test/locale.test.ts | 67 ++++++++++ test/packages.test.ts | 4 - 11 files changed, 240 insertions(+), 80 deletions(-) create mode 100644 packages/hyprlauncher/.config/hypr/hyprlauncher.conf delete mode 100644 packages/rofi/.config/rofi/config.rasi delete mode 100644 packages/wofi/.config/wofi/config create mode 100644 src/locale.ts create mode 100644 test/locale.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 9448275..1e1d39b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ There is no build step — `tsx` runs the TypeScript directly. ## 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/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 — `launcher` currently has just `hyprlauncher` but is kept as a group so alternatives can be added the same way), and an optional `dir` for when a package's Stow directory name needs to differ from the package name. `toDirs()` converts chosen package names to their Stow directory names. - `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`. diff --git a/packages/hyprland/.config/hypr/hyprland.lua b/packages/hyprland/.config/hypr/hyprland.lua index 5969166..31b1807 100644 --- a/packages/hyprland/.config/hypr/hyprland.lua +++ b/packages/hyprland/.config/hypr/hyprland.lua @@ -1,22 +1,17 @@ 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 .. " + D", hl.dsp.exec_cmd("hyprlauncher")) 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 +-- machine-generated by src/install.ts based on chosen packages/detected hardware/locale +pcall(require, "conf.d.autostart") +pcall(require, "conf.d.locale") pcall(require, "conf.d.nvidia") diff --git a/packages/hyprlauncher/.config/hypr/hyprlauncher.conf b/packages/hyprlauncher/.config/hypr/hyprlauncher.conf new file mode 100644 index 0000000..88daf41 --- /dev/null +++ b/packages/hyprlauncher/.config/hypr/hyprlauncher.conf @@ -0,0 +1,15 @@ +general { + grab_focus = true +} + +cache { + enabled = true +} + +finders { + default_finder = desktop +} + +ui { + window_size = 500 300 +} diff --git a/packages/rofi/.config/rofi/config.rasi b/packages/rofi/.config/rofi/config.rasi deleted file mode 100644 index 67dd495..0000000 --- a/packages/rofi/.config/rofi/config.rasi +++ /dev/null @@ -1,5 +0,0 @@ -configuration { - modi: "drun,run"; - show-icons: true; - display-drun: "Apps"; -} diff --git a/packages/wofi/.config/wofi/config b/packages/wofi/.config/wofi/config deleted file mode 100644 index 98c7cb9..0000000 --- a/packages/wofi/.config/wofi/config +++ /dev/null @@ -1,5 +0,0 @@ -width=600 -height=400 -location=center -show=drun -prompt=Search... diff --git a/src/install.ts b/src/install.ts index b784a07..2102236 100644 --- a/src/install.ts +++ b/src/install.ts @@ -1,18 +1,78 @@ import * as p from "@clack/prompts"; import { groups, packages, toDirs } from "./packages.js"; -import { detectGpuVendors, driverPackagesFor } from "./gpu.js"; +import { detectGpuVendors, driverPackagesFor, type GpuVendor } from "./gpu.js"; import { backupConflicts, ensureSddmEnabled, ensureSudoSession, ensureYay, ensureZsh, + ensureZshIsDefaultShell, installPackages, + run, stow, + writeAutostartConfig, + writeLocaleConfig, writeNvidiaEnvConfig, writeState, } from "./stow.js"; +const CANCELLED = Symbol("cancelled"); + +async function selectGroupPackages(chosen: Set): Promise { + for (const group of groups) { + const groupOptions = packages.filter((pkg) => pkg.group === group); + const pick = await p.select({ + message: `Choose a ${group}`, + options: groupOptions.map((pkg) => ({ value: pkg.name, label: pkg.name })), + }); + if (p.isCancel(pick)) return CANCELLED; + chosen.add(pick as string); + } +} + +async function selectRecommendedPackages(chosen: Set): Promise { + const ungrouped = packages.filter((pkg) => pkg.tier === "recommended" && !pkg.group); + const picks = await p.multiselect({ + message: "Choose recommended packages", + options: ungrouped.map((pkg) => ({ value: pkg.name, label: pkg.name })), + initialValues: ungrouped.map((pkg) => pkg.name), + required: false, + }); + if (p.isCancel(picks)) return CANCELLED; + for (const name of picks as string[]) chosen.add(name); +} + +/** Returns the vendors whose driver packages the user kept (added to `chosen`), or CANCELLED. */ +async function selectGpuDriverPackages(chosen: Set): Promise { + const vendors = detectGpuVendors(); + const options = vendors.flatMap((vendor) => + driverPackagesFor([vendor]).map((pkg) => ({ value: pkg, label: `${vendor}: ${pkg}` })), + ); + if (options.length === 0) return vendors; + + const picked = await p.multiselect({ + message: "Detected GPU(s) — confirm driver packages to install", + options, + initialValues: options.map((o) => o.value), + required: false, + }); + if (p.isCancel(picked)) return CANCELLED; + + for (const name of picked) chosen.add(name); + return vendors.filter((vendor) => driverPackagesFor([vendor]).some((pkg) => picked.includes(pkg))); +} + +async function maybeReboot(yes: boolean) { + if (yes) { + p.log.info("Rebooting now..."); + run("sudo", ["reboot"]); + return; + } + const reboot = await p.confirm({ message: "Reboot now to apply changes?" }); + if (!p.isCancel(reboot) && reboot) run("sudo", ["reboot"]); +} + export async function install({ yes = false }: { yes?: boolean } = {}) { p.intro("Hyprland dotfiles install"); @@ -23,42 +83,10 @@ export async function install({ yes = false }: { yes?: boolean } = {}) { const chosen = new Set(packages.filter((pkg) => pkg.tier === "required").map((pkg) => pkg.name)); - for (const group of groups) { - const groupOptions = packages.filter((pkg) => pkg.group === group); - const pick = await p.select({ - message: `Choose a ${group}`, - options: groupOptions.map((pkg) => ({ value: pkg.name, label: pkg.name })), - }); - if (p.isCancel(pick)) return p.cancel("Install cancelled."); - chosen.add(pick as string); - } - - const ungrouped = packages.filter((pkg) => pkg.tier === "recommended" && !pkg.group); - const picks = await p.multiselect({ - message: "Choose recommended packages", - options: ungrouped.map((pkg) => ({ value: pkg.name, label: pkg.name })), - initialValues: ungrouped.map((pkg) => pkg.name), - required: false, - }); - 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); - } + if ((await selectGroupPackages(chosen)) === CANCELLED) return p.cancel("Install cancelled."); + if ((await selectRecommendedPackages(chosen)) === CANCELLED) return p.cancel("Install cancelled."); + const keptGpuVendors = await selectGpuDriverPackages(chosen); + if (keptGpuVendors === CANCELLED) return p.cancel("Install cancelled."); p.note([...chosen].join("\n"), "Packages to install"); if (!yes) { @@ -72,12 +100,18 @@ export async function install({ yes = false }: { yes?: boolean } = {}) { ensureSddmEnabled(); - if (chosen.has("zsh")) ensureZsh(); - - if (gpuVendors.includes("nvidia") && driverPackagesFor(["nvidia"]).some((pkg) => gpuPicks.includes(pkg))) { - writeNvidiaEnvConfig(); + if (chosen.has("zsh")) { + ensureZsh(); + ensureZshIsDefaultShell(); } + if (keptGpuVendors.includes("nvidia")) writeNvidiaEnvConfig(); + + const autostartCmds = ["waybar", "ags", "mako", "hyprpaper"].filter((name) => chosen.has(name)); + if (chosen.has("hyprlauncher")) autostartCmds.push("hyprlauncher -d"); + writeAutostartConfig(autostartCmds); + writeLocaleConfig(); + const dirs = toDirs([...chosen]); const backupDir = backupConflicts(dirs); if (backupDir) p.log.info(`Backed up conflicting files to ${backupDir}`); @@ -85,5 +119,7 @@ export async function install({ yes = false }: { yes?: boolean } = {}) { stow(dirs); writeState({ dirs }); + await maybeReboot(yes); + p.outro("Done."); } diff --git a/src/locale.ts b/src/locale.ts new file mode 100644 index 0000000..5a2a3f3 --- /dev/null +++ b/src/locale.ts @@ -0,0 +1,18 @@ +import { execFileSync } from "node:child_process"; + +export function detectKeyboardLayout(): string { + try { + const output = execFileSync("localectl", ["status"], { encoding: "utf8" }); + const match = output.match(/X11 Layout:\s*(\S+)/); + if (match) return match[1]; + } catch { + // localectl unavailable — fall back below + } + // ponytail: no VC-keymap fallback or multi-layout support — good enough + // for the common case of a machine with an X11 keymap already configured. + return "us"; +} + +export function detectLang(): string | null { + return process.env.LANG ?? null; +} diff --git a/src/packages.ts b/src/packages.ts index 95e262a..ee11bbd 100644 --- a/src/packages.ts +++ b/src/packages.ts @@ -16,8 +16,7 @@ export const packages: Pkg[] = [ { name: "ttf-0xproto-nerd", tier: "required" }, { name: "waybar", tier: "recommended", group: "bar" }, { name: "ags", tier: "recommended", group: "bar", aur: true }, - { name: "wofi", tier: "recommended", group: "launcher" }, - { name: "rofi-wayland", dir: "rofi", tier: "recommended", group: "launcher", aur: true }, + { name: "hyprlauncher", tier: "recommended", group: "launcher" }, { name: "kitty", tier: "recommended" }, { name: "foot", tier: "recommended" }, { name: "mako", tier: "recommended" }, diff --git a/src/stow.ts b/src/stow.ts index 7087b6b..e54a450 100644 --- a/src/stow.ts +++ b/src/stow.ts @@ -10,8 +10,9 @@ import { rmSync, writeFileSync, } from "node:fs"; -import { homedir, tmpdir } from "node:os"; +import { homedir, tmpdir, userInfo } from "node:os"; import { join } from "node:path"; +import { detectKeyboardLayout, detectLang } from "./locale.js"; const REPO_ROOT = new URL("..", import.meta.url).pathname; const PACKAGES_DIR = join(REPO_ROOT, "packages"); @@ -19,9 +20,12 @@ const STATE_DIR = join(homedir(), ".config", "dotfiles"); const STATE_FILE = join(STATE_DIR, "state.json"); const BACKUP_ROOT = join(homedir(), ".dotfiles-backup"); -export function run(cmd: string, args: string[], options: { cwd?: string } = {}) { +type RunOptions = { cwd?: string; env?: NodeJS.ProcessEnv; stdin?: "inherit" | "ignore" }; + +export function run(cmd: string, args: string[], options: RunOptions = {}) { + const { stdin = "inherit", ...rest } = options; try { - execFileSync(cmd, args, { stdio: "inherit", ...options }); + execFileSync(cmd, args, { stdio: [stdin, "inherit", "inherit"], ...rest }); } catch (err) { if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") { throw new Error(`Command not found: ${cmd}. Is it installed and on PATH?`); @@ -70,20 +74,34 @@ export function installPackages(names: string[]) { export function ensureZsh() { const omzDir = join(homedir(), ".oh-my-zsh"); if (!existsSync(omzDir)) { - run("sh", [ - "-c", - 'sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended', - ]); + run( + "sh", + [ + "-c", + 'sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended', + ], + { stdin: "ignore", env: { ...process.env, CHSH: "no", RUNZSH: "no", KEEP_ZSHRC: "yes" } }, + ); } const zinitDir = join(homedir(), ".local", "share", "zinit", "zinit.git"); if (!existsSync(zinitDir)) { - run("sh", [ - "-c", - 'sh -c "$(curl -fsSL https://raw.githubusercontent.com/zdharma-continuum/zinit/HEAD/scripts/install.sh)"', - ]); + run( + "sh", + ["-c", 'sh -c "$(curl -fsSL https://raw.githubusercontent.com/zdharma-continuum/zinit/HEAD/scripts/install.sh)"'], + { stdin: "ignore" }, + ); } } +export function ensureZshIsDefaultShell() { + // Arch's zsh package always installs to /usr/bin/zsh and registers exactly that path in + // /etc/shells; `which zsh` can resolve to the /usr/sbin symlink instead (usr-merge), which + // doesn't literally match /etc/shells and makes chsh warn spuriously. + const zshPath = "/usr/bin/zsh"; + if (process.env.SHELL === zshPath) return; + run("sudo", ["chsh", "-s", zshPath, userInfo().username]); +} + export function ensureSddmEnabled() { try { execFileSync("systemctl", ["is-enabled", "--quiet", "sddm"]); @@ -92,9 +110,16 @@ export function ensureSddmEnabled() { } } -// Machine-generated, not stowed — only written when install detects an NVIDIA GPU. +// Machine-generated Hyprland config lives under ~/.config/hypr/conf.d, sourced from the +// stowed hyprland.lua via pcall(require, "conf.d.") — never stowed itself, since it +// varies per machine/per install run (chosen packages, detected hardware/locale). +function hyprConfD(): string { + return join(homedir(), ".config", "hypr", "conf.d"); +} + +// Only written when install detects an NVIDIA GPU. export function writeNvidiaEnvConfig() { - const dir = join(homedir(), ".config", "hypr", "conf.d"); + const dir = hyprConfD(); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "nvidia.lua"), @@ -102,6 +127,25 @@ export function writeNvidiaEnvConfig() { ); } +// Autostart list reflecting what was actually chosen (bar group pick, optional mako/hyprpaper) — +// the static hyprland.lua no longer hardcodes this, so deselected/uninstalled apps are never exec'd. +export function writeAutostartConfig(execCmds: string[]) { + const dir = hyprConfD(); + mkdirSync(dir, { recursive: true }); + const body = execCmds.map((cmd) => ` hl.exec_cmd("${cmd}")`).join("\n"); + writeFileSync(join(dir, "autostart.lua"), `hl.on("hyprland.start", function()\n${body}\nend)\n`); +} + +export function writeLocaleConfig() { + const dir = hyprConfD(); + mkdirSync(dir, { recursive: true }); + const layout = detectKeyboardLayout(); + const lang = detectLang(); + const lines = [`hl.config({ input = { kb_layout = "${layout}" } })`]; + if (lang) lines.push(`hl.env("LANG", "${lang}")`); + writeFileSync(join(dir, "locale.lua"), lines.join("\n") + "\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/locale.test.ts b/test/locale.test.ts new file mode 100644 index 0000000..ec71202 --- /dev/null +++ b/test/locale.test.ts @@ -0,0 +1,67 @@ +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 { detectKeyboardLayout, detectLang } from "../src/locale.js"; + +function withFakeLocalectl(output: string, fn: () => void) { + const dir = mkdtempSync(join(tmpdir(), "fake-localectl-")); + writeFileSync(join(dir, "localectl"), `#!/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("detectKeyboardLayout reads the X11 Layout line", () => { + withFakeLocalectl( + ` System Locale: LANG=de_DE.UTF-8 + VC Keymap: de-latin1 + X11 Layout: de + X11 Model: pc105`, + () => { + assert.strictEqual(detectKeyboardLayout(), "de"); + }, + ); +}); + +test("detectKeyboardLayout falls back to 'us' when localectl has no X11 Layout line", () => { + withFakeLocalectl(` System Locale: LANG=en_US.UTF-8\n VC Keymap: us`, () => { + assert.strictEqual(detectKeyboardLayout(), "us"); + }); +}); + +test("detectKeyboardLayout falls back to 'us' when localectl is missing entirely", () => { + const originalPath = process.env.PATH; + process.env.PATH = "/nonexistent"; + try { + assert.strictEqual(detectKeyboardLayout(), "us"); + } finally { + process.env.PATH = originalPath; + } +}); + +test("detectLang reads $LANG", () => { + const original = process.env.LANG; + process.env.LANG = "de_DE.UTF-8"; + try { + assert.strictEqual(detectLang(), "de_DE.UTF-8"); + } finally { + if (original === undefined) delete process.env.LANG; + else process.env.LANG = original; + } +}); + +test("detectLang returns null when $LANG is unset", () => { + const original = process.env.LANG; + delete process.env.LANG; + try { + assert.strictEqual(detectLang(), null); + } finally { + if (original !== undefined) process.env.LANG = original; + } +}); diff --git a/test/packages.test.ts b/test/packages.test.ts index e118e43..4865e38 100644 --- a/test/packages.test.ts +++ b/test/packages.test.ts @@ -2,10 +2,6 @@ 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"]); });