Enhance installation process with terminal selection, update autostart commands, and add PolyForm license; remove unused AGS config

This commit is contained in:
2026-07-06 07:02:34 +02:00
parent 3ee00f2de3
commit ed0a09bfcc
14 changed files with 274 additions and 30 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"js/ts.experimental.useTsgo": false
}
+1 -1
View File
@@ -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/<timestamp>/`, and wrapping `stow`/`--restow`/`-D` (always invoked with an explicit `-d <PACKAGES_DIR>` 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`.
+131
View File
@@ -0,0 +1,131 @@
# PolyForm Noncommercial License 1.0.0
<https://polyformproject.org/licenses/noncommercial/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.
+41
View File
@@ -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"
}
+17
View File
@@ -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 <name> <value>`,
`hyprctl dispatch <dispatcher> <args>` — 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.
+3 -2
View File
@@ -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"
}
}
-7
View File
@@ -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: [],
});
+1 -1
View File
@@ -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")
@@ -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
+21 -3
View File
@@ -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<string>): Promise<typeof CANCELLED | void> {
for (const group of groups) {
@@ -63,6 +66,20 @@ async function selectGpuDriverPackages(chosen: Set<string>): Promise<GpuVendor[]
return vendors.filter((vendor) => driverPackagesFor([vendor]).some((pkg) => picked.includes(pkg)));
}
/** null means no terminal was chosen — the SUPER+T keybind is skipped. */
async function selectMainTerminal(chosen: Set<string>): Promise<string | null | typeof CANCELLED> {
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);
-1
View File
@@ -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" },
+43 -8
View File
@@ -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 });
+2 -1
View File
@@ -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.");
}
+8
View File
@@ -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");