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:
parent
3fdbf6d3f0
commit
cd522bedcd
14
Makefile
14
Makefile
|
|
@ -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
75
QA.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
54
README.md
54
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,58 +851,99 @@ 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(),
|
||||
"search": lambda args: _apt_search(args[0]) if args else [],
|
||||
"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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import shutil
|
|||
import subprocess
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
VERSION = "0.4.3"
|
||||
VERSION = "0.4.4"
|
||||
PACKAGE = "sysdeck"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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().
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
95
worklog.md
95
worklog.md
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue