feat(decman): manage npm packages and deploy sddm theme assets

This commit is contained in:
2026-08-27 15:50:31 +02:00
parent b6ae6887a3
commit cd9b50feae
10 changed files with 212 additions and 14 deletions
+6
View File
@@ -295,6 +295,12 @@ def desktop_system() -> list[SystemConcern | UserConfigConcern]:
"config/system/desktop/network/NetworkManager.conf" "config/system/desktop/network/NetworkManager.conf"
), ),
"/etc/sddm.conf.d/10-theme.conf": "config/system/desktop/sddm/10-theme.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", "/etc/udev/rules.d/50-zsa.rules": "config/system/desktop/input/50-zsa.rules",
}, },
system_manifests=("desktop/hardware-system.txt",), system_manifests=("desktop/hardware-system.txt",),
+103
View File
@@ -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)
+7
View File
@@ -7,6 +7,8 @@ from pathlib import Path
import decman import decman
from decman.plugins import aur, pacman from decman.plugins import aur, pacman
from modules import npm_packages
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
PACKAGES = ROOT / "packages" PACKAGES = ROOT / "packages"
@@ -34,6 +36,11 @@ class PackageConcern(decman.Module):
def aur_packages(self) -> set[str]: def aur_packages(self) -> set[str]:
return _names(PACKAGES / f"{self.concern}-aur.txt") 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: def configure_no_removal_guard() -> None:
"""Keep Decman from pruning current dependency-orphans during adoption.""" """Keep Decman from pruning current dependency-orphans during adoption."""
+5 -2
View File
@@ -1,6 +1,7 @@
# Package lists # 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. - `common/` is portable workstation functionality.
- `laptop/` is hardware and power support for this laptop. - `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. - `ignored/` contains dependency packages Decman must not prune during adoption.
A host composes concerns in `../hosts.py`. Add a package to the narrowest 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.
@@ -2,6 +2,7 @@ decman
engram-bin engram-bin
gti gti
mongodb-compass-bin mongodb-compass-bin
onorca-bin
pi pi
postman-bin postman-bin
vagrant vagrant
@@ -0,0 +1 @@
@hypabolic/hypa
@@ -1 +1 @@
sddm-theme-noctalia-git
@@ -21,7 +21,6 @@ rustup
screengrab screengrab
seahorse seahorse
sudo sudo
sway
texlive-bin texlive-bin
texlive-meta texlive-meta
tree tree
+85 -9
View File
@@ -3,8 +3,10 @@
from __future__ import annotations from __future__ import annotations
import json
import os import os
import runpy import runpy
import shutil
import subprocess import subprocess
import sys import sys
from collections import Counter 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: def main() -> None:
repo_files = list(PACKAGES.glob("**/*-repo.txt")) repo_files = list(PACKAGES.glob("**/*-repo.txt"))
aur_files = list(PACKAGES.glob("**/*-aur.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)] repo = [name for path in repo_files for name in lines(path)]
aur = [name for path in aur_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 = {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: if duplicate:
raise SystemExit(f"duplicate package ownership: {sorted(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_repo = actual("-Qqen")
installed_aur = actual("-Qqem") installed_aur = actual("-Qqem")
installed_npm = npm_actual()
os.chdir(ROOT) os.chdir(ROOT)
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
import decman import decman
runpy.run_path(str(ROOT / "source.py")) 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_repo: set[str] = set()
loaded_aur: set[str] = set() loaded_aur: set[str] = set()
loaded_npm: set[str] = set()
for module in decman.modules: for module in decman.modules:
native = getattr(module, "native_packages", None) native = getattr(module, "native_packages", None)
foreign = getattr(module, "aur_packages", None) foreign = getattr(module, "aur_packages", None)
global_npm = getattr(module, "global_npm_packages", None)
if callable(native): if callable(native):
loaded_repo |= cast(Callable[[], set[str]], native)() loaded_repo |= cast(Callable[[], set[str]], native)()
if callable(foreign): if callable(foreign):
loaded_aur |= cast(Callable[[], set[str]], foreign)() loaded_aur |= cast(Callable[[], set[str]], foreign)()
if loaded_repo != set(repo) or loaded_aur != set(aur): if callable(global_npm):
raise SystemExit("active profile does not compose the full laptop package set") 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( 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( 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_system: set[str] = set()
loaded_user: set[str] = set() loaded_user: set[str] = set()
@@ -77,15 +141,27 @@ def main() -> None:
"alex", set() "alex", set()
) )
if loaded_system != expected_system or loaded_user != expected_user: if loaded_system != expected_system or loaded_user != expected_user:
raise SystemExit("active profile does not compose the complete unit manifests") raise SystemExit("active profile does not compose its unit manifests")
pending_remove = sorted((installed_repo - set(repo)) | (installed_aur - set(aur)))
pending_install = sorted((set(repo) - installed_repo) | (set(aur) - installed_aur)) 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( print(
f"OK: repo={len(repo)} aur={len(aur)} system={len(loaded_system)} " f"OK: repo={len(expected_repo)} aur={len(expected_aur)} "
f"user={len(loaded_user)} concerns={len(repo_files)} profile=laptop" 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 REMOVE: {' '.join(pending_remove) or 'none'}")
print(f"PENDING INSTALL: {' '.join(pending_install) 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__": if __name__ == "__main__":
+3 -1
View File
@@ -6,9 +6,11 @@ concerns and its desktop-specific Hyprland and Noctalia configuration.
import decman # pyright: ignore[reportMissingImports] # Provided by the Arch AUR package. 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 modules.packages import configure_no_removal_guard
from hosts import desktop from hosts import desktop
decman.plugins[Npm.NAME] = Npm()
configure_no_removal_guard() configure_no_removal_guard()
decman.modules += desktop() decman.modules += desktop()
decman.execution_order = ["files", "pacman", "aur", "systemd"] decman.execution_order = ["files", "pacman", "aur", "npm", "systemd"]