feat(decman): manage npm packages and deploy sddm theme assets
This commit is contained in:
@@ -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",),
|
||||||
|
|||||||
@@ -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,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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user