From 781e8c86869f7c10207f730bb7d50994c79da0f0 Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Thu, 23 Apr 2026 22:55:11 +0200 Subject: [PATCH] feat(setup): port setup.sh to Python --- .gitignore | 1 + setup.py | 337 ++++++++++++++++++++++++++++++++++++++++++++++ setup.sh | 387 ----------------------------------------------------- 3 files changed, 338 insertions(+), 387 deletions(-) create mode 100755 setup.py delete mode 100755 setup.sh diff --git a/.gitignore b/.gitignore index e69de29..bee8a64 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..821b5cb --- /dev/null +++ b/setup.py @@ -0,0 +1,337 @@ +#!/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()) diff --git a/setup.sh b/setup.sh deleted file mode 100755 index 4f7c37b..0000000 --- a/setup.sh +++ /dev/null @@ -1,387 +0,0 @@ -#!/usr/bin/env zsh - -set -e - -DISTRO="$(sed -n 's/^NAME="\([^"]\+\)"/\1/p' /etc/os-release)" - -SCRIPT_FILE="$(readlink -f -- "$0")" -SCRIPT_NAME="$(basename -- "$SCRIPT_FILE")" -SCRIPT_DIR="$(dirname -- "$SCRIPT_FILE")" - -SOURCE_DIR="$SCRIPT_DIR" -DEST_DIR="$HOME" - -PRINT_HELP=false -FORCE=false -REMOVE_EXISTING=false -IGNORE_EXISTING=false -DIFF=false -ERROR=false - -# Define packages -typeset -a PKGS -PKGS=( - "alacritty" - "i3" - "i3lock" - "i3status" - "jq" - "lf" - "rofi" - "startx" - "tmux" - "zsh" -) - -# Define paths to symlink -typeset -a SYMLINKS -SYMLINKS=( - ".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" -) - -typeset -a COPIES -COPIES=( - ".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" -) - -typeset -a SYSTEM_INSTALLS -SYSTEM_INSTALLS=( - "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" -) - -typeset -A SYMLINK_MAP -SYMLINK_MAP[zsh/rc]=".zshrc" - -error() { - local msg="$@" - ERROR=true - echo "Error: $msg" >&2 -} - -check_packages_installed() { - for pkg in "${PKGS[@]}"; do - if ! type "$pkg" >/dev/null; then - error "missing $pkg" - fi - done -} - -remove_symlink() { - local link src - link="${DEST_DIR}/$1" - - if test -L "$link"; then - src="$(readlink -f -- "$link")" - echo "Removing symlink: $link -> $src" - rm "$link" - elif test -e "$link"; then - error "object to be removed is not a symlink:" \ - "${link}: $(stat -c '%F' -- "$link")" - - return 0 - fi -} - -remove_all_symlinks() { - for link in "${SYMLINKS[@]}"; do - remove_symlink "$link" - done - - for src dst in ${(kv)SYMLINK_MAP}; do - remove_symlink "$dst" - done -} - -create_symlink() { - local rel_src rel_dst src dst dst_parent - - rel_src="$1" - rel_dst="$2" - - if test -z "$rel_src"; then - error "missing src argument: $0 $@" - return 1 - fi - - if test -z "$rel_dst"; then - error "missing dst argument: $0 $@" - return 1 - fi - - src="${SCRIPT_DIR}/$rel_src" - dst="${DEST_DIR}/$rel_dst" - dst_parent="$(dirname -- "$dst")" - - if ! test -e "$src"; then - error "the following source path does not exist: $src" - return 1 - fi - - if ! test -d "$dst_parent"; then - echo "Creating parent: $dst_parent" - mkdir -p "$dst_parent" - fi - - if test -L "$dst"; then - if test "$(readlink -f -- "$dst")" = "$src"; then - return 0 - fi - - if $FORCE; then - remove_symlink "$rel_dst" - elif $IGNORE_EXISTING; then - return 0 - else - error "symlink exists but points elsewhere:" \ - "$dst -> $(readlink -f -- "$dst")" - return 0 - fi - elif test -e "$dst"; then - error "path already exists but is not a symlink:" \ - "${dst}: $(stat -c '%F' -- "$dst")" - return 0 - fi - - echo "Creating link: $dst -> $src" - ln -s "$src" "$dst" -} - -remove_path() { - local target - target="$1" - - echo "Trashing item: $target" - gio trash "$target" -} - -copy_item() { - local rel_src rel_dst src dst dst_parent - - rel_src="$1" - rel_dst="$2" - - if test -z "$rel_src"; then - error "missing src argument: $0 $@" - return 1 - fi - - if test -z "$rel_dst"; then - error "missing dst argument: $0 $@" - return 1 - fi - - src="${SCRIPT_DIR}/$rel_src" - dst="${DEST_DIR}/$rel_dst" - dst_parent="$(dirname -- "$dst")" - - if ! test -f "$src"; then - error "source is not a regular file: $src" - return 1 - fi - - if ! test -d "$dst_parent"; then - echo "Creating parent: $dst_parent" - mkdir -p "$dst_parent" - fi - - if test -e "$dst"; then - if cmp -s "$src" "$dst"; then - return 0 - fi - - if $DIFF; then - diff -u "$dst" "$src" || true - fi - - if $FORCE; then - remove_path "$dst" - elif $IGNORE_EXISTING; then - return 0 - else - error "file already exists and differs: ${dst}" - return 0 - fi - fi - - echo "Copying item: from $rel_src to ${dst_parent}/" - cp "$src" "${dst_parent}/" -} - -create_all_symlinks() { - for link in "${SYMLINKS[@]}"; do - create_symlink "$link" "$link" - done - - for src dst in ${(kv)SYMLINK_MAP}; do - create_symlink "$src" "$dst" - done -} - -copy_all_items() { - for item in "${COPIES[@]}"; do - copy_item "$item" "$item" - done -} - -install_system_file() { - local rel src dst dst_parent - - rel="$1" - - if test -z "$rel"; then - error "missing path argument: $0 $@" - return 1 - fi - - src="${SCRIPT_DIR}/etc/$rel" - dst="/etc/$rel" - dst_parent="$(dirname -- "$dst")" - - if ! test -e "$src"; then - error "the following source path does not exist: $src" - return 1 - fi - - if ! sudo test -d "$dst_parent"; then - echo "Creating parent: $dst_parent" - sudo mkdir -p "$dst_parent" - fi - - if sudo test -e "$dst"; then - if sudo cmp -s "$src" "$dst"; then - return 0 - fi - - if $DIFF; then - sudo diff -u "$dst" "$src" || true - fi - - if $FORCE; then - echo "Overwriting: $dst" - elif $IGNORE_EXISTING; then - return 0 - else - error "system file already exists and differs: $dst" - return 0 - fi - fi - - echo "Installing: etc/$rel -> $dst" - sudo install -m 644 "$src" "$dst" -} - -install_all_system_files() { - for rel in "${SYSTEM_INSTALLS[@]}"; do - install_system_file "$rel" - done -} - -check_terminfo() { - if ! infocmp tmux-256color > /dev/null; then - error "Missing terminfo for tmux-256color. Try installing ncurses-term." - return 0 - fi -} - - -print_help() { - echo "Usage: $SCRIPT_NAME [...]" - echo "" - echo "Options:" - echo " -h, --help Print this help message" - echo " -f, --force Overwrite any existing links" - echo " -i, --ignore-existing Ignore existing symlinks" - echo " -r, --remove-existing Remove any existing symlinks" - echo " -d, --diff Show unified diff when a managed file differs" -} - -while [ $# -gt 0 ]; do - case $1 in - -h|--help) - PRINT_HELP=true - shift - ;; - -f|--force) - FORCE=true - shift - ;; - -r|--remove-existing) - REMOVE_EXISTING=true - shift - ;; - -i|--ignore-existing) - IGNORE_EXISTING=true - shift - ;; - -d|--diff) - DIFF=true - shift - ;; - *) - error "unknown option: $1" - PRINT_HELP=true - shift - ;; - esac -done - -if $PRINT_HELP; then - print_help -elif $REMOVE_EXISTING; then - remove_all_symlinks -else - check_terminfo - check_packages_installed - create_all_symlinks - copy_all_items - install_all_system_files -fi - -if $ERROR; then - exit 1 -fi