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. # Distro support: Arch Linux, Debian/Ubuntu, Fedora/RHEL/CentOS.
PACKAGE := sysdeck PACKAGE := sysdeck
VERSION := 0.4.3 VERSION := 0.4.4
LIB_DIR := $(DESTDIR)/usr/lib/$(PACKAGE) LIB_DIR := $(DESTDIR)/usr/lib/$(PACKAGE)
PYTHON_DIR := $(LIB_DIR)/bridge PYTHON_DIR := $(LIB_DIR)/bridge
SHARE_DIR := $(DESTDIR)/usr/share/$(PACKAGE) 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)" @echo ">>> Starting SysDeck Web Edition on :3000 (Ctrl+C stops next; fester keeps running)"
cd web && bun run dev 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: 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 \ tar cjf $(PACKAGE)-$(VERSION)-master.tar.bz2 \
--exclude='__pycache__' --exclude='*.pyc' --exclude='*.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-*' \ --exclude='*public/download*' --exclude='*.db' --exclude='*.db-*' \
--transform 's,^,$(PACKAGE)-$(VERSION)-,' \ --transform 's,^,$(PACKAGE)-$(VERSION)-master/,' \
bridge plugins shared tests packaging compat standalone-plugins \ bridge plugins shared tests packaging compat standalone-plugins \
prometheus scripts firewall docs web \ prometheus scripts firewall docs web klanker-gate \
Makefile README.md QUICKSTART.md BLOG.md LICENSE QA.md THIRD_PARTY.md \ 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 sysdeck-diagnose.sh cockpit-smoke-test.sh sysdeck-uninstall.sh
@echo ">>> $(PACKAGE)-$(VERSION)-master.tar.bz2 ready" @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) # MoE Quality Assurance Pass — v0.4.3 (the hardened release)
## v0.4.3 QA — multi-expert audit, findings landed ## 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) ## 11. Run without Cockpit (the complete standalone runbook, v0.3.0)
The web edition needs **nothing from sections 1–8** — no cockpit, no Python 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.** **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> 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. 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) ### 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 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 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. - **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`. - **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`. - **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 | | 15 | System Monitor (Glances web UI) | `cockpit-glances` | P1 | `glances -w` (iframe) + snapshot cards |
| 16 | Hardware Sensors | `cockpit-sensors` | P1 | `sensors` (lm_sensors) | | 16 | Hardware Sensors | `cockpit-sensors` | P1 | `sensors` (lm_sensors) |
| 17 | System Benchmark | `cockpit-benchmark` | P2 | `sysbench` | | 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) | | 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) | | 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) | | 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-mesh/
│ ├── sysdeck-vault/ │ ├── sysdeck-vault/
│ ├── sysdeck-fleet/ │ ├── 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-fester/
│ ├── sysdeck-firmware/ │ ├── sysdeck-firmware/
│ ├── sysdeck-builder/ │ ├── sysdeck-builder/
@ -519,7 +557,7 @@ sysdeck-0.0.35/
│ ├── glances.py # v0.0.34: snapshot + start-web/stop-web │ ├── glances.py # v0.0.34: snapshot + start-web/stop-web
│ ├── sensors.py # lm_sensors normalization + alert thresholds │ ├── sensors.py # lm_sensors normalization + alert thresholds
│ ├── benchmark.py # sysbench result parsing + baselines │ ├── 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 │ ├── mining.py # v0.0.34: XMRig REST API power tool
│ ├── themes.py # v0.0.34: cockpit.conf + CSS variable surface │ ├── themes.py # v0.0.34: cockpit.conf + CSS variable surface
│ ├── policy.py # Policy & Permissions module (LSM stack) │ ├── policy.py # Policy & Permissions module (LSM stack)
@ -543,7 +581,7 @@ sysdeck-0.0.35/
├── docs/ # INSTALL.md ├── docs/ # INSTALL.md
├── README.md ├── README.md
├── QUICKSTART.md ├── QUICKSTART.md
├── BLOG.md # release narrative ├── BLOG.md # engineering essay (long-form)
├── QA.md # QA notes per release ├── QA.md # QA notes per release
├── THIRD_PARTY.md # third-party attributions ├── THIRD_PARTY.md # third-party attributions
├── LICENSE # MIT ├── LICENSE # MIT
@ -616,7 +654,7 @@ MIT — see [LICENSE](./LICENSE). Third-party attributions: see [THIRD_PARTY.md]
## Release notes ## 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 ## Project history

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -3,7 +3,7 @@
# Upstream: https://dcos.net # Upstream: https://dcos.net
pkgname=sysdeck pkgname=sysdeck
pkgver=0.4.3 pkgver=0.4.4
pkgrel=1 pkgrel=1
pkgdesc="Unified operations surface for Linux infrastructure — twenty-six domain modules behind one Cockpit dashboard" pkgdesc="Unified operations surface for Linux infrastructure — twenty-six domain modules behind one Cockpit dashboard"
arch=('any') 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 sysdeck (0.4.3-1) unstable; urgency=medium
* v0.4.3: the MoE QA pass — production hardening across every axis. * v0.4.3: the MoE QA pass — production hardening across every axis.

