prepared dot files

This commit is contained in:
2026-07-04 23:48:24 +02:00
commit 913ebaab14
26 changed files with 1103 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
import { parseArgs } from "node:util";
import { install } from "./install.js";
import { update } from "./update.js";
import { remove } from "./remove.js";
const { positionals } = parseArgs({ allowPositionals: true });
const [command] = positionals;
switch (command) {
case "install":
await install();
break;
case "update":
await update();
break;
case "remove":
await remove();
break;
default:
console.error("Usage: dotfiles <install|update|remove>");
process.exit(1);
}
+51
View File
@@ -0,0 +1,51 @@
import * as p from "@clack/prompts";
import { groups, packages, toDirs } from "./packages.js";
import {
backupConflicts,
ensureYay,
ensureZsh,
installPackages,
stow,
writeState,
} from "./stow.js";
export async function install() {
p.intro("Hyprland dotfiles install");
ensureYay();
const chosen = new Set(packages.filter((pkg) => pkg.tier === "required").map((pkg) => pkg.name));
for (const group of groups) {
const options = packages.filter((pkg) => pkg.group === group);
const pick = await p.select({
message: `Choose a ${group}`,
options: options.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);
installPackages([...chosen]);
if (chosen.has("zsh")) ensureZsh();
const dirs = toDirs([...chosen]);
const backupDir = backupConflicts(dirs);
if (backupDir) p.log.info(`Backed up conflicting files to ${backupDir}`);
stow(dirs);
writeState({ dirs });
p.outro("Done.");
}
+31
View File
@@ -0,0 +1,31 @@
export type Pkg = {
name: string;
/** Stow directory name, if it differs from the package name. */
dir?: string;
aur?: boolean;
tier: "required" | "recommended";
group?: string;
};
export const packages: Pkg[] = [
{ name: "hyprland", tier: "required" },
{ name: "xdg-desktop-portal-hyprland", 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: "kitty", tier: "recommended" },
{ name: "foot", tier: "recommended" },
{ name: "mako", tier: "recommended" },
{ name: "hyprpaper", tier: "recommended" },
{ name: "zsh", tier: "recommended" },
];
export const groups = [...new Set(packages.filter((p) => p.group).map((p) => p.group!))];
const byName = new Map(packages.map((pkg) => [pkg.name, pkg]));
/** Maps chosen package names to their stow directory names (only packages that have one). */
export function toDirs(names: string[]): string[] {
return names.map((name) => byName.get(name)?.dir ?? name);
}
+10
View File
@@ -0,0 +1,10 @@
import * as p from "@clack/prompts";
import { readState, restoreLatestBackup, unstow } from "./stow.js";
export async function remove() {
p.intro("Hyprland dotfiles remove");
unstow(readState().dirs);
const restored = restoreLatestBackup();
p.log.info(restored ? `Restored backup from ${restored}` : "No backup found; nothing to restore.");
p.outro("Done.");
}
+148
View File
@@ -0,0 +1,148 @@
import { execFileSync } from "node:child_process";
import {
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
const REPO_ROOT = new URL("..", import.meta.url).pathname;
const PACKAGES_DIR = join(REPO_ROOT, "packages");
const STATE_DIR = join(homedir(), ".config", "dotfiles");
const STATE_FILE = join(STATE_DIR, "state.json");
const BACKUP_ROOT = join(homedir(), ".dotfiles-backup");
function run(cmd: string, args: string[]) {
execFileSync(cmd, args, { stdio: "inherit" });
}
function commandExists(cmd: string): boolean {
try {
execFileSync("which", [cmd], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
export function ensureYay() {
if (commandExists("yay")) return;
run("sudo", ["pacman", "-S", "--needed", "--noconfirm", "git", "base-devel"]);
const tmp = "/tmp/yay-bootstrap";
rmSync(tmp, { recursive: true, force: true });
run("git", ["clone", "https://aur.archlinux.org/yay.git", tmp]);
execFileSync("makepkg", ["-si", "--noconfirm"], { cwd: tmp, stdio: "inherit" });
rmSync(tmp, { recursive: true, force: true });
}
export function installPackages(names: string[]) {
if (names.length === 0) return;
run("yay", ["-S", "--needed", "--noconfirm", ...names]);
}
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',
]);
}
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)"',
]);
}
}
// 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, "-");
const backupDir = join(BACKUP_ROOT, timestamp);
let backedUpAnything = false;
function walk(pkgDir: string, current: string) {
for (const entry of readdirSync(current, { withFileTypes: true })) {
const src = join(current, entry.name);
if (entry.isDirectory()) {
walk(pkgDir, src);
continue;
}
const relative = src.slice(pkgDir.length + 1);
const target = join(homedir(), relative);
if (existsSync(target) && !isSymlink(target)) {
const dest = join(backupDir, relative);
mkdirSync(join(dest, ".."), { recursive: true });
renameSync(target, dest);
backedUpAnything = true;
}
}
}
for (const dir of dirs) {
const pkgDir = join(PACKAGES_DIR, dir);
if (existsSync(pkgDir)) walk(pkgDir, pkgDir);
}
return backedUpAnything ? backupDir : null;
}
function isSymlink(path: string): boolean {
try {
return lstatSync(path).isSymbolicLink();
} catch {
return false;
}
}
function existingDirs(dirs: string[]): string[] {
return dirs.filter((dir) => existsSync(join(PACKAGES_DIR, dir)));
}
export function stow(dirs: string[]) {
const existing = existingDirs(dirs);
if (existing.length === 0) return;
run("stow", ["-v", "-d", PACKAGES_DIR, "-t", homedir(), ...existing]);
}
export function restow(dirs: string[]) {
const existing = existingDirs(dirs);
if (existing.length === 0) return;
run("stow", ["-v", "--restow", "-d", PACKAGES_DIR, "-t", homedir(), ...existing]);
}
export function unstow(dirs: string[]) {
const existing = existingDirs(dirs);
if (existing.length === 0) return;
run("stow", ["-v", "-D", "-d", PACKAGES_DIR, "-t", homedir(), ...existing]);
}
export function restoreLatestBackup() {
if (!existsSync(BACKUP_ROOT)) return null;
const backups = readdirSync(BACKUP_ROOT).sort();
const latest = backups.at(-1);
if (!latest) return null;
const latestDir = join(BACKUP_ROOT, latest);
run("cp", ["-a", `${latestDir}/.`, homedir()]);
return latestDir;
}
export type State = { dirs: string[] };
export function writeState(state: State) {
mkdirSync(STATE_DIR, { recursive: true });
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
}
export function readState(): State {
if (!existsSync(STATE_FILE)) return { dirs: [] };
return JSON.parse(readFileSync(STATE_FILE, "utf8"));
}
+11
View File
@@ -0,0 +1,11 @@
import { execFileSync } from "node:child_process";
import * as p from "@clack/prompts";
import { readState, restow } from "./stow.js";
export async function update() {
p.intro("Hyprland dotfiles update");
execFileSync("sudo", ["pacman", "-Syu"], { stdio: "inherit" });
execFileSync("yay", ["-Syu"], { stdio: "inherit" });
restow(readState().dirs);
p.outro("Done.");
}