# 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.