#!/usr/bin/env python3 """ SysDeck - Packages Bridge Helper Author: Jeremy Anderson (https://dcos.net) 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. 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. No package-manager code is bundled. Usage: python3 /usr/lib/sysdeck/bridge/packages.py list-installed python3 /usr/lib/sysdeck/bridge/packages.py list-updates python3 /usr/lib/sysdeck/bridge/packages.py search python3 /usr/lib/sysdeck/bridge/packages.py info python3 /usr/lib/sysdeck/bridge/packages.py install python3 /usr/lib/sysdeck/bridge/packages.py remove python3 /usr/lib/sysdeck/bridge/packages.py update python3 /usr/lib/sysdeck/bridge/packages.py update-all python3 /usr/lib/sysdeck/bridge/packages.py dry-run [name] python3 /usr/lib/sysdeck/bridge/packages.py summary 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 `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 # Scrubbed child environment: parsed output stays locale-stable (LC_ALL=C) # and no console process state leaks into children. SCRUBBED_ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C", "LC_ALL": "C"} PACMAN_LICENSE = "GPL-2.0+ (pacman)" PACMAN_AUTHOR = "Pacman Development Team" PACMAN_URL = "https://archlinux.org/pacman/" # Detect the system package manager once at import time. # 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 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() def run(argv: list[str], timeout: int = 60, ok_rcs: tuple[int, ...] = ()) -> str: """Run a command, returning stdout; '' on real failure. check=False + an explicit accept-set: dnf check-update exits 100 when updates EXIST (and 0 when none do) — treating 100 as failure would fabricate an empty update list on every RPM host. Every child runs under the scrubbed env with a hard timeout.""" try: r = subprocess.run( argv, capture_output=True, text=True, check=False, timeout=timeout, env=SCRUBBED_ENV, ) except (subprocess.TimeoutExpired, FileNotFoundError): return "" if r.returncode == 0 or r.returncode in ok_rcs: return r.stdout return "" # ── Pacman backend ────────────────────────────────────────────────── def _pacman_list_installed() -> list[dict[str, str]]: """List installed packages via pacman -Q.""" raw = run(["pacman", "-Q"]) return [ {"name": parts[0], "version": parts[1]} for line in raw.splitlines() if (parts := line.split()) and len(parts) >= 2 ] def _pacman_list_updates() -> list[dict[str, str]]: """List available updates via pacman -Qu.""" raw = run(["pacman", "-Qu"]) return [ {"name": parts[0], "current": parts[1], "new": parts[2] if len(parts) > 2 else parts[1]} for line in raw.splitlines() if (parts := line.split()) and len(parts) >= 2 ] def _pacman_search(term: str) -> list[dict[str, str]]: """Search packages via pacman -Ss.""" raw = run(["pacman", "-Ss", term]) results: list[dict[str, str]] = [] for line in raw.splitlines(): # Format: "repo/name version [installed]" if line.startswith(" ") or not line.strip(): continue parts = line.split() if len(parts) >= 2: name_ver = parts[0] installed = "[installed]" in line name = name_ver.split("/")[-1] if "/" in name_ver else name_ver results.append({"name": name, "version": parts[1], "installed": str(installed).lower()}) return results def _pacman_info(name: str) -> dict[str, Any]: """Package info via pacman -Si.""" raw = run(["pacman", "-Si", name]) info: dict[str, Any] = {"name": name} for line in raw.splitlines(): if ":" in line: key, _, val = line.partition(":") info[key.strip().lower().replace(" ", "_")] = val.strip() return info # ── DNF backend ───────────────────────────────────────────────────── def _dnf_list_installed() -> list[dict[str, str]]: """List installed packages via dnf list installed.""" raw = run(["dnf", "list", "installed", "--quiet"]) return _parse_rpm_list(raw) def _dnf_list_updates() -> list[dict[str, str]]: """List available updates via dnf check-update. dnf check-update exits 100 when updates exist (0 when none) — the 100 is data, not failure.""" raw = run(["dnf", "check-update", "--quiet"], timeout=120, ok_rcs=(100,)) return _parse_rpm_update_list(raw) def _dnf_search(term: str) -> list[dict[str, str]]: """Search packages via dnf search.""" raw = run(["dnf", "search", term, "--quiet"]) results: list[dict[str, str]] = [] for line in raw.splitlines(): if ":" in line and not line.startswith(" "): parts = line.split(":") if len(parts) >= 2: name_ver = parts[0].strip() name = name_ver.split(".")[0] if "." in name_ver else name_ver results.append({"name": name, "description": parts[1].strip()}) return results def _dnf_info(name: str) -> dict[str, Any]: """Package info via dnf info.""" raw = run(["dnf", "info", name, "--quiet"]) return _parse_rpm_info(raw, name) # ── APT backend ───────────────────────────────────────────────────── def _apt_list_installed() -> list[dict[str, str]]: """List installed packages via dpkg-query.""" raw = run(["dpkg-query", "-W", "-f=${Package}\\t${Version}\\n"]) return [ {"name": parts[0], "version": parts[1]} for line in raw.splitlines() if (parts := line.split("\t")) and len(parts) >= 2 ] def _apt_list_updates() -> list[dict[str, str]]: """List available updates via apt list --upgradable.""" raw = run(["apt", "list", "--upgradable", "-qq"]) return [ {"name": parts[0].split("/")[0], "new": parts[1]} for line in raw.splitlines() if (parts := line.split()) and len(parts) >= 2 ] def _apt_search(term: str) -> list[dict[str, str]]: """Search packages via apt search.""" raw = run(["apt-cache", "search", term]) results: list[dict[str, str]] = [] for line in raw.splitlines(): if " - " in line: name_desc = line.split(" - ", 1) name_ver = name_desc[0].split() if name_ver: results.append({"name": name_ver[0], "description": name_desc[1] if len(name_desc) > 1 else ""}) return results def _apt_info(name: str) -> dict[str, Any]: """Package info via apt show.""" raw = run(["apt-cache", "show", name]) 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//-. 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]]: """Parse 'name.arch version repo' tabular output.""" results: list[dict[str, str]] = [] for line in raw.splitlines(): parts = line.split() if len(parts) >= 2 and not line.startswith("Last"): name = parts[0].split(".")[0] if "." in parts[0] else parts[0] results.append({"name": name, "version": parts[1]}) return results def _parse_rpm_update_list(raw: str) -> list[dict[str, str]]: """Parse dnf check-update output.""" results: list[dict[str, str]] = [] for line in raw.splitlines(): parts = line.split() if len(parts) >= 2 and not line.startswith("Last") and not line.startswith(" "): name = parts[0].split(".")[0] if "." in parts[0] else parts[0] results.append({"name": name, "new": parts[1]}) return results def _parse_rpm_info(raw: str, name: str) -> dict[str, Any]: """Parse dnf info output into key-value pairs.""" info: dict[str, Any] = {"name": name} for line in raw.splitlines(): if ":" in line: key, _, val = line.partition(":") info[key.strip().lower().replace(" ", "_")] = val.strip() return info def _parse_apt_info(raw: str, name: str) -> dict[str, Any]: """Parse apt-cache show output into key-value pairs.""" info: dict[str, Any] = {"name": name} for line in raw.splitlines(): if ":" in line: key, _, val = line.partition(":") info[key.strip().lower().replace("-", "_")] = val.strip() return info # ── Dispatch table per package manager ────────────────────────────── 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.""" 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.""" 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.""" 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.""" fn = _backend(PKG_MANAGER).get("info") return fn(args) if fn else {} def _pkg_name_ok(pkg: str) -> bool: """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 safe component: no leading dash, no whitespace/control chars, no URL scheme, bounded length.""" return ( isinstance(pkg, str) and 0 < len(pkg) <= 256 and not pkg.startswith("-") and "://" not in pkg and not re.search(r"[\s\x00\x1b]", pkg) ) def _first_pkg_arg(args: list[str]) -> str | None: """Accept the shell convention `--` as an argv separator: callers may pass `packages.py install -- `; the separator is skipped and the first package name wins.""" for a in args: if a != "--": return a 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. 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 `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"} pkg = _first_pkg_arg(args) if not pkg: return {"error": "No package name provided"} if not _pkg_name_ok(pkg): return {"error": f"invalid package name: {pkg!r}"} # _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 = _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}"} # Actually run it. The cockpit bridge runs as the cockpit user; the # JS panel's cockpit.spawn(..., { superuser: 'try' }) makes cockpit # prompt the operator for auth and run us as root via polkit. return _run_mutation(cmd, "install", pkg) def remove(args: list[str]) -> dict[str, str]: """Remove a package — actually runs the package manager. See install().""" if not args: return {"error": "No package name provided"} pkg = _first_pkg_arg(args) if not pkg: return {"error": "No package name provided"} if not _pkg_name_ok(pkg): return {"error": f"invalid package name: {pkg!r}"} 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}"} return _run_mutation(cmd, "remove", pkg) def update(args: list[str]) -> dict[str, str]: """Update a package — actually runs the package manager. See install().""" if not args: return {"error": "No package name provided"} pkg = _first_pkg_arg(args) if not pkg: return {"error": "No package name provided"} if not _pkg_name_ok(pkg): return {"error": f"invalid package name: {pkg!r}"} 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}"} return _run_mutation(cmd, "update", pkg) def _run_mutation(cmd: list[str], action: str, pkg: str = "") -> dict[str, str]: """Run a package-manager mutation; never raises. Ten-minute package operations are normal, and a missing binary is a state, not a crash: both come back as a structured failure the panel can render, exactly like the read path. """ try: r = subprocess.run( cmd, capture_output=True, text=True, check=False, timeout=600, env=SCRUBBED_ENV, ) except subprocess.TimeoutExpired: return {"action": action, "package": pkg, "manager": PKG_MANAGER, "success": False, "stderr": "package manager timed out after 600s — " "it may still be running; check with the manager directly"} except FileNotFoundError: return {"action": action, "package": pkg, "manager": PKG_MANAGER, "success": False, "stderr": f"{cmd[0]} is not installed"} return {"action": action, "package": pkg, "manager": PKG_MANAGER, "command": " ".join(cmd), "success": r.returncode == 0, "rc": r.returncode, "output": r.stdout, "stderr": r.stderr} def _run_mutation(cmd: list[str], action: str, pkg: str = "") -> dict[str, str]: """Run a package-manager mutation; never raises. Ten-minute package operations are normal, and a missing binary is a state, not a crash: both come back as a structured failure the panel can render, exactly like the read path. """ try: r = subprocess.run( cmd, capture_output=True, text=True, check=False, timeout=600, env=SCRUBBED_ENV, ) except subprocess.TimeoutExpired: return {"action": action, "package": pkg, "manager": PKG_MANAGER, "success": False, "stderr": "package manager timed out after 600s — " "it may still be running; check with the manager directly"} except FileNotFoundError: return {"action": action, "package": pkg, "manager": PKG_MANAGER, "success": False, "stderr": f"{cmd[0]} is not installed"} return {"action": action, "package": pkg, "manager": PKG_MANAGER, "command": " ".join(cmd), "success": r.returncode == 0, "rc": r.returncode, "output": r.stdout, "stderr": r.stderr} def update_all() -> dict[str, str]: """Update all packages — actually runs the package manager. 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 = _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}"} return _run_mutation(cmd, "update-all", "") def dry_run(args: list[str]) -> dict[str, str]: """Return the command that *would* be run — for the operator preview. 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 = _mutation_cmd(action, pkg) return {"action": action, "package": pkg, "manager": PKG_MANAGER, "command": " ".join(cmd) if cmd else ""} def summary() -> dict[str, Any]: """Aggregate summary: installed count, update count, manager.""" installed = list_installed() updates = list_updates() 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 = { "list-installed": lambda _args: list_installed(), "list-updates": lambda _args: list_updates(), "search": lambda args: search(args), "info": lambda args: info(args), "install": lambda args: install(args), "remove": lambda args: remove(args), "update": lambda args: update(args), "update-all": lambda _args: update_all(), "dry-run": lambda args: dry_run(args), "summary": lambda _args: summary(), } def main(argv: list[str]) -> int: if not argv or argv[0] in ("-h", "--help"): print(__doc__) return 0 cmd = COMMANDS.get(argv[0]) if not cmd: print(f"Unknown subcommand: {argv[0]}", file=sys.stderr) return 2 print(json.dumps(cmd(argv[1:]), indent=2)) return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))