prepared dot files
This commit is contained in:
+22
@@ -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);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
Reference in New Issue
Block a user