Files
dotfiles/setup.py
T

553 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
#
# Copyright 2026 Oscar Wallberg
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""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)
@dataclass(frozen=True)
class Profile:
pkgs: list[Pkg] | None = None
symlinks: list[str] | None = None
# repo-relative path -> $HOME-relative path
symlink_map: dict[str, str] | None = None
copies: list[str] | None = None
system_installs: list[str] | None = None
PROFILES: dict[str, Profile] = {
"base": Profile(
pkgs=[
Pkg("jq"),
Pkg("lf"),
Pkg("tmux"),
Pkg("vim"),
Pkg("zsh"),
],
symlinks=[
".claude/CLAUDE.md",
".claude/skills",
".config/fish",
".config/lf",
".config/opencode",
".config/tmux",
".config/yay",
".gdbinit",
".vimrc",
],
symlink_map={"zsh/rc": ".zshrc"},
copies=[
".claude/settings.json",
".codex/config.toml",
],
system_installs=[
"etc/sysctl.d/99-network.conf",
"etc/ssh/sshd_config.d/sshd_harden.conf",
],
),
"desktop": Profile(
pkgs=[
Pkg("foot"),
Pkg("firefox"),
Pkg("mupdf"),
],
symlinks=[
".config/alacritty",
".config/dolphinrc",
".config/dunst",
".config/foot",
".config/fontconfig",
".config/frogminer",
".config/ghostty",
".config/i3",
".config/i3status",
".config/kde-mimeapps.list",
".config/kglobalshortcutsrc",
".config/klipperrc",
".config/kwinrc",
".config/mimeapps.list",
".config/picom",
".config/pipewire",
".config/rofi",
".config/strawberry",
".config/vlc",
".config/wezterm",
".config/wireplumber",
".config/zed",
".local/share/dbus-1",
".local/share/easyeffects",
".local/share/fonts",
".local/share/konsole",
".xinit-scripts",
".xinitrc",
".Xresources",
],
copies=[
".config/gtk-3.0/bookmarks",
".config/gtk-3.0/settings.ini",
".config/gtk-4.0/settings.ini",
".gtkrc-2.0",
],
),
"gaming": Profile(
pkgs=[
Pkg("steam"),
],
system_installs=[
"etc/sysctl.d/99-gaming-perf.conf",
"etc/tmpfiles.d/99-gaming-perf.conf",
"etc/udev/rules.d/99-perf.rules",
],
),
}
@dataclass(frozen=True)
class Resolved:
pkgs: list[Pkg]
symlinks: list[str]
symlink_map: dict[str, str]
copies: list[str]
system_installs: list[str]
def apply_filter(ctx: Context, resolved: Resolved, filters: list[str]) -> Resolved:
wanted = set(filters)
symlinks = [p for p in resolved.symlinks if p in wanted]
symlink_map = {s: d for s, d in resolved.symlink_map.items() if s in wanted}
copies = [p for p in resolved.copies if p in wanted]
system_installs = [p for p in resolved.system_installs if p in wanted]
matched = set(symlinks) | set(symlink_map) | set(copies) | set(system_installs)
for f in wanted - matched:
ctx.error(f"--filter path not in selected profiles: {f!r}")
return Resolved(
pkgs=[],
symlinks=symlinks,
symlink_map=symlink_map,
copies=copies,
system_installs=system_installs,
)
def resolve_profiles(ctx: Context, profile_names: list[str]) -> Resolved:
pkgs: list[Pkg] = []
symlinks: list[str] = []
symlink_map: dict[str, str] = {}
copies: list[str] = []
system_installs: list[str] = []
seen: set[str] = set()
for name in profile_names:
if name in seen:
continue
seen.add(name)
profile = PROFILES.get(name)
if profile is None:
ctx.error(f"unknown profile: {name!r}")
continue
if profile.pkgs:
pkgs.extend(profile.pkgs)
if profile.symlinks:
symlinks.extend(profile.symlinks)
if profile.symlink_map:
symlink_map.update(profile.symlink_map)
if profile.copies:
copies.extend(profile.copies)
if profile.system_installs:
system_installs.extend(profile.system_installs)
return Resolved(pkgs, symlinks, symlink_map, copies, system_installs)
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, pkgs: list[Pkg]) -> None:
if not pkgs:
return
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, symlinks: list[str], symlink_map: dict[str, str]
) -> 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 / rel
dst = Path("/") / 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: {rel} -> {dst}")
subprocess.run(["sudo", "install", "-m", "644", str(src), str(dst)], check=True)
def create_all_symlinks(
ctx: Context, symlinks: list[str], symlink_map: dict[str, str]
) -> 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, rels: list[str]) -> None:
for rel in rels:
copy_item(ctx, rel, rel)
def install_all_system_files(ctx: Context, rels: list[str]) -> None:
if not rels:
return
subprocess.run(["sudo", "-v"], check=True)
for rel in rels:
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",
)
extras = sorted(PROFILES.keys() - {"base"})
parser.add_argument(
"profiles",
nargs="*",
metavar="PROFILE",
help=f"Extra profiles to apply on top of base (available: {', '.join(extras)})",
)
parser.add_argument(
"-f",
"--filter",
action="append",
metavar="PATH",
default=[],
dest="filters",
help=(
"Restrict the run to a single repo-relative path from the "
"selected profiles. Repeat to allow multiple paths. Skips "
"package and terminfo checks."
),
)
return parser.parse_args()
def main() -> int:
args = parse_args()
ctx = Context(args)
resolved = resolve_profiles(ctx, ["base", *args.profiles])
if args.filters:
resolved = apply_filter(ctx, resolved, args.filters)
if args.remove_existing:
remove_all_symlinks(ctx, resolved.symlinks, resolved.symlink_map)
else:
if not args.filters:
check_terminfo(ctx)
check_packages_installed(ctx, resolved.pkgs)
create_all_symlinks(ctx, resolved.symlinks, resolved.symlink_map)
copy_all_items(ctx, resolved.copies)
install_all_system_files(ctx, resolved.system_installs)
return 1 if ctx.errors else 0
if __name__ == "__main__":
sys.exit(main())