#!/usr/bin/env python3 """Install dotfiles and system configuration from this repo.""" from __future__ import annotations import argparse import filecmp import platform import shutil import subprocess import sys from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent HOME = Path.home() # Populated now so per-distro package tracking can key off it. try: DISTRO: str = platform.freedesktop_os_release().get("NAME", "") except OSError: DISTRO = "" PKGS: list[str] = [ "alacritty", "i3", "i3lock", "i3status", "jq", "lf", "rofi", "startx", "tmux", "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/bin", ".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 check_packages_installed(ctx: Context) -> None: for pkg in PKGS: if shutil.which(pkg) is None: ctx.error(f"missing {pkg}") 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())