From 0602623806b7c0984544913d663804ed5f54e07f Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 20 Oct 2026 20:30:57 +0200 Subject: Docs ready to publish: VM setup, specs in docs/design README says what the GUI does today and what is planned, which defaults are the author's setup, and how to set up the VM. The specs move to docs/design/; the implementation plans are dropped (working files, now kept out of git). AGENTS.md links slackrepo upstream instead of a local clone path. Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-20-sb-builder-phase4.md | 1994 -------------------- 1 file changed, 1994 deletions(-) delete mode 100644 docs/superpowers/plans/2026-10-20-sb-builder-phase4.md (limited to 'docs/superpowers/plans/2026-10-20-sb-builder-phase4.md') diff --git a/docs/superpowers/plans/2026-10-20-sb-builder-phase4.md b/docs/superpowers/plans/2026-10-20-sb-builder-phase4.md deleted file mode 100644 index 1d15e1f..0000000 --- a/docs/superpowers/plans/2026-10-20-sb-builder-phase4.md +++ /dev/null @@ -1,1994 +0,0 @@ -# sb-builder phase 4: hints, 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:** Manage slackrepo hints from sb-builder: upstream check on the desktop, hint update/new/delete on the VM through the helper, diff and revert after each. - -**Architecture:** mkhint's hint logic is ported into `remote/sbb-remote` as text edits (stdlib: `re`, `urllib`, `hashlib`), run as job kinds whose argv is the helper itself (`_hint`). The GUI runs `nvchecker` locally over a repo-tracked `nvchecker.toml` (`sb_builder/upstream.py`), shows a Hints screen (`sb_builder/hints_tab.py`) and a diff/revert dialog when a hint job ends. - -**Tech Stack:** Python 3.11+ stdlib, PyQt6 (GUI only), nvchecker CLI on the desktop. - -Spec: `docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md`. - -Every code step below was run before this plan was written: the diffs apply -to `master` at `8ad8b0b` with `git apply` and the suite ends at 111 tests, -OK. Two `ResourceWarning: subprocess ... is still running` lines are -expected (detached `_run`). - -## Files - -| File | Change | -|---|---| -| `remote/sbb-remote` | hint text functions, hint job kinds, `_hint`, `hint-diff`, `hint-revert`, `hint-delete`, `job-result`, config key `manifest_packages`, hyphenated job ids, VERSION 0.5.0 | -| `remote/config.example.toml` | the new key | -| `sb_builder/__init__.py` | VERSION 0.5.0 | -| `nvchecker.toml` | new: the VM's 64 stanzas | -| `sb_builder/upstream.py` | new: nvchecker config, JSON log parsing, states, `Checker` | -| `sb_builder/config.py`, `config.example.toml` | `[hints] nvchecker_keyfile` | -| `sb_builder/hints_tab.py` | new: Hints screen, `HintResultDialog` | -| `sb_builder/mainwindow.py` | Hints ribbon entry and tab, job end -> `job-result` -> dialog | -| `sb_builder/packages.py` | detail pane: New hint, Open in Hints | -| `tests/test_remote.py`, `tests/test_upstream.py` (new), `tests/test_gui.py` | tests | -| `README.md`, `AGENTS.md`, phase 4 spec | docs | - ---- - -### Task 1: helper, hint editing and hint jobs - -**Files:** -- Modify: `remote/sbb-remote`, `remote/config.example.toml`, `sb_builder/__init__.py` -- Test: `tests/test_remote.py` - -Key behaviors (see spec Part 2): `swap_version` never touches `MD5SUM*` -values; `apply_version` pins an unpinned (DELREQUIRES-only) hint from the -`.info`, downloads only the first URL of each DOWNLOAD pair, and with -`--no-dl` replaces every `DOWNLOAD*`/`MD5SUM*` line by `DOWNLOAD="no"`; -`cmd_hint` computes before it backs up, so a failed package keeps its hint -and old `.bak`; `hint-revert` without `.bak` deletes (reverting a new hint). - -- [ ] **Step 1: Write the failing tests.** Save the diff below as `/tmp/t1_test.diff`, then `git apply /tmp/t1_test.diff`. - -```diff -diff --git c/tests/test_remote.py i/tests/test_remote.py -index db3d0a1..14622b8 100644 ---- c/tests/test_remote.py -+++ i/tests/test_remote.py -@@ -18,6 +18,9 @@ - - import contextlib - import fcntl -+import functools -+import hashlib -+import http.server - import importlib.machinery - import importlib.util - import io -@@ -25,6 +28,7 @@ import json - import os - import shutil - import sqlite3 -+import sys - import tempfile - import threading - import time -@@ -179,6 +183,255 @@ class KindsTest(Base): - sbb.cmd_job_start(self.cfg, "rm", "-rf", "/") - - -+FLATPAK_INFO = ('PRGNAM="flatpak"\nVERSION="1.16.0"\nHOMEPAGE="https://flatpak.org/"\n' -+ 'DOWNLOAD="https://example.org/flatpak-1.16.0.tar.xz"\n' -+ 'MD5SUM="bdf866646fb312a341f84669f625488b"\n' -+ 'DOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\n' -+ 'REQUIRES="bubblewrap ostree"\nMAINTAINER="Some One"\nEMAIL="one@example.org"\n') -+ -+ -+class HintTextTest(unittest.TestCase): -+ def test_swap_forms(self): -+ text = ('VERSION="1.2.00"\nDOWNLOAD="https://example.org/build_1200/x-1.2.00.tar.gz"\n' -+ 'MD5SUM="00001200aa"\nDELREQUIRES="lib1200"\n') -+ self.assertEqual(sbb.swap_version(text, "1.2.00", "1.2.15"), -+ 'VERSION="1.2.15"\nDOWNLOAD="https://example.org/build_1215/x-1.2.15.tar.gz"\n' -+ 'MD5SUM="00001200aa"\nDELREQUIRES="lib1200"\n') -+ -+ def test_swap_underscore_as_dash(self): -+ text = 'VERSION="2026_07_07"\nDOWNLOAD="https://example.org/2026-07-07.tgz"\n' -+ self.assertEqual(sbb.swap_version(text, "2026_07_07", "2026_08_01"), -+ 'VERSION="2026_08_01"\nDOWNLOAD="https://example.org/2026-08-01.tgz"\n') -+ -+ def test_md5_values_untouched(self): -+ text = 'VERSION="4215"\nMD5SUM="aa4215bb \\\n cc4215dd"\nMD5SUM_x86_64="4215"\n' -+ self.assertEqual(sbb.swap_version(text, "4215", "4216"), -+ 'VERSION="4216"\nMD5SUM="aa4215bb \\\n cc4215dd"\nMD5SUM_x86_64="4215"\n') -+ -+ def test_tokens_set_drop(self): -+ text = 'A="x \\\n y"\nB=plain\nDOWNLOAD_x86_64=""\n' -+ self.assertEqual(sbb.hint_tokens(text, "A"), ["x", "y"]) -+ self.assertEqual(sbb.hint_tokens(text, "B"), ["plain"]) -+ self.assertEqual(sbb.hint_tokens(text, "C"), []) -+ self.assertEqual(sbb.hint_set(text, "B", "1 2"), 'A="x \\\n y"\nB="1 2"\nDOWNLOAD_x86_64=""\n') -+ self.assertEqual(sbb.hint_set("Z=1", "B", "2"), 'Z=1\nB="2"\n') -+ self.assertEqual(sbb.hint_drop(text, r"(?:A|DOWNLOAD\w*)"), "B=plain\n") -+ -+ def test_new_hint(self): -+ self.assertEqual(sbb.new_hint(FLATPAK_INFO), -+ 'VERSION="1.16.0"\nDOWNLOAD="https://example.org/flatpak-1.16.0.tar.xz"\n' -+ 'MD5SUM="bdf866646fb312a341f84669f625488b"\n' -+ 'DOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\n' -+ '#REQUIRES="bubblewrap ostree"\nARCH="x86_64"\n') -+ with self.assertRaisesRegex(sbb.Error, "no VERSION"): -+ sbb.new_hint('PRGNAM="x"\n') -+ -+ -+class Served: -+ """Files of a temp dir over http on localhost, for md5 downloads.""" -+ -+ def __init__(self, test, files): -+ root = os.path.join(test.tmp, "www") -+ os.makedirs(root) -+ for name, data in files.items(): -+ with open(os.path.join(root, name), "wb") as f: -+ f.write(data) -+ handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=root) -+ handler.log_message = lambda *a: None -+ self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) -+ threading.Thread(target=self.server.serve_forever, daemon=True).start() -+ test.addCleanup(self.server.server_close) -+ test.addCleanup(self.server.shutdown) -+ self.url = f"http://127.0.0.1:{self.server.server_port}" -+ -+ -+def md5(data): -+ return hashlib.md5(data).hexdigest() -+ -+ -+class ApplyVersionTest(Base): -+ def setUp(self): -+ super().setUp() -+ self.www = Served(self, {"x-2.0.tgz": b"two", "dep.tgz": b"dep"}) -+ self.log = [] -+ -+ def apply(self, text, new, no_dl=False, info=""): -+ return sbb.apply_version(text, info, new, no_dl, self.log.append) -+ -+ def test_download_refreshes_first_md5_only(self): -+ u = self.www.url -+ text = (f'VERSION="1.0"\nDOWNLOAD="{u}/x-1.0.tgz \\\n {u}/dep.tgz"\n' -+ 'MD5SUM="old1 \\\n keep2"\nDOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\nARCH="x86_64"\n') -+ self.assertEqual(self.apply(text, "2.0"), -+ f'VERSION="2.0"\nDOWNLOAD="{u}/x-2.0.tgz \\\n {u}/dep.tgz"\n' -+ f'MD5SUM="{md5(b"two")} \\\n keep2"\nDOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\n' -+ 'ARCH="x86_64"\n') -+ self.assertEqual(self.log, [f"downloading {u}/x-2.0.tgz"]) -+ -+ def test_arch_pair_and_unsupported(self): -+ u = self.www.url -+ text = (f'VERSION="1.0"\nDOWNLOAD="UNSUPPORTED"\nMD5SUM=""\n' -+ f'DOWNLOAD_x86_64="{u}/x-1.0.tgz"\nMD5SUM_x86_64="old"\n') -+ self.assertIn(f'MD5SUM_x86_64="{md5(b"two")}"', self.apply(text, "2.0")) -+ self.assertIn('DOWNLOAD="UNSUPPORTED"\nMD5SUM=""\n', self.apply(text, "2.0")) -+ -+ def test_unpinned_hint_pins_from_info(self): -+ u = self.www.url -+ info = (f'PRGNAM="x"\nVERSION="1.0"\nDOWNLOAD="{u}/x-1.0.tgz"\nMD5SUM="old"\n' -+ 'DOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\nREQUIRES="a"\n') -+ self.assertEqual(self.apply('DELREQUIRES="rust-opt"\n', "2.0", info=info), -+ f'VERSION="2.0"\nDOWNLOAD="{u}/x-2.0.tgz"\nMD5SUM="{md5(b"two")}"\n' -+ 'DOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\nARCH="x86_64"\nDELREQUIRES="rust-opt"\n') -+ self.assertEqual(self.apply('DELREQUIRES="rust-opt"\n', "2.0", no_dl=True, info=info), -+ 'VERSION="2.0"\nARCH="x86_64"\nDELREQUIRES="rust-opt"\nDOWNLOAD="no"\n') -+ -+ def test_no_dl(self): -+ text = ('VERSION="1.0"\nDOWNLOAD="https://example.org/x-1.0.tgz \\\n https://example.org/d"\n' -+ 'MD5SUM="a \\\n b"\nDOWNLOAD_x86_64="https://example.org/y"\nMD5SUM_x86_64="c"\n' -+ 'ARCH="x86_64"\n') -+ self.assertEqual(self.apply(text, "2.0", no_dl=True), -+ 'VERSION="2.0"\nARCH="x86_64"\nDOWNLOAD="no"\n') -+ self.assertEqual(self.log, []) -+ -+ def test_no_version_no_download(self): -+ self.assertEqual(self.apply('VERSION="1.0"\nDOWNLOAD="x"\nMD5SUM="m"\n', None), -+ 'VERSION="1.0"\nDOWNLOAD="x"\nMD5SUM="m"\n') -+ -+ def test_download_failure_raises(self): -+ with self.assertRaises(OSError): -+ self.apply(f'VERSION="1.0"\nDOWNLOAD="{self.www.url}/x-1.0.tgz"\nMD5SUM="m"\n', "3.0") -+ -+ -+class HintJobsTest(Base): -+ def setUp(self): -+ super().setUp() -+ self.www = Served(self, {"x-2.0.tgz": b"two"}) -+ self.info = (f'PRGNAM="x"\nVERSION="1.0"\nDOWNLOAD="{self.www.url}/x-1.0.tgz"\nMD5SUM="old"\n' -+ 'DOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\nREQUIRES="rust-opt a"\n') -+ self.hint = os.path.join(self.cfg["hintdir"], "x.hint") -+ -+ def read(self, path): -+ with open(path) as f: -+ return f.read() -+ -+ def run_hint(self, *args): -+ out = io.StringIO() -+ with contextlib.redirect_stdout(out): -+ try: -+ sbb.cmd_hint(self.cfg, *args) -+ rc = 0 -+ except sbb.Error: -+ rc = 1 -+ lines = out.getvalue().splitlines() -+ self.assertTrue(lines[-1].startswith("SBB-RESULT ")) -+ return rc, json.loads(lines[-1].split(" ", 1)[1]), lines -+ -+ def test_kinds_argv(self): -+ self.make_pkg("misc/x", self.info) -+ me = [sys.executable, os.path.abspath(sbb.__file__), "_hint"] -+ self.assertEqual(sbb.KINDS["hint-update"](self.cfg, ["x=2.0", "--no-dl"]), -+ (me + ["hint-update", "x=2.0", "--no-dl"], {})) -+ self.assertEqual(sbb.KINDS["hint-new"](self.cfg, ["x", "2.0"]), (me + ["hint-new", "x", "2.0"], {})) -+ self.assertFalse(sbb.signs("hint-update", ["x=2.0"])) -+ -+ def test_kinds_reject(self): -+ self.make_pkg("misc/x", self.info) -+ self.make_pkg("misc/neovim", self.info) -+ self.make_pkg("misc/shadowed", self.info) -+ os.makedirs(os.path.join(self.cfg["repo_dir"], "misc/shadowed"), exist_ok=True) -+ with open(os.path.join(self.cfg["repo_dir"], "misc/shadowed/shadowed.hint"), "w") as f: -+ f.write("") -+ for kind, args, why in (("hint-update", [], "at least one"), -+ ("hint-update", ["x"], "pkg=version"), -+ ("hint-update", ["x=1;rm"], "bad version"), -+ ("hint-update", ["x=" + "1" * 65], "bad version"), -+ ("hint-update", ["nope=1"], "no such package"), -+ ("hint-update", ["x=1", "x=2"], "twice"), -+ ("hint-update", ["x=1", "--force"], "unknown option"), -+ ("hint-update", ["neovim=1"], "bundled deps"), -+ ("hint-update", ["shadowed=1"], "not managed"), -+ ("hint-new", [], "hint-new "), -+ ("hint-new", ["x", "1", "2"], "hint-new "), -+ ("hint-new", ["neovim", "1"], "bundled deps")): -+ with self.subTest(kind=kind, args=args), self.assertRaisesRegex(sbb.Error, why): -+ sbb.KINDS[kind](self.cfg, args) -+ sbb.KINDS["hint-new"](self.cfg, ["neovim"]) # no version: no bundled deps to touch -+ self.make_pkg("misc/y", self.info, hint="") -+ with self.assertRaisesRegex(sbb.Error, "exists"): -+ sbb.KINDS["hint-new"](self.cfg, ["y"]) -+ -+ def test_update_backs_up_and_reports(self): -+ self.make_pkg("misc/x", self.info, hint='DELREQUIRES="rust-opt"\n') -+ self.make_pkg("misc/y", self.info.replace("x-1.0", "y-1.0"), hint='VERSION="1.0"\n' -+ f'DOWNLOAD="{self.www.url}/y-1.0.tgz"\nMD5SUM="old"\n') -+ rc, result, lines = self.run_hint("hint-update", "y=2.0", "x=2.0") -+ self.assertEqual(rc, 1) -+ self.assertEqual(result["ok"], ["x"]) -+ self.assertEqual([p for p, _why in result["failed"]], ["y"]) -+ self.assertIn("x: downloading", "\n".join(lines)) -+ self.assertIn(md5(b"two"), self.read(self.hint)) -+ self.assertEqual(self.read(self.hint + ".bak"), 'DELREQUIRES="rust-opt"\n') -+ y = os.path.join(self.cfg["hintdir"], "y.hint") -+ self.assertIn('MD5SUM="old"', self.read(y)) # failed: left as it was -+ self.assertFalse(os.path.exists(y + ".bak")) -+ -+ def test_update_without_hint_creates_one(self): -+ self.make_pkg("misc/x", self.info) -+ os.makedirs(self.cfg["hintdir"]) -+ with open(self.hint + ".bak", "w") as f: -+ f.write("stale") -+ rc, result, _ = self.run_hint("hint-update", "x=2.0", "--no-dl") -+ self.assertEqual((rc, result), (0, {"ok": ["x"], "failed": []})) -+ self.assertEqual(self.read(self.hint), 'VERSION="2.0"\n#REQUIRES="rust-opt a"\nARCH="x86_64"\n' -+ 'DOWNLOAD="no"\n') -+ self.assertFalse(os.path.exists(self.hint + ".bak")) # revert = delete -+ -+ def test_new(self): -+ self.make_pkg("misc/x", self.info) -+ rc, result, _ = self.run_hint("hint-new", "x") -+ self.assertEqual((rc, result), (0, {"ok": ["x"], "failed": []})) -+ self.assertIn('#REQUIRES="rust-opt a"', self.read(self.hint)) -+ self.assertIn('MD5SUM="old"', self.read(self.hint)) # no version given: no download -+ -+ def test_diff_revert_delete(self): -+ self.make_pkg("misc/x", self.info, hint="A=1\n") -+ self.assertEqual(sbb.cmd_hint_diff(self.cfg, "x"), {"bak": None, "current": "A=1\n"}) -+ self.assertEqual(sbb.cmd_hint_delete(self.cfg, "x"), {"ok": True}) -+ self.assertEqual(sbb.cmd_hint_diff(self.cfg, "x"), {"bak": "A=1\n", "current": None}) -+ with self.assertRaisesRegex(sbb.Error, "no hint"): -+ sbb.cmd_hint_delete(self.cfg, "x") -+ self.assertEqual(sbb.cmd_hint_revert(self.cfg, "x"), {"ok": True}) -+ self.assertEqual(self.read(self.hint), "A=1\n") -+ self.assertFalse(os.path.exists(self.hint + ".bak")) -+ self.assertEqual(sbb.cmd_hint_revert(self.cfg, "x"), {"ok": True}) # no .bak: revert a new hint -+ self.assertFalse(os.path.exists(self.hint)) -+ with self.assertRaisesRegex(sbb.Error, "nothing to revert"): -+ sbb.cmd_hint_revert(self.cfg, "x") -+ with self.assertRaisesRegex(sbb.Error, "no backup"): -+ sbb.cmd_hint_diff(self.cfg, "x") -+ with self.assertRaisesRegex(sbb.Error, "no such package"): -+ sbb.cmd_hint_diff(self.cfg, "../x") -+ -+ def test_job_result(self): -+ jid, d = self.make_job(["true"]) -+ with open(os.path.join(d, "log"), "w") as f: -+ f.write('x: downloading u\nSBB-RESULT {"ok": ["x"], "failed": []}\n{"error": "1 failed"}\n') -+ self.assertEqual(sbb.cmd_job_result(self.cfg, jid), {"ok": ["x"], "failed": []}) -+ jid2 = "20260101-000001-test" -+ os.mkdir(os.path.join(self.tmp, "jobs", jid2)) -+ open(os.path.join(self.tmp, "jobs", jid2, "log"), "w").close() -+ with self.assertRaisesRegex(sbb.Error, "no result"): -+ sbb.cmd_job_result(self.cfg, jid2) -+ -+ def test_end_to_end(self): -+ self.make_pkg("misc/x", self.info, hint='VERSION="1.0"\nDOWNLOAD="u"\nMD5SUM="m"\n') -+ rc, data = self.run_main("job-start", "hint-update", "x=2.0", "--no-dl") -+ d = os.path.join(self.tmp, "jobs", data["id"]) -+ self.assertEqual(self.wait_status(d, ("ok", "failed", "lost"))["status"], "ok") -+ self.assertEqual(self.run_main("job-result", data["id"]), (0, {"ok": ["x"], "failed": []})) -+ self.assertEqual(self.read(self.hint), 'VERSION="2.0"\nDOWNLOAD="no"\n') -+ -+ - class MainTest(Base): - def test_unknown_command(self): - rc, data = self.run_main("nope") -``` - -- [ ] **Step 2: Run them, expect failures.** Run `python3 -m unittest discover -s tests`. Expected: `FAILED (errors=...)`, with `AttributeError: module 'sbb_remote' has no attribute 'swap_version'` (and the other new names) plus `VersionTest` still passing. - -- [ ] **Step 3: Implement.** Save the diff below as `/tmp/t1_code.diff`, then `git apply /tmp/t1_code.diff`. - -```diff -diff --git c/remote/config.example.toml i/remote/config.example.toml -index d275401..a77ace9 100644 ---- c/remote/config.example.toml -+++ i/remote/config.example.toml -@@ -12,3 +12,4 @@ hintdir = "/etc/slackrepo/SBo-danix/hintfiles" - default_hintdir = "" # searched after hintdir when set - genreprc = "/root/.genreprc" # REPOSOWNERGPG names the signing key - slackrepo_logdir = "/var/log/slackrepo/SBo-danix" -+manifest_packages = ["neovim", "openvino"] # bundled deps: hint-update refuses them (phase 4b) -diff --git c/remote/sbb-remote i/remote/sbb-remote -index 25427c0..2674ed6 100755 ---- c/remote/sbb-remote -+++ i/remote/sbb-remote -@@ -25,6 +25,7 @@ its arguments, this file validates them and builds the argv itself. - - import fcntl - import glob -+import hashlib - import json - import os - import re -@@ -37,8 +38,9 @@ import sys - import tempfile - import time - import tomllib -+import urllib.request - --VERSION = "0.4.1" -+VERSION = "0.5.0" - DEFAULTS = { - "state_dir": "/var/lib/sb-builder", - "repo_dir": "/var/lib/sbopkg/SBo-danix", -@@ -51,11 +53,13 @@ DEFAULTS = { - "default_hintdir": "", - "genreprc": "/root/.genreprc", - "slackrepo_logdir": "/var/log/slackrepo/SBo-danix", -+ "manifest_packages": ["neovim", "openvino"], # bundled deps: left to mkhint until phase 4b - } - FLAGS = ("--yes", "--no") --JOB_ID = re.compile(r"^\d{8}-\d{6}-[a-z]+(-\d+)?$") -+JOB_ID = re.compile(r"^\d{8}-\d{6}-[a-z]+(-[a-z]+)*(-\d+)?$") - PKG = re.compile(r"^[^/]+/([^/]+)/\1\.SlackBuild$") - PKGNAME = re.compile(r"^[A-Za-z0-9._+-]+$") -+VERSION_ARG = re.compile(r"^[A-Za-z0-9._+-]{1,64}$") - ACTIONS = ("build", "rebuild", "update", "remove") - PRESET = "/usr/libexec/gpg-preset-passphrase" - ITEM = re.compile(r"^[A-Za-z0-9._+-]+/[A-Za-z0-9._+-]+$") # category/pkg -@@ -85,8 +89,9 @@ def load_config(path=None): - if type(v) is not type(DEFAULTS[k]): - raise Error(f"config: {k}: expected {type(DEFAULTS[k]).__name__}") - cfg[k] = v -- if not all(isinstance(o, str) for o in cfg["overlays"]): -- raise Error("config: overlays: expected list of strings") -+ for k in ("overlays", "manifest_packages"): -+ if not all(isinstance(o, str) for o in cfg[k]): -+ raise Error(f"config: {k}: expected list of strings") - return cfg - - -@@ -138,8 +143,67 @@ def kind_publish(cfg, args): - return ["sb-publish"], {} - - -+def _pkgmap(cfg): -+ """{package: category/package} of the repo.""" -+ return {os.path.basename(d): os.path.relpath(d, cfg["repo_dir"]) -+ for d in _pkgdirs(cfg["repo_dir"], "*/*")} -+ -+ -+def _hint_target(cfg, pkg, pkgs=None): -+ """(category/pkg, /.hint); refuses a hint slackrepo finds elsewhere first.""" -+ pkgs = pkgs or _pkgmap(cfg) -+ if not PKGNAME.match(pkg or "") or pkg not in pkgs: -+ raise Error(f"no such package: {pkg}") -+ target = os.path.join(cfg["hintdir"], pkg + ".hint") -+ found = find_hint(cfg, pkgs[pkg]) -+ if found and found != target: -+ raise Error(f"hint lives at {found}, not managed") -+ return pkgs[pkg], target -+ -+ -+def hint_args(cfg, kind, args): -+ """Validated ([(pkg, version or None)], no_dl) of a hint-update or hint-new job.""" -+ no_dl = "--no-dl" in args -+ for o in (a for a in args if a.startswith("--") and a != "--no-dl"): -+ raise Error(f"{kind}: unknown option {o}") -+ rest = [a for a in args if a != "--no-dl"] -+ if kind == "hint-new": -+ if not 1 <= len(rest) <= 2: -+ raise Error("usage: hint-new [] [--no-dl]") -+ pairs = [(rest[0], rest[1] if len(rest) == 2 else None)] -+ else: -+ if not rest: -+ raise Error("hint-update needs at least one pkg=version") -+ if not all("=" in a for a in rest): -+ raise Error("hint-update: expected pkg=version") -+ pairs = [tuple(a.split("=", 1)) for a in rest] -+ pkgs = _pkgmap(cfg) -+ seen = set() -+ for pkg, ver in pairs: -+ if ver is not None and not VERSION_ARG.match(ver): -+ raise Error(f"bad version: {ver}") -+ _, target = _hint_target(cfg, pkg, pkgs) -+ if pkg in seen: -+ raise Error(f"{pkg} named twice") -+ seen.add(pkg) -+ if ver is not None and pkg in cfg["manifest_packages"]: -+ raise Error(f"{pkg}: bundled deps: use mkhint until phase 4b") -+ if kind == "hint-new" and os.path.exists(target): -+ raise Error(f"{pkg}: hint exists") -+ return pairs, no_dl -+ -+ -+def _self_job(kind): -+ """A job kind run by this file itself (_hint), argv-free like the others.""" -+ def build(cfg, args): -+ hint_args(cfg, kind, args) -+ return [sys.executable, os.path.abspath(__file__), "_hint", kind, *args], {} -+ return build -+ -+ - KINDS = {"sbpull": kind_sbpull, "sbshadow": kind_sbshadow, -- "slackrepo": kind_slackrepo, "publish": kind_publish} -+ "slackrepo": kind_slackrepo, "publish": kind_publish, -+ **{k: _self_job(k) for k in ("hint-update", "hint-new")}} - - - def signs(kind, args): -@@ -549,6 +613,212 @@ def cmd_inventory(cfg): - return {"columns": COLUMNS, "rows": rows} - - -+# ── hint text ─────────────────────────────────────────────────────────────── -+# Hints are edited as text so comments, order and other keys stay as written. -+ -+PIN_KEYS = ("VERSION", "DOWNLOAD", "MD5SUM", "DOWNLOAD_x86_64", "MD5SUM_x86_64") -+DL_PAIRS = (("DOWNLOAD", "MD5SUM"), ("DOWNLOAD_x86_64", "MD5SUM_x86_64")) -+SKIP_URLS = ("UNSUPPORTED", "UNTESTED") -+DL_TIMEOUT = 60 -+ -+ -+def _assignment(key): -+ """KEY=value at line start; a quoted value may span lines. key is a regex.""" -+ return re.compile(rf'^(?:{key})=(?:"([^"]*)"|(\S*))[^\n]*\n?', re.M) -+ -+ -+def hint_tokens(text, key): -+ """Whitespace-separated words of KEY's value, [] when unset or empty.""" -+ m = _assignment(key).search(text) -+ if not m: -+ return [] -+ value = m.group(1) if m.group(1) is not None else m.group(2) -+ return value.replace("\\\n", " ").split() -+ -+ -+def hint_set(text, key, value): -+ """KEY="value" in place of the first KEY line, else appended.""" -+ line = f'{key}="{value}"\n' -+ m = _assignment(key).search(text) -+ if m: -+ return text[:m.start()] + line + text[m.end():] -+ return text + ("\n" if text and not text.endswith("\n") else "") + line -+ -+ -+def hint_drop(text, key): -+ return _assignment(key).sub("", text) -+ -+ -+def swap_version(text, old, new): -+ """mkhint's bump of old to new, everywhere but in MD5SUM* values. -+ -+ Also the '-' form when either has '_' (dated URLs), and the dotless form of -+ a dotted old version inside URLs, as a whole number (build_4200 for 4.2.00). -+ """ -+ pairs = [(old, new)] -+ if "_" in old or "_" in new: -+ pairs.append((old.replace("_", "-"), new.replace("_", "-"))) -+ o, n = old.replace(".", ""), new.replace(".", "") -+ dotless = "." in old and o != n -+ out, pos = [], 0 -+ for m in [*_assignment(r"MD5SUM\w*").finditer(text), None]: -+ part = text[pos:m.start() if m else len(text)] -+ for a, b in pairs: -+ part = part.replace(a, b) -+ if dotless: -+ part = re.sub(rf'(://[^\s"]*?)(?" -``` - -### Task 2: upstream check on the desktop - -**Files:** -- Create: `nvchecker.toml`, `sb_builder/upstream.py` -- Modify: `sb_builder/config.py`, `config.example.toml` -- Test: `tests/test_upstream.py` - -- [ ] **Step 1: Write the failing tests.** Save the diff below as `/tmp/t2_test.diff`, then `git apply /tmp/t2_test.diff`. - -```diff -diff --git c/tests/test_upstream.py i/tests/test_upstream.py -new file mode 100644 -index 0000000..38cba54 ---- /dev/null -+++ i/tests/test_upstream.py -@@ -0,0 +1,112 @@ -+# sb-builder - self-checks for the nvchecker glue -+# 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. -+# -+# You should have received a copy of the GNU General Public License along -+# with this program; if not, write to the Free Software Foundation, Inc., -+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -+ -+"""Run: python3 -m unittest discover -s tests (no network: nvchecker is faked)""" -+ -+import os -+os.environ["QT_QPA_PLATFORM"] = "offscreen" -+ -+import shutil # noqa: E402 -+import sys # noqa: E402 -+import tempfile # noqa: E402 -+import time # noqa: E402 -+import tomllib # noqa: E402 -+import unittest # noqa: E402 -+from unittest import mock # noqa: E402 -+ -+ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") -+sys.path.insert(0, ROOT) -+ -+from PyQt6.QtWidgets import QApplication # noqa: E402 -+ -+from sb_builder import upstream # noqa: E402 -+ -+app = QApplication.instance() or QApplication([]) -+LOG = "\n".join([ -+ '{"name": "borgbackup", "version": "1.4.5", "event": "updated", "level": "info"}', -+ '{"name": "bogus", "event": "unexpected error happened", "error": "HTTPError(404)", "level": "error"}', -+ '{"name": "bogus", "event": "no-result", "error": "later", "level": "error"}', -+ 'not json', -+]) -+ -+ -+def row(name, hint=None, info="1.0"): -+ return {"name": name, "hint_version": hint, "info_version": info} -+ -+ -+class UpstreamTest(unittest.TestCase): -+ def setUp(self): -+ self.tmp = tempfile.mkdtemp() -+ self.addCleanup(shutil.rmtree, self.tmp) -+ self.stanzas = os.path.join(self.tmp, "stanzas.toml") -+ with open(self.stanzas, "w") as f: -+ f.write('[x]\nsource = "manual"\nmanual = "2.0"\n') -+ -+ def test_repo_file(self): -+ with open(upstream.STANZAS, "rb") as f: -+ self.assertNotIn("__config__", tomllib.load(f)) -+ self.assertIn("borgbackup", upstream.stanzas()) -+ self.assertEqual(upstream.stanzas("/nonexistent"), set()) -+ -+ def test_write_config(self): -+ keys = os.path.join(self.tmp, "keys.toml") -+ out = upstream.write_config(self.tmp, keys, self.stanzas) -+ with open(out, "rb") as f: -+ self.assertEqual(tomllib.load(f), {"__config__": {}, "x": {"source": "manual", "manual": "2.0"}}) -+ open(keys, "w").close() -+ with open(upstream.write_config(self.tmp, keys, self.stanzas), "rb") as f: -+ self.assertEqual(tomllib.load(f)["__config__"], {"keyfile": keys}) -+ -+ def test_parse_log(self): -+ self.assertEqual(upstream.parse_log(LOG), ({"borgbackup": "1.4.5"}, {"bogus": "HTTPError(404)"})) -+ -+ def test_state(self): -+ names = {"a", "b", "c"} -+ v = {"a": "2.0", "b": "1.0"} -+ self.assertEqual(upstream.state(row("z"), names, v, {}), "no stanza") -+ self.assertEqual(upstream.state(row("a"), names, None, {}), "not checked") -+ self.assertEqual(upstream.state(row("a"), names, v, {}), "outdated") -+ self.assertEqual(upstream.state(row("a", hint="2.0"), names, v, {}), "current") -+ self.assertEqual(upstream.state(row("b"), names, v, {}), "current") # .info version fallback -+ self.assertEqual(upstream.state(row("c"), names, v, {}), "nvchecker error") -+ -+ def test_checker_runs_nvchecker(self): -+ bindir = os.path.join(self.tmp, "bin") -+ os.mkdir(bindir) -+ with open(os.path.join(bindir, "nvchecker"), "w") as f: -+ f.write('#!/bin/sh\n[ "$1" = -c ] && [ -f "$2" ] && [ "$3 $4" = "--logger json" ] || exit 9\n' -+ 'echo \'{"name": "x", "version": "2.0", "level": "info"}\'\n') -+ os.chmod(os.path.join(bindir, "nvchecker"), 0o755) -+ with mock.patch.dict(os.environ, {"PATH": bindir + os.pathsep + os.environ["PATH"]}): -+ got = self.run_checker() -+ self.assertEqual(got, [({"x": "2.0"}, {}, "")]) -+ -+ def test_checker_missing_program(self): -+ with mock.patch.dict(os.environ, {"PATH": self.tmp}): -+ got = self.run_checker() -+ self.assertEqual(len(got), 1) -+ self.assertTrue(got[0][2]) -+ -+ def run_checker(self): -+ c = upstream.Checker() -+ self.addCleanup(c.deleteLater) -+ got = [] -+ c.done.connect(lambda *a: got.append(a)) -+ c.run(os.path.join(self.tmp, "nokeys.toml"), self.stanzas) -+ end = time.monotonic() + 5 -+ while not got and time.monotonic() < end: -+ app.processEvents() -+ return got -``` - -- [ ] **Step 2: Run them, expect failure.** Run `python3 -m unittest tests.test_upstream`. Expected: `ImportError: cannot import name 'upstream' from 'sb_builder'`. - -- [ ] **Step 3: Create `nvchecker.toml`** from the VM's config, dropping its `[__config__]` (the first four lines and the blank line after them): - -```bash -{ printf '# sb-builder upstream versions: one nvchecker stanza per package, named after it.\n# Edited by hand. The GUI prepends its own [__config__] (keyfile) when it runs\n# nvchecker; do not add one here. Sources: https://nvchecker.readthedocs.io\n\n' - ssh buildsystem cat /root/.config/nvchecker/nvchecker.toml | sed '1,/^keyfile/d' | sed '1{/^$/d}'; } > nvchecker.toml -python3 -c "import tomllib; d = tomllib.load(open('nvchecker.toml', 'rb')); print(len(d), '__config__' in d)" -``` - -Expected: `64 False`. - -- [ ] **Step 4: Implement.** Save the diff below as `/tmp/t2_code.diff`, then `git apply /tmp/t2_code.diff`. - -```diff -diff --git c/config.example.toml i/config.example.toml -index d5fd2c3..e192a28 100644 ---- c/config.example.toml -+++ i/config.example.toml -@@ -20,3 +20,6 @@ autostart = false # true: start the VM without asking when it is off - - [builds] - publish = true # initial state of the "publish" box next to Run it -+ -+[hints] -+nvchecker_keyfile = "~/.config/nvchecker/keys.toml" # GitHub token for nvchecker; optional -diff --git c/sb_builder/config.py i/sb_builder/config.py -index da1d388..8164f75 100644 ---- c/sb_builder/config.py -+++ i/sb_builder/config.py -@@ -26,6 +26,7 @@ DEFAULTS = { - "notify": {"enabled": True, "events": ["finished", "failed", "killed", "lost", "vm-up"]}, - "vm": {"libvirt_uri": "qemu:///system", "domain": "buildsystem", "autostart": False}, - "builds": {"publish": True}, -+ "hints": {"nvchecker_keyfile": "~/.config/nvchecker/keys.toml"}, - } - - -diff --git c/sb_builder/upstream.py i/sb_builder/upstream.py -new file mode 100644 -index 0000000..d936706 ---- /dev/null -+++ i/sb_builder/upstream.py -@@ -0,0 +1,109 @@ -+# sb-builder - upstream versions through nvchecker -+# 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. -+# -+# You should have received a copy of the GNU General Public License along -+# with this program; if not, write to the Free Software Foundation, Inc., -+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -+ -+"""Upstream versions: nvchecker run on the desktop over this repo's nvchecker.toml. -+ -+No oldver/newver files: nvchecker's JSON log on stdout carries each version, -+and "outdated" is decided against the hints, not against a previous run. -+""" -+ -+import json -+import os -+import tempfile -+import tomllib -+ -+from PyQt6.QtCore import QObject, QProcess, pyqtSignal -+ -+STANZAS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "nvchecker.toml") -+ -+ -+def stanzas(path=STANZAS): -+ """Package names with a stanza; an unreadable file has none.""" -+ try: -+ with open(path, "rb") as f: -+ return set(tomllib.load(f)) - {"__config__"} -+ except (OSError, tomllib.TOMLDecodeError): -+ return set() -+ -+ -+def write_config(tmpdir, keyfile, path=STANZAS): -+ """This run's config: a [__config__] with the keyfile (when present), then the stanzas.""" -+ head = "[__config__]\n" -+ if os.path.isfile(keyfile): -+ head += f"keyfile = {json.dumps(keyfile)}\n" # a JSON string is a TOML basic string -+ with open(path) as f: -+ body = f.read() -+ out = os.path.join(tmpdir, "nvchecker.toml") -+ with open(out, "w") as f: -+ f.write(head + "\n" + body) -+ return out -+ -+ -+def parse_log(text): -+ """({name: version}, {name: error}) from nvchecker --logger json output.""" -+ versions, errors = {}, {} -+ for line in text.splitlines(): -+ try: -+ e = json.loads(line) -+ except ValueError: -+ continue -+ if not isinstance(e, dict) or "name" not in e: -+ continue -+ if e.get("version"): -+ versions[e["name"]] = e["version"] -+ elif e.get("level") == "error": -+ errors.setdefault(e["name"], str(e.get("error") or e.get("event"))) -+ return versions, errors -+ -+ -+def state(row, names, versions, errors): -+ """Hints screen state of an inventory row; versions None = not checked yet.""" -+ name = row["name"] -+ if name not in names: -+ return "no stanza" -+ if versions is None: -+ return "not checked" -+ if name not in versions: -+ return "nvchecker error" -+ return "current" if versions[name] == (row["hint_version"] or row["info_version"]) else "outdated" -+ -+ -+class Checker(QObject): -+ """One nvchecker run at a time; done(versions, errors, failure text or "").""" -+ -+ done = pyqtSignal(dict, dict, str) -+ -+ def __init__(self, parent=None): -+ super().__init__(parent) -+ self.proc = None -+ -+ def run(self, keyfile, path=STANZAS): -+ tmp = tempfile.TemporaryDirectory() -+ p = self.proc = QProcess(self) -+ -+ def finished(rc=-1, _status=None): -+ out = bytes(p.readAllStandardOutput()).decode(errors="replace") -+ err = bytes(p.readAllStandardError()).decode(errors="replace").strip() -+ tmp.cleanup() -+ self.proc = None -+ p.deleteLater() -+ versions, errors = parse_log(out) -+ self.done.emit(versions, errors, "" if rc == 0 else err or f"nvchecker: exit {rc}") -+ -+ p.finished.connect(finished) -+ p.errorOccurred.connect( -+ lambda e: e == QProcess.ProcessError.FailedToStart and finished(-1)) -+ p.start("nvchecker", ["-c", write_config(tmp.name, keyfile, path), "--logger", "json"]) -``` - -- [ ] **Step 5: Run the suite, expect pass.** Run `python3 -m unittest discover -s tests`. Expected: `OK` (108 tests). - -- [ ] **Step 6: Commit.** - -```bash -git add nvchecker.toml sb_builder/upstream.py sb_builder/config.py config.example.toml tests/test_upstream.py -git commit -m "Upstream versions from nvchecker on the desktop - -nvchecker.toml moves from the VM into the repo. The GUI runs nvchecker -with its own [__config__] (the keyfile from [hints]) and reads versions -and per-package errors from --logger json; outdated is decided against -the hints, so no oldver, newver or nvtake. - -Co-Authored-By: Claude Opus 5.5 " -``` - -### Task 3: Hints screen, result dialog, Packages pane - -**Files:** -- Create: `sb_builder/hints_tab.py` -- Modify: `sb_builder/mainwindow.py`, `sb_builder/packages.py` -- Test: `tests/test_gui.py` - -- [ ] **Step 1: Write the failing tests.** Save the diff below as `/tmp/t3_test.diff`, then `git apply /tmp/t3_test.diff`. - -```diff -diff --git c/tests/test_gui.py i/tests/test_gui.py -index 0da4243..aa9c03b 100644 ---- c/tests/test_gui.py -+++ i/tests/test_gui.py -@@ -450,13 +450,82 @@ class SmokeTest(unittest.TestCase): - w, _ = self.window() - w.poll_jobs() - self.assertEqual([a.text() for a in w.ribbon.actions()], -- ["Dashboard", "Packages", "Results", "Jobs"]) -+ ["Dashboard", "Packages", "Hints", "Results", "Jobs"]) - self.assertEqual([m.text() for m in w.menuBar().actions()], ["&File"]) - w.ribbon.actions()[1].trigger() - self.assertIs(w.tabs.currentWidget(), w.packages_tab) - w.ribbon.actions()[0].trigger() - self.assertIs(w.tabs.currentWidget(), w.dashboard) - -+ def test_hints_tab(self): -+ w, remote = self.window(**{"job-start": ({"id": "20260101-000001-hint-update"}, None), -+ "hint-delete": ({"ok": True}, None)}) -+ w.poll_jobs() -+ tab = w.open_hints() -+ with tempfile.NamedTemporaryFile("w", suffix=".toml", delete=False) as f: -+ f.write('[nuclei]\nsource = "manual"\n[curl]\nsource = "manual"\n') -+ self.addCleanup(os.unlink, f.name) -+ tab.stanzas = f.name -+ tab.reload() -+ self.assertEqual(sorted(r["name"] for r in tab.rows), ["curl", "foo", "nuclei"]) # foo: hint only -+ states = {tab.table.item(i, 1).text(): tab.table.item(i, 5).text() for i in range(3)} -+ self.assertEqual(states, {"curl": "not checked", "foo": "no stanza", "nuclei": "not checked"}) -+ tab._checked({"nuclei": "3.12.0", "curl": "1.0"}, {}, "") -+ self.assertEqual(tab.checked(), ["nuclei"]) # outdated rows come checked -+ tab.chips["current"].setChecked(True) -+ visible = [tab.table.item(i, 1).text() for i in range(3) if not tab.table.isRowHidden(i)] -+ self.assertEqual(visible, ["curl"]) -+ tab.no_dl.setChecked(True) -+ tab.update_selected() -+ self.assertIn(("job-start", "hint-update", "nuclei=3.12.0", "--no-dl"), remote.calls) -+ tab._checked({}, {"curl": "HTTPError(404)"}, "") -+ row = next(i for i in range(3) if tab.table.item(i, 1).text() == "curl") -+ self.assertEqual(tab.table.item(row, 5).toolTip(), "HTTPError(404)") -+ self.assertIs(w.open_hints("foo"), tab) -+ self.assertEqual(tab.current(), "foo") -+ -+ def test_hint_job_end_opens_diff(self): -+ job = dict(JOB, id="20260101-000000-hint-update", kind="hint-update", args=["nuclei=3.12.0"]) -+ w, remote = self.window(jobs=({"jobs": [job]}, None), -+ **{"job-result": ({"ok": ["nuclei"], "failed": [["curl", "HTTP 404"]]}, None), -+ "hint-diff": ({"bak": 'VERSION="3.11.1"\n', "current": 'VERSION="3.12.0"\n'}, -+ None), -+ "hint-revert": ({"ok": True}, None), -+ "job-start": ({"id": "20260101-000001-slackrepo"}, None)}) -+ w.poll_jobs() -+ remote.answers["jobs"] = ({"jobs": [dict(job, status="failed", end=5, exit=1)]}, None) -+ w.poll_jobs() -+ self.assertEqual(len(w.hint_dialogs), 1) -+ d = w.hint_dialogs[0] -+ self.assertIn('-VERSION="3.11.1"\n+VERSION="3.12.0"', d.diff.toPlainText()) -+ d.revert() -+ d.revert() # a second revert must not reach the helper: it would delete the hint -+ self.assertEqual([c for c in remote.calls if c[0] == "hint-revert"], [("hint-revert", "nuclei")]) -+ self.assertEqual(d.list.item(0).text(), "nuclei (reverted)") -+ d.build() # nothing left to build -+ self.assertFalse(any(c[0] == "job-start" for c in remote.calls)) -+ self.assertEqual(w.hint_dialogs, []) -+ w.show_hint_result({"ok": ["nuclei"], "failed": []}) -+ w.hint_dialogs[0].build() -+ self.assertIn(("job-start", "slackrepo", "update", "--preview", "nuclei"), remote.calls) -+ -+ def test_packages_new_hint(self): -+ w, remote = self.window(**{"job-start": ({"id": "20260101-000001-hint-new"}, None)}) -+ w.poll_jobs() -+ tab = w.open_packages() -+ tab.table.setCurrentIndex(tab.proxy.index(1, 1)) # foo, has a hint -+ self.assertFalse(tab.new_hint_btn.isVisibleTo(tab)) -+ tab.open_hint_btn.click() -+ self.assertIs(w.tabs.currentWidget(), w.hints_tab) -+ tab.table.setCurrentIndex(tab.proxy.index(3, 1)) # curl, no hint -+ self.assertTrue(tab.new_hint_btn.isVisibleTo(tab)) -+ tab.ask_new_hint = lambda name: None -+ tab.new_hint() -+ self.assertFalse(any(c[0] == "job-start" for c in remote.calls)) -+ tab.ask_new_hint = lambda name: ("8.0", True) -+ tab.new_hint() -+ self.assertIn(("job-start", "hint-new", "curl", "8.0", "--no-dl"), remote.calls) -+ - def test_tab_shortcuts_wrap(self): - w, _ = self.window() - w.poll_jobs() -``` - -- [ ] **Step 2: Run them, expect failures.** Run `python3 -m unittest tests.test_gui`. Expected: `AttributeError: 'MainWindow' object has no attribute 'open_hints'` and the ribbon list mismatch. - -- [ ] **Step 3: Implement.** Save the diff below as `/tmp/t3_code.diff`, then `git apply /tmp/t3_code.diff`. - -```diff -diff --git c/sb_builder/hints_tab.py i/sb_builder/hints_tab.py -new file mode 100644 -index 0000000..6be57fc ---- /dev/null -+++ i/sb_builder/hints_tab.py -@@ -0,0 +1,277 @@ -+# sb-builder - Hints tab -+# 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. -+# -+# You should have received a copy of the GNU General Public License along -+# with this program; if not, write to the Free Software Foundation, Inc., -+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -+ -+"""Hints tab: upstream check, hint jobs, and the diff/revert dialog after them.""" -+ -+import difflib -+import os -+ -+from PyQt6.QtCore import Qt, pyqtSignal -+from PyQt6.QtGui import QColor, QFontDatabase -+from PyQt6.QtWidgets import (QCheckBox, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, -+ QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton, QSplitter, -+ QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget) -+ -+from . import upstream -+from .dashboard import STATUS -+from .packages import chip, load_rows -+ -+STATES = ("outdated", "current", "no stanza", "not checked", "nvchecker error") -+STATE_COLOR = {"outdated": STATUS["aborted"], "current": STATUS["ok"], -+ "nvchecker error": STATUS["failed"]} -+COLUMNS = ("", "package", "hint", ".info", "upstream", "state") -+HINT_JOBS = ("hint-update", "hint-new") -+ -+ -+class HintsTab(QWidget): -+ start_requested = pyqtSignal(list) # job-start arguments -+ -+ def __init__(self, remote, cfg, stanzas=upstream.STANZAS, parent=None): -+ super().__init__(parent) -+ self.remote, self.cfg, self.stanzas = remote, cfg, stanzas -+ self.rows = [] -+ self.names = upstream.stanzas(stanzas) -+ self.versions = None # {pkg: version} after Check upstream -+ self.errors, self.failure = {}, "" -+ self.checker = upstream.Checker(self) -+ self.checker.done.connect(self._checked) -+ -+ self.query = QLineEdit(placeholderText="search package") -+ self.query.textChanged.connect(self.refilter) -+ self.chips = {} -+ chips = QHBoxLayout() -+ for name in STATES: -+ self.chips[name] = chip(name) -+ self.chips[name].toggled.connect(self.refilter) -+ chips.addWidget(self.chips[name]) -+ chips.addStretch() -+ self.refresh_btn = QPushButton("Refresh") -+ self.refresh_btn.clicked.connect(self.reload) -+ chips.addWidget(self.refresh_btn) -+ -+ self.table = QTableWidget(0, len(COLUMNS)) -+ self.table.setHorizontalHeaderLabels(COLUMNS) -+ self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) -+ self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) -+ self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) -+ self.table.verticalHeader().hide() -+ self.table.horizontalHeader().setStretchLastSection(True) -+ for col, width in enumerate((28, 220, 110, 110, 110)): -+ self.table.setColumnWidth(col, width) -+ -+ self.status = QLabel() -+ self.check_btn = QPushButton("Check upstream") -+ self.check_btn.clicked.connect(self.check) -+ self.update_btn = QPushButton("Update selected") -+ self.update_btn.clicked.connect(self.update_selected) -+ self.no_dl = QCheckBox("no download") -+ self.no_dl.setToolTip('Write DOWNLOAD="no": slackrepo uses the source already in its cache') -+ bar = QHBoxLayout() -+ bar.addWidget(self.status) -+ bar.addStretch() -+ for w in (self.check_btn, self.update_btn, self.no_dl): -+ bar.addWidget(w) -+ delete = QPushButton("Delete hint") -+ delete.clicked.connect(self.delete) -+ bar.addWidget(delete) -+ -+ layout = QVBoxLayout(self) -+ layout.addWidget(self.query) -+ layout.addLayout(chips) -+ layout.addWidget(self.table) -+ layout.addLayout(bar) -+ -+ def reload(self): -+ self.status.setText("loading…") -+ self.remote.call(self._loaded, "inventory") -+ -+ def _loaded(self, data, err): -+ if err: -+ self.status.setText(f"inventory failed: {err}") -+ return -+ self.names = upstream.stanzas(self.stanzas) # re-read: the file is edited by hand -+ self.rows = [r for r in load_rows(data) if r["hint_path"] or r["name"] in self.names] -+ self._fill() -+ -+ def _fill(self, check_outdated=False): -+ checked = self.checked() if not check_outdated else [] -+ current = self.current() -+ self.table.setSortingEnabled(False) -+ self.table.setRowCount(len(self.rows)) -+ for i, r in enumerate(self.rows): -+ r["state"] = upstream.state(r, self.names, self.versions, self.errors) -+ box = QTableWidgetItem() -+ box.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable) -+ on = r["state"] == "outdated" if check_outdated else r["name"] in checked -+ box.setCheckState(Qt.CheckState.Checked if on else Qt.CheckState.Unchecked) -+ box.setData(Qt.ItemDataRole.UserRole, r["name"]) -+ self.table.setItem(i, 0, box) -+ up = (self.versions or {}).get(r["name"], "") -+ for col, text in enumerate((r["name"], r["hint_version"] or "", r["info_version"] or "", -+ up, r["state"]), 1): -+ self.table.setItem(i, col, QTableWidgetItem(text)) -+ state = self.table.item(i, 5) -+ if r["state"] in STATE_COLOR: -+ state.setForeground(QColor(STATE_COLOR[r["state"]])) -+ if r["state"] == "nvchecker error": -+ state.setToolTip(self.errors.get(r["name"]) or self.failure or "no result") -+ self.table.setSortingEnabled(True) -+ if current: -+ self.select(current) -+ self.refilter() -+ -+ def refilter(self): -+ on = {n for n, b in self.chips.items() if b.isChecked()} -+ text = self.query.text().lower() -+ shown = 0 -+ for i in range(self.table.rowCount()): -+ name, state = self.table.item(i, 1).text(), self.table.item(i, 5).text() -+ hide = (on and state not in on) or text not in name.lower() -+ self.table.setRowHidden(i, bool(hide)) -+ shown += not hide -+ n = len(self.checked()) -+ self.status.setText(f"{shown} shown · {n} selected" + (f" · {self.failure}" if self.failure else "")) -+ -+ def checked(self): -+ return [self.table.item(i, 0).data(Qt.ItemDataRole.UserRole) -+ for i in range(self.table.rowCount()) -+ if self.table.item(i, 0).checkState() == Qt.CheckState.Checked] -+ -+ def current(self): -+ i = self.table.currentRow() -+ return self.table.item(i, 1).text() if i >= 0 and self.table.item(i, 1) else None -+ -+ def select(self, name): -+ for i in range(self.table.rowCount()): -+ if self.table.item(i, 1).text() == name: -+ self.table.setRowHidden(i, False) -+ self.table.selectRow(i) -+ return -+ -+ def check(self): -+ self.check_btn.setEnabled(False) -+ self.status.setText("checking upstream…") -+ self.checker.run(os.path.expanduser(self.cfg["hints"]["nvchecker_keyfile"]), self.stanzas) -+ -+ def _checked(self, versions, errors, failure): -+ self.check_btn.setEnabled(True) -+ self.versions, self.errors, self.failure = versions, errors, failure -+ self._fill(check_outdated=True) -+ -+ def update_selected(self): -+ versions = self.versions or {} -+ pairs = [f"{n}={versions[n]}" for n in self.checked() if n in versions] -+ if pairs: -+ self.start_requested.emit(["hint-update", *pairs] + (["--no-dl"] if self.no_dl.isChecked() else [])) -+ -+ def delete(self): -+ name = self.current() -+ if name and QMessageBox.question(self, "Delete hint", f"Delete the hint of {name}? " -+ "(kept as .bak, Revert restores it)") \ -+ == QMessageBox.StandardButton.Yes: -+ self.remote.call(self._changed, "hint-delete", name) -+ -+ def _changed(self, _data, err): -+ if err: -+ QMessageBox.warning(self, "Hint", err) -+ self.reload() -+ -+ -+class HintResultDialog(QDialog): -+ """After a hint job: each changed package's diff, Revert per package, Build these.""" -+ -+ build_requested = pyqtSignal(list) # package names -+ changed = pyqtSignal() -+ -+ def __init__(self, remote, result, parent=None): -+ super().__init__(parent) -+ self.remote = remote -+ self.setWindowTitle("Hint changes") -+ self.resize(900, 600) -+ self.list = QListWidget() -+ for pkg in result["ok"]: -+ item = QListWidgetItem(pkg) -+ item.setData(Qt.ItemDataRole.UserRole, pkg) -+ self.list.addItem(item) -+ self.list.currentItemChanged.connect(self._show) -+ self.diff = QPlainTextEdit(readOnly=True) -+ self.diff.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) -+ split = QSplitter() -+ split.addWidget(self.list) -+ split.addWidget(self.diff) -+ split.setSizes([250, 650]) -+ failed = QLabel("\n".join(f"{p}: {why}" for p, why in result["failed"])) -+ failed.setStyleSheet(f"color: {STATUS['failed']}") -+ failed.setVisible(bool(result["failed"])) -+ self.revert_btn = QPushButton("Revert") -+ self.revert_btn.clicked.connect(self.revert) -+ build = QPushButton("Build these") -+ build.clicked.connect(self.build) -+ close = QPushButton("Close") -+ close.clicked.connect(self.accept) -+ bar = QHBoxLayout() -+ bar.addWidget(self.revert_btn) -+ bar.addStretch() -+ bar.addWidget(build) -+ bar.addWidget(close) -+ layout = QVBoxLayout(self) -+ layout.addWidget(split, 1) -+ layout.addWidget(failed) -+ layout.addLayout(bar) -+ if self.list.count(): -+ self.list.setCurrentRow(0) # the diff of each package is fetched when selected -+ -+ def _show(self, item, _prev): -+ self.diff.clear() -+ if item: -+ pkg = item.data(Qt.ItemDataRole.UserRole) -+ self.remote.call(lambda d, err: self._diff(pkg, d, err), "hint-diff", pkg) -+ -+ def _diff(self, pkg, data, err): -+ item = self.list.currentItem() -+ if not item or item.data(Qt.ItemDataRole.UserRole) != pkg: -+ return # another package was selected meanwhile -+ if err: -+ self.diff.setPlainText(err) -+ return -+ old = (data["bak"] or "").splitlines(keepends=True) -+ new = (data["current"] or "").splitlines(keepends=True) -+ self.diff.setPlainText("".join(difflib.unified_diff( -+ old, new, f"{pkg}.hint.bak" if data["bak"] is not None else "/dev/null", -+ f"{pkg}.hint" if data["current"] is not None else "/dev/null")) or "no change") -+ -+ def revert(self): -+ item = self.list.currentItem() -+ if not item or item.data(Qt.ItemDataRole.UserRole + 1): -+ return # a second revert would delete the restored hint -+ pkg = item.data(Qt.ItemDataRole.UserRole) -+ -+ def done(_data, err): -+ if err: -+ QMessageBox.warning(self, "Revert", err) -+ return -+ item.setText(f"{pkg} (reverted)") -+ item.setData(Qt.ItemDataRole.UserRole + 1, True) -+ self.diff.setPlainText("reverted") -+ self.changed.emit() -+ self.remote.call(done, "hint-revert", pkg) -+ -+ def build(self): -+ pkgs = [self.list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.list.count()) -+ if not self.list.item(i).data(Qt.ItemDataRole.UserRole + 1)] -+ if pkgs: -+ self.build_requested.emit(pkgs) -+ self.accept() -diff --git c/sb_builder/mainwindow.py i/sb_builder/mainwindow.py -index 247b0cf..0331eb9 100644 ---- c/sb_builder/mainwindow.py -+++ i/sb_builder/mainwindow.py -@@ -27,6 +27,7 @@ from PyQt6.QtWidgets import (QLabel, QMainWindow, QMessageBox, QPushButton, QSty - from . import VERSION, config - from .dashboard import Dashboard - from .fmt import job_title -+from .hints_tab import HINT_JOBS, HintResultDialog, HintsTab - from .jobs import JobsTab - from .packages import PackagesTab - from .results import ResultsTab -@@ -37,6 +38,7 @@ NOTIFY_PATH = "/org/freedesktop/Notifications" - # ribbon entries: label, freedesktop icon name, Qt fallback icon - RIBBON = (("Dashboard", "go-home", QStyle.StandardPixmap.SP_DirHomeIcon), - ("Packages", "package-x-generic", QStyle.StandardPixmap.SP_FileDialogDetailedView), -+ ("Hints", "document-edit", QStyle.StandardPixmap.SP_FileDialogContentsView), - ("Results", "view-list-text", QStyle.StandardPixmap.SP_MessageBoxWarning), - ("Jobs", "system-run", QStyle.StandardPixmap.SP_MediaPlay)) - # tabs as a distinct, centered row; colors from the palette so the theme applies -@@ -87,6 +89,8 @@ class MainWindow(QMainWindow): - self.jobs_tab = None - self.packages_tab = None - self.results_tab = None -+ self.hints_tab = None -+ self.hint_dialogs = [] # open HintResultDialogs, kept alive while shown - self.seen = None # job id -> status at the last poll; None before the first - self.online = None # None until the first answer - self.overlays = [] -@@ -119,7 +123,8 @@ class MainWindow(QMainWindow): - self.ribbon.setIconSize(QSize(32, 32)) - self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.ribbon) - slots = {"Dashboard": lambda: self.tabs.setCurrentWidget(self.dashboard), -- "Packages": self.open_packages, "Results": lambda: self.open_results(), -+ "Packages": self.open_packages, "Hints": lambda: self.open_hints(), -+ "Results": lambda: self.open_results(), - "Jobs": self.open_jobs} - for label, name, fallback in RIBBON: - icon = QIcon.fromTheme(name, self.style().standardIcon(fallback)) -@@ -281,9 +286,27 @@ class MainWindow(QMainWindow): - if event and prev.get(j["id"], "running") != j["status"]: - self.notify(event, job_title(j), j["id"]) - if j["kind"] in ("slackrepo", "publish"): # build state changed -- for tab in (self.packages_tab, self.results_tab): -- if tab: -- tab.reload() -+ self._reload(self.packages_tab, self.results_tab) -+ if j["kind"] in HINT_JOBS and j["status"] in ("ok", "failed"): -+ self._reload(self.packages_tab, self.hints_tab) -+ self.remote.call(lambda d, err: d and self.show_hint_result(d), "job-result", j["id"]) -+ -+ @staticmethod -+ def _reload(*tabs): -+ for tab in tabs: -+ if tab: -+ tab.reload() -+ -+ def show_hint_result(self, result): -+ if not result["ok"] and not result["failed"]: -+ return # nothing changed, nothing failed: no news -+ d = HintResultDialog(self.remote, result, self) -+ d.build_requested.connect( -+ lambda pkgs: self.open_jobs().start("slackrepo", "update", "--preview", *pkgs)) -+ d.changed.connect(lambda: self._reload(self.packages_tab, self.hints_tab)) -+ d.finished.connect(lambda _r: self.hint_dialogs.remove(d)) -+ self.hint_dialogs.append(d) -+ d.show() - - # ── tabs ──────────────────────────────────────────────────────────────── - -@@ -304,11 +327,23 @@ class MainWindow(QMainWindow): - if self.packages_tab is None: - self.packages_tab = PackagesTab(self.remote) - self.packages_tab.start_requested.connect(lambda args: self.open_jobs().start(*args)) -+ self.packages_tab.hints_requested.connect(self.open_hints) - self.tabs.addTab(self.packages_tab, "Packages") - self.packages_tab.reload() - self.tabs.setCurrentWidget(self.packages_tab) - return self.packages_tab - -+ def open_hints(self, name=None): -+ if self.hints_tab is None: -+ self.hints_tab = HintsTab(self.remote, self.cfg) -+ self.hints_tab.start_requested.connect(lambda args: self.open_jobs().start(*args)) -+ self.tabs.addTab(self.hints_tab, "Hints") -+ self.hints_tab.reload() -+ if name: -+ self.hints_tab.select(name) -+ self.tabs.setCurrentWidget(self.hints_tab) -+ return self.hints_tab -+ - def open_results(self, result=None): - if self.results_tab is None: - self.results_tab = ResultsTab(self.remote) -@@ -338,6 +373,8 @@ class MainWindow(QMainWindow): - self.packages_tab = None - if w is self.results_tab: - self.results_tab = None -+ if w is self.hints_tab: -+ self.hints_tab = None - w.deleteLater() - - def reload_config(self): -diff --git c/sb_builder/packages.py i/sb_builder/packages.py -index 34ce7f5..146db51 100644 ---- c/sb_builder/packages.py -+++ i/sb_builder/packages.py -@@ -20,8 +20,9 @@ import html - - from PyQt6.QtCore import QAbstractTableModel, QModelIndex, QSortFilterProxyModel, Qt, pyqtSignal - from PyQt6.QtGui import QColor --from PyQt6.QtWidgets import (QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, -- QScrollArea, QSplitter, QTableView, QVBoxLayout, QWidget) -+from PyQt6.QtWidgets import (QCheckBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout, -+ QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea, QSplitter, -+ QTableView, QVBoxLayout, QWidget) - - from . import hints as hintlib - from .dashboard import STATUS -@@ -209,6 +210,7 @@ def table(rows): - - class PackagesTab(QWidget): - start_requested = pyqtSignal(list) # job-start arguments -+ hints_requested = pyqtSignal(str) # package to show in the Hints tab - - def __init__(self, remote, parent=None): - super().__init__(parent) -@@ -245,8 +247,22 @@ class PackagesTab(QWidget): - alignment=Qt.AlignmentFlag.AlignTop) - self.detail.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - self.detail.setContentsMargins(12, 8, 12, 8) -+ self.new_hint_btn = QPushButton("New hint") -+ self.new_hint_btn.clicked.connect(self.new_hint) -+ self.open_hint_btn = QPushButton("Open in Hints") -+ self.open_hint_btn.clicked.connect(lambda: self.hints_requested.emit(self.current()["name"])) -+ detail_bar = QHBoxLayout() -+ detail_bar.addWidget(self.new_hint_btn) -+ detail_bar.addWidget(self.open_hint_btn) -+ detail_bar.addStretch() -+ pane = QWidget() -+ pane_layout = QVBoxLayout(pane) -+ pane_layout.addWidget(self.detail) -+ pane_layout.addLayout(detail_bar) -+ pane_layout.addStretch() - scroll = QScrollArea(widgetResizable=True) -- scroll.setWidget(self.detail) -+ scroll.setWidget(pane) -+ self.show_detail(QModelIndex(), None) - split = QSplitter() - split.addWidget(self.table) - split.addWidget(scroll) -@@ -327,6 +343,37 @@ class PackagesTab(QWidget): - == QMessageBox.StandardButton.Yes: - self.start_requested.emit(["publish"]) - -+ def current(self): -+ index = self.table.currentIndex() -+ return index.data(Qt.ItemDataRole.UserRole) if index.isValid() else None -+ - def show_detail(self, current, _previous): - row = current.data(Qt.ItemDataRole.UserRole) if current.isValid() else None - self.detail.setText(detail_html(row) if row else "") -+ self.new_hint_btn.setVisible(bool(row) and not row["hint_path"]) -+ self.open_hint_btn.setVisible(bool(row) and bool(row["hint_path"])) -+ -+ def ask_new_hint(self, name): -+ """(version or "", no download) for a new hint, None on cancel.""" -+ d = QDialog(self, windowTitle=f"New hint: {name}") -+ version = QLineEdit(placeholderText="the .info version") -+ no_dl = QCheckBox('no download (DOWNLOAD="no")') -+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok -+ | QDialogButtonBox.StandardButton.Cancel) -+ buttons.accepted.connect(d.accept) -+ buttons.rejected.connect(d.reject) -+ form = QFormLayout(d) -+ form.addRow("version", version) -+ form.addRow(no_dl) -+ form.addRow(buttons) -+ if d.exec() != QDialog.DialogCode.Accepted: -+ return None -+ return version.text().strip(), no_dl.isChecked() -+ -+ def new_hint(self): -+ row = self.current() -+ answer = row and self.ask_new_hint(row["name"]) -+ if answer: -+ version, no_dl = answer -+ self.start_requested.emit(["hint-new", row["name"], *([version] if version else []), -+ *(["--no-dl"] if no_dl else [])]) -``` - -- [ ] **Step 4: Run the suite, expect pass.** Run `python3 -m unittest discover -s tests`. Expected: `OK` (111 tests). - -- [ ] **Step 5: Look at it.** Render the screens offscreen and read the PNGs (checked while writing this plan: table, chips, button bar, diff dialog with the failure line under the split, New hint under the detail pane): - -```bash -QT_QPA_PLATFORM=offscreen python3 - <<'EOF' -import sys; sys.path.insert(0, "tests"); sys.argv = ["x"] -import test_gui as t -from sb_builder.mainwindow import MainWindow -from sb_builder import config -r = t.FakeRemote({"version": ({"version": t.VERSION, "overlays": []}, None), "stats": (t.STATS, None), - "jobs": ({"jobs": []}, None), "gpg-status": ({"unlocked": True, "key": "K"}, None), - "inventory": (t.INVENTORY, None), - "hint-diff": ({"bak": 'VERSION="3.11.1"\n', "current": 'VERSION="3.12.0"\n'}, None)}) -w = MainWindow(config.load("/nonexistent")[0], r, t.FakeNotifier()); w.resize(1100, 700); w.poll_jobs() -w.open_hints()._checked({"nuclei": "3.12.0"}, {}, "") -w.show(); t.app.processEvents(); w.grab().save("/tmp/hints.png") -w.show_hint_result({"ok": ["nuclei"], "failed": [["curl", "HTTP Error 404: Not Found"]]}) -t.app.processEvents(); w.hint_dialogs[0].grab().save("/tmp/dialog.png") -EOF -``` - -- [ ] **Step 6: Commit.** - -```bash -git add sb_builder/hints_tab.py sb_builder/mainwindow.py sb_builder/packages.py tests/test_gui.py -git commit -m "Hints screen: upstream check, hint jobs, diff and revert - -Check upstream fills the upstream column and ticks the outdated hints, -Update selected starts hint-update, Delete hint acts on the VM. When a hint job ends its SBB-RESULT opens a dialog with each -changed hint's diff, Revert per package (once) and Build these. The -Packages detail pane gets New hint and Open in Hints. - -Co-Authored-By: Claude Opus 5.5 " -``` - -### Task 4: docs - -**Files:** `README.md`, `AGENTS.md`, `docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md` - -The spec edits record what writing the code found: nvchecker needs no -newver and reports through `--logger json`; DELREQUIRES-only hints are -pinned from the `.info`; failure leaves `.bak` alone; `update` builds -unbuilt items; 64 stanzas, not 65; pinned hints do carry -`DOWNLOAD_x86_64`. - -- [ ] **Step 1: Apply.** Save the diff below as `/tmp/t4.diff`, then `git apply /tmp/t4.diff`. - -```diff -diff --git c/AGENTS.md i/AGENTS.md -index 9113e34..9a8918c 100644 ---- c/AGENTS.md -+++ i/AGENTS.md -@@ -17,7 +17,8 @@ Design and phasing: `docs/superpowers/specs/`. Phase 1 (core) is - - - Every GUI action is `ssh sbb-remote [args]`; the helper - answers with JSON. `sb_builder/remote.py` is the only code that runs ssh -- (and local `virsh`). -+ (and local `virsh`). `sb_builder/upstream.py` runs the local `nvchecker` -+ over the repo's `nvchecker.toml`; the VM never sees the GitHub token. - - Trust boundary: the helper runs only a fixed table of job kinds and builds - argv itself from validated args. Never pass shell strings or paths from the - GUI to the helper. -@@ -32,10 +33,16 @@ Design and phasing: `docs/superpowers/specs/`. Phase 1 (core) is - - Hints are parsed (`shlex`), never sourced. `sb_builder/hints.py` mirrors - slackrepo's own rules (`../GITHUB/slackrepo/libexec/functions.d/parsefunctions.sh`); - check that file when changing it. -+- Hint editing lives in the helper (`hint-update`, `hint-new` jobs run the -+ helper's own `_hint` command): text edits, `.bak` before each -+ write, a final `SBB-RESULT {json}` log line read back with `job-result`. -+ It replaces mkhint, which stays on the VM for neovim and openvino -+ (bundled deps, `manifest_packages`) and for the `DELREQUIRES` hints -+ sbshadow regenerates with `mkhint -F` after every sync, until phase 4b. - - Jobs never get input. Any tool run as a job must be non-interactive - (`--yes`/`--no` style flags). If a tool needs a flag it lacks, add it in -- that tool's own repo (e.g. `slackrepo_setup` for sbshadow, `mkhintfile` -- for mkhint) rather than working around it here. -+ that tool's own repo (e.g. `slackrepo_setup` for sbshadow) rather than -+ working around it here. - - ## Conventions - -diff --git c/README.md i/README.md -index e6434c9..e1f93cc 100644 ---- c/README.md -+++ i/README.md -@@ -1,8 +1,8 @@ - # sb-builder - - Desktop GUI for managing a Slackware SlackBuilds build VM from one place. It --runs slackrepo builds, overlay syncs (sbpull, sbshadow), hintfile work --(mkhint) and SBo orphan discovery on the VM over ssh, as background jobs that -+runs slackrepo builds, overlay syncs (sbpull, sbshadow), hint file updates -+and SBo orphan discovery on the VM over ssh, as background jobs that - keep running when the GUI disconnects and can be reattached later. A - dashboard shows VM and repository state, and the GUI can start the VM through - libvirt when it is off. -@@ -12,10 +12,9 @@ Work in progress. See `docs/superpowers/specs/` for the design. - ## Requirements - - - Desktop: Python 3.11 or newer, PyQt6 (with QtDBus), ssh access to the VM, -- optionally `virsh` for starting the VM -+ optionally `virsh` for starting the VM and `nvchecker` for upstream checks - - VM: Python 3.11 or newer, root ssh login, the - [slackrepo_setup](https://forge.slackware.nl/danix/slackrepo_setup) tools (`sbpull`, `sbshadow`) -- and mkhint - - ## Install - -@@ -58,6 +57,19 @@ Every key is optional. `File > Reload config` rereads the GUI config. - the package's last attempt (slackrepo keeps one per command: `build.log`, - `update.log`, `rebuild.log`, `remove.log`), where it reports the failure, - with its `resource.log` (load, memory, tmp over the build). -+- **Hints** (ribbon): every package with a hint or an nvchecker stanza, with -+ hint, `.info` and upstream version. **Check upstream** runs nvchecker on the -+ desktop over `nvchecker.toml` in this repo (stanza name = package name, edit -+ it by hand to track a package; `hints.nvchecker_keyfile` gives it a GitHub -+ token) and ticks the outdated packages. **Update selected** rewrites their -+ hints on the VM (version, URLs, md5 of the downloaded source; **no download** -+ writes `DOWNLOAD="no"` instead; a hint sbshadow wrote, `DELREQUIRES` only, -+ gets the `.info` version lines added). **Delete hint** keeps a `.bak`. The Packages detail pane has **New hint** for a package -+ without one. -+- After a hint job a dialog shows the diff of each changed hint, with -+ **Revert** per package and **Build these** (a slackrepo update preview). -+ neovim and openvino are refused for now: their bundled deps still need -+ mkhint on the VM. - - Real runs sign the repository. When the package signing key is locked - (status bar 🔒), the GUI asks for its passphrase first and hands it to the - VM's gpg-agent; it is never stored. -diff --git c/docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md i/docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md -index df360eb..c45c720 100644 ---- c/docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md -+++ i/docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md -@@ -1,15 +1,15 @@ - # sb-builder phase 4: hints - - Date: 2026-10-20 --Status: draft -+Status: approved - Builds on: `2026-10-19-sb-builder-phase1-design.md` (helper, job model, GUI shell), - `2026-10-19-sb-builder-phase2-design.md` (inventory, hint view, slackrepo jobs) - - ## Goal - - Manage slackrepo hint files from sb-builder: check upstream versions, update --version hints, create and delete hints, sweep `DELREQUIRES` for -current --phantom deps, and see a diff of every change with a way to revert it. -+version hints, create and delete hints, and see a diff of every change -+with a way to revert it. - - Direction set by the user: the repo is run from the desktop, the VM shell is - for upgrading Slackware only. The VM-side tools were built for a terminal; a -@@ -23,9 +23,11 @@ Verified on the VM, in the mkhint source (`~/Programming/GIT/mkhintfile/mkhint`, - 1.4.0, 2291 lines) and in slackrepo (`../GITHUB/slackrepo`): - - - The VM has 607 hints, all in `hintdir`, none in subdirectories and none in -- the SBo tree. 594 carry `DELREQUIRES` (from `mkhint -F`), 13 carry -+ the SBo tree. 594 carry only `DELREQUIRES`, 13 carry - `VERSION` + `ARCH` + `DOWNLOAD` + `MD5SUM`, one carries `OPTIONS`. No hint -- carries `NODOWNLOAD` or a `DOWNLOAD_x86_64`/`MD5SUM_x86_64` line. -+ carries `NODOWNLOAD`. Pinned hints carry `DOWNLOAD_x86_64`/`MD5SUM_x86_64` -+ lines, mostly empty (`claude-code`: `DOWNLOAD="UNSUPPORTED"` with the URL in -+ `DOWNLOAD_x86_64`), and `ARCH="x86_64"`. - - slackrepo reads `DOWNLOAD="no"` as "no download" and ignores `NODOWNLOAD`. - `DOWNLOAD_$ARCH` replaces `DOWNLOAD` when set - (`libexec/functions.d/parsefunctions.sh:457`), so `DOWNLOAD="no"` next to a -@@ -44,12 +46,24 @@ Verified on the VM, in the mkhint source (`~/Programming/GIT/mkhintfile/mkhint`, - - mkhint's `-F`: for every `.info` in the repo whose `REQUIRES` contains a - phantom dep, merge those deps into the hint's `DELREQUIRES` (union, first - seen order, `.bak` only when something changes, minimal hint created when -- absent). --- The VM's nvchecker config has 65 stanzas named after the packages, plus a -+ absent). It edits only the `DELREQUIRES` line. sbshadow -+ (slackrepo_setup) runs it after every sync: that is where the 594 -+ DELREQUIRES-only hints come from, and why they exist (slackrepo must not -+ build deps -current does not need). Until now sbshadow ran it on `--no` -+ previews too. -+- The VM's nvchecker config has 64 stanzas named after the packages, plus a - `[__config__]` with oldver, newver and keyfile. nvchecker v2.22 is - installed on the desktop, with a GitHub token in -- `~/.config/nvchecker/keys.toml`. Its newver file is -- `{"version": 2, "data": {"": {"version": "..."}, ...}}`. -+ `~/.config/nvchecker/keys.toml`. With `--logger json` it prints one JSON -+ object per line on stdout: `{"name", "version", "event": "updated"}` per -+ result, `{"name", "error", "level": "error"}` per failure; it exits 0 when -+ entries fail and needs no oldver or newver to run. -+- A DELREQUIRES-only hint has no `VERSION`; mkhint's update cannot bump it. -+ Updating one pins it from the `.info` first; the next `mkhint -F` keeps the -+ pin. A package with a phantom dep always has such a hint, so `hint-new` -+ never sees one. -+- slackrepo `update ` builds an item that exists, built or not -+ (`update_command` calls `build_command`). - - The phantom deps list on the VM (`/root/.config/mkhint/phantom-deps`) has - 18 entries. - - Two packages use bundled-dep manifests: neovim (url mode) and openvino -@@ -57,33 +71,37 @@ Verified on the VM, in the mkhint source (`~/Programming/GIT/mkhintfile/mkhint`, - - ## Scope - --In: upstream check, version update, new hint, delete hint, fix-current, -+In: upstream check, version update, new hint, delete hint, - `--no-dl` as `DOWNLOAD="no"`, diff and revert. - - Out, for good (the Hints screen covers them): mkhint `--list`, `--versions`, - `--review`, `--strip-version`, `--info`, `--clean`. - --Out, later: bundled-dep reconcile for neovim and openvino (phase 4b, which --then removes mkhint and nvchecker from the VM), generating nvchecker stanzas -+Out, later (phase 4b, which then removes mkhint and nvchecker from the VM): -+bundled-dep reconcile for neovim and openvino, and a helper `fix-current` -+that sbshadow calls in place of `mkhint -F`, with the phantom deps list -+moved into the helper config as its single source. Also out: generating nvchecker stanzas - for new packages (add them to `nvchecker.toml` by hand), free text hint - editing, migration of `NODOWNLOAD=yes` lines (none exist). - - ## Part 1: upstream check, on the desktop - --- `nvchecker.toml` in the repo root: the VM's 65 stanzas, without -+- `nvchecker.toml` in the repo root: the VM's 64 stanzas, without - `[__config__]`. Stanza name = package name. Git-tracked, edited by hand. - - `sb_builder/upstream.py` runs nvchecker through `QProcess` (as `remote.py` -- runs `virsh`) with a fixed argv: `nvchecker -c /nvchecker.toml`. The -- temp dir holds a generated config: a `[__config__]` with `keyfile` (from -- the GUI config) and `newver = "/new.json"`, followed by the repo -- file's text. No oldver, no `nvtake`: the comparison is against the hints. --- Result: `{pkg: version}` from the newver file. Failure: the exit status and -- stderr, shown as a `nvchecker error` state. -+ runs `virsh`) with a fixed argv: `nvchecker -c /nvchecker.toml -+ --logger json`. The temp dir holds a generated config: a `[__config__]` -+ with `keyfile` (from the GUI config, when the file exists), followed by the -+ repo file's text. No oldver, newver or `nvtake`: the comparison is against -+ the hints. -+- Result: `{pkg: version}` and `{pkg: error}` from the JSON log. A package -+ without a result, or a failed run (exit status, stderr), shows as -+ `nvchecker error` with the reason in the tooltip. - - A package is outdated when its upstream version differs from the hint - `VERSION`, or from the `.info` `VERSION` when the hint has none. Versions - are compared as strings; no ordering is attempted. - --GUI config gains `nvchecker_keyfile`, default -+GUI config gains `[hints] nvchecker_keyfile`, default - `~/.config/nvchecker/keys.toml` (expanded). A missing keyfile is not an - error: nvchecker runs without a token. - -@@ -91,8 +109,7 @@ error: nvchecker runs without a token. - - ### Config - --New key `phantom_deps`, list of strings, default `[]`. The VM config gets --the 18 entries at deploy. `remote/config.example.toml` shows the key. -+New key `manifest_packages` (below). `remote/config.example.toml` shows it. - - ### Arguments - -@@ -108,27 +125,36 @@ the 18 entries at deploy. `remote/config.example.toml` shows the key. - - ### Job kinds - --- `hint-update =... [--no-dl]`: per package, in order: back up the -- hint to `.bak`, apply the version (below), write atomically. A failure -- restores the hint from the `.bak` it just wrote, logs `: failed: ` -- and continues with the next package. The last log line is -- `SBB-RESULT {"ok": [...], "failed": [...]}`. Exit status 1 when any -- package failed. -+- `hint-update =... [--no-dl]`: per package, in order: compute the -+ new text (apply the version, below), then back the hint up to `.bak` and -+ write atomically. A failure (download, missing `.info` VERSION) leaves the -+ hint and its old `.bak` untouched, logs `: failed: ` and -+ continues with the next package. A package without a hint gets a new one -+ built as `hint-new` does. The last log line is -+ `SBB-RESULT {"ok": [pkg...], "failed": [[pkg, why]...]}`. Exit status 1 -+ when any package failed. - - `hint-new [] [--no-dl]`: refuses when the hint exists. Builds -- the hint from the `.info` as mkhint does, phantom deps from -- `phantom_deps`, then applies the version when given. No `.bak` (nothing to -- back up); revert means delete. --- `fix-current`: mkhint's `-F` with `phantom_deps`. Logs one line per -- changed hint, then `SBB-RESULT {"ok": [...], "failed": []}`. -+ the hint from the `.info` as mkhint does, without phantom deps (see the -+ facts), then applies the version when given. No `.bak` (a stale -+ one is removed); revert means delete. - --These jobs never sign, so they do not check the signing key. -+These jobs never sign, so they do not check the signing key. Their argv is -+the helper itself: `python3 sbb-remote _hint `, validated again -+there. Job ids now allow a hyphenated kind (`...-hint-update`). -+ -+`job-result `: the last `SBB-RESULT` object of a job's log. - - ### Applying a version - --1. Old version = hint `VERSION`, else `.info` `VERSION`. --2. Replace old by new everywhere in the file. When either contains `_`, also -- the `-` forms. When old contains `.`, also its dotless form inside URLs, -- as a whole number (no digit before or after), so md5 hex is untouched. -+1. A hint without `VERSION` is first pinned from the `.info`: its -+ `VERSION`, `DOWNLOAD*` and `MD5SUM*` lines, plus `ARCH="x86_64"` when the -+ hint has no `ARCH`, are put in front of the hint's text. Old version = the -+ hint's `VERSION`. With no version given (`hint-new `), steps 2 and 3 -+ are skipped. -+2. Replace old by new everywhere in the file except inside `MD5SUM*` -+ values. When either contains `_`, also the `-` forms. When old contains -+ `.`, also its dotless form inside URLs, as a whole number (no digit before -+ or after). - 3. Without `--no-dl`: for `DOWNLOAD`/`MD5SUM` and - `DOWNLOAD_x86_64`/`MD5SUM_x86_64`, split the values on whitespace (hint - values may continue over lines with `\`). Skip a pair whose value is empty, -@@ -141,9 +167,8 @@ These jobs never sign, so they do not check the signing key. - written, because `DOWNLOAD_$ARCH` would override it. - - Hint text is edited as text, not regenerated, so comments, order and other --keys stay as they are. Assignments are located with the rules the helper's --`parse_assignments` already uses (`KEY="..."` at line start, values may span --lines inside the quotes). -+keys stay as they are. An assignment is `KEY="..."` (the value may span -+lines inside the quotes) or `KEY=word`, at line start. - - ### Commands (answer at once, not jobs) - -@@ -177,22 +202,21 @@ convention. - - **Update selected**: starts `hint-update pkg=ver ...` for the checked - rows that have an upstream version; a **No download** checkbox adds - `--no-dl`. -- - **Fix current**: starts `fix-current`. - - **Delete hint**: confirm dialog, then `hint-delete` for the current row. - - Jobs go through the Jobs tab like every other job. - - ### After a hint job - --When a `hint-update`, `hint-new` or `fix-current` job ends, the GUI reads -+When a `hint-update` or `hint-new` job ends, the GUI reads - its `SBB-RESULT` line and opens a dialog with one entry per changed package: - a unified diff (`difflib`) of `hint-diff`, and a **Revert** button per - package. Failed packages are listed with their reason. The dialog ends with --**Build these**, which starts the usual slackrepo preview for the changed --packages (`update` when built, `build` otherwise, as the Packages tab --decides), and **Close**. After Revert or Delete the inventory is reloaded. -+**Build these**, which starts `slackrepo update --preview` for the changed -+packages that were not reverted (update builds unbuilt items too), and -+**Close**. Revert is offered once per package: a second one would delete -+the restored hint. After Revert or Delete the inventory is reloaded. - --For `fix-current` the dialog lists the packages without opening every diff --(it can touch hundreds); clicking one shows its diff. -+Each diff is fetched when its package is selected. - - ### Packages detail pane - -@@ -219,13 +243,13 @@ For `fix-current` the dialog lists the packages without opening every diff - touched), multi-line `DOWNLOAD`/`MD5SUM` with continuation md5s kept, - `UNSUPPORTED` skipped, `--no-dl` removing `DOWNLOAD_x86_64` and writing - `DOWNLOAD="no"`, a download failure restoring the hint while the next -- package still updates, `SBB-RESULT` contents, `hint-new` from `.info` -- with phantom deps, `fix-current` merge and idempotence (second run -- changes nothing and writes no `.bak`), new -> revert, update -> diff -> -+ package still updates, `SBB-RESULT` contents, `hint-new` from `.info`, -+ a DELREQUIRES-only hint pinned from the `.info`, new -> revert, update -> diff -> - revert, delete -> revert, manifest package refusal, unmanaged hint path - refusal, bad version and package rejection. --- `test_upstream.py`: generated config text, newver parsing from a fixture, -- outdated comparison (hint version, `.info` fallback). No network. -+- `test_upstream.py`: generated config, JSON log parsing, states (hint -+ version, `.info` fallback), the repo file has no `[__config__]`, `Checker` -+ against a fake `nvchecker` on `PATH` and a missing one. No network. - - `test_gui.py`: Hints screen with a fake remote and injected upstream - results (states, checked rows, Update selected argv), diff dialog Revert - call. -@@ -233,6 +257,6 @@ For `fix-current` the dialog lists the packages without opening every diff - ## Deploy (asks first) - - 1. `./install.sh --remote` with helper 0.5.0. --2. Add `phantom_deps` (the VM's 18 entries) to `/etc/sb-builder/config.toml`. -+2. slackrepo_setup's sbshadow, which no longer runs `mkhint -F` on `--no`. - 3. mkhint, nvchecker and `/root/.config/{mkhint,nvchecker}` stay on the VM - until phase 4b. -``` - -- [ ] **Step 2: Commit and push.** - -```bash -git add README.md AGENTS.md docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md -git commit -m "Document the Hints screen; phase 4 spec from the implementation - -Co-Authored-By: Claude Opus 5.5 " -git push -git log --format='%h %G? %s' -5 # every line G -``` - -### Task 5: deploy (ask the user first) and check on the VM - -- [ ] **Step 1: Ask**, then deploy the helper: `./install.sh --remote`. Expected last line: `{"version": "0.5.0", ...}`. -- [ ] **Step 2: sbshadow.** slackrepo_setup `f5b14ff` (`--no` skips `mkhint -F`): show the user the VM copy's diff, then `scp ~/Programming/GIT/slackrepo_setup/sbshadow buildsystem:$(ssh buildsystem command -v sbshadow)` and `ssh buildsystem 'sbshadow --no | tail -2'`, expecting `sbshadow: preview, phantom-dep hintfiles not regenerated.` -- [ ] **Step 3: Read-only check on real data:** `ssh buildsystem sbb-remote hint-diff flatpak` returns `{"bak": null, "current": "VERSION=..."}` (or the `.bak` mkhint left). -- [ ] **Step 4: User's manual run** in the GUI, reported back: Hints > Check upstream fills the column; Update selected on one outdated package, dialog shows the diff, Revert restores it; Update again and Build these opens the slackrepo preview; New hint and Delete hint on a throwaway package, each reverted. -- cgit v1.2.3