Files
dotfiles/setup.py
T

430 lines
11 KiB
Python
Executable File

#!/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/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 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())