From 769fbc11813f6a617569ee552e050bafee3257cb Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Fri, 24 Apr 2026 00:08:25 +0200 Subject: [PATCH] feat(setup): per-distro package detection and install prompt --- setup.py | 132 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 112 insertions(+), 20 deletions(-) diff --git a/setup.py b/setup.py index 821b5cb..298021f 100755 --- a/setup.py +++ b/setup.py @@ -6,32 +6,94 @@ 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() - -# Populated now so per-distro package tracking can key off it. -try: - DISTRO: str = platform.freedesktop_os_release().get("NAME", "") -except OSError: - DISTRO = "" +DISTRO: str = platform.freedesktop_os_release().get("ID", "") -PKGS: list[str] = [ - "alacritty", - "i3", - "i3lock", - "i3status", - "jq", - "lf", - "rofi", - "startx", - "tmux", - "zsh", +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"), ] @@ -114,10 +176,40 @@ class Context: 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: - for pkg in PKGS: - if shutil.which(pkg) is None: - ctx.error(f"missing {pkg}") + 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: