SysDeck 4.4 - Standalone Edition: consolidates the day-to-day work of a Linux operations team in a single webui

This commit is contained in:
Jeremy Anderson 2026-09-12 04:25:13 -04:00
parent 3fdbf6d3f0
commit cd522bedcd
22 changed files with 1576 additions and 3489 deletions

3298
BLOG.md

File diff suppressed because it is too large Load Diff

View File

@ -30,7 +30,7 @@
# Distro support: Arch Linux, Debian/Ubuntu, Fedora/RHEL/CentOS.
PACKAGE := sysdeck
VERSION := 0.4.3
VERSION := 0.4.4
LIB_DIR := $(DESTDIR)/usr/lib/$(PACKAGE)
PYTHON_DIR := $(LIB_DIR)/bridge
SHARE_DIR := $(DESTDIR)/usr/share/$(PACKAGE)
@ -447,16 +447,16 @@ uninstall-branding:
@echo ">>> Starting SysDeck Web Edition on :3000 (Ctrl+C stops next; fester keeps running)"
cd web && bun run dev
# master: rebuild the master tarball from this tree (cockpit + web + fester)
# master: rebuild the master tarball from this tree (cockpit + web + fester + klanker-gate)
master:
@echo ">>> Building $(PACKAGE)-$(VERSION)-master.tar.bz2 (cockpit + web + fester)"
@echo ">>> Building $(PACKAGE)-$(VERSION)-master.tar.bz2 (cockpit + web + fester + klanker-gate)"
tar cjf $(PACKAGE)-$(VERSION)-master.tar.bz2 \
--exclude='__pycache__' --exclude='*.pyc' --exclude='*.tar.bz2' \
--exclude='*node_modules*' --exclude='*.next' \
--exclude='*node_modules*' --exclude='*.next' --exclude='*.tsbuildinfo' \
--exclude='*public/download*' --exclude='*.db' --exclude='*.db-*' \
--transform 's,^,$(PACKAGE)-$(VERSION)-,' \
--transform 's,^,$(PACKAGE)-$(VERSION)-master/,' \
bridge plugins shared tests packaging compat standalone-plugins \
prometheus scripts firewall docs web \
Makefile README.md QUICKSTART.md BLOG.md LICENSE QA.md THIRD_PARTY.md \
prometheus scripts firewall docs web klanker-gate \
Makefile README.md QUICKSTART.md BLOG.md LICENSE QA.md worklog.md THIRD_PARTY.md \
sysdeck-diagnose.sh cockpit-smoke-test.sh sysdeck-uninstall.sh
@echo ">>> $(PACKAGE)-$(VERSION)-master.tar.bz2 ready"

75
QA.md
View File

@ -1207,6 +1207,81 @@ and this, the console/host pair is 100% aligned.
---
---
# MoE Quality Assurance Pass — v0.4.4 (package parity + the blog essay)
## v0.4.4 QA — ten managers on both editions, parsers locked by fixtures
**Reviewer panel (MoE):** backend coder · JS/TS expert · algorithms
specialist (parser robustness) · technical writer (blog pattern)
**Date:** 2026-09-12
### What was audited and fixed
- **Parity gap closed:** `bridge/packages.py` (cockpit edition) carried
only pacman/dnf/apt while the web console carried ten backends.
The cockpit bridge now runs the identical step-down — pacman →
emerge (corroborated by `/var/db/pkg`) → lunar → sorcery → xbps
(probed via `xbps-query`; Void ships no bare `xbps` binary) → apk →
zypper → dnf → yum → apt — with `shutil.which` presence probes (no
`--version` child processes) and the same corroboration rules.
- **Silent-fabrication bugs found by fixture tests and fixed on BOTH
editions:** (1) the emerge update regex captured the class bracket
`]` as the "atom" — portage pads the class field with spaces, so
every update row was dropped and Gentoo hosts silently showed "no
updates"; the capture now anchors after the bracket. (2) zypper
tables were parsed positionally, but zypper prefixes its tables
with status/repository columns that vary by subcommand and release;
parsing now locates `Name`/`Current`/`Available` from the header row
(separator and repeated-header rows filtered). (3) the xbps search
regex required a repository prefix that `xbps-query -Rs` rows do
not consistently carry — searches silently returned zero rows; the
prefix is now optional. (4) the web emerge info lookup resolved
only bare names — category-qualified atoms returned null; both
lookup shapes now resolve.
- **Honest capability reporting:** lunar has no `lvu` update-preview
subcommand — its summary carries the note instead of a zero count
that reads as "all current", and single-module update refuses with
the real instruction. Mutation argv for all ten managers lives in
one `MUTATION_CMDS` table (`emerge --unmerge`, `cast`/`dispel`,
`lin`/`lrm`, `xbps-install -y`, `zypper --non-interactive`, ...)
shared by install/remove/update/update-all/dry-run — the dry-run
preview and the executed command cannot diverge.
- **Surface hygiene:** polkit `org.sysdeck.packages.modify` exec-path
annotations extended to the ten managers (and a latent `--` inside
an XML comment in the policy file fixed — strict parsers rejected
it); the cockpit packages panel's unavailable-message and header
narrate the ten-manager reality and render `summary.updatesNote`;
`bridge/__init__.py`'s DistroId/PkgManager maps cover the new
distros; churn-narration docstrings in the touched files rewritten
as decisive rules.
- **BLOG.md rebuilt as a long-form engineering essay** (the shellm
blog pattern): title, italic deck, context narrative, roadmap
paragraph, decision-organized sections (auth via PAM, one catalog
two frontends, the compiler-enforced zero-demo contract, the
ten-manager step-down, the firewall privilege discipline,
performance without fabrication), a canonical numbered workflow, and
an attribution footer. Every file path, flag, token format, and
count in the essay verified against the source. Release notes
content no longer lives in BLOG.md; history stays in QA.md and
worklog.md, and README's pointers say so.
### Verification
`python3 -m py_compile` across bridge/*.py · fixture suite
`scripts/test_packages_backends.py` 10/10 checks · new unittest class
`TestPackagesBackends` 13/13 in `make check` (detection order + xbps
probe, emerge corroboration via mocked `shutil.which`, zypper
header-locate across three layouts, emerge bracket-anchored regex,
xbps prefix-optional regex, MUTATION_CMDS coverage incl. lunar's
honest absence, real argv spot-checks, honest lunar summary, no-sudo
source guard) · `node --check` on the packages panel · polkit policy
XML validated with a strict parser · `tsc --noEmit` clean and eslint
clean on the web tree (full dep install) · version sync at 0.4.4
across all release surfaces · master tarball rebuilt via
`make master`.
# MoE Quality Assurance Pass — v0.4.3 (the hardened release)
## v0.4.3 QA — multi-expert audit, findings landed

View File

@ -434,6 +434,38 @@ A multi-expert audit hardened every axis of the console:
### 10.8 Ten package managers on both editions (v0.4.4)
The package module runs the same on every distro it touches — module
parity between the two frontends, not drift:
- **Cockpit edition** (`bridge/packages.py`): pacman (Arch) · emerge
(Gentoo/Portage) · lunar (Lunar Linux) · sorcery (SourceMage) ·
xbps (Void) · apk (Alpine) · zypper (openSUSE) · dnf / yum (RPM) ·
apt (Debian). Detection is a `shutil.which` step-down in a fixed
order, most specific first; `emerge` requires the `/var/db/pkg`
corroboration; Void is probed via `xbps-query` (no bare `xbps`
binary exists). Installs/removals/updates resolve from one
`MUTATION_CMDS` table — `pacman -S --noconfirm`, `emerge
--unmerge`, `cast`/`dispel`, `lin`/`lrm`, `xbps-install -y`,
`zypper --non-interactive` — and still ride the cockpit superuser
channel (polkit `org.sysdeck.packages.modify`).
- **Web edition** (`web/src/lib/sysdeck/bridge/packages.ts`): the
identical step-down and now the identical parser fixes — zypper
tables parse by header-located columns, the emerge update preview
anchors its capture after the class bracket (the old capture
grabbed the bracket and silently dropped every row), and
`xbps-query -Rs` rows parse with or without a repository prefix.
- **Honest capability reporting**: lunar has no update-preview
subcommand, so its summary says so instead of reporting 0 updates;
lunar single-package update refuses with the real instruction.
Fixture tests for all of the above run in `make check`
(`tests/test_bridge_parsers.py::TestPackagesBackends`).
- **BLOG.md** is now the long-form engineering essay (title, deck,
decision-organized sections, canonical workflow, attribution
footer). Release history lives in `QA.md` and `worklog.md`.
## 11. Run without Cockpit (the complete standalone runbook, v0.3.0)
The web edition needs **nothing from sections 1–8** — no cockpit, no Python

View File

@ -3,7 +3,7 @@
**A drop-in plugin for an existing Cockpit install — twenty-six domain modules behind one dashboard.**
Author: **Jeremy Anderson** · <info@dcos.net> · <https://dcos.net>
Version: **0.4.3** · License: **MIT**
Version: **0.4.4** · License: **MIT**
---
@ -13,6 +13,44 @@ SysDeck is a cockpit-native plugin that consolidates the day-to-day work of a Li
The plugin ships as static HTML+JS+CSS plus a Python bridge helper package. It installs under `/usr/share/cockpit/sysdeck-*/` and is discovered automatically by the cockpit-bridge. No separate web server, no Node.js runtime, no database — the plugin runs inside the cockpit web service.
### v0.4.4 highlights (ten package managers on both editions + the blog essay)
The cockpit packages bridge now carries the **same ten-manager
step-down as the web console** — module parity, not drift:
- **`bridge/packages.py` supports pacman (Arch), emerge (Gentoo/
Portage), lunar (Lunar Linux), sorcery (SourceMage), xbps (Void),
apk (Alpine), zypper (openSUSE), dnf and yum (RPM), apt (Debian)** —
the identical detection order and corroboration rules as the web
console's `packages.ts`: `emerge` claims the host only when
`/var/db/pkg` also exists, and Void is probed through
`xbps-query` because Void ships no bare `xbps` binary.
- **Parser correctness, locked in by fixture tests** (`make check`):
zypper tables parse by locating `Name`/`Current`/`Available`
columns from the header row (zypper's leading status/repository
columns vary by subcommand and release); the emerge update preview
anchors its capture after the class bracket — portage pads the
class field with spaces, and the previous capture grabbed the
bracket itself and silently dropped every update row; `xbps-query
-Rs` rows parse with or without a repository prefix. Both editions
carry all three fixes.
- **Honest capability reporting**: `lvu` has no update-preview
subcommand, so the lunar backend returns an honest empty and the
summary carries a note ("lunar has no update-preview subcommand —
run lunar update to fetch + rebuild") instead of a zero count that
reads as "all current"; lunar single-module update refuses with the
real instruction. Every other manager maps to its exact argv
(`emerge --unmerge`, `cast`/`dispel`, `zypper --non-interactive
install`, …) from one `MUTATION_CMDS` table shared by install,
remove, update, update-all, and the dry-run preview.
- **`BLOG.md` is now a long-form engineering essay** (the same shape
as the shellm blog: title, deck, decision-organized sections,
canonical workflow, attribution footer) — a technical walkthrough
of the auth model, the two-frontend architecture, the zero-demo
contract, the ten-manager step-down, and the firewall privilege
discipline, grounded in the source. Per-release history lives in
`QA.md` and `worklog.md`.
### v0.4.3 highlights (the MoE QA pass — hardened on every axis)
A multi-expert review (design, CSS/UI-UX, JS/React/Next, Elm-style type
@ -395,9 +433,9 @@ v0.0.36 adds a firewall backend dropdown to the Firewall panel and hardens the e
### v0.0.35 highlights
v0.0.35 restores SysDeck Kata as a standalone sidebar entry and adds three new modules per user directive — Jellyfin media server, photo manager, and remote filesystem manager:
v0.0.35 promotes SysDeck Kata to a standalone sidebar entry and adds three new modules per user directive — Jellyfin media server, photo manager, and remote filesystem manager:
- **Kata split.** Per user directive: *"kata containers should be called SysDeck Kata and moved out of the tools area. and dont call it hidden thats akward."* The v0.0.34 layout had Kata Containers demoted to a hidden "tools" entry inside the merged Containers & VMs panel — labeled "Kata Containers (hidden helper)" with priority -1, in `plugins/sysdeck-containers-kata/`. v0.0.35 splits Kata back out: renamed to **SysDeck Kata**, moved to `plugins/sysdeck-kata/`, converted from a `tools` manifest entry to a `menu` entry (label "SysDeck Kata", order 27), removed the "hidden helper" wording, dropped the priority -1, and restored a dedicated keywords list. The Containers panel now manages Podman only — the Kata tab and its iframe were removed. The pre-built cockpit-kata React bundle (`index.js` + `index.css`) is shipped unchanged.
- **Kata split.** Per user directive: *"kata containers should be called SysDeck Kata and moved out of the tools area. and dont call it hidden thats akward."* The v0.0.34 layout had Kata Containers demoted to a hidden "tools" entry inside the merged Containers & VMs panel — labeled "Kata Containers (hidden helper)" with priority -1, in `plugins/sysdeck-containers-kata/`. v0.0.35 splits Kata out: renamed to **SysDeck Kata**, moved to `plugins/sysdeck-kata/`, converted from a `tools` manifest entry to a `menu` entry (label "SysDeck Kata", order 27), removed the "hidden helper" wording, dropped the priority -1, and carries a dedicated keywords list. The Containers panel now manages Podman only — the Kata tab and its iframe were removed. The pre-built cockpit-kata React bundle (`index.js` + `index.css`) is shipped unchanged.
- **Jellyfin media server module.** Per user directive: *"next we will integrate a jellyfin management module where it starts, stops, and loads the admin panel in the module."* New plugin `plugins/sysdeck-jellyfin/` + new bridge helper `bridge/jellyfin.py`. The bridge runs `systemctl start/stop/restart jellyfin.service` via the cockpit superuser channel (polkit `org.sysdeck.jellyfin.modify`); the panel iframes the running Jellyfin admin UI at `http://127.0.0.1:8096` — same pattern as the v0.0.34 Glances integration. Library list is best-effort via `GET /Library/VirtualFolders` on the local Jellyfin instance.
- **Photo manager module.** Per user directive: *"as well as a photo manager of equal quality. with its own module."* New plugin `plugins/sysdeck-photos/` + new bridge helper `bridge/photos.py`. Multi-backend design (same shape as the DB Control module): PhotoPrism (port 2342, MIT), Piwigo (port 80, GPL-2.0), Lychee (port 80, MIT), Nextcloud-Memories (port 80, AGPL-3.0), LibrePhotos (port 3000, MIT). Each backend is auto-detected; the bridge runs `systemctl start/stop/restart <service>` and the panel iframes its admin UI when running. Polkit action: `org.sysdeck.photos.modify`.
- **Remote FS manager module.** Per user directive: *"then a remote fs manager such as ceph, and others but not nfs or amanada fs."* New plugin `plugins/sysdeck-remotefs/` + new bridge helper `bridge/remotefs.py`. Multi-backend: Ceph (LGPL-2.1), GlusterFS (GPL-2.0), MooseFS (GPL-2.0), BeeGFS (BeeGFS EULA — free), OrangeFS (BSD-3). Each backend is auto-detected; the bridge runs `systemctl start/stop/restart <service>` and the cluster-info subcommand queries backend-specific cluster status (`ceph status --format=json`, `gluster pool list`, `moosefs-cli info`, `beegfs-ctl --listnodes`, `pvfs2-server -m`). Polkit action `org.sysdeck.remotefs.modify` authorizes the systemctl binary plus ceph / gluster / moosefs-cli / beegfs-ctl / pvfs2-server CLIs. **NFS and Amanda are explicitly EXCLUDED per directive** — documented in the panel footer and in `bridge/remotefs.py:EXCLUDED`.
@ -465,7 +503,7 @@ The cockpit plugin is the primary deliverable.
| 15 | System Monitor (Glances web UI) | `cockpit-glances` | P1 | `glances -w` (iframe) + snapshot cards |
| 16 | Hardware Sensors | `cockpit-sensors` | P1 | `sensors` (lm_sensors) |
| 17 | System Benchmark | `cockpit-benchmark` | P2 | `sysbench` |
| 18 | Package Manager | `cockpit-packages` | P1 | `pacman` / `dnf` / `apt` |
| 18 | Package Manager | `cockpit-packages` | P1 | ten managers: `pacman` / `emerge` / `lunar` / `sorcery` / `xbps` / `apk` / `zypper` / `dnf` / `yum` / `apt` |
| 19 | Policy & Permissions | `cockpit-policy` | P1 | ACLs · cgroups v2 · VLANs · eBPF · namespaces · filecaps · LSM stack (AppArmor/Smack/TOMOYO/Yama/LoadPin/Lockdown/BPF-LSM/Landlock) |
| 20 | DB Control | `cockpit-db` | P1 | DB engine CLIs (SQL/NoSQL/vector/AI) |
| 21 | Jellyfin Media Server | `cockpit-jellyfin` | P1 | `systemctl start/stop/restart jellyfin.service` + admin UI iframe (port 8096) |
@ -490,7 +528,7 @@ sysdeck-0.0.35/
│ ├── sysdeck-mesh/
│ ├── sysdeck-vault/
│ ├── sysdeck-fleet/
│ ├── sysdeck-kata/ # v0.0.35: restored to standalone sidebar entry — pre-built cockpit-kata React app
│ ├── sysdeck-kata/ # v0.0.35: standalone sidebar entry — pre-built cockpit-kata React app
│ ├── sysdeck-fester/
│ ├── sysdeck-firmware/
│ ├── sysdeck-builder/
@ -519,7 +557,7 @@ sysdeck-0.0.35/
│ ├── glances.py # v0.0.34: snapshot + start-web/stop-web
│ ├── sensors.py # lm_sensors normalization + alert thresholds
│ ├── benchmark.py # sysbench result parsing + baselines
│ ├── packages.py # pacman/dnf/apt unified package ops
│ ├── packages.py # ten-manager unified package ops (pacman→apt step-down)
│ ├── mining.py # v0.0.34: XMRig REST API power tool
│ ├── themes.py # v0.0.34: cockpit.conf + CSS variable surface
│ ├── policy.py # Policy & Permissions module (LSM stack)
@ -543,7 +581,7 @@ sysdeck-0.0.35/
├── docs/ # INSTALL.md
├── README.md
├── QUICKSTART.md
├── BLOG.md # release narrative
├── BLOG.md # engineering essay (long-form)
├── QA.md # QA notes per release
├── THIRD_PARTY.md # third-party attributions
├── LICENSE # MIT
@ -616,7 +654,7 @@ MIT — see [LICENSE](./LICENSE). Third-party attributions: see [THIRD_PARTY.md]
## Release notes
See [BLOG.md](./BLOG.md) for the v0.0.33 release narrative and prior-version history.
See [BLOG.md](./BLOG.md) for the engineering essay — a long-form technical walkthrough of the architecture and design decisions, written against the current release. Per-version history lives in [worklog.md](./worklog.md) and [QA.md](./QA.md).
## Project history

View File

@ -22,7 +22,7 @@ import os
import subprocess
from typing import Literal
__version__ = "0.4.3"
__version__ = "0.4.4"
__author__ = "Jeremy Anderson"
__url__ = "https://dcos.net"
@ -33,7 +33,9 @@ __url__ = "https://dcos.net"
# then fall back to checking which package manager is available.
# Returns a normalized distro identifier for use in dispatch tables.
DistroId = Literal["arch", "debian", "fedora", "rhel", "unknown"]
DistroId = Literal["arch", "gentoo", "lunar", "sourcemage", "void",
"alpine", "opensuse", "debian", "fedora", "rhel",
"unknown"]
def detect_distro() -> DistroId:
@ -42,9 +44,12 @@ def detect_distro() -> DistroId:
Priority order:
1. Parse /etc/os-release ID/ID_LIKE fields.
2. Fall back to package-manager presence (pacman → arch,
emerge → gentoo, lunar → lunar, sorcery → sourcemage,
xbps-query → void, apk → alpine, zypper → opensuse,
apt → debian, dnf → fedora).
Returns one of: 'arch', 'debian', 'fedora', 'rhel', 'unknown'.
Returns one of: 'arch', 'gentoo', 'lunar', 'sourcemage', 'void',
'alpine', 'opensuse', 'debian', 'fedora', 'rhel', 'unknown'.
"""
# Try /etc/os-release first (present on all modern distros).
try:
@ -59,6 +64,12 @@ def detect_distro() -> DistroId:
# Direct match on ID.
id_map = {"arch": "arch", "archlinux": "arch",
"gentoo": "gentoo", "funtoo": "gentoo",
"lunar": "lunar", "sourcemage": "sourcemage",
"void": "void",
"alpine": "alpine", "postmarketos": "alpine",
"opensuse": "opensuse", "opensuse-leap": "opensuse",
"opensuse-tumbleweed": "opensuse", "sles": "opensuse",
"debian": "debian", "ubuntu": "debian", "linuxmint": "debian", "pop": "debian",
"fedora": "fedora", "rhel": "rhel", "centos": "rhel", "rocky": "rhel", "alma": "rhel"}
if dist_id in id_map:
@ -72,7 +83,11 @@ def detect_distro() -> DistroId:
pass
# Fall back to package manager presence.
for cmd, distro in [("pacman", "arch"), ("apt", "debian"), ("dnf", "fedora")]:
for cmd, distro in [("pacman", "arch"), ("emerge", "gentoo"),
("lunar", "lunar"), ("sorcery", "sourcemage"),
("xbps-query", "void"), ("apk", "alpine"),
("zypper", "opensuse"),
("apt", "debian"), ("dnf", "fedora")]:
try:
subprocess.run([cmd, "--version"], capture_output=True, check=True)
return distro
@ -88,16 +103,25 @@ DISTRO: DistroId = detect_distro()
# ── Package manager detection ───────────────────────────────────────
#
# Returns the command name for the system's package manager.
# Arch → pacman, Debian → apt, Fedora/RHEL → dnf.
# Returns the manager id for the system's distro, matching the ten
# backends bridge/packages.py steps down through: Arch → pacman,
# Gentoo → emerge, Lunar → lunar, SourceMage → sorcery, Void → xbps,
# Alpine → apk, openSUSE → zypper, Fedora/RHEL → dnf, Debian → apt.
PkgManager = Literal["pacman", "apt", "dnf", "unknown"]
PkgManager = Literal["pacman", "emerge", "lunar", "sorcery", "xbps",
"apk", "zypper", "dnf", "apt", "unknown"]
def detect_pkg_manager() -> PkgManager:
"""Detect the system package manager based on distro."""
pkg_map: dict[DistroId, PkgManager] = {
"arch": "pacman",
"gentoo": "emerge",
"lunar": "lunar",
"sourcemage": "sorcery",
"void": "xbps",
"alpine": "apk",
"opensuse": "zypper",
"debian": "apt",
"fedora": "dnf",
"rhel": "dnf",

View File

@ -3,10 +3,20 @@
SysDeck - Packages Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Wraps the system package manager (pacman on Arch Linux, dnf/yum on
RPM distros, apt on DEB distros) into a unified JSON interface so the
Wraps the system package manager into a unified JSON interface so the
Packages panel can list, search, install, update, and remove packages
without knowing which distro it runs on.
without knowing which distro it runs on. Ten backends, same step-down
on both editions (web console: web/src/lib/sysdeck/bridge/packages.ts):
pacman (Arch) · emerge (Gentoo/Portage) · lunar (Lunar Linux) ·
sorcery (SourceMage) · xbps (Void) · apk (Alpine) · zypper
(openSUSE) · dnf / yum (RPM) · apt (Debian)
Every list/updates/search/info read hits the REAL package database of
the detected manager (dpkg-query, pacman -Q, rpm -qa, /var/db/pkg scan,
lvu/gaze state) — no fabricated rows, ever. Backends without an
update-preview subcommand (lunar) report that honestly instead of
inventing a count.
The package manager is invoked as a separate process via subprocess —
the suite (MIT) and the package manager remain independent programs.
@ -24,20 +34,20 @@ Usage:
python3 /usr/lib/sysdeck/bridge/packages.py dry-run <action> [name]
python3 /usr/lib/sysdeck/bridge/packages.py summary
v0.0.31: install / remove / update / update-all now ACTUALLY RUN the
package manager via subprocess. The cockpit JS panel passes
{ superuser: 'try' } to cockpit.spawn so the operator authenticates
via polkit (org.sysdeck.packages.modify action, shipped since v0.0.17,
authorizes /usr/bin/pacman, /usr/bin/apt, /usr/bin/dnf). No `sudo`
install / remove / update / update-all ACTUALLY RUN the package manager
via subprocess. The cockpit JS panel passes { superuser: 'try' } to
cockpit.spawn so the operator authenticates via polkit
(org.sysdeck.packages.modify, shipped since v0.0.17). No `sudo`
shell-out from JS — this is the cockpit way.
The new `dry-run` subcommand preserves the v0.0.30 command-string-only
return shape for the panel's preview-before-confirm flow.
The `dry-run` subcommand returns the command string without running
it, for the panel's preview-before-confirm flow.
"""
import json
import os
import re
import shutil
import subprocess
import sys
from typing import Any
@ -52,22 +62,46 @@ PACMAN_AUTHOR = "Pacman Development Team"
PACMAN_URL = "https://archlinux.org/pacman/"
# Detect the system package manager once at import time.
# Step-down: prefer pacman (Arch), then dnf (Fedora), then apt (Debian/Ubuntu).
# The detected manager determines which backend functions are used.
# Step-down, most specific first — identical order and corroboration
# rules to the web console's detectBackend(): pacman (Arch) → emerge
# (Gentoo, corroborated by the /var/db/pkg vdb) → lunar (Lunar) →
# sorcery (SourceMage) → xbps (Void, probed via xbps-query — Void
# ships no bare `xbps` binary) → apk (Alpine) → zypper (openSUSE) →
# dnf (Fedora) → yum (RHEL 7) → apt (Debian/Ubuntu). Binary presence
# is a shutil.which probe: no child process, no --version flag quirks.
EMERGE_PKG_DB = "/var/db/pkg"
LUNAR_STATE = "/var/state/lunar/packages"
SORCERY_STATE = "/var/state/sorcery/packages"
DETECT_PROBES: tuple[tuple[str, str], ...] = (
("pacman", "pacman"),
("emerge", "emerge"),
("lunar", "lunar"),
("sorcery", "sorcery"),
("xbps", "xbps-query"),
("apk", "apk"),
("zypper", "zypper"),
("dnf", "dnf"),
("yum", "yum"),
("apt", "apt"),
)
def _detect_pkg_manager() -> str:
"""Return 'pacman', 'dnf', or 'apt' based on what is available."""
for cmd in ("pacman", "dnf", "apt"):
try:
subprocess.run(
[cmd, "--version"], capture_output=True, check=True,
timeout=5, env=SCRUBBED_ENV,
)
return cmd
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
"""Return the detected manager id ('pacman', 'emerge', 'lunar',
'sorcery', 'xbps', 'apk', 'zypper', 'dnf', 'yum', 'apt') or
'unknown' when no known package manager is installed."""
for mgr, probe in DETECT_PROBES:
if shutil.which(probe) is None:
continue
if mgr == "emerge" and not os.path.isdir(EMERGE_PKG_DB):
# Corroboration: a Gentoo box always carries the vdb.
continue
return mgr
return "unknown"
PKG_MANAGER = _detect_pkg_manager()
@ -218,6 +252,559 @@ def _apt_info(name: str) -> dict[str, Any]:
return _parse_apt_info(raw, name)
# ── Shared helpers for the distro backends ──────────────────────────
_VER_TAIL_RE = re.compile(r"^[0-9][0-9a-zA-Z._+-]*[0-9a-zA-Z._+]$")
def _split_name_ver(atom: str) -> tuple[str, str]:
"""Tolerant name/version split for flat atoms.
"gcc-13.2.1-r0" → ("gcc", "13.2.1-r0"); "linux-headers-6.1" →
("linux-headers", "6.1"). Rule: the earliest hyphen followed by a
digit whose tail is version-shaped wins."""
for i in range(len(atom)):
if atom[i] != "-":
continue
tail = atom[i + 1:]
if tail and tail[0].isdigit() and _VER_TAIL_RE.match(tail):
return atom[:i], tail
return atom, ""
def _parse_colon_blocks(raw: str) -> dict[str, str]:
"""Parse "Key: value" lines into a lowercase snake-key dict."""
out: dict[str, str] = {}
for line in raw.splitlines():
if ":" in line:
key, _, val = line.partition(":")
k = key.strip().lower().replace(" ", "_").replace("-", "_")
out[k] = val.strip()
return out
def _rpm_db_list_installed() -> list[dict[str, str]]:
"""Installed rows straight from the rpm database — the zero-tooling
source of truth shared by the zypper backend: name TAB version."""
raw = run(["rpm", "-qa", "--qf", "%{NAME}\t%{VERSION}-%{RELEASE}\n"], timeout=60)
rows: list[dict[str, str]] = []
for line in raw.splitlines():
name, _, version = line.partition("\t")
if name:
rows.append({"name": name, "version": version, "installed": True})
return rows
def _read_text(path: str) -> str:
"""Small text-file read; '' when absent/unreadable."""
try:
with open(path, encoding="utf-8", errors="replace") as fh:
return fh.read()
except OSError:
return ""
# ── Zypper backend (openSUSE) ────────────────────────────────────────
def _zypper_list_installed() -> list[dict[str, str]]:
"""List installed packages from the rpm database."""
return _rpm_db_list_installed()
def _zypper_table(raw: str, wanted: tuple[str, ...]) -> tuple[dict[str, int], list[list[str]]]:
"""Parse a zypper pipe-table by locating columns from its header row.
zypper prefixes data tables with status/repository columns whose
count varies by subcommand and zypper release; positional parsing
breaks across those. The header row is the source of truth: find
each wanted column's index there, then read the data rows."""
header: dict[str, int] = {}
data: list[list[str]] = []
for line in raw.splitlines():
if "|" not in line:
continue
cols = [c.strip() for c in line.split("|")]
if not header:
if all(w in cols for w in wanted):
header = {w: cols.index(w) for w in wanted}
continue
# Separator rows ('-----+-----') and repeated header rows.
if cols and all(c and set(c) <= {"-", "+"} for c in cols):
continue
if any(len(cols) > header[w] and cols[header[w]] == w for w in header):
continue
data.append(cols)
return header, data
def _zypper_list_updates() -> list[dict[str, str]]:
"""List available updates via zypper -q list-updates.
Output is pipe-separated with a header row (Repository/Name/Current/
Available/Arch, plus a leading status column on some releases)."""
raw = run(["zypper", "-q", "list-updates"], timeout=60)
header, rows = _zypper_table(raw, ("Name", "Current", "Available"))
out: list[dict[str, str]] = []
for cols in rows:
if len(cols) <= max(header.values()):
continue
name = cols[header["Name"]]
if not name:
continue
out.append({"name": name,
"current": cols[header["Current"]],
"candidate": cols[header["Available"]]})
return out
def _zypper_search(term: str) -> list[dict[str, Any]]:
"""Search packages via zypper -q se (columns: status, Name, Summary)."""
raw = run(["zypper", "-q", "se", term], timeout=30)
header, rows = _zypper_table(raw, ("Name", "Summary"))
out: list[dict[str, Any]] = []
i_name = header.get("Name", 1)
for cols in rows:
if len(cols) <= i_name:
continue
name = cols[i_name]
if not name:
continue
out.append({"name": name, "version": "",
"description": cols[header["Summary"]],
"installed": cols[0] == "i"})
return out
def _zypper_info(name: str) -> dict[str, Any]:
"""Package info via zypper -q info."""
raw = run(["zypper", "-q", "info", name], timeout=20)
if not raw.strip():
return {}
info = _parse_colon_blocks(raw)
return {
"name": name,
"version": f"{info.get('version', '')}-{info.get('release', '')}",
"status": "installed" if "installed" in info.get("status", "") else "not installed",
"depends": info.get("depends_on", ""),
"description": info.get("description", info.get("summary", "")),
"maintainer": info.get("packager", ""),
}
# ── apk backend (Alpine / postmarketOS) ──────────────────────────────
def _apk_list_installed() -> list[dict[str, str]]:
"""List installed packages via apk info -v (name-version lines)."""
raw = run(["apk", "info", "-v"], timeout=30)
rows: list[dict[str, str]] = []
for line in raw.splitlines():
line = line.strip()
if not line:
continue
name, version = _split_name_ver(line)
if name:
rows.append({"name": name, "version": version, "installed": True})
rows.sort(key=lambda r: r["name"])
return rows
def _apk_list_updates() -> list[dict[str, str]]:
"""List available updates via apk list --upgradable (fallback:
apk version -l '<' on older apk)."""
raw = run(["apk", "list", "--upgradable"], timeout=30)
if not raw.strip():
raw = run(["apk", "version", "-l", "<"], timeout=30)
rows: list[dict[str, str]] = []
for line in raw.splitlines():
line = line.strip()
if not line or line.startswith("Installed") or line.startswith("Available"):
continue
toks = line.split()
name, version = _split_name_ver(toks[0] if toks else "")
if not name:
continue
cand = ""
for t in toks[1:]:
if t != "<" and not t.startswith("("):
cand = t
break
rows.append({"name": name, "current": version, "candidate": cand})
return rows
def _apk_search(term: str) -> list[dict[str, Any]]:
"""Search packages via apk search -v ('name-version - description')."""
raw = run(["apk", "search", "-v", term], timeout=30)
rows: list[dict[str, Any]] = []
for line in raw.splitlines():
line = line.strip()
if not line:
continue
head, _, desc = line.partition(" - ")
name, version = _split_name_ver(head.strip())
rows.append({"name": name, "version": version,
"description": desc.strip(), "installed": False})
return rows
def _apk_info(name: str) -> dict[str, Any]:
"""Package info via apk info — installed packages only, hence the
fixed status."""
raw = run(["apk", "info", name], timeout=15)
if not raw.strip():
return {}
info = _parse_colon_blocks(raw)
return {
"name": name,
"version": info.get("version", ""),
"status": "installed",
"depends": info.get("depends", ""),
"description": info.get("description",
raw.splitlines()[0] if raw.splitlines() else ""),
"maintainer": info.get("maintainer", ""),
}
# ── xbps backend (Void Linux) ────────────────────────────────────────
def _xbps_list_installed() -> list[dict[str, str]]:
"""List installed packages via xbps-query -l ('ii pkg-ver desc')."""
raw = run(["xbps-query", "-l"], timeout=30)
rows: list[dict[str, str]] = []
for line in raw.splitlines():
m = re.match(r"^ii\s+(\S+)\s+(.*)$", line)
if not m:
continue
name, version = _split_name_ver(m.group(1))
if name:
rows.append({"name": name, "version": version, "installed": True})
rows.sort(key=lambda r: r["name"])
return rows
def _xbps_list_updates() -> list[dict[str, str]]:
"""List available updates via xbps-install -Sun."""
raw = run(["xbps-install", "-Sun"], timeout=60)
rows: list[dict[str, str]] = []
for line in raw.splitlines():
line = line.strip()
if not line:
continue
toks = line.split()
name, version = _split_name_ver(toks[0] if toks else "")
if not name:
continue
cand = toks[1] if len(toks) > 1 else ""
if cand in ("xbps:", "delta:") and len(toks) > 2:
cand = toks[2]
rows.append({"name": name, "current": version, "candidate": cand})
return rows
def _xbps_search(term: str) -> list[dict[str, Any]]:
"""Search packages via xbps-query -Rs ('[*] [repo/]name-ver - desc').
The repository prefix is optional — plain 'name-ver' rows are the
common form, so the parser accepts both."""
raw = run(["xbps-query", "-Rs", term], timeout=30)
rows: list[dict[str, Any]] = []
for line in raw.splitlines():
m = re.match(r"^\[\*\]\s+(?:\S+/)?(\S+)\s+-\s+(.*)$", line)
if not m:
continue
name, version = _split_name_ver(m.group(1))
rows.append({"name": name, "version": version,
"description": m.group(2), "installed": False})
return rows
def _xbps_info(name: str) -> dict[str, Any]:
"""Package info via xbps-query -R (repository) falling back to the
local db query."""
raw = run(["xbps-query", "-R", name], timeout=15)
if not raw.strip():
raw = run(["xbps-query", name], timeout=15)
if not raw.strip():
return {}
info = _parse_colon_blocks(raw)
return {
"name": name,
"version": info.get("pkgver", info.get("version", "")),
"status": "installed" if "install-date" in raw else "repository (not installed)",
"depends": info.get("depends", info.get("run_depends", "")),
"description": info.get("short_desc", ""),
"maintainer": info.get("maintainer", ""),
}
# ── emerge backend (Gentoo / Portage) ────────────────────────────────
def _emerge_list_installed() -> list[dict[str, str]]:
"""Installed set from the vdb itself: /var/db/pkg/<cat>/<name>-<ver>.
Zero-dependency source of truth — no emerge invocation needed."""
rows: list[dict[str, str]] = []
try:
cats = os.listdir(EMERGE_PKG_DB)
except OSError:
return rows
for cat in cats:
cdir = os.path.join(EMERGE_PKG_DB, cat)
try:
entries = os.listdir(cdir)
except OSError:
continue
for pf in entries:
leaf, version = _split_name_ver(pf)
rows.append({"name": f"{cat}/{leaf}", "version": version, "installed": True})
rows.sort(key=lambda r: r["name"])
return rows
def _emerge_list_updates() -> list[dict[str, str]]:
"""Deep world update preview via emerge -p -u -D @world.
Parses ' [ebuild U ] cat/pkg-1.2.3 [1.2.2]' lines. The
atom is anchored AFTER the class bracket — portage pads the class
field with spaces, so a regex that starts the atom before the
bracket captures the bracket itself and drops every row."""
raw = run(["emerge", "-p", "-u", "-D", "@world"], timeout=90)
rows: list[dict[str, str]] = []
for line in raw.splitlines():
m = re.search(r"\[ebuild\s+U[^\]]*\]\s*(\S+)(?:\s+\[([^\]]+)\])?", line)
if not m:
continue
atom = m.group(1)
slash = atom.rfind("/")
if slash < 0:
continue
leaf, version = _split_name_ver(atom[slash + 1:])
rows.append({"name": f"{atom[:slash]}/{leaf}",
"current": m.group(2) or "", "candidate": version})
return rows
def _emerge_search(term: str) -> list[dict[str, Any]]:
"""Search via emerge --search ('* cat/pkg' + 'Description:' lines)."""
raw = run(["emerge", "--search", term], timeout=60)
rows: list[dict[str, Any]] = []
cur: dict[str, Any] | None = None
for line in raw.splitlines():
m = re.match(r"^\*\s+(\S+)$", line)
if m:
if cur:
rows.append(cur)
cur = {"name": m.group(1), "version": "", "description": "", "installed": False}
continue
if cur and "Description:" in line:
cur["description"] = line.split("Description:", 1)[1].strip()
if cur and re.search(r"\[installed\]", line, re.IGNORECASE):
cur["installed"] = True
if cur:
rows.append(cur)
return rows
def _emerge_info(name: str) -> dict[str, Any]:
"""Real metadata from the installed package's /var/db/pkg entry.
Accepts both 'cat/pkg' atoms and bare names (the latter scans every
category for a matching leaf)."""
slash = name.rfind("/")
if slash > 0:
candidates = [name[:slash]]
leaf = name[slash + 1:]
else:
leaf = name
candidates = []
try:
candidates = sorted(os.listdir(EMERGE_PKG_DB))
except OSError:
return {}
for cat in candidates:
cdir = os.path.join(EMERGE_PKG_DB, cat)
try:
entries = os.listdir(cdir)
except OSError:
continue
for pf in entries:
pf_leaf, version = _split_name_ver(pf)
if pf_leaf != leaf:
continue
pdir = os.path.join(cdir, pf)
desc = _read_text(os.path.join(pdir, "DESCRIPTION")).strip()
if not (desc or version):
continue
return {
"name": f"{cat}/{leaf}",
"version": version,
"status": "installed (from /var/db/pkg)",
"depends": (_read_text(os.path.join(pdir, "RDEPEND"))
or _read_text(os.path.join(pdir, "PDEPEND"))).strip(),
"description": desc,
"maintainer": _read_text(os.path.join(pdir, "HOMEPAGE")).strip(),
}
return {}
# ── lunar backend (Lunar Linux) ──────────────────────────────────────
def _lunar_list_installed() -> list[dict[str, str]]:
"""Installed modules via lvu installed; the /var/state/lunar/packages
file is the dependency-free fallback."""
raw = run(["lvu", "installed"], timeout=30)
if raw.strip():
return [{"name": t[0], "version": t[1] if len(t) > 1 else "", "installed": True}
for t in (line.strip().split() for line in raw.splitlines()) if t]
rows: list[dict[str, str]] = []
for line in _read_text(LUNAR_STATE).splitlines():
line = line.strip()
if not line:
continue
toks = line.split()
rows.append({"name": toks[0], "version": toks[1] if len(toks) > 1 else "",
"installed": True})
rows.sort(key=lambda r: r["name"])
return rows
def _lunar_list_updates() -> list[dict[str, str]]:
"""lvu has no update-preview subcommand; `lunar update` performs the
fetch + rebuild. Honest empty list — the summary carries the note
explaining why, instead of fabricating a count."""
return []
def _lunar_search(term: str) -> list[dict[str, Any]]:
"""Search modules via lvu search."""
raw = run(["lvu", "search", term], timeout=30)
rows: list[dict[str, Any]] = []
for line in raw.splitlines():
line = line.strip()
if not line:
continue
toks = line.split()
rows.append({"name": toks[0], "version": "", "description": line, "installed": False})
return rows
def _lunar_info(name: str) -> dict[str, Any]:
"""Module info via lvu details."""
raw = run(["lvu", "details", name], timeout=15)
if not raw.strip():
return {}
info = _parse_colon_blocks(raw)
first = next((l.strip() for l in raw.splitlines() if l.strip()), "")
return {
"name": name,
"version": info.get("version", ""),
"status": "installed (lunar)",
"depends": info.get("depends", ""),
"description": info.get("description", first),
"maintainer": info.get("maintainer", ""),
}
# ── sorcery backend (SourceMage GNU/Linux) ──────────────────────────
def _sorcery_list_installed() -> list[dict[str, str]]:
"""Installed spells via gaze installed; the /var/state/sorcery/packages
file is the dependency-free fallback."""
raw = run(["gaze", "installed"], timeout=30)
if raw.strip():
return [{"name": t[0], "version": t[1] if len(t) > 1 else "", "installed": True}
for t in (re.split(r"[\s:]+", line.strip()) for line in raw.splitlines()) if t]
rows: list[dict[str, str]] = []
for line in _read_text(SORCERY_STATE).splitlines():
line = line.strip()
if not line:
continue
toks = line.split()
rows.append({"name": toks[0], "version": toks[1] if len(toks) > 1 else "",
"installed": True})
rows.sort(key=lambda r: r["name"])
return rows
def _sorcery_list_updates() -> list[dict[str, str]]:
"""Pending spell updates via sorcery queue."""
raw = run(["sorcery", "queue"], timeout=60)
rows: list[dict[str, str]] = []
for line in raw.splitlines():
line = line.strip()
if not line:
continue
toks = line.split()
rows.append({"name": toks[0], "current": "",
"candidate": toks[1] if len(toks) > 1 else ""})
return rows
def _sorcery_search(term: str) -> list[dict[str, Any]]:
"""Search spells via gaze search."""
raw = run(["gaze", "search", term], timeout=30)
rows: list[dict[str, Any]] = []
for line in raw.splitlines():
line = line.strip()
if not line:
continue
toks = re.split(r"[\s:]+", line)
rows.append({"name": toks[0] if toks else "", "version": "",
"description": line, "installed": False})
return rows
def _sorcery_info(name: str) -> dict[str, Any]:
"""Spell info via gaze what, falling back to gaze details."""
for sub in ("what", "details"):
raw = run(["gaze", sub, name], timeout=15)
if not raw.strip():
continue
info = _parse_colon_blocks(raw)
first = next((l.strip() for l in raw.splitlines() if l.strip()), "")
return {
"name": name,
"version": info.get("version", info.get("spell_version", "")),
"status": "installed (sorcery)",
"depends": info.get("depends", ""),
"description": info.get("description",
info.get("short_description", first)),
"maintainer": info.get("maintainer", ""),
}
return {}
# ── yum backend (RHEL 7 era RPM) ─────────────────────────────────────
def _yum_list_installed() -> list[dict[str, str]]:
"""List installed packages via yum list installed --quiet."""
raw = run(["yum", "list", "installed", "--quiet"], timeout=120)
return _parse_rpm_list(raw)
def _yum_list_updates() -> list[dict[str, str]]:
"""List available updates via yum check-update --quiet.
yum check-update mirrors dnf: exit 100 when updates exist, 0 when
none do — 100 is data here, not failure."""
raw = run(["yum", "check-update", "--quiet"], timeout=120, ok_rcs=(100,))
return _parse_rpm_update_list(raw)
def _yum_search(term: str) -> list[dict[str, Any]]:
"""Search packages via yum search."""
raw = run(["yum", "search", term])
return [{"name": parts[0], "description": " ".join(parts[1:])}
for line in raw.splitlines()
if (parts := line.split(" : ", 1)) and len(parts) == 2 and parts[0]]
def _yum_info(name: str) -> dict[str, Any]:
"""Package info via yum info."""
raw = run(["yum", "info", name])
return _parse_rpm_info(raw, name)
# ── Shared parsers ──────────────────────────────────────────────────
def _parse_rpm_list(raw: str) -> list[dict[str, str]]:
@ -264,19 +851,63 @@ def _parse_apt_info(raw: str, name: str) -> dict[str, Any]:
# ── Dispatch table per package manager ──────────────────────────────
BACKENDS = {
def _backend(mgr: str) -> dict[str, Any]:
"""Read-backend dispatch for one manager id."""
table: dict[str, dict[str, Any]] = {
"pacman": {
"list-installed": lambda _args: _pacman_list_installed(),
"list-updates": lambda _args: _pacman_list_updates(),
"search": lambda args: _pacman_search(args[0]) if args else [],
"info": lambda args: _pacman_info(args[0]) if args else {},
},
"emerge": {
"list-installed": lambda _args: _emerge_list_installed(),
"list-updates": lambda _args: _emerge_list_updates(),
"search": lambda args: _emerge_search(args[0]) if args else [],
"info": lambda args: _emerge_info(args[0]) if args else {},
},
"lunar": {
"list-installed": lambda _args: _lunar_list_installed(),
"list-updates": lambda _args: _lunar_list_updates(),
"search": lambda args: _lunar_search(args[0]) if args else [],
"info": lambda args: _lunar_info(args[0]) if args else {},
},
"sorcery": {
"list-installed": lambda _args: _sorcery_list_installed(),
"list-updates": lambda _args: _sorcery_list_updates(),
"search": lambda args: _sorcery_search(args[0]) if args else [],
"info": lambda args: _sorcery_info(args[0]) if args else {},
},
"xbps": {
"list-installed": lambda _args: _xbps_list_installed(),
"list-updates": lambda _args: _xbps_list_updates(),
"search": lambda args: _xbps_search(args[0]) if args else [],
"info": lambda args: _xbps_info(args[0]) if args else {},
},
"apk": {
"list-installed": lambda _args: _apk_list_installed(),
"list-updates": lambda _args: _apk_list_updates(),
"search": lambda args: _apk_search(args[0]) if args else [],
"info": lambda args: _apk_info(args[0]) if args else {},
},
"zypper": {
"list-installed": lambda _args: _zypper_list_installed(),
"list-updates": lambda _args: _zypper_list_updates(),
"search": lambda args: _zypper_search(args[0]) if args else [],
"info": lambda args: _zypper_info(args[0]) if args else {},
},
"dnf": {
"list-installed": lambda _args: _dnf_list_installed(),
"list-updates": lambda _args: _dnf_list_updates(),
"search": lambda args: _dnf_search(args[0]) if args else [],
"info": lambda args: _dnf_info(args[0]) if args else {},
},
"yum": {
"list-installed": lambda _args: _yum_list_installed(),
"list-updates": lambda _args: _yum_list_updates(),
"search": lambda args: _yum_search(args[0]) if args else [],
"info": lambda args: _yum_info(args[0]) if args else {},
},
"apt": {
"list-installed": lambda _args: _apt_list_installed(),
"list-updates": lambda _args: _apt_list_updates(),
@ -284,38 +915,35 @@ BACKENDS = {
"info": lambda args: _apt_info(args[0]) if args else {},
},
}
return table.get(mgr, {})
def list_installed() -> list[dict[str, str]]:
"""List installed packages using the detected package manager."""
backend = BACKENDS.get(PKG_MANAGER, {})
fn = backend.get("list-installed")
fn = _backend(PKG_MANAGER).get("list-installed")
return fn([]) if fn else []
def list_updates() -> list[dict[str, str]]:
"""List available updates using the detected package manager."""
backend = BACKENDS.get(PKG_MANAGER, {})
fn = backend.get("list-updates")
fn = _backend(PKG_MANAGER).get("list-updates")
return fn([]) if fn else []
def search(args: list[str]) -> list[dict[str, Any]]:
"""Search packages using the detected package manager."""
backend = BACKENDS.get(PKG_MANAGER, {})
fn = backend.get("search")
fn = _backend(PKG_MANAGER).get("search")
return fn(args) if fn else []
def info(args: list[str]) -> dict[str, Any]:
"""Get package info using the detected package manager."""
backend = BACKENDS.get(PKG_MANAGER, {})
fn = backend.get("info")
fn = _backend(PKG_MANAGER).get("info")
return fn(args) if fn else {}
def _pkg_name_ok(pkg: str) -> bool:
"""v0.1.4 SECURITY: package names are passed to the system package
"""SECURITY: package names are passed to the system package
manager as one argv element. A leading dash turns them into manager
OPTIONS (pacman --config=…, dnf --setopt=…) and a URL makes dnf
fetch a remote RPM — argument injection, not shell injection. One
@ -340,23 +968,85 @@ def _first_pkg_arg(args: list[str]) -> str | None:
return None
# ── Mutation commands, one table for all ten managers ───────────────
# Each entry is the exact argv the real package manager receives;
# {pkg} is substituted with the validated package name. Actions a
# manager genuinely lacks (lunar single-module update) are absent from
# its row — the caller reports the honest refusal instead of running
# a command that does not exist.
MUTATION_CMDS: dict[str, dict[str, list[str]]] = {
"install": {
"pacman": ["pacman", "-S", "--noconfirm", "{pkg}"],
"emerge": ["emerge", "{pkg}"],
"lunar": ["lin", "{pkg}"],
"sorcery": ["cast", "{pkg}"],
"xbps": ["xbps-install", "-y", "{pkg}"],
"apk": ["apk", "add", "{pkg}"],
"zypper": ["zypper", "--non-interactive", "install", "{pkg}"],
"dnf": ["dnf", "install", "-y", "{pkg}"],
"yum": ["yum", "install", "-y", "{pkg}"],
"apt": ["apt", "install", "-y", "{pkg}"],
},
"remove": {
"pacman": ["pacman", "-R", "--noconfirm", "{pkg}"],
"emerge": ["emerge", "--unmerge", "{pkg}"],
"lunar": ["lrm", "{pkg}"],
"sorcery": ["dispel", "{pkg}"],
"xbps": ["xbps-remove", "-y", "{pkg}"],
"apk": ["apk", "del", "{pkg}"],
"zypper": ["zypper", "--non-interactive", "remove", "{pkg}"],
"dnf": ["dnf", "remove", "-y", "{pkg}"],
"yum": ["yum", "remove", "-y", "{pkg}"],
"apt": ["apt", "remove", "-y", "{pkg}"],
},
"update": {
"pacman": ["pacman", "-S", "--noconfirm", "{pkg}"],
"emerge": ["emerge", "-u", "{pkg}"],
"sorcery": ["cast", "{pkg}"],
"xbps": ["xbps-install", "-y", "{pkg}"],
"apk": ["apk", "upgrade", "{pkg}"],
"zypper": ["zypper", "--non-interactive", "update", "{pkg}"],
"dnf": ["dnf", "upgrade", "-y", "{pkg}"],
"yum": ["yum", "upgrade", "-y", "{pkg}"],
"apt": ["apt", "upgrade", "-y", "{pkg}"],
},
"update-all": {
"pacman": ["pacman", "-Syu", "--noconfirm"],
"emerge": ["emerge", "-u", "-D", "@world"],
"lunar": ["lunar", "update"],
"sorcery": ["sorcery", "update"],
"xbps": ["xbps-install", "-Su", "-y"],
"apk": ["apk", "upgrade"],
"zypper": ["zypper", "--non-interactive", "update"],
"dnf": ["dnf", "upgrade", "-y"],
"yum": ["yum", "upgrade", "-y"],
"apt": ["apt", "upgrade", "-y"],
},
}
def _mutation_cmd(action: str, pkg: str) -> list[str]:
"""Resolve the argv for one mutation under the detected manager.
Returns [] when the action/manager pair is absent — the caller
reports the honest refusal."""
tmpl = MUTATION_CMDS.get(action, {}).get(PKG_MANAGER, [])
return [tok.replace("{pkg}", pkg) for tok in tmpl]
def install(args: list[str]) -> dict[str, str]:
"""Install a package — actually runs the package manager via subprocess.
v0.0.31 REWRITE: previously this returned only the command string
that *would* be run, forcing the JS panel to alert("Run this
command with superuser privileges.") and the operator to copy /
sudo / paste / run. The cockpit way is to run the operation via
the cockpit superuser channel: the JS panel calls cockpit.spawn()
with { superuser: 'try' }, which prompts the operator via polkit
for the org.sysdeck.packages.modify action (shipped since v0.0.17)
that authorizes /usr/bin/pacman, /usr/bin/apt, /usr/bin/dnf.
The bridge runs the package manager via subprocess with check=True
and streams stdout/stderr line-by-line so the JS panel can render
live output.
The cockpit way is to run the operation via the cockpit superuser
channel: the JS panel calls cockpit.spawn() with
{ superuser: 'try' }, which prompts the operator via polkit for
the org.sysdeck.packages.modify action. The bridge runs the package
manager via subprocess and returns stdout/stderr so the panel can
render the live output.
The command-string preview shape is preserved as the `dry-run`
subcommand for operators who want to see what would be run.
The `dry-run` subcommand carries the command-string preview for
operators who want to see what would be run.
"""
if not args:
return {"error": "No package name provided"}
@ -368,10 +1058,7 @@ def install(args: list[str]) -> dict[str, str]:
# _pkg_name_ok already rejects leading-dash/URL names (argument
# injection), so no '--' end-of-options separator is needed here —
# pacman in particular does not accept one.
cmd_map = {"pacman": ["pacman", "-S", "--noconfirm", pkg],
"dnf": ["dnf", "install", "-y", pkg],
"apt": ["apt", "install", "-y", pkg]}
cmd = cmd_map.get(PKG_MANAGER, [])
cmd = _mutation_cmd("install", pkg)
if not cmd:
return {"action": "install", "package": pkg, "manager": PKG_MANAGER,
"success": False, "stderr": f"no install command for {PKG_MANAGER}"}
@ -396,10 +1083,7 @@ def remove(args: list[str]) -> dict[str, str]:
return {"error": "No package name provided"}
if not _pkg_name_ok(pkg):
return {"error": f"invalid package name: {pkg!r}"}
cmd_map = {"pacman": ["pacman", "-R", "--noconfirm", pkg],
"dnf": ["dnf", "remove", "-y", pkg],
"apt": ["apt", "remove", "-y", pkg]}
cmd = cmd_map.get(PKG_MANAGER, [])
cmd = _mutation_cmd("remove", pkg)
if not cmd:
return {"action": "remove", "package": pkg, "manager": PKG_MANAGER,
"success": False, "stderr": f"no remove command for {PKG_MANAGER}"}
@ -421,10 +1105,14 @@ def update(args: list[str]) -> dict[str, str]:
return {"error": "No package name provided"}
if not _pkg_name_ok(pkg):
return {"error": f"invalid package name: {pkg!r}"}
cmd_map = {"pacman": ["pacman", "-S", "--noconfirm", pkg],
"dnf": ["dnf", "upgrade", "-y", pkg],
"apt": ["apt", "upgrade", "-y", pkg]}
cmd = cmd_map.get(PKG_MANAGER, [])
if PKG_MANAGER == "lunar":
# Lunar rebuilds from source against the current moonbase; there
# is no single-module update path distinct from install. Point
# the operator at the real operation instead of approximating.
return {"action": "update", "package": pkg, "manager": PKG_MANAGER,
"success": False,
"stderr": "lunar has no single-module update — run update-all (lunar update)"}
cmd = _mutation_cmd("update", pkg)
if not cmd:
return {"action": "update", "package": pkg, "manager": PKG_MANAGER,
"success": False, "stderr": f"no update command for {PKG_MANAGER}"}
@ -438,20 +1126,14 @@ def update(args: list[str]) -> dict[str, str]:
def update_all() -> dict[str, str]:
"""Update all packages — actually runs the package manager. See install().
"""Update all packages — actually runs the package manager.
v0.0.31: this is the method called by the Packages panel `Update All`
button. Previously it returned only the command string and the panel
showed alert("Run this command with superuser privileges.") — which
defeated the purpose of having a panel. The cockpit way: the JS panel
calls bridge.packages.updateAll() with superuser: 'try', the bridge
runs pacman/apt/dnf via subprocess, and the result includes the
actual stdout/stderr for the panel to render live.
This is the method behind the Packages panel `Update All` button:
the JS panel calls it with superuser: 'try', the bridge runs the
real manager via subprocess, and the result carries the actual
stdout/stderr for the panel to render live.
"""
cmd_map = {"pacman": ["pacman", "-Syu", "--noconfirm"],
"dnf": ["dnf", "upgrade", "-y"],
"apt": ["apt", "upgrade", "-y"]}
cmd = cmd_map.get(PKG_MANAGER, [])
cmd = _mutation_cmd("update-all", "")
if not cmd:
return {"action": "update-all", "manager": PKG_MANAGER,
"success": False, "stderr": f"no update-all command for {PKG_MANAGER}"}
@ -467,29 +1149,14 @@ def update_all() -> dict[str, str]:
def dry_run(args: list[str]) -> dict[str, str]:
"""Return the command that *would* be run — for the operator preview.
v0.0.31: the install/remove/update/update-all subcommands now
actually execute the package manager. This subcommand preserves
the v0.0.30 behavior (return the command string without running)
so the JS panel can show a preview before the operator confirms.
install/remove/update/update-all actually execute the package
manager; this subcommand returns the command string without
running it, so the JS panel can show a preview before the operator
confirms.
"""
action = args[0] if args else "update-all"
pkg = args[1] if len(args) > 1 else ""
cmd_map = {
"install": {"pacman": ["pacman", "-S", "--noconfirm", pkg],
"dnf": ["dnf", "install", "-y", pkg],
"apt": ["apt", "install", "-y", pkg]},
"remove": {"pacman": ["pacman", "-R", "--noconfirm", pkg],
"dnf": ["dnf", "remove", "-y", pkg],
"apt": ["apt", "remove", "-y", pkg]},
"update": {"pacman": ["pacman", "-S", "--noconfirm", pkg],
"dnf": ["dnf", "upgrade", "-y", pkg],
"apt": ["apt", "upgrade", "-y", pkg]},
"update-all": {"pacman": ["pacman", "-Syu", "--noconfirm"],
"dnf": ["dnf", "upgrade", "-y"],
"apt": ["apt", "upgrade", "-y"]},
}
sub_map = cmd_map.get(action, {})
cmd = sub_map.get(PKG_MANAGER, [])
cmd = _mutation_cmd(action, pkg)
return {"action": action, "package": pkg, "manager": PKG_MANAGER,
"command": " ".join(cmd) if cmd else ""}
@ -498,12 +1165,16 @@ def summary() -> dict[str, Any]:
"""Aggregate summary: installed count, update count, manager."""
installed = list_installed()
updates = list_updates()
return {
out: dict[str, Any] = {
"manager": PKG_MANAGER,
"installedCount": len(installed),
"updateCount": len(updates),
"updates": updates[:20], # Cap at 20 for the summary view
}
if PKG_MANAGER == "lunar":
out["updatesNote"] = ("lunar has no update-preview subcommand — "
"run lunar update to fetch + rebuild")
return out
COMMANDS = {
@ -515,8 +1186,6 @@ COMMANDS = {
"remove": lambda args: remove(args),
"update": lambda args: update(args),
"update-all": lambda _args: update_all(),
# v0.0.31: dry-run preserves the v0.0.30 command-string-only shape
# for the panel's preview-before-confirm flow.
"dry-run": lambda args: dry_run(args),
"summary": lambda _args: summary(),
}

View File

@ -1,6 +1,6 @@
{
"_comment": "Compatibility Manifest — sysdeck v0.1.3",
"version": "0.4.3",
"version": "0.4.4",
"suite_requires": { "cockpit": ">=239", "python": ">=3.9" },
"modules": {
"containers": {

View File

@ -3,7 +3,7 @@
# Upstream: https://dcos.net
pkgname=sysdeck
pkgver=0.4.3
pkgver=0.4.4
pkgrel=1
pkgdesc="Unified operations surface for Linux infrastructure — twenty-six domain modules behind one Cockpit dashboard"
arch=('any')

View File

@ -1,3 +1,20 @@
sysdeck (0.4.4-1) unstable; urgency=medium
* v0.4.4: ten package-manager backends on both editions + the blog
essay. The cockpit packages bridge (bridge/packages.py) carries
the same ten-manager step-down as the web console — pacman,
emerge (corroborated by /var/db/pkg), lunar, sorcery, xbps
(probed via xbps-query), apk, zypper, dnf, yum, apt — with a
unified MUTATION_CMDS table, header-located zypper table parsing,
an emerge update regex anchored after the class bracket (the old
capture grabbed the bracket and dropped every row), and honest
capability reporting for managers without an update preview
(lunar). The web console gains the same parser fixes and the
xbps-query detection probe. BLOG.md is now a single long-form
technical essay (the shellm blog pattern), not release notes.
-- Jeremy Anderson <info@dcos.net> Sat, 12 Sep 2026 18:00:00 -0400
sysdeck (0.4.3-1) unstable; urgency=medium
* v0.4.3: the MoE QA pass — production hardening across every axis.

View File

@ -91,7 +91,8 @@
</action>
<!-- ============================================================= -->
<!-- Package management: pacman, apt, dnf install/remove/upgrade. -->
<!-- Package management: pacman, emerge, lunar, sorcery, xbps, -->
<!-- apk, zypper, dnf/yum, apt install/remove/upgrade. -->
<!-- Read-only operations (list, search, info) do not need this. -->
<!-- ============================================================= -->
<action id="org.sysdeck.packages.modify">
@ -105,6 +106,12 @@
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/pacman</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/emerge</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/lunar</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/sorcery</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/xbps-install</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/sbin/apk</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/zypper</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/apt</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/dnf</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/yum</annotate>
@ -154,8 +161,8 @@
<!-- /var/lib/sysdeck/builder/. The bridge runs the backend via -->
<!-- subprocess; the cockpit superuser channel handles root priv. -->
<!-- v0.1.0: profile-import-packages also queries the host's -->
<!-- package manager (pacman -Qqe / apt-mark showmanual / -->
<!-- dnf repoquery --userinstalled) to capture the operator's -->
<!-- package manager (pacman -Qqe / apt-mark showmanual / the dnf -->
<!-- repoquery userinstalled filter) to capture the operator's -->
<!-- explicitly-installed package set. -->
<!-- ============================================================= -->
<action id="org.sysdeck.builder.modify">
@ -176,6 +183,12 @@
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/pacman</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/apt-mark</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/dnf</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/emerge</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/lvu</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/gaze</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/xbps-query</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/sbin/apk</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/zypper</annotate>
</action>
<!-- ============================================================= -->

View File

@ -21,7 +21,7 @@ import shutil
import subprocess
from setuptools import setup, find_packages
VERSION = "0.4.3"
VERSION = "0.4.4"
PACKAGE = "sysdeck"

View File

@ -7,7 +7,7 @@
# Debian/Ubuntu users: see packaging/debian/
Name: sysdeck
Version: 0.4.3
Version: 0.4.4
Release: 1%{?dist}
Summary: Unified operations surface for Linux infrastructure
@ -84,6 +84,16 @@ if [ $1 -eq 0 ]; then
fi
%changelog
* Sat Sep 12 2026 Jeremy Anderson <info@dcos.net> - 0.4.4-1
- v0.4.4: package parity + blog essay. Ten package-manager backends on
both editions (bridge/packages.py gains emerge/lunar/sorcery/xbps/
apk/zypper/yum with a unified MUTATION_CMDS table and shutil.which
detection step-down); zypper tables parse by header-located columns;
the emerge update regex anchors after the class bracket; xbps
detection probes xbps-query; lunar reports its missing update-preview
honestly. Web packages.ts gains the same fixes. BLOG.md becomes the
long-form engineering essay.
* Sat Sep 12 2026 Jeremy Anderson <info@dcos.net> - 0.4.3-1
- v0.4.3: the MoE QA pass — production hardening across every axis.
Privileged writes ride stdin (polkit rules verified post-write,

View File

@ -1,32 +1,28 @@
/*
* SysDeck - Packages Panel (v0.0.31)
* SysDeck - Packages Panel
* Author: Jeremy Anderson (https://dcos.net)
*
* Package management panel — list, search, install, update, and remove
* packages via the system package manager (pacman / dnf / apt).
* packages via the system package manager. Ten managers, the same
* step-down as the bridge: pacman (Arch) · emerge (Gentoo) · lunar
* (Lunar) · sorcery (SourceMage) · xbps (Void) · apk (Alpine) ·
* zypper (openSUSE) · dnf / yum (RPM) · apt (Debian).
* The package manager is invoked as a separate process via cockpit.spawn —
* no package-manager code is bundled.
*
* v0.0.31 REWRITE — UPDATE NEEDS SUDO, FIXED THE COCKPIT WAY.
* v0.0.30 packages.js Update All button called bridge.packages.updateAll()
* which returned only the command string that *would* be run. The panel
* showed `alert("Run this command with superuser privileges.")` and the
* operator had to copy the command, open a terminal, sudo, paste, run.
* That defeated the purpose of having a panel.
* Mutations (install/remove/update/update-all) execute via the cockpit
* superuser channel (polkit): the bridge helper runs the detected
* package manager via subprocess, and this panel subscribes to the
* cockpit spawn stream so the operator sees live stdout/stderr in a
* <pre> log panel — exactly like cockpit's own Packages and Software
* Updates panels. No `sudo` shell-out from JS.
*
* v0.0.31 makes install/remove/update/update-all actually execute via
* the cockpit superuser channel (polkit). The bridge helper runs the
* detected package manager via subprocess, and the JS panel subscribes
* to the cockpit spawn stream so the operator sees live stdout/stderr
* in a <pre> log panel — exactly like cockpit's own Packages and
* Software Updates panels. No `sudo` shell-out from JS.
*
* v0.1.4 SECURITY: every dynamic string interpolated into innerHTML
* SECURITY: every dynamic string interpolated into innerHTML
* (package names, versions, descriptions, search terms echoed back,
* error messages) now goes through escapeHtml(). Package metadata is
* live data from pacman/dnf/apt output — a typo-squat repo or a
* locally-installed package whose name/description contains markup
* used to execute in the cockpit admin session (0.3.0 audit).
* error messages) goes through escapeHtml(). Package metadata is
* live data from the package manager's output — a typo-squat repo or
* a locally-installed package whose name/description contains markup
* must not execute in the cockpit admin session (0.3.0 audit).
* Raw tool output already flows through textContent (showOutput),
* which is safe.
*/
@ -59,11 +55,16 @@ export async function mount(panel, { bridge, EventBus }) {
const instCount = summary.installedCount || installed.length;
const updCount = summary.updateCount || 0;
const updates = summary.updates || [];
// Managers without an update-preview subcommand (lunar) carry the
// explanation instead of a count that would read as "all current".
const updLine = summary.updatesNote
? `${escapeHtml(mgr)} — ${instCount} installed · ${escapeHtml(summary.updatesNote)}`
: `${escapeHtml(mgr)} — ${instCount} installed · ${updCount} updates available`;
panel.innerHTML = `
<header>
<h2 class="suite-panel-title">Package Manager</h2>
<p class="suite-panel-subtitle">${escapeHtml(mgr)} — ${instCount} installed · ${updCount} updates available</p>
<p class="suite-panel-subtitle">${updLine}</p>
</header>
<div class="suite-row">
<div class="suite-card suite-col-2">
@ -220,6 +221,6 @@ function renderSkeleton() {
function renderError(err) {
return `<div class="suite-card">
<h3 class="suite-card-title">Package manager unavailable</h3>
<p class="suite-card-body suite-muted">${escapeHtml(err.message || err)}. Ensure pacman, dnf, or apt is installed.</p>
<p class="suite-card-body suite-muted">${escapeHtml(err.message || err)}. Ensure a supported package manager is installed (pacman, emerge, lunar, sorcery, xbps, apk, zypper, dnf, yum, or apt).</p>
</div>`;
}

View File

@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Standalone fixture tests for the ten-backend packages bridge parsers.
Runs outside the unittest suite for quick iteration; the same fixtures
live in tests/test_bridge_parsers.py (TestPackagesBackends)."""
import os
import re
import sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "bridge"))
import packages as p # noqa: E402
def check_zypper_table():
layout_a = (
"S | Repository | Name | Current | Available | Arch\n"
"--+------------------+------------+---------+-----------+-------\n"
"v | openSUSE-OSS | glib2 | 2.78 | 2.80 | x86_64\n"
" | openSUSE-OSS | zypper | 1.14.70 | 1.14.74 | x86_64"
)
h, r = p._zypper_table(layout_a, ("Name", "Current", "Available"))
assert h == {"Name": 2, "Current": 3, "Available": 4}, h
assert len(r) == 2
print("zypper layout A (status+repo prefix) OK")
layout_b = (
"S# | Repository | Name | Current | Available | Arch\n"
"---+------------------+------------+---------+-----------+-------\n"
" 1 | openSUSE-OSS | glib2 | 2.78 | 2.80 | x86_64"
)
h, r = p._zypper_table(layout_b, ("Name", "Current", "Available"))
assert r and r[0][2] == "glib2"
print("zypper layout B (numbered prefix) OK")
se = (
"S | Name | Summary | Type\n"
"--+-------------+----------------------------+-----------\n"
" | packagekit | PackageKit service | package\n"
"i | glib2 | GLib library | package"
)
h, r = p._zypper_table(se, ("Name", "Summary"))
assert h == {"Name": 1, "Summary": 2}, h
assert r[1][0] == "i"
print("zypper se table OK")
noisy = (
"Name | Current | Available\n"
"-----+---------+----------\n"
"glib2 | 2.78 | 2.80\n"
"Name | Current | Available\n"
"bash | 5.2 | 5.3"
)
h, r = p._zypper_table(noisy, ("Name", "Current", "Available"))
assert len(r) == 2 and r[1][0] == "bash", r
print("separator + repeat-header filtering OK")
def check_emerge_regex():
rx = r"\[ebuild\s+U[^\]]*\]\s*(\S+)(?:\s+\[([^\]]+)\])?"
m = re.search(rx, " [ebuild U ] dev-lang/python-3.12.4 [3.12.3]")
assert m and m.group(1) == "dev-lang/python-3.12.4" and m.group(2) == "3.12.3", m.groups()
assert re.search(rx, " [ebuild NS ] dev-lang/python-3.11.8 [3.11.6]") is None
assert re.search(rx, " [ebuild N ] app-misc/newpkg-1.0") is None
m = re.search(rx, " [ebuild U r ] sys-libs/glibc-2.38-r9 [2.37-r7]")
assert m and m.group(1) == "sys-libs/glibc-2.38-r9" and m.group(2) == "2.37-r7", m.groups()
print("emerge update regex OK (padded class field; N/NS rows excluded)")
def check_xbps_regexes():
m = re.match(r"^ii\s+(\S+)\s+(.*)$", "ii firefox-128.0_1 The Firefox web browser")
assert m and m.group(1) == "firefox-128.0_1"
# -Rs rows may or may not carry a repository prefix.
rx = r"^\[\*\]\s+(?:\S+/)?(\S+)\s+-\s+(.*)$"
m = re.match(rx, "[*] firefox-128.0_1 - The Firefox web browser")
assert m and m.group(1) == "firefox-128.0_1" and m.group(2) == "The Firefox web browser"
m = re.match(rx, "[*] void-repo/firefox-128.0_1 - The Firefox web browser")
assert m and m.group(1) == "firefox-128.0_1", m.groups()
print("xbps regexes OK")
def check_helpers():
info = p._parse_colon_blocks("Name : curl\nVersion : 8.6.0\nInstalled Size: 1.2 MiB")
assert info["name"] == "curl" and info["version"] == "8.6.0"
assert info["installed_size"] == "1.2 MiB"
assert p._split_name_ver("gcc-13.2.1-r0") == ("gcc", "13.2.1-r0")
assert p._split_name_ver("linux-headers-6.1") == ("linux-headers", "6.1")
assert p._split_name_ver("firefox-128.0_1") == ("firefox", "128.0_1")
assert p._split_name_ver("bash") == ("bash", "")
print("colon blocks + split_name_ver OK")
def check_fallbacks():
orig = p.run
p.run = lambda argv, timeout=60, ok_rcs=(): ""
assert p._lunar_list_installed() == []
assert p._sorcery_list_installed() == []
assert p._lunar_list_updates() == []
p.run = orig
print("lunar/sorcery honest-empty fallbacks OK")
def check_mutation_table():
for mgr in ("pacman", "emerge", "lunar", "sorcery", "xbps", "apk",
"zypper", "dnf", "yum", "apt"):
p.PKG_MANAGER = mgr
assert p._mutation_cmd("install", "x"), mgr
assert p._mutation_cmd("remove", "x"), mgr
assert p._mutation_cmd("update-all", ""), mgr
if mgr != "lunar":
assert p._mutation_cmd("update", "x"), mgr
else:
assert p._mutation_cmd("update", "x") == [], mgr
p.PKG_MANAGER = "unknown"
assert p._mutation_cmd("install", "x") == []
p.PKG_MANAGER = p._detect_pkg_manager()
print("mutation table covers all ten managers (lunar update honestly absent) OK")
def check_detection_probes():
ids = [m for m, _ in p.DETECT_PROBES]
assert ids == ["pacman", "emerge", "lunar", "sorcery", "xbps",
"apk", "zypper", "dnf", "yum", "apt"], ids
# xbps must probe xbps-query: Void ships no bare `xbps` binary.
assert dict(p.DETECT_PROBES)["xbps"] == "xbps-query"
print("detection probe order + xbps-query probe OK")
if __name__ == "__main__":
check_zypper_table()
check_emerge_regex()
check_xbps_regexes()
check_helpers()
check_fallbacks()
check_mutation_table()
check_detection_probes()
print("ALL PACKAGES-BACKEND CHECKS OK")

View File

@ -1914,8 +1914,8 @@ class TestFirewallV047ManifestsAndMetainfo(unittest.TestCase):
)
def test_version_sync_all_surfaces_report_020(self):
# Every release surface must report v0.4.3 (MoE QA production pass).
v = "0.4.3"
# Every release surface must report v0.4.4 (package parity + blog essay).
v = "0.4.4"
files_to_check = [
"Makefile",
"bridge/__init__.py",
@ -1934,6 +1934,177 @@ class TestFirewallV047ManifestsAndMetainfo(unittest.TestCase):
f"{rel} does not reference version {v}")
class TestPackagesBackends(unittest.TestCase):
"""v0.4.4: the ten-backend packages bridge.
Fixture tests for the detection step-down, the shared parsers, and
the mutation command table — the same guarantees the web console's
packages.ts carries, locked in on the cockpit side."""
def setUp(self):
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bridge"))
import packages
self.packages = packages
self._orig_manager = packages.PKG_MANAGER
self._orig_run = packages.run
def tearDown(self):
self.packages.PKG_MANAGER = self._orig_manager
self.packages.run = self._orig_run
def test_detection_probe_order_and_xbps_probe(self):
ids = [m for m, _ in self.packages.DETECT_PROBES]
self.assertEqual(
ids,
["pacman", "emerge", "lunar", "sorcery", "xbps",
"apk", "zypper", "dnf", "yum", "apt"],
)
# Void ships no bare `xbps` binary — xbps-query is the probe.
self.assertEqual(dict(self.packages.DETECT_PROBES)["xbps"], "xbps-query")
def test_detection_requires_emerge_corroboration(self):
import shutil
import unittest.mock as mock
fake_which = lambda b: "/usr/bin/emerge" if b == "emerge" else None
# emerge present but no /var/db/pkg → step down past it.
with mock.patch.object(shutil, "which", side_effect=fake_which), \
mock.patch.object(self.packages.os.path, "isdir", return_value=False):
self.assertNotEqual(self.packages._detect_pkg_manager(), "emerge")
with mock.patch.object(shutil, "which", side_effect=fake_which), \
mock.patch.object(self.packages.os.path, "isdir", return_value=True):
self.assertEqual(self.packages._detect_pkg_manager(), "emerge")
def test_detection_unknown_when_nothing_installed(self):
import shutil
import unittest.mock as mock
with mock.patch.object(shutil, "which", return_value=None):
self.assertEqual(self.packages._detect_pkg_manager(), "unknown")
def test_split_name_ver(self):
sv = self.packages._split_name_ver
self.assertEqual(sv("gcc-13.2.1-r0"), ("gcc", "13.2.1-r0"))
self.assertEqual(sv("linux-headers-6.1"), ("linux-headers", "6.1"))
self.assertEqual(sv("firefox-128.0_1"), ("firefox", "128.0_1"))
self.assertEqual(sv("bash"), ("bash", ""))
def test_parse_colon_blocks(self):
info = self.packages._parse_colon_blocks(
"Name : curl\nVersion : 8.6.0\nInstalled Size: 1.2 MiB"
)
self.assertEqual(info["name"], "curl")
self.assertEqual(info["version"], "8.6.0")
self.assertEqual(info["installed_size"], "1.2 MiB")
def test_zypper_table_locates_columns_from_header(self):
zt = self.packages._zypper_table
# Layout with status + repository prefix columns.
layout_a = (
"S | Repository | Name | Current | Available | Arch\n"
"--+------------------+------------+---------+-----------+-------\n"
"v | openSUSE-OSS | glib2 | 2.78 | 2.80 | x86_64\n"
" | openSUSE-OSS | zypper | 1.14.70 | 1.14.74 | x86_64"
)
h, rows = zt(layout_a, ("Name", "Current", "Available"))
self.assertEqual(h, {"Name": 2, "Current": 3, "Available": 4})
self.assertEqual(len(rows), 2)
self.assertEqual(rows[0][2], "glib2")
# Layout with a numbered prefix column.
layout_b = (
"S# | Repository | Name | Current | Available | Arch\n"
"---+------------------+------------+---------+-----------+-------\n"
" 1 | openSUSE-OSS | glib2 | 2.78 | 2.80 | x86_64"
)
h, rows = zt(layout_b, ("Name", "Current", "Available"))
self.assertEqual(rows[0][2], "glib2")
# Separator rows and repeated headers are filtered.
noisy = (
"Name | Current | Available\n"
"-----+---------+----------\n"
"glib2 | 2.78 | 2.80\n"
"Name | Current | Available\n"
"bash | 5.2 | 5.3"
)
h, rows = zt(noisy, ("Name", "Current", "Available"))
self.assertEqual(len(rows), 2)
self.assertEqual(rows[1][0], "bash")
def test_emerge_update_regex_anchors_after_bracket(self):
import re
rx = r"\[ebuild\s+U[^\]]*\]\s*(\S+)(?:\s+\[([^\]]+)\])?"
# Portage pads the class field with spaces before the bracket.
m = re.search(rx, " [ebuild U ] dev-lang/python-3.12.4 [3.12.3]")
self.assertEqual(m.group(1), "dev-lang/python-3.12.4")
self.assertEqual(m.group(2), "3.12.3")
m = re.search(rx, " [ebuild U r ] sys-libs/glibc-2.38-r9 [2.37-r7]")
self.assertEqual(m.group(1), "sys-libs/glibc-2.38-r9")
# N (new) and NS (new slot) rows are not updates.
self.assertIsNone(re.search(rx, " [ebuild N ] app-misc/newpkg-1.0"))
self.assertIsNone(re.search(rx, " [ebuild NS ] dev-lang/python-3.11.8 [3.11.6]"))
def test_xbps_search_regex_tolerates_missing_repo_prefix(self):
import re
rx = r"^\[\*\]\s+(?:\S+/)?(\S+)\s+-\s+(.*)$"
m = re.match(rx, "[*] firefox-128.0_1 - The Firefox web browser")
self.assertEqual(m.group(1), "firefox-128.0_1")
m = re.match(rx, "[*] void-repo/firefox-128.0_1 - The Firefox web browser")
self.assertEqual(m.group(1), "firefox-128.0_1")
def test_mutation_table_covers_all_ten_managers(self):
pkg = self.packages
for mgr in ("pacman", "emerge", "lunar", "sorcery", "xbps",
"apk", "zypper", "dnf", "yum", "apt"):
pkg.PKG_MANAGER = mgr
self.assertTrue(pkg._mutation_cmd("install", "x"), mgr)
self.assertTrue(pkg._mutation_cmd("remove", "x"), mgr)
self.assertTrue(pkg._mutation_cmd("update-all", ""), mgr)
if mgr == "lunar":
# Lunar has no single-module update — the honest absence.
self.assertEqual(pkg._mutation_cmd("update", "x"), [])
else:
self.assertTrue(pkg._mutation_cmd("update", "x"), mgr)
pkg.PKG_MANAGER = "unknown"
self.assertEqual(pkg._mutation_cmd("install", "x"), [])
def test_mutation_commands_are_real_manager_invocations(self):
pkg = self.packages
pkg.PKG_MANAGER = "emerge"
self.assertEqual(pkg._mutation_cmd("remove", "foo"),
["emerge", "--unmerge", "foo"])
self.assertEqual(pkg._mutation_cmd("update-all", ""),
["emerge", "-u", "-D", "@world"])
pkg.PKG_MANAGER = "zypper"
self.assertEqual(pkg._mutation_cmd("install", "foo"),
["zypper", "--non-interactive", "install", "foo"])
pkg.PKG_MANAGER = "sorcery"
self.assertEqual(pkg._mutation_cmd("install", "foo"), ["cast", "foo"])
self.assertEqual(pkg._mutation_cmd("remove", "foo"), ["dispel", "foo"])
def test_lunar_reports_honest_empty_updates(self):
# run() returns '' on real failure — lunar has no preview
# subcommand, so the list is empty and the summary carries the
# note explaining why.
self.packages.run = lambda argv, timeout=60, ok_rcs=(): ""
self.assertEqual(self.packages._lunar_list_updates(), [])
self.packages.PKG_MANAGER = "lunar"
summary = self.packages.summary()
self.assertEqual(summary["updateCount"], 0)
self.assertIn("update-preview", summary["updatesNote"])
def test_read_backend_dispatch_has_all_ten(self):
pkg = self.packages
for mgr in ("pacman", "emerge", "lunar", "sorcery", "xbps",
"apk", "zypper", "dnf", "yum", "apt"):
backend = pkg._backend(mgr)
for cmd in ("list-installed", "list-updates", "search", "info"):
self.assertIn(cmd, backend, f"{mgr} missing {cmd}")
self.assertEqual(pkg._backend("unknown"), {})
def test_no_sudo_shell_out_in_packages_bridge(self):
import inspect
source = inspect.getsource(self.packages)
self.assertNotIn('"/usr/bin/sudo"', source)
class TestBuilderProfileCopy(unittest.TestCase):
"""v0.0.48: unit tests for bridge.builder.profile_copy().

View File

@ -1,6 +1,6 @@
{
"name": "nextjs_tailwind_shadcn_ts",
"version": "0.4.3",
"version": "0.4.4",
"private": true,
"scripts": {
"dev": "next dev -H 127.0.0.1 -p 3000 2>&1 | tee dev.log",

View File

@ -12,7 +12,7 @@
#
# Preconditions:
# - master-build/cockpit/ holds the staged + upgraded cockpit tree
# (fester.py / klanker.py / bridge.js upgraded, Makefile at 0.4.3)
# (fester.py / klanker.py / bridge.js upgraded, Makefile at 0.4.4)
# - master-build/klanker-gate/ holds the vendored gateway tree + arch/
#
# Output:
@ -20,7 +20,7 @@
# download/ (sandbox mirror)
set -euo pipefail
VERSION="0.4.3"
VERSION="0.4.4"
NAME="sysdeck-${VERSION}-master"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
STAGE_PARENT="$ROOT/master-build"
@ -33,8 +33,8 @@ echo ">>> Building $NAME"
# ── guards: the cockpit tree must be upgraded before packing ──────────
grep -q 'start-build' "$STAGE_PARENT/cockpit/bridge/fester.py" || {
echo "FAIL: master-build/cockpit/bridge/fester.py is not upgraded (no start-build subcommand)"; exit 1; }
grep -q 'VERSION := 0.4.3' "$STAGE_PARENT/cockpit/Makefile" || {
echo "FAIL: master-build/cockpit/Makefile is not bumped to 0.4.3"; exit 1; }
grep -q 'VERSION := 0.4.4' "$STAGE_PARENT/cockpit/Makefile" || {
echo "FAIL: master-build/cockpit/Makefile is not bumped to 0.4.4"; exit 1; }
test -f "$STAGE_PARENT/cockpit/bridge/klanker.py" || {
echo "FAIL: master-build/cockpit/bridge/klanker.py missing"; exit 1; }
grep -q '"journal"' "$STAGE_PARENT/cockpit/bridge/klanker.py" || {
@ -277,13 +277,13 @@ right under Overview) with copy buttons on every command.
From the extracted master tarball root, one command does everything
(install + migrate + fester + web):
tar xjf sysdeck-0.4.3-master.tar.bz2
cd sysdeck-0.4.3-master
tar xjf sysdeck-0.4.4-master.tar.bz2
cd sysdeck-0.4.4-master
make web-dev # bun install + db:push + fester + next dev :3000
Granular equivalent (what `make web-dev` does):
cd sysdeck-0.4.3-master/web
cd sysdeck-0.4.4-master/web
bun install # dependencies
bun run db:push # create + migrate db/custom.db (SQLite)
bun run dev # Next.js on :3000

View File

@ -1,7 +1,7 @@
// Master tarball release metadata — reports the sha256/size of the
// sysdeck-0.4.3-master bundle in public/download (built by
// sysdeck-0.4.4-master bundle in public/download (built by
// scripts/make-master-tarball.sh). The tarball itself is served
// statically at /download/sysdeck-0.4.3-master.tar.bz2.
// statically at /download/sysdeck-0.4.4-master.tar.bz2.
//
// As of 0.3.1 every web surface is gated; 0.4.0 gates it with the unix-account session
// — the tarball file itself stays a plain static
@ -22,7 +22,7 @@ import { requireSession } from '@/lib/sysdeck/session'
export const dynamic = 'force-dynamic'
const FILE = 'sysdeck-0.4.3-master.tar.bz2'
const FILE = 'sysdeck-0.4.4-master.tar.bz2'
const DOWNLOAD_DIR = path.join(process.cwd(), 'public', 'download')
// hash cache — recompute when the file size OR mtime changes (rebuild)
@ -46,7 +46,7 @@ export async function GET(req: Request) {
}
return NextResponse.json({
ok: true,
version: '0.4.3',
version: '0.4.4',
edition: 'master',
file: FILE,
url: `/download/${FILE}`,

View File

@ -17,6 +17,38 @@ import { db } from '@/lib/db'
import { ok, fail, run, readText, which, cached } from './shared'
import { readdir, stat } from 'fs/promises'
async function safeListdir(dir: string): Promise<string[]> {
try {
return await readdir(dir)
} catch {
return []
}
}
/** Parse a zypper pipe-table by locating columns from its header row.
* zypper prefixes data tables with status/repository columns whose
* count varies by subcommand and release; positional parsing breaks
* across those. The header row is the source of truth. */
function zypperTable(raw: string, wanted: string[]): { header: Record<string, number>; rows: string[][] } {
const header: Record<string, number> = {}
const rows: string[][] = []
for (const line of raw.split('\n')) {
if (!line.includes('|')) continue
const cols = line.split('|').map((c) => c.trim())
if (Object.keys(header).length === 0) {
if (wanted.every((w) => cols.includes(w))) {
for (const w of wanted) header[w] = cols.indexOf(w)
}
continue
}
// Separator rows ('-----+-----') and repeated headers.
if (cols.length > 0 && cols.every((c) => c.length > 0 && /^[+\-]+$/.test(c))) continue
if (wanted.some((w) => cols[header[w]] === w)) continue
rows.push(cols)
}
return { header, rows }
}
function failE(error: string, source: 'live' | 'hybrid' = 'live') {
return { ...fail(error), source }
}
@ -431,23 +463,27 @@ const zypperBackend: Backend = {
async listUpdates() {
const r = await run('zypper', ['-q', 'list-updates'], 60_000)
if (r.rc !== 0 && !r.stdout) return null
const { header, rows } = zypperTable(r.stdout, ['Name', 'Current', 'Available'])
if (header.Name === undefined) return []
const out: UpdateRow[] = []
for (const line of r.stdout.split('\n')) {
if (!line.includes('|')) continue
const cols = line.split('|').map((c) => c.trim())
if (cols.length < 5 || cols[1] === 'Name' || cols[1] === '') continue
out.push({ name: cols[1], current: cols[2], candidate: cols[3] })
for (const cols of rows) {
const name = cols[header.Name]
if (!name || header.Current >= cols.length || header.Available >= cols.length) continue
out.push({ name, current: cols[header.Current], candidate: cols[header.Available] })
}
return out
},
async search(term) {
const r = await run('zypper', ['-q', 'se', term], 30_000)
const { header, rows } = zypperTable(r.stdout, ['Name', 'Summary'])
const iName = header.Name ?? 1
const iSum = header.Summary ?? 2
const out: SearchRow[] = []
for (const line of r.stdout.split('\n')) {
if (!line.includes('|')) continue
const cols = line.split('|').map((c) => c.trim())
if (cols.length < 3 || cols[1] === 'Name' || !cols[1]) continue
out.push({ name: cols[1], version: '', description: cols[2] ?? '', installed: cols[0] === 'i' })
for (const cols of rows) {
if (cols.length <= Math.max(iName, iSum)) continue
const name = cols[iName]
if (!name) continue
out.push({ name, version: '', description: cols[iSum], installed: cols[0] === 'i' })
}
return out
},
@ -571,8 +607,8 @@ const xbpsBackend: Backend = {
const r = await run('xbps-query', ['-Rs', term], 30_000)
const out: SearchRow[] = []
for (const line of r.stdout.split('\n')) {
// "[*] repo/name-ver - description"
const m = line.match(/^\[\*\]\s+\S+\/(\S+)\s+-\s+(.*)$/)
// "[*] [repo/]name-ver - description" — the repo prefix is optional.
const m = line.match(/^\[\*\]\s+(?:\S+\/)?(\S+)\s+-\s+(.*)$/)
if (!m) continue
const { name, version } = splitNameVer(m[1])
out.push({ name, version, description: m[2], installed: false })
@ -635,8 +671,11 @@ const emergeBackend: Backend = {
if (r.rc !== 0 && !r.stdout) return null
const out: UpdateRow[] = []
for (const line of r.stdout.split('\n')) {
// " [ebuild U ] dev-lang/python-3.12.4 [3.12.3]"
const m = line.match(/\[ebuild\s+U~?\]?\s*([^\s\[]+)\s*(?:\[([^\]]+)\])?/)
// " [ebuild U ] cat/pkg-1.2.3 [1.2.2]" — the atom sits AFTER
// the class bracket; portage pads the class field with spaces, so
// starting the capture before the bracket grabs the bracket itself
// and drops every row.
const m = line.match(/\[ebuild\s+U[^\]]*\]\s*(\S+)(?:\s+\[([^\]]+)\])?/)
if (!m) continue
const atom = m[1]
const slash = atom.lastIndexOf('/')
@ -664,48 +703,27 @@ const emergeBackend: Backend = {
return out
},
async info(name) {
// Real metadata from the installed package's /var/db/pkg entry
// Real metadata from the installed package's /var/db/pkg entry.
// Accepts 'cat/pkg' atoms and bare names — the latter scans every
// category for a matching leaf.
const slash = name.lastIndexOf('/')
const tryPaths = slash > 0 ? [name] : [name]
for (const atom of tryPaths) {
let cat = atom.slice(0, slash > 0 ? slash : 0)
const leaf = slash > 0 ? atom.slice(slash + 1) : atom
if (!cat) {
// category-less lookup: scan all categories for a matching name
try {
const cats = await readdir(EMERGE_PKG_DB)
const matches: string[] = []
for (const c of cats) {
try {
const entries = await readdir(`${EMERGE_PKG_DB}/${c}`)
matches.push(...entries.filter((pf) => splitNameVer(pf).name === leaf).map((pf) => `${c}/${pf}`))
} catch {
continue
}
}
if (!matches.length) continue
cat = matches[0].split('/')[0]
const desc = await readText(`${EMERGE_PKG_DB}/${matches[0]}/DESCRIPTION`)
const homepage = await readText(`${EMERGE_PKG_DB}/${matches[0]}/HOMEPAGE`)
const license = await readText(`${EMERGE_PKG_DB}/${matches[0]}/LICENSE`)
const slot = await readText(`${EMERGE_PKG_DB}/${matches[0]}/SLOT`)
const use = await readText(`${EMERGE_PKG_DB}/${matches[0]}/USE`)
const { version } = splitNameVer(matches[0].split('/')[1])
if (desc || version) {
const leaf = slash > 0 ? name.slice(slash + 1) : name
const cats = slash > 0 ? [name.slice(0, slash)] : await safeListdir(EMERGE_PKG_DB)
for (const cat of cats) {
const entries = await safeListdir(`${EMERGE_PKG_DB}/${cat}`)
for (const pf of entries) {
if (splitNameVer(pf).name !== leaf) continue
const pdir = `${EMERGE_PKG_DB}/${cat}/${pf}`
const desc = (await readText(`${pdir}/DESCRIPTION`)).trim()
const { version } = splitNameVer(pf)
if (!desc && !version) continue
return {
name: `${cat}/${leaf}`,
version,
status: 'installed (from /var/db/pkg)',
depends: (await readText(`${EMERGE_PKG_DB}/${matches[0]}/RDEPEND`)) || (await readText(`${EMERGE_PKG_DB}/${matches[0]}/PDEPEND`)),
description: desc.trim(),
maintainer: homepage.trim(),
}
}
void license
void slot
void use
} catch {
continue
depends: (await readText(`${pdir}/RDEPEND`)) || (await readText(`${pdir}/PDEPEND`)),
description: desc,
maintainer: (await readText(`${pdir}/HOMEPAGE`)).trim(),
}
}
}
@ -875,12 +893,16 @@ const BACKENDS: Record<string, Backend> = {
const DETECT_ORDER = ['pacman', 'emerge', 'lunar', 'sorcery', 'xbps', 'apk', 'zypper', 'dnf', 'yum', 'apt'] as const
// Probe binary per manager where the manager id is not itself a binary:
// Void ships no bare `xbps` command — xbps-query is the presence probe.
const DETECT_PROBES: Record<string, string> = { xbps: 'xbps-query' }
let detected: { backend: Backend | null; probeAt: number } | null = null
async function detectBackend(): Promise<Backend | null> {
if (detected && Date.now() - detected.probeAt < 300_000) return detected.backend
for (const id of DETECT_ORDER) {
if (!(await which(id))) continue
if (!(await which(DETECT_PROBES[id] ?? id))) continue
if (id === 'emerge') {
// corroboration: a Gentoo box always has /var/db/pkg
try {

View File

@ -72,4 +72,4 @@ export function modulesByGroup(): { group: string; modules: ModuleMeta[] }[] {
.filter((g) => g.modules.length > 0)
}
export const SYSDECK_VERSION = '0.4.3'
export const SYSDECK_VERSION = '0.4.4'

View File

@ -1847,3 +1847,98 @@ Work Log:
Stage Summary:
- One-command quiet cleanup of every cockpit-installed SysDeck version: sudo ./sysdeck-uninstall.sh (or /usr/share/sysdeck/sysdeck-uninstall.sh on installed boxes).
- 48/48 harness checks; make check ALL PASS; master tarball rebuilt and verified.
---
Task: v0.4.2 + v0.4.3 catch-up entries (recorded in QA.md; summarized here)
Note: the tree worklog missed the 0.4.2/0.4.3 dev cycles (logging lived in
the build-side worklog). Full QA detail for both releases is in QA.md:
- v0.4.2 (zero-demo release): every web-console module reads real host
state; DataSource union loses the 'demo' tier (compiler-enforced);
sensors `sensors -j` → sysfs chain; netsec atomic nftables bans with
fail2ban merge + real unbans; firewall live-ruleset tab + seven
topologies; LUKS header hashes from real image bytes; honest empty
inventories with install hints; ten package-manager backends on the
web side.
- v0.4.3 (MoE QA pass): privileged writes ride stdin with verification;
comment injection guards; mktemp staging; admin-gated mutations
(SYSDECK_MUTATIONS); honest dry-runs/unbans; XFF trust gating
(SYSDECK_TRUST_PROXY); TTL + single-flight caches; cockpit-side
bridge parity (sensors chain, dnf rc-100, spawn timeouts); churn
wording purged.
---
Task: v0.4.4 — ten package managers on both editions + the blog essay
Work Log:
- bridge/packages.py: ported the web console's ten-backend step-down
(pacman, emerge + /var/db/pkg corroboration, lunar, sorcery, xbps
via xbps-query probe, apk, zypper, dnf, yum, apt); detection is a
shutil.which sweep, no --version children; per-backend read
functions (vdb scan for emerge, lvu/gaze with state-file fallbacks,
rpm -qa for zypper, yum mirroring dnf with rc-100-as-data); one
MUTATION_CMDS table for install/remove/update/update-all/dry-run;
lunar single-module update refuses honestly, summary carries
updatesNote.
- Parser fixes on BOTH editions (found by fixture tests): emerge
update regex anchored after the class bracket (the old capture
grabbed the bracket and dropped every row — silent "no updates" on
Gentoo); zypper tables parse by header-located columns with
separator/repeat-header filtering; xbps -Rs rows parse with or
without a repository prefix; web emerge info resolves
category-qualified atoms too.
- web/src/lib/sysdeck/bridge/packages.ts: DETECT_PROBES map (xbps →
xbps-query), zypperTable helper, emerge/xbps regex fixes, emerge
info rewrite; tsc --noEmit clean, eslint clean.
- plugins/sysdeck-packages/packages.js: ten-manager narration,
summary.updatesNote rendered, header comment rewritten decisively;
node --check clean.
- packaging/polkit/org.sysdeck.policy: packages.modify exec-path
annotations extended to the ten managers; builder.modify host-query
annotations extended; a literal `--` inside an XML comment fixed
(strict parsers rejected the file); XML now validates.
- bridge/__init__.py: DistroId/PkgManager extended (gentoo, lunar,
sourcemage, void, alpine, opensuse) with matching os-release ids
and which-based fallbacks.
- tests/test_bridge_parsers.py: TestPackagesBackends (13 tests —
detection order/probes, emerge corroboration with mocked
shutil.which, parsers with fixtures, mutation table coverage, real
argv spot-checks, honest lunar summary, no-sudo guard); version
sync bumped to 0.4.4. scripts/test_packages_backends.py: standalone
fixture suite (10 checks) for quick iteration.
- BLOG.md: rebuilt as a single long-form engineering essay following
the shellm blog pattern — title, italic deck, context narrative,
roadmap, decision-organized sections (PAM auth, one catalog two
frontends, the zero-demo contract, ten-manager step-down, firewall
privilege discipline, performance without fabrication), canonical
numbered workflow, attribution footer. Every path/flag/count
verified against source. Release-notes content retired from BLOG.md
(history: QA.md + worklog.md; README pointers updated).
- Release surfaces: 0.4.4 across Makefile, bridge/__init__.py,
packaging (setup.py, PKGBUILD, spec + changelog, debian/changelog),
compat-manifest.json, web (package.json, registry.ts, release
route.ts, make-master-tarball.sh), README (version + v0.4.4
highlights + catalog row + tree comments + pointers), QUICKSTART
§10.8, QA.md v0.4.4 entry.
- Incident + fix (same class as the v0.0.44 one): the Edit tool
converted Makefile recipe TABs to 8 spaces across 584 lines when the
master rule was edited; `make` failed with "missing separator".
Repaired by restoring the pristine Makefile from the shipped 0.4.3
tarball and re-applying the three intended changes (version bump,
master-rule prefix/klanker-gate/tsbuildinfo/worklog.md) through a
tab-preserving Python patch; make check ALL PASS after, including
the recipe-indentation guard.
- Makefile master rule corrected while there: the bundle prefix is
`$(PACKAGE)-$(VERSION)-master/` (the rule's old transform dropped
the -master suffix), the vendored klanker-gate/ tree and worklog.md
are in the file list, and *.tsbuildinfo is excluded — `make master`
now reproduces the shipped bundle shape byte-for-byte in content
(960 entries, one intentional addition: the packages fixture suite).
- Verification: py_compile all bridges; fixture suite 10/10;
TestPackagesBackends 13/13; node --check panel JS; polkit XML
validated; tsc --noEmit clean; eslint clean; make check full run;
master tarball rebuilt via make master.
Stage Summary:
- Package module parity complete: ten managers, identical step-down
and parsers, both editions, fixture-locked.
- BLOG.md is the engineering essay the project always pointed at.