feat(setup): port setup.sh to Python

This commit is contained in:
2026-04-23 22:55:11 +02:00
parent b5c28d9e8f
commit 781e8c8686
3 changed files with 338 additions and 387 deletions
Executable
+337
View File
@@ -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())