diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-10-15-updates.md | 1196 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-10-15-updates-design.md | 29 |
2 files changed, 1213 insertions, 12 deletions
diff --git a/docs/superpowers/plans/2026-10-15-updates.md b/docs/superpowers/plans/2026-10-15-updates.md new file mode 100644 index 0000000..c0c5c48 --- /dev/null +++ b/docs/superpowers/plans/2026-10-15-updates.md @@ -0,0 +1,1196 @@ +# Updates Module Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A drawer tile and page that sort every pending update into the next action: Slackware upgrade/new/removed, upgrade here, awaiting build, upstream newer, app updates. + +**Architecture:** One stdlib Python script, `desktop/modules/updates/updatesctl`, reads every source and prints one JSON document with the sections already decided. `UpdatesModule.qml` runs it while the drawer is open and `UpdatesPage.qml` only draws. Spec: `docs/superpowers/specs/2026-10-15-updates-design.md`. + +**Tech Stack:** `/usr/bin/python3` (stdlib only), Quickshell 0.3.1 QML. + +**Rules that bite here (from AGENTS.md):** +- Use `/usr/bin/python3`, never `python3` (a pyenv shim). +- Never start `qs` from a tool call, never `pkill -f`. QML hot-reloads on save; ask the user to look. +- No home paths in committed files: write `~` in docs; code builds paths from `$HOME`. +- An injected id must not share the property's name (`up: mod`, never `mod: mod`). +- Every source file carries the GPLv2 header. Commits are GPG-signed and carry the reasoning. + +--- + +## File map + +- Create `desktop/modules/updates/updatesctl`: the script (sources, version order, sections, JSON). +- Create `desktop/modules/updates/test_updatesctl.py`: fixture tree + asserts, no framework. +- Create `desktop/modules/updates/UpdatesModule.qml`: process, freshness, tile. +- Create `desktop/modules/updates/UpdatesPage.qml`: the sections. +- Create `desktop/modules/updates/README.md`. +- Modify `desktop/shell.qml`: import and register the module after `ClipboardModule`. +- Modify `AGENTS.md`: add "updates" to the drawer's list. + +--- + +### Task 1: Version order + +**Files:** +- Create: `desktop/modules/updates/updatesctl` +- Create: `desktop/modules/updates/test_updatesctl.py` + +- [ ] **Step 1: Write the failing test** + +`desktop/modules/updates/test_updatesctl.py`: + +```python +#!/usr/bin/python3 +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +"""Run: /usr/bin/python3 desktop/modules/updates/test_updatesctl.py + +Imports updatesctl for vercmp, then drives it as a CLI against a fixture tree +under a temp dir (UPDATESCTL_ROOT for system paths, HOME for the user's), +never the live system. Plain asserts, no framework. +""" + +import importlib.machinery +import importlib.util +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent / "updatesctl" + +loader = importlib.machinery.SourceFileLoader("updatesctl", str(SCRIPT)) +spec = importlib.util.spec_from_loader("updatesctl", loader) +u = importlib.util.module_from_spec(spec) +loader.exec_module(u) + +# --- vercmp --- +assert u.vercmp("1.14.10", "1.14.9") > 0 +assert u.vercmp("2.1.278", "2.1.267") > 0 +assert u.vercmp("2026_10_02", "2026_09_12") > 0 +assert u.vercmp("v1.2", "1.2") == 0 +assert u.vercmp("1.0", "1.0") == 0 +# A pre-release is older than the release, a suffix is never behind. +assert u.vercmp("0.12.3_beta.4", "0.12.3") < 0 +assert u.vercmp("9.018.00_6.18.41", "9.018.00") > 0 +assert u.vercmp("3.1-659215d", "3.1") > 0 +assert u.vercmp("1.0.1", "1.0rc1") > 0 + +print("ok") +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `/usr/bin/python3 desktop/modules/updates/test_updatesctl.py` +Expected: FAIL, `FileNotFoundError` for `updatesctl`. + +- [ ] **Step 3: Write the script skeleton with vercmp** + +`desktop/modules/updates/updatesctl`: + +```python +#!/usr/bin/python3 +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +"""updatesctl - what to do next to bring this machine up to date. + +Prints one JSON document for the drawer's updates module, with every pending +update already sorted into the action it needs. The sections and the reasons +for them are in docs/superpowers/specs/2026-10-15-updates-design.md. + + updatesctl from the last nvchecker results + updatesctl --refresh run nvchecker first + +UPDATESCTL_ROOT prefixes every system path and HOME roots the user's, so the +test can point both at a fixture tree. +""" + +import json +import os +import re +import shutil +import subprocess +import sys +import time +from functools import cmp_to_key +from pathlib import Path + +ROOT = os.environ.get("UPDATESCTL_ROOT", "") +HOME = Path(os.environ["HOME"]) +NFS_TIMEOUT = float(os.environ.get("UPDATESCTL_NFS_TIMEOUT", "5")) + + +def sysp(p): + return Path(ROOT + p) + + +def vercmp(a, b): + """Version order: numeric chunks as numbers, numbers after letters. + + When one version runs out, the longer one is newer unless its next chunk + is alphabetic, which makes 0.12.3_beta.4 older than 0.12.3 and a kernel + suffix or git hash (9.018.00_6.18.41) newer than its base, never behind. + """ + def chunks(v): + v = v.lower() + return re.findall(r"\d+|[a-z]+", v[1:] if v.startswith("v") else v) + + x, y = chunks(a), chunks(b) + for p, q in zip(x, y): + if p == q: + continue + if p.isdigit() and q.isdigit(): + return (int(p) > int(q)) - (int(p) < int(q)) + if p.isdigit() != q.isdigit(): + return 1 if p.isdigit() else -1 + return (p > q) - (p < q) + if len(x) == len(y): + return 0 + longer, sign = (x, 1) if len(x) > len(y) else (y, -1) + return sign if longer[min(len(x), len(y))].isdigit() else -sign + + +def main(): + pass + + +if __name__ == "__main__": + main() +``` + +Then: `chmod +x desktop/modules/updates/updatesctl` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `/usr/bin/python3 desktop/modules/updates/test_updatesctl.py` +Expected: `ok` + +- [ ] **Step 5: Show the test can fail.** Temporarily change `return sign if longer[...]` to `return sign`, rerun, and expect an `AssertionError` on the beta line. Revert, rerun, expect `ok`. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/modules/updates/updatesctl desktop/modules/updates/test_updatesctl.py +git commit -S -F - <<'EOF' +feat(updates): version order for updatesctl + +nvtable compares versions for equality, which cannot tell an installed +2.1.278 from an SBo recipe of 2.1.267 that is older than it. vercmp +orders numeric chunks as numbers and treats a trailing alphabetic chunk +as a pre-release, so a kernel suffix or git hash orders as newer than +its base and never reads as behind. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> +EOF +``` + +--- + +### Task 2: Tracked packages into sections + +**Files:** +- Modify: `desktop/modules/updates/updatesctl` +- Modify: `desktop/modules/updates/test_updatesctl.py` + +- [ ] **Step 1: Write the failing test.** Replace the final `print("ok")` in `test_updatesctl.py` with the fixture tree and the section asserts below, and add `import json, os, shutil, subprocess, tempfile` to the imports at the top. + +```python +# --- fixture tree --- +tmp = Path(tempfile.mkdtemp(prefix="updatesctl-test-")) +home = tmp / "home" + + +def w(path, text=""): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + +def info(repo_rel, version): + w(home / "Programming/GIT/slackbuilds-superrepo" / repo_rel, + f'PRGNAM="x"\nVERSION="{version}"\n') + + +def sbo(rel, version): + w(tmp / "var/lib/sbopkg/SBo-git" / rel, f'VERSION="{version}"\n') + + +def pkgtxt(entries): + return "".join(f"PACKAGE NAME: {full}.txz\nPACKAGE LOCATION: ./{loc}\n\n" + for loc, full in entries) + + +installed = [ + "kitty-0.49.1-x86_64-1_danix", # built 0.49.2 -> upgrade + "gamescope-3.16.30-x86_64-1_danix", # recipe bumped -> build + "discord-1.0.159-x86_64-1_danix", # recipe behind -> upstream + "claude-code-2.1.278-x86_64-1_SBo", # ahead of SBo -> upstream + "nessus-10.12.4-x86_64-1_SBo", # SBo current -> build + "neovim-0.12.6-x86_64-1_SBo", # current -> nothing + "r8125-9.018.00_6.18.41-x86_64-1_danix", # suffix -> nothing + "solvespace-3.1-659215d-x86_64-1_danix", # dash version -> nothing + "glibc-2.44-x86_64-3", # mirror has -4 -> slackware upgrade + "bash-5.3-x86_64-1", # testing/ newer, blacklisted -> nothing + "dhcp-4.4.3-x86_64-1", # removed upstream -> slackware removed + "foo-1.0-x86_64-1_SBo", # blacklisted build -> nothing +] +for f in installed: + w(tmp / "var/log/packages" / f) + +w(home / ".config/nvchecker/new_ver.json", json.dumps({"version": 2, "data": { + "kitty": {"version": "0.49.2"}, + "gamescope": {"version": "3.16.31"}, + "discord": {"version": "1.0.162"}, + "claude-code": {"version": "2.1.300"}, + "nessus": {"version": "10.12.5"}, + "neovim": {"version": "0.12.6"}, + "r8125": {"version": "9.018.00"}, + "solvespace": {"version": "3.1"}, + "exploitdb": {"version": "2026_10_02"}, # not installed, my recipe behind + "obsidian": {"version": "1.14.4"}, # not installed, SBo behind: hidden + "Typora": {"version": "1.14.10"}, # AppImage behind + "GIMP": {"version": "3.0.6"}, # flatpak behind +}})) + +info("my-slackbuilds/kitty-bin/kitty-bin.info", "0.49.2") +info("my-slackbuilds/gamescope/gamescope.info", "3.16.31") +info("my-slackbuilds/network/discord/discord.info", "1.0.161") +info("my-slackbuilds/r8125/r8125.info", "9.018.00") +info("my-slackbuilds/solvespace/solvespace.info", "3.1") +info("sbo-slackbuilds/system/exploitdb/exploitdb.info", "2026_09_12") +sbo("development/claude-code-bin/claude-code-bin.info", "2.1.267") +sbo("network/nessus/nessus.info", "10.12.5") +sbo("development/neovim/neovim.info", "0.12.4") +sbo("office/obsidian/obsidian.info", "1.13.7") + +w(home / ".local/state/appimage-updater/installed.json", + json.dumps({"Typora": {"version": "1.14.9"}})) +w(tmp / "bin/flatpak", "#!/bin/sh\nprintf 'org.gimp.GIMP\\t3.0.4\\n'\n") +(tmp / "bin/flatpak").chmod(0o755) + +w(tmp / "etc/slackpkg/slackpkgplus.conf", "MIRRORPLUS['danix']=dir://repo/\n") +w(tmp / "repo/PACKAGES.TXT", pkgtxt([ + ("devel/kitty", "kitty-0.49.2-x86_64-1_danix"), + ("games/gamescope", "gamescope-3.16.30-x86_64-1_danix"), +])) + +w(tmp / "etc/slackpkg/mirrors", "# a comment\nfile://mnt/mirror/\n") +w(tmp / "etc/slackpkg/blacklist", '"testing/"\n[0-9]+_SBo\n') +w(tmp / "mnt/mirror/PACKAGES.TXT", pkgtxt([ + ("slackware64/l", "glibc-2.44-x86_64-4"), + ("slackware64/a", "bash-5.3-x86_64-1"), + ("testing/packages", "bash-5.4-x86_64-1"), + ("slackware64/a", "foo-1.1-x86_64-1"), + ("slackware64/l", "libtatsu-1.0.5-x86_64-1"), +])) +w(tmp / "mnt/mirror/ChangeLog.txt", """\ +Wed Oct 14 21:48:34 UTC 2026 +l/glibc-2.44-x86_64-4.txz: Rebuilt. +l/libtatsu-1.0.5-x86_64-1.txz: Upgraded. +n/dhcp-4.4.3-x86_64-1.txz: Removed. ++--------------------------+ +Thu Oct 1 10:00:00 UTC 2026 +x/oldthing-1.0-x86_64-1.txz: Removed. +l/libtatsu-1.0.4-x86_64-1.txz: Added. ++--------------------------+ +Tue Sep 1 10:00:00 UTC 2026 +x/oldthing-1.0-x86_64-1.txz: Added. +""") + + +def run(*args, timeout="5"): + env = {"HOME": str(home), "UPDATESCTL_ROOT": str(tmp), + "UPDATESCTL_NFS_TIMEOUT": timeout, + "PATH": f"{tmp}/bin:/usr/bin:/bin"} + r = subprocess.run(["/usr/bin/python3", str(SCRIPT), *args], + capture_output=True, text=True, env=env, timeout=30) + assert r.returncode == 0, r.stderr + return json.loads(r.stdout) + + +def rows(section): + return {x["name"]: (x["repo"], x["from"], x["to"]) for x in section} + + +d = run() +assert d["errors"] == {}, d["errors"] +assert d["checked"] > 0 +assert rows(d["upgrade"]) == {"kitty": ("personal", "0.49.1", "0.49.2")}, d["upgrade"] +assert rows(d["build"]) == { + "gamescope": ("personal", "3.16.30", "3.16.31"), + "nessus": ("SBo", "10.12.4", "10.12.5"), +}, d["build"] +assert rows(d["upstream"]) == { + "discord": ("personal", "1.0.161", "1.0.162"), + "claude-code": ("SBo", "2.1.278", "2.1.300"), + "exploitdb": ("my-sbo", "2026_09_12", "2026_10_02"), +}, d["upstream"] +assert rows(d["apps"]) == { + "Typora": ("AppImage", "1.14.9", "1.14.10"), + "GIMP": ("flatpak", "3.0.4", "3.0.6"), +}, d["apps"] + +shutil.rmtree(tmp) +print("ok") +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `/usr/bin/python3 desktop/modules/updates/test_updatesctl.py` +Expected: FAIL, `json.decoder.JSONDecodeError` (main prints nothing). + +- [ ] **Step 3: Implement tracked packages.** In `updatesctl`, add after `sysp()`: + +```python +NVCONF = HOME / ".config/nvchecker/nvchecker.toml" +NEWVER = HOME / ".config/nvchecker/new_ver.json" +SUPER = HOME / "Programming/GIT/slackbuilds-superrepo" +SBO = "/var/lib/sbopkg/SBo-git" +AI_STATE = (Path(os.environ.get("XDG_STATE_HOME") or HOME / ".local/state") + / "appimage-updater/installed.json") + +# Repo directory -> the label nvtable shows for it. +REPOS = [("sbo-slackbuilds", "my-sbo"), ("my-slackbuilds", "personal"), + ("Slackware-Pentesting-Suite", "pentesting")] + +# From nvtable. Stanza name -> installed package name(s), where they differ. +ALIAS = { + "kvantum": ["kvantum-qt5", "kvantum-qt6"], + "metasploit-framework": ["metasploit-framework-bin"], + "Typora": ["typora-bin"], +} + +# From nvtable. Stanza name -> SlackBuild directory, where they differ. +REPO_ALIAS = { + "kitty": ["kitty-bin"], + "claude-code": ["claude-code-bin"], + "kvantum": ["kvantum-qt6"], + "metasploit-framework": ["metasploit-framework-bin"], + "Typora": ["typora-bin"], +} + + +def read(path): + try: + return Path(path).read_text(errors="replace") + except OSError: + return "" + + +def nfs_read(path): + """A file on the NFS share, or None. The mount is hard, so a read during + a NAS outage blocks: it runs in a child that the timeout kills.""" + try: + r = subprocess.run(["cat", str(path)], capture_output=True, + timeout=NFS_TIMEOUT) + except subprocess.TimeoutExpired: + return None + return r.stdout.decode(errors="replace") if r.returncode == 0 else None + + +def split(full): + """name-version-arch-build. Right for Slackware's own packages, whose + versions never contain a dash; tracked ones go through version_in().""" + return full.rsplit("-", 3) + + +def version_in(fulls, names): + """The version of the first of names found among package file names. + + Anchored on "<name>-" followed by a digit, so a version containing a + dash (solvespace-3.1-659215d) survives and a longer sibling + (kvantum-qt5 when asked for kvantum) is never taken for a match.""" + for want in names: + for full in fulls: + stem = full.rsplit("-", 2)[0] + if stem.lower().startswith(want.lower() + "-"): + rest = stem[len(want) + 1:] + if rest[:1].isdigit(): + return rest + return "" + + +def packages_txt(text): + """PACKAGES.TXT as (location, full name) pairs, location without ./""" + out, name = [], None + for line in text.splitlines(): + if line.startswith("PACKAGE NAME:"): + name = re.sub(r"\.t[gx]z$", "", line.split(":", 1)[1].strip()) + elif line.startswith("PACKAGE LOCATION:") and name: + out.append((line.split(":", 1)[1].strip().lstrip("./"), name)) + name = None + return out + + +def local_dir(url): + m = re.match(r"(?:file|dir)://(.*)", url.strip()) + return sysp("/" + m.group(1).lstrip("/")) if m else None + + +def repo_dir(): + m = re.search(r"^MIRRORPLUS\['danix'\]=(\S+)", + read(sysp("/etc/slackpkg/slackpkgplus.conf")), re.M) + return local_dir(m.group(1)) if m else None + + +def info_version(path): + m = re.search(r'^VERSION="(.*)"$', read(path), re.M) + return m.group(1) if m else "" + + +def recipes(): + """SlackBuild directory -> (version, label) over the user's repos.""" + out = {} + for d, label in REPOS: + base = SUPER / d + for inf in sorted(list(base.glob("*/*.info")) + list(base.glob("*/*/*.info"))): + v = info_version(inf) + if v: + out.setdefault(inf.stem, (v, label)) + return out + + +def recipe_of(name, mine): + """(version, label) of the recipe: the user's first, then the SBo clone.""" + cands = [name] + REPO_ALIAS.get(name, []) + ALIAS.get(name, []) + for c in cands: + if c in mine: + return mine[c] + for c in cands: + for inf in sysp(SBO).glob(f"*/{c}/{c}.info"): + v = info_version(inf) + if v: + return v, "SBo" + return "", "" + + +def apps(): + """Lowercased app key -> (version, source) for AppImages and flatpaks.""" + out = {} + try: + for k, v in json.loads(read(AI_STATE) or "{}").items(): + out[k.lower()] = (v.get("version", ""), "AppImage") + except (ValueError, AttributeError): + pass + if shutil.which("flatpak"): + r = subprocess.run(["flatpak", "list", "--app", "--columns=application,version"], + capture_output=True, text=True) + for line in r.stdout.splitlines(): + app, _, v = line.partition("\t") + if app: + out[app.rsplit(".", 1)[-1].lower()] = (v, "flatpak") + return out + + +def row(name, repo, frm, to): + return {"name": name, "repo": repo, "from": frm, "to": to} + + +def tracked(installed, errors): + out = {k: [] for k in ("upgrade", "build", "upstream", "apps")} + try: + data = json.loads(NEWVER.read_text()) + except (OSError, ValueError): + errors["nvchecker"] = "no results" + return out + data = data.get("data", data) + + rd = repo_dir() + pk = nfs_read(rd / "PACKAGES.TXT") if rd else None + if pk is None: + errors["repo"] = "unreachable" + built = [full for _, full in packages_txt(pk or "")] + mine, app = recipes(), apps() + + for name in sorted(data, key=str.lower): + v = data[name] + up = v.get("version", "") if isinstance(v, dict) else str(v) + names = ALIAS.get(name, [name]) + + i = version_in(installed, names) + if not i: + hit = next((app[n.lower()] for n in [name] + names if n.lower() in app), None) + if hit: + if vercmp(hit[0], up) < 0: + out["apps"].append(row(name, hit[1], hit[0], up)) + continue + + r, label = recipe_of(name, mine) + b = version_in(built, [name] + names + REPO_ALIAS.get(name, [])) + label = label or "--" + + if i and b and vercmp(b, i) > 0: + out["upgrade"].append(row(name, label, i, b)) + elif i and r and vercmp(r, up) >= 0 and (not b or vercmp(b, up) < 0): + if vercmp(i, up) < 0: + out["build"].append(row(name, label, b or i, up)) + elif i or (r and label != "SBo"): + # ponytail: base is the newest of recipe, build and install; an + # installed version ahead of every recipe still counts as behind + # upstream when upstream moved past it. + base = max((x for x in (r, b, i) if x), + key=cmp_to_key(vercmp), default="") + if base and vercmp(up, base) > 0: + out["upstream"].append(row(name, label, base, up)) + return out +``` + +Replace `main()`: + +```python +def main(): + errors = {} + if "--refresh" in sys.argv[1:]: + # Exit status is not checked: nvchecker exits 0 with failed stanzas + # logged, and a partial run still rewrites new_ver.json. + try: + subprocess.run(["nvchecker", "-c", str(NVCONF)], + capture_output=True, timeout=600) + except (OSError, subprocess.SubprocessError): + errors["nvchecker"] = "refresh failed" + try: + installed = os.listdir(sysp("/var/log/packages")) + except OSError: + installed = [] + doc = {"checked": int(NEWVER.stat().st_mtime) if NEWVER.exists() else 0} + doc.update(tracked(installed, errors)) + doc["errors"] = errors + json.dump(doc, sys.stdout) + print() +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `/usr/bin/python3 desktop/modules/updates/test_updatesctl.py` +Expected: `ok`. If an assert fails, it prints the actual section. Fix the code, not the fixture, unless the fixture contradicts the spec. + +- [ ] **Step 5: Show the test can fail.** Temporarily remove `and (not b or vercmp(b, up) < 0)`, rerun, and expect the `build` assert to fail. Revert, expect `ok`. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/modules/updates/updatesctl desktop/modules/updates/test_updatesctl.py +git commit -S -F - <<'EOF' +feat(updates): sort tracked packages into the next action + +Each nvchecker stanza lands in one section: upgrade here when the binary +repo's PACKAGES.TXT holds a newer build than installed, awaiting build +when the recipe (the user's or SBo's) has caught up but the build has +not, upstream newer when the recipe lags, app updates for AppImages and +flatpaks. Built comes from PACKAGES.TXT rather than the .info, which is +why gamescope and kitty no longer read as installable: the repo still +held the old build. The repo is on a hard NFS mount, so it is read by a +child the timeout can kill. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> +EOF +``` + +--- + +### Task 3: Slackware upgrade, new, removed + +**Files:** +- Modify: `desktop/modules/updates/updatesctl` +- Modify: `desktop/modules/updates/test_updatesctl.py` + +- [ ] **Step 1: Write the failing test.** In `test_updatesctl.py`, after the `apps` assert and before `shutil.rmtree(tmp)`, add: + +```python +sw = d["slackware"] +assert sw["upgrade"] == [{"name": "glibc", "from": "2.44-3", "to": "2.44-4"}], sw +assert sw["new"] == [{"name": "libtatsu", "version": "1.0.5-1"}], sw +assert sw["removed"] == [{"name": "dhcp", "date": "2026-10-14"}], sw +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `/usr/bin/python3 desktop/modules/updates/test_updatesctl.py` +Expected: FAIL, `KeyError: 'slackware'`. + +- [ ] **Step 3: Implement.** In `updatesctl`, add before `main()`: + +```python +def blacklist(): + """slackpkg's blacklist as regexes, its surrounding quotes dropped. + + ponytail: a pattern blocks when it occurs anywhere in location/full + name, so a bare "dhcp" would also block "dhcpcd"; slackpkg itself is + stricter. Fine for a list of build tags and directories.""" + out = [] + for line in read(sysp("/etc/slackpkg/blacklist")).splitlines(): + p = line.strip().strip('"') + if p and not p.startswith("#"): + out.append(re.compile(p)) + return out + + +def blocked(bl, path): + return any(p.search(path) for p in bl) + + +DATE = re.compile(r"^[A-Z][a-z]{2} [A-Z][a-z]{2} +\d+ \d\d:\d\d:\d\d UTC \d{4}$") +CLPKG = re.compile(r"^(\S+?)\.t[gx]z:\s+(Added|Removed)\.") + + +def changelog(text): + """Package name -> (Added|Removed, date) from its newest such line. + + The ChangeLog is newest first, so the first line seen wins. Other + actions are skipped: a package added and later upgraded is still new.""" + seen, date = {}, "" + for line in text.splitlines(): + if DATE.match(line): + date = time.strftime("%Y-%m-%d", + time.strptime(line, "%a %b %d %H:%M:%S UTC %Y")) + continue + m = CLPKG.match(line) + if m: + name = split(m.group(1).rsplit("/", 1)[-1])[0] + seen.setdefault(name, (m.group(2), date)) + return seen + + +def mirror_dir(): + for line in read(sysp("/etc/slackpkg/mirrors")).splitlines(): + line = line.strip() + if line and not line.startswith("#"): + return local_dir(line) + return None + + +def verbuild(full): + _, v, _, b = split(full) + return f"{v}-{b}" + + +def slackware(installed, errors): + out = {"upgrade": [], "new": [], "removed": []} + d = mirror_dir() + if d is None: + errors["mirror"] = "not a local mirror" + return out + pk, cl = nfs_read(d / "PACKAGES.TXT"), nfs_read(d / "ChangeLog.txt") + if pk is None or cl is None: + errors["mirror"] = "unreachable" + return out + + bl = blacklist() + mirror = {} + for loc, full in packages_txt(pk): + if not blocked(bl, f"{loc}/{full}"): + mirror.setdefault(split(full)[0], full) + every = {split(f)[0] for f in installed if f.count("-") >= 3} + mine = {split(f)[0]: f for f in installed + if f.count("-") >= 3 and not blocked(bl, f)} + + for name, full in sorted(mine.items()): + if name in mirror and mirror[name] != full: + out["upgrade"].append({"name": name, "from": verbuild(full), + "to": verbuild(mirror[name])}) + for name, (act, date) in sorted(changelog(cl).items()): + if act == "Added" and name in mirror and name not in every: + out["new"].append({"name": name, "version": verbuild(mirror[name])}) + elif act == "Removed" and name in mine and name not in mirror: + out["removed"].append({"name": name, "date": date}) + return out +``` + +In `main()`, change the `doc = ...` line to: + +```python + doc = {"checked": int(NEWVER.stat().st_mtime) if NEWVER.exists() else 0, + "slackware": slackware(installed, errors)} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `/usr/bin/python3 desktop/modules/updates/test_updatesctl.py` +Expected: `ok`. That covers the blacklisted `foo` (`_SBo`) and the `testing/` bash staying out, `oldthing` (Added, then Removed) appearing nowhere, and libtatsu (Added, then Upgraded) counting as new. + +- [ ] **Step 5: Show the test can fail.** Temporarily change `if not blocked(bl, f"{loc}/{full}")` to `if True`, rerun, and expect the `upgrade` assert to fail on bash 5.4. Revert, expect `ok`. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/modules/updates/updatesctl desktop/modules/updates/test_updatesctl.py +git commit -S -F - <<'EOF' +feat(updates): Slackware upgrades, new and removed packages + +Compared mirror against installed, as upgrade-all does, not against +slackpkg's local ChangeLog: `slackpkg update` replaces that without +installing anything, so a diff against it reads clean after an update +that was never applied. New and removed come from the mirror's +ChangeLog, the newest Added or Removed line deciding, so a package +added and later upgraded still counts as new. Blacklisted build tags +and directories are skipped, as slackpkg skips them. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> +EOF +``` + +--- + +### Task 4: A hung NFS read + +**Files:** +- Modify: `desktop/modules/updates/test_updatesctl.py` + +`nfs_read` already has the timeout (Task 2). This task proves it with a source that blocks like a hard mount. + +- [ ] **Step 1: Write the test.** Before `shutil.rmtree(tmp)`, add: + +```python +# A fifo with no writer blocks cat at open(), as a hard NFS mount blocks a +# read while the NAS is down. +import time as _t +(tmp / "repo/PACKAGES.TXT").unlink() +os.mkfifo(tmp / "repo/PACKAGES.TXT") +t0 = _t.monotonic() +d = run(timeout="0.5") +assert _t.monotonic() - t0 < 5, "a hung read stalled the script" +assert d["errors"] == {"repo": "unreachable"}, d["errors"] +assert d["upgrade"] == [], d["upgrade"] +assert d["slackware"]["upgrade"], "the mirror sections must still fill" +``` + +- [ ] **Step 2: Run it** + +Run: `/usr/bin/python3 desktop/modules/updates/test_updatesctl.py` +Expected: `ok`, in about a second. + +- [ ] **Step 3: Show the test can fail.** Temporarily set `timeout=None` in `nfs_read`, rerun, and expect `subprocess.TimeoutExpired` from the test's own 30s guard. Revert, expect `ok`. + +- [ ] **Step 4: Commit** + +```bash +git add desktop/modules/updates/test_updatesctl.py +git commit -S -F - <<'EOF' +test(updates): a hung NFS read reports unreachable + +A fifo with no writer blocks cat at open() the way a hard NFS mount +blocks during a NAS outage. The repo section reports unreachable within +the timeout and the mirror sections still fill. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> +EOF +``` + +--- + +### Task 5: Live run against the real machine + +No code. This checks the script against what is installed. + +- [ ] **Step 1: Run it** + +Run: `time /usr/bin/python3 desktop/modules/updates/updatesctl | /usr/bin/python3 -m json.tool | head -80` +Expected: `errors` is `{}`. kitty and gamescope are under `build` or `upstream`, not `upgrade` (the repo holds 0.49.1 and 3.16.30, the installed versions). `slackware.new` lists emacs, libtatsu and f3, and `slackware.removed` lists dhcp, unless the machine changed since the spec. Note the wall time. + +- [ ] **Step 2: Cross-check against nvtable.** Run `nvtable -n -C -E` and check that every row it marks is in exactly one updatesctl section or deliberately hidden (an installed version at or past upstream). Investigate any difference before going on; record the reason if it is intended. + +--- + +### Task 6: Module and tile + +**Files:** +- Create: `desktop/modules/updates/UpdatesModule.qml` +- Modify: `desktop/shell.qml` + +- [ ] **Step 1: Write the module** + +`desktop/modules/updates/UpdatesModule.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Io +import QtQuick +import "../.." + +// Pending updates, sorted by updatesctl into the next action. Nothing runs +// while the drawer is closed: the tile's lifetime is the check's, as with +// KDE Connect. A result older than 30 minutes is followed by a refresh, +// which runs nvchecker. +Module { + id: mod + + name: "updates" + label: "Updates" + + // linux-slackware, present in Inconsolata Nerd Font. + icon: "\uf318" + + property var doc: null + property bool checking: false + + readonly property int toInstall: doc + ? doc.slackware.upgrade.length + doc.slackware.new.length + + doc.slackware.removed.length + doc.upgrade.length + : 0 + readonly property int toBuild: doc ? doc.build.length : 0 + + active: toInstall > 0 + + property bool polling: false + onPollingChanged: if (polling) run(false) + + function run(refresh) { + if (proc.running && mod.checking) return; // never cut nvchecker short + proc.command = ["/usr/bin/python3", + `${Quickshell.shellDir}/modules/updates/updatesctl`].concat(refresh ? ["--refresh"] : []); + proc.running = false; + // After the stop: a killed run can still finish its stream, and + // its handler clears `checking`. + mod.checking = refresh; + proc.running = true; + } + + property Process proc: Process { + stdout: StdioCollector { + onStreamFinished: { + const refreshed = mod.checking; + mod.checking = false; + try { mod.doc = JSON.parse(this.text); } catch (e) { return; } + // Only after a cached read: a refresh whose nvchecker failed + // leaves `checked` old and would otherwise loop. + if (!refreshed && mod.polling && Date.now() / 1000 - mod.doc.checked > 1800) + mod.run(true); + } + } + } + + tileContent: Component { + Text { + Component.onCompleted: mod.polling = true + Component.onDestruction: mod.polling = false + + width: parent ? parent.width : implicitWidth + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: mod.active ? Theme.text : Theme.subtext + text: { + if (!mod.doc) return "checking…"; + const parts = []; + if (mod.toInstall) parts.push(mod.toInstall + " to install"); + if (mod.toBuild) parts.push(mod.toBuild + " to build"); + return parts.join(" · ") || "up to date"; + } + } + } + + page: Component { + Page { + title: "Updates" + UpdatesPage { + width: parent.width + up: mod + } + } + } +} +``` + +- [ ] **Step 2: Register it.** In `desktop/shell.qml`, add `import "modules/updates"` after `import "modules/status"`, and `UpdatesModule {},` after `ClipboardModule {},` in the `modules:` list. Editing `shell.qml` forces the qmldir rescan for the new directory (AGENTS.md, Reloading). + +- [ ] **Step 3: Create a minimal `UpdatesPage.qml`** so the module resolves. Task 7 replaces it: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick +import "../.." + +Column { + required property var up +} +``` + +- [ ] **Step 4: Ask the user** to open the drawer and confirm the Slackware-logo tile showing "checking…" and then a count. If the tile is missing, the module failed to load: ask the user for the drawer's terminal or log output rather than starting a `qs` from a tool call. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/updates/UpdatesModule.qml desktop/modules/updates/UpdatesPage.qml desktop/shell.qml +git commit -S -F - <<'EOF' +feat(updates): the drawer tile + +The tile counts what can be installed now (Slackware and the binary +repo) and what only needs a build, and goes accent when there is +something to install. It runs updatesctl while the drawer is open and +not otherwise; a result over 30 minutes old is followed by a refresh +that runs nvchecker, never after a refresh, so a failing nvchecker +cannot loop. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> +EOF +``` + +--- + +### Task 7: The page + +**Files:** +- Modify: `desktop/modules/updates/UpdatesPage.qml` + +- [ ] **Step 1: Write the page** + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick +import "../.." + +// One section per action, in the order they are done, each hidden when it +// has nothing and no error. Read-only: installing and building stay in the +// terminal. +Column { + id: page + + required property var up + readonly property var d: up.doc + + spacing: 16 + + function tracked(list) { + return list.map(r => ({ name: r.name, repo: r.repo, ver: r.from + " → " + r.to })); + } + + readonly property var sections: !d ? [] : [ + { title: "Slackware upgrades", err: d.errors.mirror || "", + rows: d.slackware.upgrade.map(r => ({ name: r.name, repo: "", ver: r.from + " → " + r.to })) }, + { title: "New in Slackware", err: "", + rows: d.slackware.new.map(r => ({ name: r.name, repo: "", ver: r.version })) }, + { title: "Removed from Slackware", err: "", + rows: d.slackware.removed.map(r => ({ name: r.name, repo: "", ver: "removed " + r.date })) }, + { title: "Upgrade here", err: d.errors.repo || "", rows: tracked(d.upgrade) }, + { title: "Awaiting build", err: "", rows: tracked(d.build) }, + { title: "Upstream newer", err: d.errors.nvchecker || "", rows: tracked(d.upstream) }, + { title: "App updates", err: "", rows: tracked(d.apps) }, + ] + + function ago(t) { + const m = Math.round((Date.now() / 1000 - t) / 60); + return m < 1 ? "just now" : m < 60 ? m + " min ago" : Math.round(m / 60) + " h ago"; + } + + Text { + visible: page.d !== null && page.up.toInstall === 0 && page.up.toBuild === 0 + && page.d.upstream.length === 0 && page.d.apps.length === 0 + text: "Up to date" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + } + + Repeater { + model: page.sections + + Column { + required property var modelData + width: page.width + spacing: 4 + visible: modelData.rows.length > 0 || modelData.err !== "" + + Text { + text: modelData.title + (modelData.rows.length ? " " + modelData.rows.length : "") + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true } + } + + Text { + visible: modelData.err !== "" + text: modelData.err + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3; italic: true } + } + + Repeater { + model: modelData.rows + + Item { + required property var modelData + width: page.width + height: 22 + + Text { + anchors { left: parent.left; right: repo.left; rightMargin: 8; verticalCenter: parent.verticalCenter } + elide: Text.ElideRight + textFormat: Text.PlainText + text: modelData.name + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Text { + id: repo + anchors { right: ver.left; rightMargin: 12; verticalCenter: parent.verticalCenter } + text: modelData.repo + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Text { + id: ver + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + textFormat: Text.PlainText + text: modelData.ver + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + } + } + } + } + + Row { + spacing: 10 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: page.up.checking ? "checking…" + : page.d && page.d.checked ? "checked " + page.ago(page.d.checked) : "" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + + Button { + text: "Refresh" + enabled: !page.up.checking + onClicked: page.up.run(true) + } + } +} +``` + +- [ ] **Step 2: Ask the user** to open the page and check: section order, versions readable, the repo tag column, the footer age, and that Refresh shows "checking…" and then updates (nvchecker takes a while). Fix anything they report. + +- [ ] **Step 3: Commit** + +```bash +git add desktop/modules/updates/UpdatesPage.qml +git commit -S -F - <<'EOF' +feat(updates): the page, one section per next action + +Slackware upgrades, new and removed first, then upgrade here, awaiting +build, upstream newer, app updates: the order the work is done in. Each +row names the package, the repo it comes from and the one version jump +its action covers. A source that could not be read says so under the +section it empties instead of reading as nothing pending. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> +EOF +``` + +--- + +### Task 8: Docs + +**Files:** +- Create: `desktop/modules/updates/README.md` +- Modify: `AGENTS.md` (the `desktop/` line of the layout block) + +- [ ] **Step 1: Write the README** + +```markdown +# updates + +What to do next to bring this machine up to date, as a drawer page. Each +pending update appears once, under the action it needs: + +- **Slackware upgrades / new / removed**: the slackpkg mirror's + `PACKAGES.TXT` and `ChangeLog.txt` against `/var/log/packages`, the + blacklist honoured. `upgrade-all`, `install-new` and `clean-system`. +- **Upgrade here**: the binary repo (the `danix` slackpkg+ mirror) holds a + newer build than installed. +- **Awaiting build**: the recipe (the user's `.info` or SBo's) has caught up + with upstream, the build has not. +- **Upstream newer**: the recipe lags upstream; bump the `.info` or the + slackrepo hint. +- **App updates**: AppImages and flatpaks behind upstream. + +`updatesctl` does all of it and prints JSON; the QML only draws. Upstream +versions are nvchecker's `new_ver.json`; a result over 30 minutes old is +followed by `updatesctl --refresh`, which runs nvchecker first. Nothing runs +while the drawer is closed. + +**The mirror and the repo are on a hard NFS mount.** A read during a NAS +outage blocks, so every NFS read is a `cat` child with a 5s timeout, and an +unreachable source shows as a line under the section it empties. + +**SBo bumps through slackrepo hints are not visible here:** the hint files +live on the build VM. Such a package stays under *Upstream newer* until its +build lands in the repo. + +Tested with: + + /usr/bin/python3 desktop/modules/updates/test_updatesctl.py +``` + +- [ ] **Step 2: Update AGENTS.md.** In the layout block, change + +``` + desktop/ the drawer (SUPER alone): sound, mail, VMs, status, performance, + clipboard, appearance +``` + +to + +``` + desktop/ the drawer (SUPER alone): sound, mail, VMs, status, performance, + clipboard, updates, appearance +``` + +- [ ] **Step 3: Commit** + +```bash +git add desktop/modules/updates/README.md AGENTS.md +git commit -S -F - <<'EOF' +docs(updates): the module's README + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> +EOF +``` + +--- + +## After the plan + +Retiring conky's updates card (`bin/updates-sample.sh`, `widgets/updates.lua` in `conky-theme-udt`) waits until the user says the tile is trusted. It is a separate commit in that repo. diff --git a/docs/superpowers/specs/2026-10-15-updates-design.md b/docs/superpowers/specs/2026-10-15-updates-design.md index e6a3458..54ebfbf 100644 --- a/docs/superpowers/specs/2026-10-15-updates-design.md +++ b/docs/superpowers/specs/2026-10-15-updates-design.md @@ -42,10 +42,12 @@ matches, so a row shows the next step and nothing after it. 1. **Slackware**, three groups, all from the mirror: - *Upgrade*: an installed package whose name is on the mirror with a different version or build. - - *New*: the ChangeLog's newest line for the package says `Added.`, it is - on the mirror, and it is not installed. These are slackpkg's - `install-new` semantics. - - *Removed*: the ChangeLog's newest line for the package says `Removed.`, + - *New*: of the ChangeLog's `Added.` and `Removed.` lines for the package, + the newest says `Added.`; it is on the mirror and not installed. These + are slackpkg's `install-new` semantics. Other lines (`Upgraded.`, + `Rebuilt.`) are ignored, or a package added and later upgraded would + never count as new. + - *Removed*: of the same lines, the newest says `Removed.`, it is installed, and it is not on the mirror. The row carries the date of that ChangeLog entry. @@ -54,11 +56,13 @@ matches, so a row shows the next step and nothing after it. skipped in all three, as slackpkg skips them; so are blacklisted names. 2. **Upgrade here**: B newer than I. The package is built and sitting in the repo. -3. **Awaiting build**: R is the user's (not `SBo`), R equals U, and B is older - than U. The SlackBuild is bumped and only needs building. +3. **Awaiting build**: R is at or past U, B is older than U, and the package + is installed. The SlackBuild, the user's or SBo's, is current and only + needs building. 4. **Upstream newer**: U newer than both R and B. The SlackBuild or its hint - needs bumping, then a build. `SBo` rows land here until the build appears - in B. + needs bumping, then a build. An `SBo` recipe that lags upstream lands + here: the bump is a hint file on the build VM. Shown when the package is + installed, or when the recipe is the user's (repo work either way). 5. **App updates**: I comes from AppImage or flatpak and is older than U. "Newer" and "older" are version order, never equality: an installed 2.1.278 @@ -83,10 +87,11 @@ Rows are `{name, repo, from, to}`; Slackware rows are `{name, from, to}`, **Version order** is a small `vercmp`: split on non-alphanumerics, compare numeric chunks as numbers and alphabetic chunks as strings, numbers before -letters. It replaces nvtable's normalised equality, including its rule that -an installed version extending upstream at a component boundary (a kernel -suffix, a git hash) counts as equal; that rule is kept as a prefix check -before ordering. +letters; when one runs out, the longer is newer unless its next chunk is +alphabetic (a pre-release). It replaces nvtable's normalised equality. Its +rule that an installed version extending upstream at a component boundary (a +kernel suffix, a git hash) counts as equal needs no special case: the longer +version orders as newer, which is never behind. **NFS reads** (`PACKAGES.TXT` of the repo and of the mirror, the mirror's `ChangeLog.txt`) go through `subprocess.run(["cat", path], timeout=5)`. The |