View File

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

View File

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

View File

@ -7,7 +7,7 @@
# Debian/Ubuntu users: see packaging/debian/ # Debian/Ubuntu users: see packaging/debian/
Name: sysdeck Name: sysdeck
Version: 0.4.3 Version: 0.4.4
Release: 1%{?dist} Release: 1%{?dist}
Summary: Unified operations surface for Linux infrastructure Summary: Unified operations surface for Linux infrastructure
@ -84,6 +84,16 @@ if [ $1 -eq 0 ]; then
fi fi
%changelog %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 * 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. - v0.4.3: the MoE QA pass — production hardening across every axis.
Privileged writes ride stdin (polkit rules verified post-write, 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) * Author: Jeremy Anderson (https://dcos.net)
* *
* Package management panel — list, search, install, update, and remove * 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 — * The package manager is invoked as a separate process via cockpit.spawn —
* no package-manager code is bundled. * no package-manager code is bundled.
* *
* v0.0.31 REWRITE — UPDATE NEEDS SUDO, FIXED THE COCKPIT WAY. * Mutations (install/remove/update/update-all) execute via the cockpit
* v0.0.30 packages.js Update All button called bridge.packages.updateAll() * superuser channel (polkit): the bridge helper runs the detected
* which returned only the command string that *would* be run. The panel * package manager via subprocess, and this panel subscribes to the
* showed `alert("Run this command with superuser privileges.")` and the * cockpit spawn stream so the operator sees live stdout/stderr in a
* operator had to copy the command, open a terminal, sudo, paste, run. * <pre> log panel — exactly like cockpit's own Packages and Software
* That defeated the purpose of having a panel. * Updates panels. No `sudo` shell-out from JS.
* *
* v0.0.31 makes install/remove/update/update-all actually execute via * SECURITY: every dynamic string interpolated into innerHTML
* 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
* (package names, versions, descriptions, search terms echoed back, * (package names, versions, descriptions, search terms echoed back,
* error messages) now goes through escapeHtml(). Package metadata is * error messages) goes through escapeHtml(). Package metadata is
* live data from pacman/dnf/apt output — a typo-squat repo or a * live data from the package manager's output — a typo-squat repo or
* locally-installed package whose name/description contains markup * a locally-installed package whose name/description contains markup
* used to execute in the cockpit admin session (0.3.0 audit). * must not execute in the cockpit admin session (0.3.0 audit).
* Raw tool output already flows through textContent (showOutput), * Raw tool output already flows through textContent (showOutput),
* which is safe. * which is safe.
*/ */
@ -59,11 +55,16 @@ export async function mount(panel, { bridge, EventBus }) {
const instCount = summary.installedCount || installed.length; const instCount = summary.installedCount || installed.length;
const updCount = summary.updateCount || 0; const updCount = summary.updateCount || 0;
const updates = summary.updates || []; 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 = ` panel.innerHTML = `
<header> <header>
<h2 class="suite-panel-title">Package Manager</h2> <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> </header>
<div class="suite-row"> <div class="suite-row">
<div class="suite-card suite-col-2"> <div class="suite-card suite-col-2">
@ -220,6 +221,6 @@ function renderSkeleton() {
function renderError(err) { function renderError(err) {
return `<div class="suite-card"> return `<div class="suite-card">
<h3 class="suite-card-title">Package manager unavailable</h3> <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>`; </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): def test_version_sync_all_surfaces_report_020(self):
# Every release surface must report v0.4.3 (MoE QA production pass). # Every release surface must report v0.4.4 (package parity + blog essay).
v = "0.4.3" v = "0.4.4"
files_to_check = [ files_to_check = [
"Makefile", "Makefile",
"bridge/__init__.py", "bridge/__init__.py",
@ -1934,6 +1934,177 @@ class TestFirewallV047ManifestsAndMetainfo(unittest.TestCase):
f"{rel} does not reference version {v}") 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): class TestBuilderProfileCopy(unittest.TestCase):
"""v0.0.48: unit tests for bridge.builder.profile_copy(). """v0.0.48: unit tests for bridge.builder.profile_copy().

View File

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

View File

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

View File

@ -1,7 +1,7 @@
// Master tarball release metadata — reports the sha256/size of the // 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 // 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 // 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 // — the tarball file itself stays a plain static
@ -22,7 +22,7 @@ import { requireSession } from '@/lib/sysdeck/session'
export const dynamic = 'force-dynamic' 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') const DOWNLOAD_DIR = path.join(process.cwd(), 'public', 'download')
// hash cache — recompute when the file size OR mtime changes (rebuild) // hash cache — recompute when the file size OR mtime changes (rebuild)
@ -46,7 +46,7 @@ export async function GET(req: Request) {
} }
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
version: '0.4.3', version: '0.4.4',
edition: 'master', edition: 'master',
file: FILE, file: FILE,
url: `/download/${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 { ok, fail, run, readText, which, cached } from './shared'
import { readdir, stat } from 'fs/promises' 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') { function failE(error: string, source: 'live' | 'hybrid' = 'live') {
return { ...fail(error), source } return { ...fail(error), source }
} }
@ -431,23 +463,27 @@ const zypperBackend: Backend = {
async listUpdates() { async listUpdates() {
const r = await run('zypper', ['-q', 'list-updates'], 60_000) const r = await run('zypper', ['-q', 'list-updates'], 60_000)
if (r.rc !== 0 && !r.stdout) return null 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[] = [] const out: UpdateRow[] = []
for (const line of r.stdout.split('\n')) { for (const cols of rows) {
if (!line.includes('|')) continue const name = cols[header.Name]
const cols = line.split('|').map((c) => c.trim()) if (!name || header.Current >= cols.length || header.Available >= cols.length) continue
if (cols.length < 5 || cols[1] === 'Name' || cols[1] === '') continue out.push({ name, current: cols[header.Current], candidate: cols[header.Available] })
out.push({ name: cols[1], current: cols[2], candidate: cols[3] })
} }
return out return out
}, },
async search(term) { async search(term) {
const r = await run('zypper', ['-q', 'se', term], 30_000) 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[] = [] const out: SearchRow[] = []
for (const line of r.stdout.split('\n')) { for (const cols of rows) {
if (!line.includes('|')) continue if (cols.length <= Math.max(iName, iSum)) continue
const cols = line.split('|').map((c) => c.trim()) const name = cols[iName]
if (cols.length < 3 || cols[1] === 'Name' || !cols[1]) continue if (!name) continue
out.push({ name: cols[1], version: '', description: cols[2] ?? '', installed: cols[0] === 'i' }) out.push({ name, version: '', description: cols[iSum], installed: cols[0] === 'i' })
} }
return out return out
}, },
@ -571,8 +607,8 @@ const xbpsBackend: Backend = {
const r = await run('xbps-query', ['-Rs', term], 30_000) const r = await run('xbps-query', ['-Rs', term], 30_000)
const out: SearchRow[] = [] const out: SearchRow[] = []
for (const line of r.stdout.split('\n')) { for (const line of r.stdout.split('\n')) {
// "[*] repo/name-ver - description" // "[*] [repo/]name-ver - description" — the repo prefix is optional.
const m = line.match(/^\[\*\]\s+\S+\/(\S+)\s+-\s+(.*)$/) const m = line.match(/^\[\*\]\s+(?:\S+\/)?(\S+)\s+-\s+(.*)$/)
if (!m) continue if (!m) continue
const { name, version } = splitNameVer(m[1]) const { name, version } = splitNameVer(m[1])
out.push({ name, version, description: m[2], installed: false }) out.push({ name, version, description: m[2], installed: false })
@ -635,8 +671,11 @@ const emergeBackend: Backend = {
if (r.rc !== 0 && !r.stdout) return null if (r.rc !== 0 && !r.stdout) return null
const out: UpdateRow[] = [] const out: UpdateRow[] = []
for (const line of r.stdout.split('\n')) { for (const line of r.stdout.split('\n')) {
// " [ebuild U ] dev-lang/python-3.12.4 [3.12.3]" // " [ebuild U ] cat/pkg-1.2.3 [1.2.2]" — the atom sits AFTER
const m = line.match(/\[ebuild\s+U~?\]?\s*([^\s\[]+)\s*(?:\[([^\]]+)\])?/) // 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 if (!m) continue
const atom = m[1] const atom = m[1]
const slash = atom.lastIndexOf('/') const slash = atom.lastIndexOf('/')
@ -664,48 +703,27 @@ const emergeBackend: Backend = {
return out return out
}, },
async info(name) { 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 slash = name.lastIndexOf('/')
const tryPaths = slash > 0 ? [name] : [name] const leaf = slash > 0 ? name.slice(slash + 1) : name
for (const atom of tryPaths) { const cats = slash > 0 ? [name.slice(0, slash)] : await safeListdir(EMERGE_PKG_DB)
let cat = atom.slice(0, slash > 0 ? slash : 0) for (const cat of cats) {
const leaf = slash > 0 ? atom.slice(slash + 1) : atom const entries = await safeListdir(`${EMERGE_PKG_DB}/${cat}`)
if (!cat) { for (const pf of entries) {
// category-less lookup: scan all categories for a matching name if (splitNameVer(pf).name !== leaf) continue
try { const pdir = `${EMERGE_PKG_DB}/${cat}/${pf}`
const cats = await readdir(EMERGE_PKG_DB) const desc = (await readText(`${pdir}/DESCRIPTION`)).trim()
const matches: string[] = [] const { version } = splitNameVer(pf)
for (const c of cats) { if (!desc && !version) continue
try { return {
const entries = await readdir(`${EMERGE_PKG_DB}/${c}`) name: `${cat}/${leaf}`,
matches.push(...entries.filter((pf) => splitNameVer(pf).name === leaf).map((pf) => `${c}/${pf}`)) version,
} catch { status: 'installed (from /var/db/pkg)',
continue depends: (await readText(`${pdir}/RDEPEND`)) || (await readText(`${pdir}/PDEPEND`)),
} description: desc,
} maintainer: (await readText(`${pdir}/HOMEPAGE`)).trim(),
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) {
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
} }
} }
} }
@ -875,12 +893,16 @@ const BACKENDS: Record<string, Backend> = {
const DETECT_ORDER = ['pacman', 'emerge', 'lunar', 'sorcery', 'xbps', 'apk', 'zypper', 'dnf', 'yum', 'apt'] as const 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 let detected: { backend: Backend | null; probeAt: number } | null = null
async function detectBackend(): Promise<Backend | null> { async function detectBackend(): Promise<Backend | null> {
if (detected && Date.now() - detected.probeAt < 300_000) return detected.backend if (detected && Date.now() - detected.probeAt < 300_000) return detected.backend
for (const id of DETECT_ORDER) { for (const id of DETECT_ORDER) {
if (!(await which(id))) continue if (!(await which(DETECT_PROBES[id] ?? id))) continue
if (id === 'emerge') { if (id === 'emerge') {
// corroboration: a Gentoo box always has /var/db/pkg // corroboration: a Gentoo box always has /var/db/pkg
try { try {

View File

@ -72,4 +72,4 @@ export function modulesByGroup(): { group: string; modules: ModuleMeta[] }[] {
.filter((g) => g.modules.length > 0) .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: 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). - 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. - 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.