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
+1
View File
@@ -0,0 +1 @@
__pycache__
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())
-387
View File
@@ -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