diff options
| -rw-r--r-- | docs/superpowers/plans/2026-10-20-sb-builder-phase4.md | 1980 |
1 files changed, 1980 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-20-sb-builder-phase4.md b/docs/superpowers/plans/2026-10-20-sb-builder-phase4.md new file mode 100644 index 0000000..2608459 --- /dev/null +++ b/docs/superpowers/plans/2026-10-20-sb-builder-phase4.md @@ -0,0 +1,1980 @@ +# 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/fix-current as helper jobs, 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 113 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 keys `phantom_deps`, `manifest_packages`, hyphenated job ids, VERSION 0.5.0 | +| `remote/config.example.toml` | the two new keys | +| `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..3e546ef 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,275 @@ 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, ["ostree", "rust-opt"]), ++ '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"\nDELREQUIRES="ostree"\n') ++ ++ def test_merge_delrequires(self): ++ self.assertEqual(sbb.merge_delrequires('DELREQUIRES="a"\n', ["b", "a"]), 'DELREQUIRES="a b"\n') ++ self.assertIsNone(sbb.merge_delrequires('DELREQUIRES="a b"\n', ["b"])) ++ self.assertEqual(sbb.merge_delrequires("", ["b"]), 'DELREQUIRES="b"\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.cfg["phantom_deps"] = ["rust-opt", "ostree"] ++ 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.assertEqual(sbb.KINDS["fix-current"](self.cfg, []), (me + ["fix-current"], {})) ++ 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 <pkg>"), ++ ("hint-new", ["x", "1", "2"], "hint-new <pkg>"), ++ ("hint-new", ["neovim", "1"], "bundled deps"), ++ ("fix-current", ["x"], "no arguments")): ++ 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' ++ 'DELREQUIRES="rust-opt"\nDOWNLOAD="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('DELREQUIRES="rust-opt"', self.read(self.hint)) ++ self.assertIn('MD5SUM="old"', self.read(self.hint)) ++ ++ def test_fix_current(self): ++ self.make_pkg("misc/x", self.info, hint='DELREQUIRES="b"\n') ++ self.make_pkg("misc/z", 'VERSION="1"\nREQUIRES="ostree"\n') ++ self.make_pkg("misc/w", 'VERSION="1"\nREQUIRES="a"\n') ++ rc, result, _ = self.run_hint("fix-current") ++ self.assertEqual((rc, result), (0, {"ok": ["x", "z"], "failed": []})) ++ self.assertEqual(self.read(self.hint), 'DELREQUIRES="b rust-opt"\n') ++ z = os.path.join(self.cfg["hintdir"], "z.hint") ++ self.assertEqual(self.read(z), 'DELREQUIRES="ostree"\n') ++ self.assertFalse(os.path.exists(z + ".bak")) ++ rc, result, _ = self.run_hint("fix-current") ++ self.assertEqual(result, {"ok": [], "failed": []}) ++ self.assertEqual(self.read(self.hint + ".bak"), 'DELREQUIRES="b"\n') ++ ++ 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..a489bff 100644 +--- c/remote/config.example.toml ++++ i/remote/config.example.toml +@@ -12,3 +12,5 @@ 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" ++phantom_deps = [] # deps unneeded on -current, DELREQUIREd by hint-new, fix-current ++manifest_packages = ["neovim", "openvino"] # bundled deps: hint-update refuses them (phase 4b) +diff --git c/remote/sbb-remote i/remote/sbb-remote +index 25427c0..230dde6 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,14 @@ DEFAULTS = { + "default_hintdir": "", + "genreprc": "/root/.genreprc", + "slackrepo_logdir": "/var/log/slackrepo/SBo-danix", ++ "phantom_deps": [], # deps unneeded on -current: hint-new and fix-current DELREQUIRES them ++ "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 +90,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", "phantom_deps", "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 +144,71 @@ 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, <hintdir>/<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 <pkg> [<version>] [--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): ++ if kind == "fix-current": ++ if args: ++ raise Error("fix-current takes no arguments") ++ else: ++ 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", "fix-current")}} + + + def signs(kind, args): +@@ -549,6 +618,234 @@ 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"]*?)(?<!\d){re.escape(o)}(?!\d)', ++ lambda x: x.group(1) + n, part) ++ out.append(part) ++ if m: ++ out.append(m.group(0)) ++ pos = m.end() ++ return "".join(out) ++ ++ ++def _pin(info): ++ """The version pin of an .info: its VERSION, DOWNLOAD* and MD5SUM* lines.""" ++ lines = [m.group(0) for k in PIN_KEYS if (m := _assignment(k).search(info))] ++ if not lines or not lines[0].startswith("VERSION="): ++ raise Error("no VERSION in the .info") ++ return "".join(ln.rstrip("\n") + "\n" for ln in lines) ++ ++ ++def new_hint(info, phantom): ++ """mkhint's hint from an .info: the pin, REQUIRES commented, ARCH, phantom deps.""" ++ text = _pin(info) ++ requires = hint_tokens(info, "REQUIRES") ++ m = _assignment("REQUIRES").search(info) ++ if m: ++ text += "#" + m.group(0).rstrip("\n") + "\n" ++ text += 'ARCH="x86_64"\n' ++ deps = [d for d in phantom if d in requires] ++ return text + f'DELREQUIRES="{" ".join(deps)}"\n' if deps else text ++ ++ ++def merge_delrequires(text, deps): ++ """text with deps added to DELREQUIRES, or None when all are there already.""" ++ have = hint_tokens(text, "DELREQUIRES") ++ add = [d for d in dict.fromkeys(deps) if d not in have] ++ return hint_set(text, "DELREQUIRES", " ".join(have + add)) if add else None ++ ++ ++def _md5_url(url): ++ req = urllib.request.Request(url, headers={"User-Agent": "sbb-remote"}) ++ h = hashlib.md5() ++ with urllib.request.urlopen(req, timeout=DL_TIMEOUT) as r: ++ for chunk in iter(lambda: r.read(1 << 16), b""): ++ h.update(chunk) ++ return h.hexdigest() ++ ++ ++def apply_version(text, info, new, no_dl, log): ++ """The hint text at version new (None: keep the version), log(str) per download. ++ ++ An unpinned hint (DELREQUIRES only) gets the .info pin first. Downloads the ++ first URL of each DOWNLOAD pair for its md5; continuation URLs keep theirs, ++ as in mkhint. no_dl: no download, DOWNLOAD="no" in place of every ++ DOWNLOAD*/MD5SUM* line, since DOWNLOAD_$ARCH would override it. ++ """ ++ if not hint_tokens(text, "VERSION"): ++ text = _pin(info) + ("" if hint_tokens(text, "ARCH") else 'ARCH="x86_64"\n') + text ++ if new is not None: ++ text = swap_version(text, hint_tokens(text, "VERSION")[0], new) ++ if no_dl: ++ return hint_drop(text, r"(?:DOWNLOAD|MD5SUM)\w*") + 'DOWNLOAD="no"\n' ++ if new is None: ++ return text ++ for dl, sums in DL_PAIRS: ++ urls = hint_tokens(text, dl) ++ if not urls or urls[0] in SKIP_URLS: ++ continue ++ log(f"downloading {urls[0]}") ++ text = hint_set(text, sums, " \\\n ".join([_md5_url(urls[0]), *hint_tokens(text, sums)[1:]])) ++ return text ++ ++ ++# ── hint jobs and commands ────────────────────────────────────────────────── ++ ++def _read(path): ++ try: ++ with open(path, errors="replace") as f: ++ return f.read() ++ except FileNotFoundError: ++ return None ++ ++ ++def _write(path, text): ++ os.makedirs(os.path.dirname(path), exist_ok=True) ++ tmp = path + ".tmp" ++ with open(tmp, "w") as f: ++ f.write(text) ++ os.replace(tmp, path) ++ ++ ++def _save(target, text): ++ """Write a hint, its previous text to .bak; a new hint drops a stale .bak (revert = delete).""" ++ if os.path.exists(target): ++ shutil.copy2(target, target + ".bak") ++ elif os.path.exists(target + ".bak"): ++ os.remove(target + ".bak") ++ _write(target, text) ++ ++ ++def cmd_hint(cfg, kind=None, *args): ++ """Body of the hint jobs: log lines, then SBB-RESULT {"ok": [...], "failed": [[pkg, why]]}.""" ++ ok, failed = [], [] ++ pkgs = _pkgmap(cfg) ++ if kind == "fix-current": ++ for pkg, path in sorted(pkgs.items()): ++ info = _read(os.path.join(cfg["repo_dir"], path, pkg + ".info")) or "" ++ deps = [d for d in cfg["phantom_deps"] if d in hint_tokens(info, "REQUIRES")] ++ if not deps: ++ continue ++ try: ++ _, target = _hint_target(cfg, pkg, pkgs) ++ text = merge_delrequires(_read(target) or "", deps) ++ if text is not None: ++ _save(target, text) ++ print(f"{pkg}: DELREQUIRES {' '.join(deps)}", flush=True) ++ ok.append(pkg) ++ except (Error, OSError) as e: ++ failed.append([pkg, str(e)]) ++ elif kind in ("hint-update", "hint-new"): ++ pairs, no_dl = hint_args(cfg, kind, list(args)) ++ for pkg, ver in pairs: ++ try: ++ path, target = _hint_target(cfg, pkg, pkgs) ++ info = _read(os.path.join(cfg["repo_dir"], path, pkg + ".info")) or "" ++ text = _read(target) ++ if text is None: ++ text = new_hint(info, cfg["phantom_deps"]) ++ _save(target, apply_version(text, info, ver, no_dl, ++ lambda s, p=pkg: print(f"{p}: {s}", flush=True))) ++ print(f"{pkg}: ok", flush=True) ++ ok.append(pkg) ++ except (Error, OSError) as e: ++ print(f"{pkg}: failed: {e}", flush=True) ++ failed.append([pkg, str(e)]) ++ else: ++ raise Error(f"unknown hint job: {kind}") ++ print("SBB-RESULT " + json.dumps({"ok": ok, "failed": failed}), flush=True) ++ if failed: ++ raise Error(f"{len(failed)} failed") ++ ++ ++def cmd_hint_diff(cfg, pkg=None): ++ _, target = _hint_target(cfg, pkg) ++ bak, current = _read(target + ".bak"), _read(target) ++ if bak is None and current is None: ++ raise Error("no backup") ++ return {"bak": bak, "current": current} ++ ++ ++def cmd_hint_revert(cfg, pkg=None): ++ _, target = _hint_target(cfg, pkg) ++ if os.path.exists(target + ".bak"): ++ os.replace(target + ".bak", target) ++ elif os.path.exists(target): ++ os.remove(target) # a new hint: reverting it is deleting it ++ else: ++ raise Error("nothing to revert") ++ return {"ok": True} ++ ++ ++def cmd_hint_delete(cfg, pkg=None): ++ _, target = _hint_target(cfg, pkg) ++ if not os.path.exists(target): ++ raise Error(f"no hint for {pkg}") ++ os.replace(target, target + ".bak") ++ return {"ok": True} ++ ++ ++def cmd_job_result(cfg, jid=None): ++ """The SBB-RESULT of a hint job's log.""" ++ text, _ = _tail(os.path.join(job_dir(cfg, jid), "log"), LOG_TAIL) ++ for line in reversed(text.splitlines()): ++ if line.startswith("SBB-RESULT "): ++ return json.loads(line.split(" ", 1)[1]) ++ raise Error(f"no result in {jid}") ++ ++ + # ── signing key ───────────────────────────────────────────────────────────── + + def _signing_key(cfg): +@@ -626,7 +923,12 @@ COMMANDS = { + "build-log": cmd_build_log, + "gpg-status": cmd_gpg_status, + "gpg-unlock": cmd_gpg_unlock, ++ "hint-diff": cmd_hint_diff, ++ "hint-revert": cmd_hint_revert, ++ "hint-delete": cmd_hint_delete, ++ "job-result": cmd_job_result, + "_run": cmd_run, ++ "_hint": cmd_hint, + } + + +diff --git c/sb_builder/__init__.py i/sb_builder/__init__.py +index e0f577e..21a93ab 100644 +--- c/sb_builder/__init__.py ++++ i/sb_builder/__init__.py +@@ -16,4 +16,4 @@ + + """sb-builder: Qt6 GUI driving the SlackBuild tooling on a build VM.""" + +-VERSION = "0.4.1" # must match remote/sbb-remote VERSION ++VERSION = "0.5.0" # must match remote/sbb-remote VERSION +``` + +- [ ] **Step 4: Run the suite, expect pass.** Run `python3 -m unittest discover -s tests`. Expected: `OK` (104 tests at this point). + +- [ ] **Step 5: Commit.** + +```bash +git add remote/sbb-remote remote/config.example.toml sb_builder/__init__.py tests/test_remote.py +git commit -m "sbb-remote 0.5.0: hint jobs replace mkhint's hint editing + +hint-update, hint-new and fix-current run the helper's own _hint command: +version bumps as mkhint does them (also '-' and dotless URL forms, never +inside MD5SUM values), md5 of the first URL of each DOWNLOAD pair, --no-dl +as DOWNLOAD=\"no\", .bak before each write and a final SBB-RESULT line +that job-result returns. hint-diff, hint-revert and hint-delete answer at +once. neovim and openvino are refused until phase 4b. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +### 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. <danix@danix.xyz> ++# ++# This program is free software; you can redistribute it and/or modify ++# it under the terms of the GNU General Public License version 2 as ++# published by the Free Software Foundation. ++# ++# This program is distributed in the hope that it will be useful, ++# but WITHOUT ANY WARRANTY; without even the implied warranty of ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++# GNU General Public License for more details. ++# ++# 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. <danix@danix.xyz> ++# ++# This program is free software; you can redistribute it and/or modify ++# it under the terms of the GNU General Public License version 2 as ++# published by the Free Software Foundation. ++# ++# This program is distributed in the hope that it will be useful, ++# but WITHOUT ANY WARRANTY; without even the implied warranty of ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++# GNU General Public License for more details. ++# ++# 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` (110 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 <noreply@anthropic.com>" +``` + +### 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..abe6eb9 +--- /dev/null ++++ i/sb_builder/hints_tab.py +@@ -0,0 +1,283 @@ ++# sb-builder - Hints tab ++# Copyright (C) 2026 Danilo M. <danix@danix.xyz> ++# ++# This program is free software; you can redistribute it and/or modify ++# it under the terms of the GNU General Public License version 2 as ++# published by the Free Software Foundation. ++# ++# This program is distributed in the hope that it will be useful, ++# but WITHOUT ANY WARRANTY; without even the implied warranty of ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++# GNU General Public License for more details. ++# ++# 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", "fix-current") ++ ++ ++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) ++ for label, slot in (("Fix current", self.fix_current), ("Delete hint", self.delete)): ++ b = QPushButton(label) ++ b.clicked.connect(slot) ++ bar.addWidget(b) ++ ++ 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 fix_current(self): ++ if QMessageBox.question(self, "Fix current", "Add DELREQUIRES for the -current phantom deps " ++ "to every package that needs them?") == QMessageBox.StandardButton.Yes: ++ self.start_requested.emit(["fix-current"]) ++ ++ 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` (113 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, Fix current and Delete hint act 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 <noreply@anthropic.com>" +``` + +### 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..eda2dd8 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 <target> sbb-remote <cmd> [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,15 @@ 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`, `fix-current` ++ 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 only for neovim and openvino ++ (bundled deps, `manifest_packages`) 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..63ebd1a 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,20 @@ 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). **Fix current** adds `DELREQUIRES` for the ++ -current phantom deps (`phantom_deps` in the VM config). **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..4b659c7 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,7 +1,7 @@ + # 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) + +@@ -25,7 +25,9 @@ Verified on the VM, in the mkhint source (`~/Programming/GIT/mkhintfile/mkhint`, + - 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 + `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 +@@ -45,11 +47,17 @@ Verified on the VM, in the mkhint source (`~/Programming/GIT/mkhintfile/mkhint`, + 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 ++- 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": {"<name>": {"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. ++- slackrepo `update <item>` 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 +@@ -70,20 +78,22 @@ 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 <tmp>/nvchecker.toml`. The +- temp dir holds a generated config: a `[__config__]` with `keyfile` (from +- the GUI config) and `newver = "<tmp>/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 <tmp>/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. + +@@ -108,27 +118,38 @@ the 18 entries at deploy. `remote/config.example.toml` shows the key. + + ### Job kinds + +-- `hint-update <pkg>=<ver>... [--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 `<pkg>: failed: <why>` +- and continues with the next package. The last log line is +- `SBB-RESULT {"ok": [...], "failed": [...]}`. Exit status 1 when any +- package failed. ++- `hint-update <pkg>=<ver>... [--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 `<pkg>: failed: <why>` 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 <pkg> [<ver>] [--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. ++ `phantom_deps`, then applies the version when given. No `.bak` (a stale ++ one is removed); revert means delete. + - `fix-current`: mkhint's `-F` with `phantom_deps`. Logs one line per + changed hint, then `SBB-RESULT {"ok": [...], "failed": []}`. + +-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 <kind> <args>`, validated again ++there. Job ids now allow a hyphenated kind (`...-hint-update`). ++ ++`job-result <id>`: 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 <pkg>`), 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 +162,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) + +@@ -187,9 +207,10 @@ When a `hint-update`, `hint-new` or `fix-current` 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. +@@ -224,8 +245,9 @@ For `fix-current` the dialog lists the packages without opening every diff + changes nothing and writes no `.bak`), 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. +``` + +- [ ] **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 <noreply@anthropic.com>" +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: Phantom deps into the helper config.** Build the line locally (a JSON array of strings is a TOML array), show it and the VM config to the user, then append it: + +```bash +line=$(ssh buildsystem cat /root/.config/mkhint/phantom-deps | python3 -c ' +import json, sys +deps = [l.split("#")[0].strip() for l in sys.stdin] +print("phantom_deps = " + json.dumps([d for d in deps if d]))') +echo "$line"; ssh buildsystem cat /etc/sb-builder/config.toml +printf '%s\n' "$line" | ssh buildsystem 'cat >> /etc/sb-builder/config.toml && sbb-remote version' +``` + +Expected: an 18-entry array, then the version JSON (a config error would print `{"error": ...}`). +- [ ] **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; Fix current reports no changes (`mkhint -F` already ran); New hint and Delete hint on a throwaway package, each reverted. |
