diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d55e27e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "js/ts.experimental.useTsgo": false +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 1e1d39b..983c043 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 — `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/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 an alternative both having `group: "bar"`, only one gets picked via a select prompt — `bar` and `launcher` currently each have just one member but are kept as groups 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/LICENSE b/LICENSE new file mode 100644 index 0000000..fe70743 --- /dev/null +++ b/LICENSE @@ -0,0 +1,131 @@ +# PolyForm Noncommercial License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the +software to do everything you might do with the software +that would otherwise infringe the licensor's copyright +in it for any permitted purpose. However, you may +only distribute the software according to [Distribution +License](#distribution-license) and make changes or new works +based on the software according to [Changes and New Works +License](#changes-and-new-works-license). + +## Distribution License + +The licensor grants you an additional copyright license +to distribute copies of the software. Your license +to distribute covers distributing the software with +changes and new works permitted by [Changes and New Works +License](#changes-and-new-works-license). + +## Notices + +You must ensure that anyone who gets a copy of any part of +the software from you also gets a copy of these terms or the +URL for them above, as well as copies of any plain-text lines +beginning with `Required Notice:` that the licensor provided +with the software. For example: + +> Required Notice: Copyright Yoyodyne, Inc. (http://example.com) + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Noncommercial Purposes + +Any noncommercial purpose is a permitted purpose. + +## Personal Uses + +Personal use for research, experiment, and testing for +the benefit of public knowledge, personal study, private +entertainment, hobby projects, amateur pursuits, or religious +observance, without any anticipated commercial application, +is use for a permitted purpose. + +## Noncommercial Organizations + +Use by any charitable organization, educational institution, +public research organization, public safety or health +organization, environmental protection organization, +or government institution is use for a permitted purpose +regardless of the source of funding or obligations resulting +from the funding. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. \ No newline at end of file diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 0000000..98f0d66 --- /dev/null +++ b/PKGBUILD @@ -0,0 +1,41 @@ +# Maintainer: Haylan +pkgname=dotfiles-git +pkgver=r0.0000000 +pkgrel=1 +pkgdesc="Interactive CLI to install/update/remove Haylan's Hyprland dotfiles" +arch=('any') +url="https://git.arthurerlich.de/haylan/DotFiles" +license=('LicenseRef-PolyForm-Noncommercial-1.0.0') +depends=('nodejs') +makedepends=('npm' 'git') +provides=('dotfiles') +conflicts=('dotfiles') +source=("$pkgname::git+https://git.arthurerlich.de/haylan/DotFiles.git") +sha256sums=('SKIP') + +pkgver() { + cd "$pkgname" + printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" +} + +build() { + cd "$pkgname" + npm ci + npm prune --production +} + +package() { + cd "$pkgname" + + install -d "$pkgdir/usr/lib/node_modules/$pkgname" + cp -a src packages node_modules package.json package-lock.json \ + "$pkgdir/usr/lib/node_modules/$pkgname/" + + install -Dm755 /dev/stdin "$pkgdir/usr/bin/dotfiles" <<-EOF + #!/usr/bin/env bash + exec "/usr/lib/node_modules/$pkgname/node_modules/.bin/tsx" \\ + "/usr/lib/node_modules/$pkgname/src/cli.ts" "\$@" + EOF + + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" +} diff --git a/README.md b/README.md index ac8acda..8f00f9f 100644 --- a/README.md +++ b/README.md @@ -62,3 +62,20 @@ preinstalled and the repo copied in, without touching the host: ./docker/test.sh # interactive shell, deps installed ./docker/test.sh "npx tsc --noEmit" # run one command non-interactively ``` + +## Testing in a VM + +The container above can't run a graphical Hyprland session (no display, no +GPU). To actually see a config change before installing it on a real machine, +boot an Arch Linux VM (QEMU/KVM, virt-manager, or whatever you're used to — +there's no scripted provisioning for this, it's a manual one-off): + +1. Get the repo into the VM (shared folder, `git clone`, `scp`, whatever's + convenient), then `sudo pacman -Syu && npm install && npm start install`, + choosing `hyprland` + `hyprpaper` + a bar. +2. Reboot into SDDM and log into Hyprland. +3. From a terminal in that session, use `hyprctl` to inspect/validate config + live — `hyprctl reload`, `hyprctl monitors`, `hyprctl keyword `, + `hyprctl dispatch ` — instead of editing-and-relaunching + blindly. See the [hyprctl wiki page](https://wiki.hypr.land/Configuring/Advanced-and-Cool/Using-hyprctl/) + for the full command set, including its interactive REPL mode. diff --git a/package.json b/package.json index fb0813e..9281335 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,18 @@ { "name": "dotfiles", "private": true, + "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "type": "module", "scripts": { "start": "tsx src/cli.ts", "test": "tsx --test \"test/**/*.test.ts\"" }, "dependencies": { - "@clack/prompts": "^0.9.0" + "@clack/prompts": "^0.9.0", + "tsx": "^4.19.0" }, "devDependencies": { "@types/node": "^22.10.0", - "tsx": "^4.19.0", "typescript": "^5.7.0" } } diff --git a/packages/ags/.config/ags/config.js b/packages/ags/.config/ags/config.js deleted file mode 100644 index 17b976c..0000000 --- a/packages/ags/.config/ags/config.js +++ /dev/null @@ -1,7 +0,0 @@ -// ponytail: placeholder ags config, flesh out if ags is chosen over waybar -import App from "resource:///com/github/Aylur/ags/app.js"; - -App.config({ - style: "", - windows: [], -}); diff --git a/packages/hyprland/.config/hypr/hyprland.lua b/packages/hyprland/.config/hypr/hyprland.lua index 31b1807..a3fd34c 100644 --- a/packages/hyprland/.config/hypr/hyprland.lua +++ b/packages/hyprland/.config/hypr/hyprland.lua @@ -2,7 +2,6 @@ hl.monitor({ output = "", mode = "preferred", position = "auto", scale = 1 }) local mainMod = "SUPER" -hl.bind(mainMod .. " + T", hl.dsp.exec_cmd("kitty")) hl.bind(mainMod .. " + D", hl.dsp.exec_cmd("hyprlauncher")) hl.bind(mainMod .. " + Q", hl.dsp.window.close()) hl.bind(mainMod .. " + M", hl.dsp.exit()) @@ -15,3 +14,4 @@ hl.config({ pcall(require, "conf.d.autostart") pcall(require, "conf.d.locale") pcall(require, "conf.d.nvidia") +pcall(require, "conf.d.terminal") diff --git a/packages/hyprpaper/.config/hypr/hyprpaper.conf b/packages/hyprpaper/.config/hypr/hyprpaper.conf index 301b622..a7e3966 100644 --- a/packages/hyprpaper/.config/hypr/hyprpaper.conf +++ b/packages/hyprpaper/.config/hypr/hyprpaper.conf @@ -1,7 +1,4 @@ +preload = ~/.config/hypr/wallpapers/wallpaper.png +wallpaper = ,~/.config/hypr/wallpapers/wallpaper.png splash = false - -wallpaper { - monitor = - path = ~/.config/hypr/wallpapers/wallpaper.png - fit_mode = cover -} +ipc = on diff --git a/src/install.ts b/src/install.ts index 2102236..285b2d5 100644 --- a/src/install.ts +++ b/src/install.ts @@ -2,6 +2,7 @@ import * as p from "@clack/prompts"; import { groups, packages, toDirs } from "./packages.js"; import { detectGpuVendors, driverPackagesFor, type GpuVendor } from "./gpu.js"; import { + autostartCmdsFor, backupConflicts, ensureSddmEnabled, ensureSudoSession, @@ -15,9 +16,11 @@ import { writeLocaleConfig, writeNvidiaEnvConfig, writeState, + writeTerminalConfig, } from "./stow.js"; const CANCELLED = Symbol("cancelled"); +const TERMINAL_CANDIDATES = ["kitty", "foot"]; async function selectGroupPackages(chosen: Set): Promise { for (const group of groups) { @@ -63,6 +66,20 @@ async function selectGpuDriverPackages(chosen: Set): Promise driverPackagesFor([vendor]).some((pkg) => picked.includes(pkg))); } +/** null means no terminal was chosen — the SUPER+T keybind is skipped. */ +async function selectMainTerminal(chosen: Set): Promise { + const candidates = TERMINAL_CANDIDATES.filter((name) => chosen.has(name)); + if (candidates.length === 0) return null; + if (candidates.length === 1) return candidates[0]; + + const picked = await p.select({ + message: "Choose your main terminal (bound to SUPER+T)", + options: candidates.map((name) => ({ value: name, label: name })), + }); + if (p.isCancel(picked)) return CANCELLED; + return picked; +} + async function maybeReboot(yes: boolean) { if (yes) { p.log.info("Rebooting now..."); @@ -85,6 +102,8 @@ export async function install({ yes = false }: { yes?: boolean } = {}) { if ((await selectGroupPackages(chosen)) === CANCELLED) return p.cancel("Install cancelled."); if ((await selectRecommendedPackages(chosen)) === CANCELLED) return p.cancel("Install cancelled."); + const mainTerminal = await selectMainTerminal(chosen); + if (mainTerminal === CANCELLED) return p.cancel("Install cancelled."); const keptGpuVendors = await selectGpuDriverPackages(chosen); if (keptGpuVendors === CANCELLED) return p.cancel("Install cancelled."); @@ -107,10 +126,9 @@ export async function install({ yes = false }: { yes?: boolean } = {}) { 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); + writeAutostartConfig(autostartCmdsFor([...chosen])); writeLocaleConfig(); + if (mainTerminal) writeTerminalConfig(mainTerminal); const dirs = toDirs([...chosen]); const backupDir = backupConflicts(dirs); diff --git a/src/packages.ts b/src/packages.ts index ee11bbd..4670731 100644 --- a/src/packages.ts +++ b/src/packages.ts @@ -15,7 +15,6 @@ export const packages: Pkg[] = [ { name: "stow", tier: "required" }, { name: "ttf-0xproto-nerd", tier: "required" }, { name: "waybar", tier: "recommended", group: "bar" }, - { name: "ags", tier: "recommended", group: "bar", aur: true }, { name: "hyprlauncher", tier: "recommended", group: "launcher" }, { name: "kitty", tier: "recommended" }, { name: "foot", tier: "recommended" }, diff --git a/src/stow.ts b/src/stow.ts index e54a450..d542213 100644 --- a/src/stow.ts +++ b/src/stow.ts @@ -1,4 +1,4 @@ -import { execFileSync } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { existsSync, lstatSync, @@ -36,13 +36,22 @@ export function run(cmd: string, args: string[], options: RunOptions = {}) { export function ensureSudoSession() { execFileSync("sudo", ["-v"], { stdio: "inherit" }); - setInterval(() => { - try { - execFileSync("sudo", ["-n", "-v"]); - } catch { - // credential expired or revoked; the next real sudo call will re-prompt - } - }, 60_000).unref(); + // A JS setInterval can't refresh this during a long synchronous step (e.g. makepkg's + // multi-minute build in ensureYay) — execFileSync blocks the entire single-threaded event + // loop, so the timer callback never gets a chance to run and the ticket silently expires + // mid-build. Spawn the refresh loop as a real child process instead, so it keeps running + // regardless of what our own process is blocked on. It's deliberately NOT `detached` — + // sudo's default tty-scoped ticket is tied to the session/controlling-tty, and `detached` + // calls setsid() on Linux, which moves the child to a new session and makes its `sudo -n -v` + // fail to match our ticket (verified empirically: a setsid'd sudo call can't reuse a ticket + // established outside it, while a plain background child in the same session can). It exits + // on its own once this process (checked via `kill -0`) is gone. + const child = spawn( + "sh", + ["-c", `while kill -0 ${process.pid} 2>/dev/null; do sleep 60; sudo -n -v 2>/dev/null; done`], + { stdio: "ignore" }, + ); + child.unref(); } function commandExists(cmd: string): boolean { @@ -60,6 +69,14 @@ export function ensureYay() { const tmp = mkdtempSync(join(tmpdir(), "yay-bootstrap-")); try { run("git", ["clone", "https://aur.archlinux.org/yay.git", tmp]); + // makepkg's own root pacman calls are always `sudo -k ...` (confirmed by reading + // /usr/bin/makepkg) — per sudo(8), `-k` combined with a command deliberately ignores any + // cached credential and forces a fresh password prompt. This is upstream makepkg's own + // design, not something ensureSudoSession's ticket caching can avoid, and it happens once + // per root escalation makepkg performs (dependency install, then package install), so a + // clean install (yay never already present) will ask for the password again here — possibly + // more than once. + console.log("Building yay from source (one-time) — you may be asked for your password again during this step."); run("makepkg", ["-si", "--noconfirm"], { cwd: tmp }); } finally { rmSync(tmp, { recursive: true, force: true }); @@ -136,6 +153,24 @@ export function writeAutostartConfig(execCmds: string[]) { writeFileSync(join(dir, "autostart.lua"), `hl.on("hyprland.start", function()\n${body}\nend)\n`); } +// Autostart commands for whichever of these package names are present. Assumes chosen +// package names == state.dirs (true today: no package sets a custom `dir`). If a future +// package needs dir != name AND is autostart-relevant, store chosen names in state.json too. +export function autostartCmdsFor(names: string[]): string[] { + const set = new Set(names); + const cmds = ["waybar", "mako", "hyprpaper"].filter((name) => set.has(name)); + if (set.has("hyprlauncher")) cmds.push("hyprlauncher -d"); + return cmds; +} + +// SUPER+T's target — the static hyprland.lua no longer hardcodes a terminal, since kitty/foot +// are both optional and either, both, or neither may be installed. +export function writeTerminalConfig(terminal: string) { + const dir = hyprConfD(); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "terminal.lua"), `hl.bind("SUPER + T", hl.dsp.exec_cmd("${terminal}"))\n`); +} + export function writeLocaleConfig() { const dir = hyprConfD(); mkdirSync(dir, { recursive: true }); diff --git a/src/update.ts b/src/update.ts index 4c1c0e6..53e718d 100644 --- a/src/update.ts +++ b/src/update.ts @@ -1,5 +1,5 @@ import * as p from "@clack/prompts"; -import { ensureSudoSession, readState, restow, run } from "./stow.js"; +import { autostartCmdsFor, ensureSudoSession, readState, restow, run, writeAutostartConfig } from "./stow.js"; export async function update({ yes = false }: { yes?: boolean } = {}) { p.intro("Hyprland dotfiles update"); @@ -18,5 +18,6 @@ export async function update({ yes = false }: { yes?: boolean } = {}) { run("sudo", ["pacman", "-Syu"]); run("yay", ["-Syu"]); restow(dirs); + writeAutostartConfig(autostartCmdsFor(dirs)); p.outro("Done."); } diff --git a/test/stow.test.ts b/test/stow.test.ts index 979e600..a92f2bc 100644 --- a/test/stow.test.ts +++ b/test/stow.test.ts @@ -29,6 +29,7 @@ process.env.HOME = fakeHome; after(() => rmSync(fakeHome, { recursive: true, force: true })); const { + autostartCmdsFor, backupConflicts, restoreLatestBackup, restow, @@ -42,6 +43,13 @@ const dirs = ["hyprland", "waybar", "kitty", "hyprpaper"]; const hyprlandTarget = join(fakeHome, ".config", "hypr", "hyprland.lua"); const wallpaperTarget = join(fakeHome, ".config", "hypr", "wallpapers", "wallpaper.png"); +test("autostartCmdsFor picks known autostart packages and appends hyprlauncher -d", { skip }, () => { + assert.deepStrictEqual( + autostartCmdsFor(["hyprland", "mako", "hyprlauncher"]), + ["mako", "hyprlauncher -d"], + ); +}); + test("seeds a pre-existing conflicting file", { skip }, () => { mkdirSync(join(fakeHome, ".config", "hypr"), { recursive: true }); writeFileSync(hyprlandTarget, "old config\n");