diff --git a/system-config/modules/host_system.py b/system-config/modules/host_system.py index f1eca6c..bf4822c 100644 --- a/system-config/modules/host_system.py +++ b/system-config/modules/host_system.py @@ -295,6 +295,12 @@ def desktop_system() -> list[SystemConcern | UserConfigConcern]: "config/system/desktop/network/NetworkManager.conf" ), "/etc/sddm.conf.d/10-theme.conf": "config/system/desktop/sddm/10-theme.conf", + "/usr/share/sddm/themes/noctalia/Assets/ember-oled-background.svg": ( + "config/system/desktop/sddm/ember-oled-background.svg" + ), + "/usr/share/sddm/themes/noctalia/theme.conf.user": ( + "config/system/desktop/sddm/theme.conf.user" + ), "/etc/udev/rules.d/50-zsa.rules": "config/system/desktop/input/50-zsa.rules", }, system_manifests=("desktop/hardware-system.txt",), diff --git a/system-config/modules/npm_packages.py b/system-config/modules/npm_packages.py new file mode 100644 index 0000000..640865d --- /dev/null +++ b/system-config/modules/npm_packages.py @@ -0,0 +1,103 @@ +"""Declarative global npm command packages for Decman profiles.""" + +from __future__ import annotations + +import json +import shutil + +import decman +import decman.core.error as errors +import decman.core.module as module +import decman.core.output as output +import decman.core.store as store_module +from decman import Plugin +from decman.plugins import run_methods_with_attribute + + +def packages(fn): + """Annotate a method that returns globally installed npm package names.""" + fn.__npm_packages__ = True + return fn + + +class Npm(Plugin): + """Install declared global npm packages without pruning unrelated packages.""" + + NAME = "npm" + + def __init__(self) -> None: + self.packages: set[str] = set() + + def process_modules( + self, store: store_module.Store, modules: list[module.Module] + ) -> None: + store.ensure("npm_packages_for_module", {}) + + for mod in modules: + store["npm_packages_for_module"].setdefault(mod.name, set()) + declared = set().union( + *run_methods_with_attribute(mod, "__npm_packages__") + ) + + if store["npm_packages_for_module"][mod.name] != declared: + mod._changed = True + output.print_debug( + f"Module '{mod.name}' changed due to modified global npm packages." + ) + + self.packages |= declared + store["npm_packages_for_module"][mod.name] = declared + + def apply( + self, + store: store_module.Store, + dry_run: bool = False, + params: list[str] | None = None, + ) -> bool: + if not self.packages: + return True + + if shutil.which("npm") is None: + if dry_run: + output.print_list( + "Installing global npm packages after npm becomes available:", + sorted(self.packages), + ) + return True + output.print_error("npm is unavailable after the pacman step.") + return False + + try: + installed = self._installed_packages() + to_install = self.packages - installed + output.print_list("Installing global npm packages:", sorted(to_install)) + if to_install and not dry_run: + decman.prg( + [ + "npm", + "install", + "--global", + "--no-audit", + "--no-fund", + *sorted(to_install), + ] + ) + except (errors.CommandFailedError, ValueError) as error: + output.print_error(f"Managing global npm packages failed: {error}") + return False + + return True + + @staticmethod + def _installed_packages() -> set[str]: + raw = decman.prg( + ["npm", "list", "--global", "--depth=0", "--json"], pty=False + ) + try: + result = json.loads(raw) + except json.JSONDecodeError as error: + raise ValueError("npm returned invalid JSON") from error + dependencies = result.get("dependencies", {}) + if not isinstance(dependencies, dict): + raise ValueError("npm dependencies must be an object") + return set(dependencies) diff --git a/system-config/modules/packages.py b/system-config/modules/packages.py index 40c1135..08654cd 100644 --- a/system-config/modules/packages.py +++ b/system-config/modules/packages.py @@ -7,6 +7,8 @@ from pathlib import Path import decman from decman.plugins import aur, pacman +from modules import npm_packages + ROOT = Path(__file__).resolve().parents[1] PACKAGES = ROOT / "packages" @@ -34,6 +36,11 @@ class PackageConcern(decman.Module): def aur_packages(self) -> set[str]: return _names(PACKAGES / f"{self.concern}-aur.txt") + @npm_packages.packages + def global_npm_packages(self) -> set[str]: + path = PACKAGES / f"{self.concern}-npm.txt" + return _names(path) if path.exists() else set() + def configure_no_removal_guard() -> None: """Keep Decman from pruning current dependency-orphans during adoption.""" diff --git a/system-config/packages/README.md b/system-config/packages/README.md index e39dc49..05717c9 100644 --- a/system-config/packages/README.md +++ b/system-config/packages/README.md @@ -1,6 +1,7 @@ # Package lists -Each concern owns package names exactly once: +Each concern owns package names exactly once. Native, AUR, and global npm +packages use `*-repo.txt`, `*-aur.txt`, and optional `*-npm.txt` files: - `common/` is portable workstation functionality. - `laptop/` is hardware and power support for this laptop. @@ -8,4 +9,6 @@ Each concern owns package names exactly once: - `ignored/` contains dependency packages Decman must not prune during adoption. A host composes concerns in `../hosts.py`. Add a package to the narrowest -concern that explains why it exists, then run `../scripts/verify.py`. +concern that explains why it exists, then run `../scripts/verify.py`. The npm +plugin installs missing declared command packages globally after the pacman and +AUR steps; it does not remove unrelated global npm packages. diff --git a/system-config/packages/common/development-aur.txt b/system-config/packages/common/development-aur.txt index 72e5fa7..f6775ff 100644 --- a/system-config/packages/common/development-aur.txt +++ b/system-config/packages/common/development-aur.txt @@ -2,6 +2,7 @@ decman engram-bin gti mongodb-compass-bin +onorca-bin pi postman-bin vagrant diff --git a/system-config/packages/common/development-npm.txt b/system-config/packages/common/development-npm.txt new file mode 100644 index 0000000..57cbeb9 --- /dev/null +++ b/system-config/packages/common/development-npm.txt @@ -0,0 +1 @@ +@hypabolic/hypa diff --git a/system-config/packages/common/services-aur.txt b/system-config/packages/common/services-aur.txt index 8b13789..45c7394 100644 --- a/system-config/packages/common/services-aur.txt +++ b/system-config/packages/common/services-aur.txt @@ -1 +1 @@ - +sddm-theme-noctalia-git diff --git a/system-config/packages/desktop/local-repo.txt b/system-config/packages/desktop/local-repo.txt index 0ab4ef2..5b5dde6 100644 --- a/system-config/packages/desktop/local-repo.txt +++ b/system-config/packages/desktop/local-repo.txt @@ -21,7 +21,6 @@ rustup screengrab seahorse sudo -sway texlive-bin texlive-meta tree diff --git a/system-config/scripts/verify.py b/system-config/scripts/verify.py index 0406bc5..318eb34 100755 --- a/system-config/scripts/verify.py +++ b/system-config/scripts/verify.py @@ -3,8 +3,10 @@ from __future__ import annotations +import json import os import runpy +import shutil import subprocess import sys from collections import Counter @@ -32,38 +34,100 @@ def actual(command: str) -> set[str]: ) +def npm_actual() -> set[str]: + if shutil.which("npm") is None: + return set() + raw = subprocess.run( + ["npm", "list", "--global", "--depth=0", "--json"], + check=True, + text=True, + stdout=subprocess.PIPE, + ).stdout + try: + result = json.loads(raw) + except json.JSONDecodeError as error: + raise SystemExit("npm returned invalid JSON") from error + dependencies = result.get("dependencies", {}) + if not isinstance(dependencies, dict): + raise SystemExit("npm dependencies must be an object") + return set(dependencies) + + +def optional_lines(path: Path) -> set[str]: + return lines(path) if path.exists() else set() + + def main() -> None: repo_files = list(PACKAGES.glob("**/*-repo.txt")) aur_files = list(PACKAGES.glob("**/*-aur.txt")) + npm_files = list(PACKAGES.glob("**/*-npm.txt")) repo = [name for path in repo_files for name in lines(path)] aur = [name for path in aur_files for name in lines(path)] + npm = [name for path in npm_files for name in lines(path)] duplicate = {name for name, count in Counter(repo + aur).items() if count > 1} + duplicate_npm = {name for name, count in Counter(npm).items() if count > 1} if duplicate: raise SystemExit(f"duplicate package ownership: {sorted(duplicate)}") + if duplicate_npm: + raise SystemExit(f"duplicate npm package ownership: {sorted(duplicate_npm)}") installed_repo = actual("-Qqen") installed_aur = actual("-Qqem") + installed_npm = npm_actual() os.chdir(ROOT) sys.path.insert(0, str(ROOT)) import decman runpy.run_path(str(ROOT / "source.py")) + active_concerns = { + concern + for module in decman.modules + if isinstance((concern := getattr(module, "concern", None)), str) + } + expected_repo = set().union( + *(lines(PACKAGES / f"{concern}-repo.txt") for concern in active_concerns) + ) + expected_aur = set().union( + *(lines(PACKAGES / f"{concern}-aur.txt") for concern in active_concerns) + ) + expected_npm = set().union( + *(optional_lines(PACKAGES / f"{concern}-npm.txt") for concern in active_concerns) + ) loaded_repo: set[str] = set() loaded_aur: set[str] = set() + loaded_npm: set[str] = set() for module in decman.modules: native = getattr(module, "native_packages", None) foreign = getattr(module, "aur_packages", None) + global_npm = getattr(module, "global_npm_packages", None) if callable(native): loaded_repo |= cast(Callable[[], set[str]], native)() if callable(foreign): loaded_aur |= cast(Callable[[], set[str]], foreign)() - if loaded_repo != set(repo) or loaded_aur != set(aur): - raise SystemExit("active profile does not compose the full laptop package set") + if callable(global_npm): + loaded_npm |= cast(Callable[[], set[str]], global_npm)() + if ( + loaded_repo != expected_repo + or loaded_aur != expected_aur + or loaded_npm != expected_npm + ): + raise SystemExit("active profile does not compose its package concerns") + + system_manifests = { + name + for module in decman.modules + for name in getattr(module, "_system_manifests", ()) + } + user_manifests = { + name + for module in decman.modules + for name in getattr(module, "_user_manifests", ()) + } expected_system = set().union( - *(lines(path) for path in (ROOT / "manifests").glob("**/*-system.txt")) + *(lines(ROOT / "manifests" / name) for name in system_manifests) ) expected_user = set().union( - *(lines(path) for path in (ROOT / "manifests").glob("**/*-user.txt")) + *(lines(ROOT / "manifests" / name) for name in user_manifests) ) loaded_system: set[str] = set() loaded_user: set[str] = set() @@ -77,15 +141,27 @@ def main() -> None: "alex", set() ) if loaded_system != expected_system or loaded_user != expected_user: - raise SystemExit("active profile does not compose the complete unit manifests") - pending_remove = sorted((installed_repo - set(repo)) | (installed_aur - set(aur))) - pending_install = sorted((set(repo) - installed_repo) | (set(aur) - installed_aur)) + raise SystemExit("active profile does not compose its unit manifests") + + host_scopes = active_concerns & {"laptop/hardware", "desktop/local"} + if len(host_scopes) != 1: + raise SystemExit(f"active profile has ambiguous host concerns: {host_scopes}") + profile = next(iter(host_scopes)).split("/", maxsplit=1)[0] + pending_remove = sorted( + (installed_repo - expected_repo) | (installed_aur - expected_aur) + ) + pending_install = sorted( + (expected_repo - installed_repo) | (expected_aur - installed_aur) + ) + pending_npm_install = sorted(expected_npm - installed_npm) print( - f"OK: repo={len(repo)} aur={len(aur)} system={len(loaded_system)} " - f"user={len(loaded_user)} concerns={len(repo_files)} profile=laptop" + f"OK: repo={len(expected_repo)} aur={len(expected_aur)} " + f"npm={len(expected_npm)} system={len(loaded_system)} " + f"user={len(loaded_user)} concerns={len(active_concerns)} profile={profile}" ) print(f"PENDING REMOVE: {' '.join(pending_remove) or 'none'}") print(f"PENDING INSTALL: {' '.join(pending_install) or 'none'}") + print(f"PENDING NPM INSTALL: {' '.join(pending_npm_install) or 'none'}") if __name__ == "__main__": diff --git a/system-config/source.py b/system-config/source.py index 749061f..efc2fc5 100644 --- a/system-config/source.py +++ b/system-config/source.py @@ -6,9 +6,11 @@ concerns and its desktop-specific Hyprland and Noctalia configuration. import decman # pyright: ignore[reportMissingImports] # Provided by the Arch AUR package. +from modules.npm_packages import Npm from modules.packages import configure_no_removal_guard from hosts import desktop +decman.plugins[Npm.NAME] = Npm() configure_no_removal_guard() decman.modules += desktop() -decman.execution_order = ["files", "pacman", "aur", "systemd"] +decman.execution_order = ["files", "pacman", "aur", "npm", "systemd"]