#!/usr/bin/env python3 """Install dotfiles and system configuration from this repo.""" from __future__ import annotations import argparse import filecmp import platform import shlex import shutil import subprocess import sys from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent HOME = Path.home() DISTRO: str = platform.freedesktop_os_release().get("ID", "") class PackageManager(ABC): @property @abstractmethod def check_cmd(self) -> list[str]: ... @property @abstractmethod def install_cmd(self) -> list[str]: ... def is_installed(self, name: str) -> bool: return ( subprocess.run( [*self.check_cmd, name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ).returncode == 0 ) def install(self, names: list[str]) -> None: subprocess.run([*self.install_cmd, *names], check=True) class Pacman(PackageManager): @property def check_cmd(self) -> list[str]: return ["pacman", "-Q"] @property def install_cmd(self) -> list[str]: return ["sudo", "pacman", "-S", "--needed"] class Apt(PackageManager): @property def check_cmd(self) -> list[str]: return ["dpkg-query", "-W", "-f=${Status}"] @property def install_cmd(self) -> list[str]: return ["sudo", "apt", "install"] def is_installed(self, name: str) -> bool: r = subprocess.run( [*self.check_cmd, name], capture_output=True, text=True ) return r.returncode == 0 and r.stdout.strip() == "install ok installed" _apt = Apt() PACKAGE_MANAGERS: dict[str, PackageManager] = { "arch": Pacman(), "ubuntu": _apt, "debian": _apt, } PM: PackageManager | None = PACKAGE_MANAGERS.get(DISTRO) @dataclass(frozen=True) class Pkg: default_name: str distro_names: dict[str, str] | None = None def name(self, distro: str) -> str: return (self.distro_names or {}).get(distro, self.default_name) PKGS: list[Pkg] = [ Pkg("foot"), Pkg("jq"), Pkg("lf"), Pkg("tmux"), Pkg("vim"), Pkg("zsh"), ] SYMLINKS: list[str] = [ ".config/alacritty", ".config/dolphinrc", ".config/dunst", ".config/fish", ".config/foot", ".config/fontconfig", ".config/frogminer", ".config/ghostty", ".config/i3", ".config/i3status", ".config/kde-mimeapps.list", ".config/kglobalshortcutsrc", ".config/klipperrc", ".config/kwinrc", ".config/lf", ".config/mimeapps.list", ".config/opencode", ".config/picom", ".config/pipewire", ".config/rofi", ".config/strawberry", ".config/tmux", ".config/vlc", ".config/wezterm", ".config/wireplumber", ".config/yay", ".config/zed", ".local/share/dbus-1", ".local/share/easyeffects", ".local/share/fonts", ".local/share/konsole", ".gdbinit", ".vimrc", ".xinit-scripts", ".xinitrc", ".Xresources", ] COPIES: list[str] = [ ".claude/settings.json", ".codex/config.toml", ".config/gtk-3.0/bookmarks", ".config/gtk-3.0/settings.ini", ".config/gtk-4.0/settings.ini", ".gtkrc-2.0", ] SYSTEM_INSTALLS: list[str] = [ "sysctl.d/99-gaming-perf.conf", "sysctl.d/99-network.conf", "tmpfiles.d/99-gaming-perf.conf", "udev/rules.d/99-perf.rules", "ssh/sshd_config.d/sshd_harden.conf", ] # repo-relative path -> $HOME-relative path SYMLINK_MAP: dict[str, str] = { "zsh/rc": ".zshrc", } class Context: def __init__(self, args: argparse.Namespace) -> None: self.force: bool = args.force self.ignore_existing: bool = args.ignore_existing self.diff: bool = args.diff self.errors: list[str] = [] def error(self, *parts: str) -> None: msg = " ".join(parts) self.errors.append(msg) print(f"Error: {msg}", file=sys.stderr) def prompt_yes_no(question: str, default: bool = False) -> bool: if not sys.stdin.isatty(): return default suffix = " [Y/n] " if default else " [y/N] " try: answer = input(question + suffix).strip().lower() except EOFError: return default if answer in ("y", "yes"): return True if answer in ("n", "no"): return False return default def check_packages_installed(ctx: Context) -> None: if PM is None: ctx.error(f"don't know how to manage packages on distro {DISTRO!r}") return missing = [name for pkg in PKGS if not PM.is_installed(name := pkg.name(DISTRO))] if not missing: return print(f"Missing packages: {', '.join(missing)}") if not prompt_yes_no(f"Install with `{shlex.join([*PM.install_cmd, *missing])}`?"): ctx.error(f"packages not installed: {', '.join(missing)}") return try: PM.install(missing) except subprocess.CalledProcessError as e: ctx.error(f"package install failed (exit {e.returncode})") def check_terminfo(ctx: Context) -> None: result = subprocess.run( ["infocmp", "tmux-256color"], stdout=subprocess.DEVNULL, ) if result.returncode != 0: ctx.error("Missing terminfo for tmux-256color. Try installing ncurses-term.") def _describe_kind(p: Path) -> str: return "directory" if p.is_dir() else "regular file" def remove_symlink(ctx: Context, rel_dst: str) -> None: link = HOME / rel_dst if link.is_symlink(): target = link.resolve() print(f"Removing symlink: {link} -> {target}") link.unlink() elif link.exists(): ctx.error( "object to be removed is not a symlink:", f"{link}: {_describe_kind(link)}", ) def remove_all_symlinks(ctx: Context) -> None: for rel in SYMLINKS: remove_symlink(ctx, rel) for rel_dst in SYMLINK_MAP.values(): remove_symlink(ctx, rel_dst) def create_symlink(ctx: Context, rel_src: str, rel_dst: str) -> None: src = SCRIPT_DIR / rel_src dst = HOME / rel_dst if not src.exists(): ctx.error(f"the following source path does not exist: {src}") return if not dst.parent.is_dir(): print(f"Creating parent: {dst.parent}") dst.parent.mkdir(parents=True, exist_ok=True) if dst.is_symlink(): dst_target = dst.resolve() if dst_target == src.resolve(): return if ctx.force: remove_symlink(ctx, rel_dst) elif ctx.ignore_existing: return else: ctx.error( "symlink exists but points elsewhere:", f"{dst} -> {dst_target}", ) return elif dst.exists(): ctx.error( "path already exists but is not a symlink:", f"{dst}: {_describe_kind(dst)}", ) return print(f"Creating link: {dst} -> {src}") dst.symlink_to(src) def remove_path(path: Path) -> None: print(f"Trashing item: {path}") subprocess.run(["gio", "trash", str(path)], check=True) def copy_item(ctx: Context, rel_src: str, rel_dst: str) -> None: src = SCRIPT_DIR / rel_src dst = HOME / rel_dst if not src.is_file(): ctx.error(f"source is not a regular file: {src}") return if not dst.parent.is_dir(): print(f"Creating parent: {dst.parent}") dst.parent.mkdir(parents=True, exist_ok=True) if dst.exists(): if filecmp.cmp(src, dst, shallow=False): return if ctx.diff: subprocess.run(["diff", "--color=always", "-u", str(dst), str(src)]) if ctx.force: remove_path(dst) elif ctx.ignore_existing: return else: ctx.error(f"file already exists and differs: {dst}") return print(f"Copying item: from {rel_src} to {dst.parent}/") shutil.copy2(src, dst) def _sudo_test(*args: str) -> bool: return subprocess.run(["sudo", "test", *args]).returncode == 0 def _sudo_cmp(a: Path, b: Path) -> bool: return subprocess.run(["sudo", "cmp", "-s", str(a), str(b)]).returncode == 0 def install_system_file(ctx: Context, rel: str) -> None: src = SCRIPT_DIR / "etc" / rel dst = Path("/etc") / rel if not src.exists(): ctx.error(f"the following source path does not exist: {src}") return if not _sudo_test("-d", str(dst.parent)): print(f"Creating parent: {dst.parent}") subprocess.run(["sudo", "mkdir", "-p", str(dst.parent)], check=True) if _sudo_test("-e", str(dst)): if _sudo_cmp(src, dst): return if ctx.diff: subprocess.run(["sudo", "diff", "--color=always", "-u", str(dst), str(src)]) if ctx.force: print(f"Overwriting: {dst}") elif ctx.ignore_existing: return else: ctx.error(f"system file already exists and differs: {dst}") return print(f"Installing: etc/{rel} -> {dst}") subprocess.run(["sudo", "install", "-m", "644", str(src), str(dst)], check=True) def create_all_symlinks(ctx: Context) -> None: for rel in SYMLINKS: create_symlink(ctx, rel, rel) for src, dst in SYMLINK_MAP.items(): create_symlink(ctx, src, dst) def copy_all_items(ctx: Context) -> None: for rel in COPIES: copy_item(ctx, rel, rel) def install_all_system_files(ctx: Context) -> None: if not SYSTEM_INSTALLS: return subprocess.run(["sudo", "-v"], check=True) for rel in SYSTEM_INSTALLS: install_system_file(ctx, rel) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Install dotfiles and system configuration.", ) parser.add_argument( "-f", "--force", action="store_true", help="Overwrite any existing links or files", ) parser.add_argument( "-i", "--ignore-existing", action="store_true", help="Ignore existing links or files", ) parser.add_argument( "-r", "--remove-existing", action="store_true", help="Remove any existing symlinks", ) parser.add_argument( "-d", "--diff", action="store_true", help="Show unified diff when a managed file differs", ) return parser.parse_args() def main() -> int: args = parse_args() ctx = Context(args) if args.remove_existing: remove_all_symlinks(ctx) else: check_terminfo(ctx) check_packages_installed(ctx) create_all_symlinks(ctx) copy_all_items(ctx) install_all_system_files(ctx) return 1 if ctx.errors else 0 if __name__ == "__main__": sys.exit(main())