#!/usr/bin/env python3 """ 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 Packages panel can list, search, install, update, and remove packages without knowing which distro it runs on. 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 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` 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. """ import json import os import re import subprocess import sys from typing import Any 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: prefer pacman (Arch), then dnf (Fedora), then apt (Debian/Ubuntu). # The detected manager determines which backend functions are used. 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) return cmd except (subprocess.CalledProcessError, FileNotFoundError): continue return "unknown" PKG_MANAGER = _detect_pkg_manager() def run(argv: list[str]) -> str: """Run a command, returning stdout. Returns '' on failure.""" try: return subprocess.run( argv, capture_output=True, text=True, check=True, ).stdout except (subprocess.CalledProcessError, FileNotFoundError): 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.""" raw = run(["dnf", "check-update", "--quiet"]) 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 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 ────────────────────────────── BACKENDS = { "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 {}, }, "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 {}, }, "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 {}, }, } 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") 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") 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") 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") 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 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: """v0.1.4 FIX: firewall.py's install-backend used to call this helper as `packages.py install -- ` (a `--` argv separator, shell convention) — install() read args[0] == '--' and the backend-install path has been broken since it shipped. Skip any leading '--' separators instead of choking on them.""" for a in args: if a != "--": return a return None 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 command-string preview shape is preserved as the `dry-run` subcommand 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_map = {"pacman": ["pacman", "-S", "--noconfirm", pkg], "dnf": ["dnf", "install", "-y", pkg], "apt": ["apt", "install", "-y", pkg]} cmd = cmd_map.get(PKG_MANAGER, []) 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. r = subprocess.run(cmd, capture_output=True, text=True, check=False) return {"action": "install", "package": pkg, "manager": PKG_MANAGER, "command": " ".join(cmd), "success": r.returncode == 0, "rc": r.returncode, "output": r.stdout, "stderr": r.stderr} 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_map = {"pacman": ["pacman", "-R", "--noconfirm", pkg], "dnf": ["dnf", "remove", "-y", pkg], "apt": ["apt", "remove", "-y", pkg]} cmd = cmd_map.get(PKG_MANAGER, []) if not cmd: return {"action": "remove", "package": pkg, "manager": PKG_MANAGER, "success": False, "stderr": f"no remove command for {PKG_MANAGER}"} r = subprocess.run(cmd, capture_output=True, text=True, check=False) return {"action": "remove", "package": pkg, "manager": PKG_MANAGER, "command": " ".join(cmd), "success": r.returncode == 0, "rc": r.returncode, "output": r.stdout, "stderr": r.stderr} 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}"} cmd_map = {"pacman": ["pacman", "-S", "--noconfirm", pkg], "dnf": ["dnf", "upgrade", "-y", pkg], "apt": ["apt", "upgrade", "-y", pkg]} cmd = cmd_map.get(PKG_MANAGER, []) if not cmd: return {"action": "update", "package": pkg, "manager": PKG_MANAGER, "success": False, "stderr": f"no update command for {PKG_MANAGER}"} r = subprocess.run(cmd, capture_output=True, text=True, check=False) return {"action": "update", "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. See install(). 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. """ cmd_map = {"pacman": ["pacman", "-Syu", "--noconfirm"], "dnf": ["dnf", "upgrade", "-y"], "apt": ["apt", "upgrade", "-y"]} cmd = cmd_map.get(PKG_MANAGER, []) if not cmd: return {"action": "update-all", "manager": PKG_MANAGER, "success": False, "stderr": f"no update-all command for {PKG_MANAGER}"} r = subprocess.run(cmd, capture_output=True, text=True, check=False) return {"action": "update-all", "manager": PKG_MANAGER, "command": " ".join(cmd), "success": r.returncode == 0, "rc": r.returncode, "output": r.stdout, "stderr": r.stderr} 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. """ 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, []) 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() return { "manager": PKG_MANAGER, "installedCount": len(installed), "updateCount": len(updates), "updates": updates[:20], # Cap at 20 for the summary view } 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(), # 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(), } 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:]))