feat: track Decman configuration

This commit is contained in:
2026-08-13 23:03:55 +02:00
parent 34acd648c9
commit f99cfeae79
96 changed files with 7120 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Refresh dependency packages that Decman must ignore to guarantee no removal."""
from __future__ import annotations
import subprocess
from pathlib import Path
import pyalpm
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "packages" / "ignored"
def repository_names(handle: pyalpm.Handle) -> set[str]:
return {
package.name
for database in handle.get_syncdbs()
for package in database.pkgcache
}
def deep_orphans(handle: pyalpm.Handle, predicate) -> set[str]:
local = {package.name: package for package in handle.get_localdb().pkgcache}
candidates = {
package.name
for package in local.values()
if package.reason == pyalpm.PKG_REASON_DEPEND and predicate(package.name)
}
while True:
retained = {
name
for name in candidates
if not (set(local[name].compute_requiredby()) - candidates)
}
if retained == candidates:
return retained
candidates = retained
def write(name: str, values: set[str]) -> None:
(OUTPUT / name).write_text("\n".join(sorted(values)) + "\n", encoding="utf-8")
def main() -> None:
handle = pyalpm.Handle("/", "/var/lib/pacman")
repositories = subprocess.run(
["pacman-conf", "--repo-list"], check=True, text=True, stdout=subprocess.PIPE
).stdout.split()
for repository in repositories:
handle.register_syncdb(repository, 0)
native = repository_names(handle)
write("repo-dependencies.txt", deep_orphans(handle, lambda name: name in native))
write("aur-dependencies.txt", deep_orphans(handle, lambda name: name not in native))
if __name__ == "__main__":
main()
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Generate disjoint, concern-scoped package lists from the current machine."""
from __future__ import annotations
import subprocess
from pathlib import Path
from typing import Final
ROOT = Path(__file__).resolve().parents[1]
PACKAGES = ROOT / "packages"
# A concern is reusable; a machine profile composes concerns elsewhere.
# Every installed explicit package must be assigned exactly once on this laptop.
CONCERNS: Final = {
"common/core": """base base-devel linux linux-firmware linux-headers cryptsetup refind
arch-install-scripts git yadm openssh man-db jq ripgrep fd fzf 7zip unzip rsync
ntfs-3g os-prober fwupd fish zsh zoxide htop glances starship cowsay ex-vi-compat
mtools dmidecode libsmbios catdoc odt2txt hunspell-de""",
"common/wayland": """hyprland hypridle hyprlock xdg-desktop-portal-hyprland
pipewire-alsa pipewire-pulse wireplumber pavucontrol pamixer mako waybar wofi grim
slurp satty wl-clipboard wlr-randr alacritty kitty tmux neovim vim yazi ueberzugpp
flameshot playerctl bluez bluez-utils blueman gnome-keyring xorg-xwayland
noctalia-git ranger-git""",
"common/fonts": """noto-fonts noto-fonts-cjk noto-fonts-emoji ttf-jetbrains-mono-nerd
ttf-icomoon-feather ttf-meslo-nerd-font-powerlevel10k""",
"common/development": """nodejs npm yarn python python-pip pyenv uv rust cargo go ruby
jdk-openjdk docker docker-compose podman vagrant virtualbox virtualbox-host-dkms
cloudflared openbsd-netcat pycharm postman-bin mongodb-compass-bin pi engram-bin
decman yay gti fcron""",
"common/productivity": """brave-bin firefox chromium thunderbird obsidian bitwarden
nextcloud-client signal-desktop telegram-desktop libreoffice-fresh evince okular
nomacs zotero-bin qalculate-gtk qbittorrent teams teamspeak3 cerebro-bin chatgtk
torbrowser-launcher spotify""",
"common/media": """vlc mpv smplayer audacity imagemagick steam cudnn""",
"common/research": """biber r texlive-basic texlive-bibtexextra texlive-binextra
texlive-context texlive-fontsextra texlive-fontsrecommended texlive-fontutils
texlive-formatsextra texlive-games texlive-humanities texlive-latex texlive-latexextra
texlive-latexrecommended texlive-luatex texlive-mathscience texlive-metapost
texlive-music texlive-pictures texlive-plaingeneric texlive-pstricks texlive-publishers
texlive-xetex resvg""",
"common/network": """networkmanager network-manager-applet avahi nss-mdns dhcpcd gvfs-smb
cups cups-pdf mullvad-vpn-beta-bin""",
"laptop/hardware": """intel-ucode thermald tlp tlp-pd tlp-rdw powertop psensor lm_sensors
ddcutil intel-gpu-tools intel-media-driver mesa-utils vulkan-intel vulkan-tools
nvidia-open nvidia-prime nvtop envycontrol light keyd nwg-displays
wireless_tools caffeine woeusb""",
"common/services": """sddm ly pcmanfm libva-utils""",
}
def explicit(command: str) -> set[str]:
return set(
subprocess.run(
["pacman", command], check=True, text=True, stdout=subprocess.PIPE
).stdout.split()
)
def write(path: Path, values: set[str]) -> None:
resolved = path.resolve()
package_root = PACKAGES.resolve()
if package_root not in resolved.parents:
raise ValueError(f"refusing to write outside packages: {path}")
resolved.parent.mkdir(parents=True, exist_ok=True)
resolved.write_text("\n".join(sorted(values)) + "\n", encoding="utf-8")
def main() -> None:
official, aur = explicit("-Qqen"), explicit("-Qqem")
assigned: set[str] = set()
for name, raw in CONCERNS.items():
selected = set(raw.split()) & (official | aur)
overlap = selected & assigned
if overlap:
raise SystemExit(f"duplicate ownership in {name}: {sorted(overlap)}")
assigned |= selected
write(PACKAGES / f"{name}-repo.txt", selected & official)
write(PACKAGES / f"{name}-aur.txt", selected & aur)
remaining = (official | aur) - assigned
if remaining:
raise SystemExit(
"unclassified explicit packages; add them to a named concern: "
+ ", ".join(sorted(remaining))
)
print(f"assigned={len(assigned)}")
if __name__ == "__main__":
main()
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Read-only invariant checks for the active Decman configuration."""
from __future__ import annotations
import os
import runpy
import subprocess
import sys
from collections import Counter
from collections.abc import Callable
from pathlib import Path
from typing import cast
ROOT = Path(__file__).resolve().parents[1]
PACKAGES = ROOT / "packages"
def lines(path: Path) -> set[str]:
return {
line.strip()
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip()
}
def actual(command: str) -> set[str]:
return set(
subprocess.run(
["pacman", command], check=True, text=True, stdout=subprocess.PIPE
).stdout.split()
)
def main() -> None:
repo_files = list(PACKAGES.glob("**/*-repo.txt"))
aur_files = list(PACKAGES.glob("**/*-aur.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)]
duplicate = {name for name, count in Counter(repo + aur).items() if count > 1}
if duplicate:
raise SystemExit(f"duplicate package ownership: {sorted(duplicate)}")
installed_repo = actual("-Qqen")
installed_aur = actual("-Qqem")
os.chdir(ROOT)
sys.path.insert(0, str(ROOT))
import decman
runpy.run_path(str(ROOT / "source.py"))
loaded_repo: set[str] = set()
loaded_aur: set[str] = set()
for module in decman.modules:
native = getattr(module, "native_packages", None)
foreign = getattr(module, "aur_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")
expected_system = set().union(
*(lines(path) for path in (ROOT / "manifests").glob("**/*-system.txt"))
)
expected_user = set().union(
*(lines(path) for path in (ROOT / "manifests").glob("**/*-user.txt"))
)
loaded_system: set[str] = set()
loaded_user: set[str] = set()
for module in decman.modules:
system = getattr(module, "system_units", None)
user = getattr(module, "user_units", None)
if callable(system):
loaded_system |= cast(Callable[[], set[str]], system)()
if callable(user):
loaded_user |= cast(Callable[[], dict[str, set[str]]], user)().get(
"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))
print(
f"OK: repo={len(repo)} aur={len(aur)} system={len(loaded_system)} "
f"user={len(loaded_user)} concerns={len(repo_files)} profile=laptop"
)
print(f"PENDING REMOVE: {' '.join(pending_remove) or 'none'}")
print(f"PENDING INSTALL: {' '.join(pending_install) or 'none'}")
if __name__ == "__main__":
main()