refactor(setup): profile-based configuration

This commit is contained in:
2026-04-24 00:41:06 +02:00
parent 8c800227b2
commit 040e219c21
+128 -58
View File
@@ -64,9 +64,7 @@ class Apt(PackageManager):
return ["sudo", "apt", "install"]
def is_installed(self, name: str) -> bool:
r = subprocess.run(
[*self.check_cmd, name], capture_output=True, text=True
)
r = subprocess.run([*self.check_cmd, name], capture_output=True, text=True)
return r.returncode == 0 and r.stdout.strip() == "install ok installed"
@@ -89,21 +87,54 @@ class Pkg:
return (self.distro_names or {}).get(distro, self.default_name)
PKGS: list[Pkg] = [
Pkg("foot"),
@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: list[str] = [
],
symlinks=[
".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/fish",
".config/foot",
".config/fontconfig",
".config/frogminer",
@@ -114,56 +145,80 @@ SYMLINKS: list[str] = [
".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/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",
],
copies=[
".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",
],
),
"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 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
@@ -192,12 +247,15 @@ def prompt_yes_no(question: str, default: bool = False) -> bool:
return default
def check_packages_installed(ctx: Context) -> None:
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))]
missing = [name for pkg in pkgs if not PM.is_installed(name := pkg.name(DISTRO))]
if not missing:
return
@@ -239,10 +297,12 @@ def remove_symlink(ctx: Context, rel_dst: str) -> None:
)
def remove_all_symlinks(ctx: Context) -> None:
for rel in SYMLINKS:
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():
for rel_dst in symlink_map.values():
remove_symlink(ctx, rel_dst)
@@ -329,8 +389,8 @@ def _sudo_cmp(a: Path, b: Path) -> bool:
def install_system_file(ctx: Context, rel: str) -> None:
src = SCRIPT_DIR / "etc" / rel
dst = Path("/etc") / rel
src = SCRIPT_DIR / rel
dst = Path("/") / rel
if not src.exists():
ctx.error(f"the following source path does not exist: {src}")
@@ -355,27 +415,29 @@ def install_system_file(ctx: Context, rel: str) -> None:
ctx.error(f"system file already exists and differs: {dst}")
return
print(f"Installing: etc/{rel} -> {dst}")
print(f"Installing: {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:
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():
for src, dst in symlink_map.items():
create_symlink(ctx, src, dst)
def copy_all_items(ctx: Context) -> None:
for rel in COPIES:
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) -> None:
if not SYSTEM_INSTALLS:
def install_all_system_files(ctx: Context, rels: list[str]) -> None:
if not rels:
return
subprocess.run(["sudo", "-v"], check=True)
for rel in SYSTEM_INSTALLS:
for rel in rels:
install_system_file(ctx, rel)
@@ -407,21 +469,29 @@ def parse_args() -> argparse.Namespace:
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)})",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
ctx = Context(args)
resolved = resolve_profiles(ctx, ["base", *args.profiles])
if args.remove_existing:
remove_all_symlinks(ctx)
remove_all_symlinks(ctx, resolved.symlinks, resolved.symlink_map)
else:
check_terminfo(ctx)
check_packages_installed(ctx)
create_all_symlinks(ctx)
copy_all_items(ctx)
install_all_system_files(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