# 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. # # 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. # # 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 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 "-" 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 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 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 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. // // 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. // // 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 EOF ``` --- ### Task 7: The page **Files:** - Modify: `desktop/modules/updates/UpdatesPage.qml` - [ ] **Step 1: Write the page** ```qml // Copyright (C) 2026 Danilo M. // // 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 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 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.