aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/design/2026-10-19-sb-builder-phase1-design.md (renamed from docs/superpowers/specs/2026-10-19-sb-builder-phase1-design.md)0
-rw-r--r--docs/design/2026-10-19-sb-builder-phase2-design.md (renamed from docs/superpowers/specs/2026-10-19-sb-builder-phase2-design.md)7
-rw-r--r--docs/design/2026-10-20-sb-builder-bundled-deps-design.md (renamed from docs/superpowers/specs/2026-10-20-sb-builder-bundled-deps-design.md)0
-rw-r--r--docs/design/2026-10-20-sb-builder-phantom-deps-design.md (renamed from docs/superpowers/specs/2026-10-20-sb-builder-phantom-deps-design.md)0
-rw-r--r--docs/design/2026-10-20-sb-builder-phase4-design.md (renamed from docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md)2
-rw-r--r--docs/design/2026-10-20-sb-builder-ui-design.md (renamed from docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md)0
-rw-r--r--docs/superpowers/plans/2026-10-19-sb-builder-phase1.md2259
-rw-r--r--docs/superpowers/plans/2026-10-19-sb-builder-phase2.md3883
-rw-r--r--docs/superpowers/plans/2026-10-20-sb-builder-bundled-deps.md456
-rw-r--r--docs/superpowers/plans/2026-10-20-sb-builder-phase4.md1994
-rw-r--r--docs/superpowers/plans/2026-10-20-sb-builder-ui.md1651
11 files changed, 3 insertions, 10249 deletions
diff --git a/docs/superpowers/specs/2026-10-19-sb-builder-phase1-design.md b/docs/design/2026-10-19-sb-builder-phase1-design.md
index 3ec3fc0..3ec3fc0 100644
--- a/docs/superpowers/specs/2026-10-19-sb-builder-phase1-design.md
+++ b/docs/design/2026-10-19-sb-builder-phase1-design.md
diff --git a/docs/superpowers/specs/2026-10-19-sb-builder-phase2-design.md b/docs/design/2026-10-19-sb-builder-phase2-design.md
index 21a041f..e0eb794 100644
--- a/docs/superpowers/specs/2026-10-19-sb-builder-phase2-design.md
+++ b/docs/design/2026-10-19-sb-builder-phase2-design.md
@@ -15,7 +15,7 @@ GUI although jobs have no terminal.
## Facts this design rests on
-Verified on the VM and in the slackrepo source (`~/Programming/GIT/GITHUB/slackrepo`):
+Verified on the VM and in the slackrepo source ([slackrepo](https://github.com/aclemons/slackrepo)):
- slackrepo usage: `slackrepo {build|rebuild|update|remove} [--preview|--dry-run] [item...]`,
`--repo=SBo-danix`. Items may be bare package names.
@@ -27,8 +27,6 @@ Verified on the VM and in the slackrepo source (`~/Programming/GIT/GITHUB/slackr
`danix_gitfetch_hook` (fetch, rebase `danix-current` onto upstream; runs on
every invocation, previews included) and finish hooks
`manage_packages_hook bell_hook genrepos_hook gen_web_hook dep_template_hook rsync_push_hook recap_hook gotify_hook`.
- It contains a Gotify key and site hostnames. Stale copies exist in
- `repo-html-structure/` and in the upstream slackrepo clone.
- `genrepos_hook` runs `gen_repos_files.sh`, which signs with gpg.
`/root/.genreprc` sets `USE_GPGAGENT=1` and `REPOSOWNERGPG=<fingerprint>`.
The agent uses `pinentry-curses` (needs a tty) with
@@ -74,8 +72,7 @@ slackrepo_setup becomes the source of truth for the slackrepo config.
`allow-preset-passphrase`. Deploy runs `gpgconf --reload gpg-agent`.
- Terminal runs without `SBB_PUBLISH` behave as today, except that previews
no longer notify Gotify or touch packages.
-- The stale config copy in `repo-html-structure` (untracked, may still hold
- the Gotify key) is deleted after asking the user.
+- A stale untracked copy of the old config is deleted after asking the user.
- Deploy (asks first): config to `/etc/slackrepo/`, `.local` created from the
current live values only if missing, `sb-publish` to `/usr/local/bin`,
`gpg-agent.conf` to `/root/.gnupg/`.
diff --git a/docs/superpowers/specs/2026-10-20-sb-builder-bundled-deps-design.md b/docs/design/2026-10-20-sb-builder-bundled-deps-design.md
index de6ad1d..de6ad1d 100644
--- a/docs/superpowers/specs/2026-10-20-sb-builder-bundled-deps-design.md
+++ b/docs/design/2026-10-20-sb-builder-bundled-deps-design.md
diff --git a/docs/superpowers/specs/2026-10-20-sb-builder-phantom-deps-design.md b/docs/design/2026-10-20-sb-builder-phantom-deps-design.md
index 46bc41c..46bc41c 100644
--- a/docs/superpowers/specs/2026-10-20-sb-builder-phantom-deps-design.md
+++ b/docs/design/2026-10-20-sb-builder-phantom-deps-design.md
diff --git a/docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md b/docs/design/2026-10-20-sb-builder-phase4-design.md
index b746044..62b8d53 100644
--- a/docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md
+++ b/docs/design/2026-10-20-sb-builder-phase4-design.md
@@ -20,7 +20,7 @@ upstream check moves to the desktop. mkhint is not extended.
## Facts this design rests on
Verified on the VM, in the mkhint source (`~/Programming/GIT/mkhintfile/mkhint`,
-1.4.0, 2291 lines) and in slackrepo (`../GITHUB/slackrepo`):
+1.4.0, 2291 lines) and in slackrepo ([slackrepo](https://github.com/aclemons/slackrepo)):
- The VM has 607 hints, all in `hintdir`, none in subdirectories and none in
the SBo tree. 594 carry only `DELREQUIRES`, 13 carry
diff --git a/docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md b/docs/design/2026-10-20-sb-builder-ui-design.md
index 8a6844d..8a6844d 100644
--- a/docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md
+++ b/docs/design/2026-10-20-sb-builder-ui-design.md
diff --git a/docs/superpowers/plans/2026-10-19-sb-builder-phase1.md b/docs/superpowers/plans/2026-10-19-sb-builder-phase1.md
deleted file mode 100644
index 15a1b5f..0000000
--- a/docs/superpowers/plans/2026-10-19-sb-builder-phase1.md
+++ /dev/null
@@ -1,2259 +0,0 @@
-# sb-builder Phase 1 (Core) Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** A PyQt6 desktop GUI plus a stdlib VM helper that run `sbpull` and `sbshadow` as detachable jobs on the build VM, with a Dashboard, a Jobs tab, VM start/resume via virsh, TOML config and desktop notifications.
-
-**Architecture:** The GUI calls `ssh <target> sbb-remote <cmd>` through async QProcess (ControlMaster reused) and gets JSON back. `sbb-remote` runs a fixed table of job kinds, detached with `setsid` and `stdin=/dev/null`, one at a time under a flock, logging to `/var/lib/sb-builder/jobs/<id>/`; `job-follow <id> <offset>` streams a log so the GUI can drop and resume. Spec: `docs/superpowers/specs/2026-10-19-sb-builder-phase1-design.md`.
-
-**Tech Stack:** Python 3.11+ stdlib (`tomllib`, `fcntl`, `sqlite3`, `subprocess`), PyQt6 6.11 incl. QtDBus (stock on Slackware -current), `unittest`, bash, tmux not used.
-
-**Verified while planning:** all code below was run in a scratch copy: 38 tests pass; the sbshadow and sbpull changes were exercised in a throwaway repo; `sbb-remote stats` and `version` ran against the real VM data (warm 0.14s, cold 6.7s on first disk access); the real-ssh GUI path reported "sbb-remote missing" against the VM before deploy, as designed; the QtDBus `Notify` call was checked against the desktop's notification daemon (`actions` must be a typed `QStringList`, `replaces_id` a typed `uint`).
-
-**Rules for whoever executes this:**
-- Commits are GPG-signed by global config. Never disable signing. A pause on the first commit is the smartcard PIN.
-- Global pre-commit and commit-msg hooks scan for personal data. A rejection is correct until proven otherwise.
-- Never stage `HANDOFF.md`.
-- Anything that writes to the VM (`buildsystem`) is outward-facing: ask the user before Task 1 Step 6 and Task 5.
-
----
-
-## File map
-
-| file | responsibility |
-|---|---|
-| `remote/sbb-remote` | VM helper, single file, stdlib only: config, job kinds, job lifecycle, stats |
-| `remote/config.example.toml` | helper config example, equal to its defaults |
-| `sb_builder/__init__.py` | package, `VERSION` (must equal the helper's) |
-| `sb_builder/config.py` | GUI config defaults, load, validate |
-| `sb_builder/remote.py` | only code running ssh/virsh; `parse()` maps results to (data, error) |
-| `sb_builder/fmt.py` | text formatting shared by the tabs |
-| `sb_builder/dashboard.py` | Dashboard tab |
-| `sb_builder/jobs.py` | Jobs tab |
-| `sb_builder/mainwindow.py` | window, polling, status bar, VM control, notifications |
-| `sb_builder/__main__.py` | entry point |
-| `sb-builder` | launcher script |
-| `install.sh` | local install, `--remote` helper deploy |
-| `config.example.toml` | GUI config example, equal to its defaults |
-| `tests/test_remote.py` | helper checks |
-| `tests/test_gui.py` | GUI checks, headless |
-
-Outside this repo: `~/Programming/GIT/slackrepo_setup/{sbshadow,sbpull.bash,README.md}` (Task 1).
-
----
-
-### Task 1: Non-interactive flags in slackrepo_setup
-
-Jobs have no stdin, so `sbshadow`'s `Proceed?` prompt needs `--yes`/`--no`. Also `sbpull` currently returns `popd`'s status, which would make a failed `sbshadow` look like a successful job.
-
-**Files (repo `~/Programming/GIT/slackrepo_setup`):**
-- Modify: `sbshadow`, `sbpull.bash`, `README.md`
-
-- [ ] **Step 1: Write the throwaway-repo check and run it against the current sbshadow to see it fail**
-
-```bash
-T=$(mktemp -d)
-cd "$T" && git init -q -b current \
- && mkdir -p cat/foo cat/bar && touch cat/foo/foo.SlackBuild cat/bar/bar.SlackBuild \
- && git add -A && git commit -qm upstream \
- && git checkout -qb danix-current \
- && mkdir -p personal/foo && touch personal/foo/foo.SlackBuild \
- && git add -A && git commit -qm overlay
-SBO_REPO=$T PATH=/usr/bin:/bin bash ~/Programming/GIT/slackrepo_setup/sbshadow --no </dev/null; echo "rc=$?"
-```
-
-(`PATH=/usr/bin:/bin` keeps a locally installed `mkhint -F` from running. The throwaway commits are signed like any other, that is fine.)
-
-Expected now: `SBO_REPO` is ignored, so it fails with `cd: /var/lib/sbopkg/SBo-danix: No such file or directory` (or, on a host that has that repo, prompts and fails on EOF). `rc=1`.
-
-- [ ] **Step 2: Apply the change**
-
-Save this patch as `/tmp/slackrepo_setup.patch` and apply it from the repo root:
-
-````diff
-diff '--color=auto' -ru a/README.md b/README.md
---- a/README.md
-+++ b/README.md
-@@ -22,9 +22,13 @@
- carried in an overlay, and restores (unshadows) upstream packages no longer
- carried, from the pristine `current` branch. Asks once before changing
- anything, commits, then runs `mkhint -F` to regenerate phantom-dep hintfiles.
--- `sbpull.bash`: `sbpull {personal|pentesting}` shell function. Pulls one
-- overlay subtree and runs `sbshadow`, to carry overlay changes without a full
-- rebuild. Source it from root's `~/.bashrc`.
-+ `--yes` applies without asking, `--no` only shows the plan (for
-+ non-interactive callers such as sb-builder). `SBO_REPO` overrides the repo
-+ path.
-+- `sbpull.bash`: `sbpull {personal|pentesting} [--yes|--no]` shell function.
-+ Pulls one overlay subtree and runs `sbshadow` (passing the flag on), to
-+ carry overlay changes without a full rebuild. Source it from root's
-+ `~/.bashrc`.
-
- ## Install
-
-diff '--color=auto' -ru a/sbpull.bash b/sbpull.bash
---- a/sbpull.bash
-+++ b/sbpull.bash
-@@ -1,4 +1,5 @@
- # sbpull: pull one overlay subtree into SBo-danix, then reconcile shadowing.
-+# Usage: sbpull {personal|pentesting} [--yes|--no], the flag goes to sbshadow.
- # Source from root's ~/.bashrc: . /path/to/sbpull.bash
- #
- # Copyright (C) 2026 Danilo M. <danix@danix.xyz>
-@@ -9,7 +10,7 @@
-
- sbpull() {
- local repo_dir="/var/lib/sbopkg/SBo-danix"
-- local target="$1"
-+ local target="$1" flag="${2:-}"
- local prefix branch remote
-
- case "$target" in
-@@ -22,7 +23,7 @@
- remote="my-pentesting"
- ;;
- *)
-- echo "sbpull: usage: sbpull {personal|pentesting}" >&2
-+ echo "sbpull: usage: sbpull {personal|pentesting} [--yes|--no]" >&2
- return 1
- ;;
- esac
-@@ -33,12 +34,13 @@
-
- if git subtree pull --prefix="$prefix" "$remote" "$branch" --squash; then
- echo "sbpull: $target subtree updated."
-- sbshadow
-+ sbshadow ${flag:+"$flag"}
-+ local rc=$?
-+ popd > /dev/null
-+ return "$rc"
- else
- echo "sbpull: git subtree pull failed for $target, check output above." >&2
- popd > /dev/null
- return 1
- fi
--
-- popd > /dev/null
- }
-diff '--color=auto' -ru a/sbshadow b/sbshadow
---- a/sbshadow
-+++ b/sbshadow
-@@ -13,10 +13,24 @@
- # phantom-dep hintfiles with mkhint -F.
- #
- # Called by slackrepo_setup_SBo-danix.sh and by sbpull after a subtree pull.
-+#
-+# Usage: sbshadow [--yes|--no]
-+# no flag show the plan and ask before changing anything
-+# --yes apply the plan without asking
-+# --no show the plan only, tree unchanged (preview)
-+# SBO_REPO overrides the repo path (used to test against a throwaway repo).
-
- set -euo pipefail
-
--REPO="/var/lib/sbopkg/SBo-danix"
-+answer=""
-+case "${1:-}" in
-+ --yes) answer=y ;;
-+ --no) answer=n ;;
-+ "") ;;
-+ *) echo "usage: sbshadow [--yes|--no]" >&2; exit 2 ;;
-+esac
-+
-+REPO="${SBO_REPO:-/var/lib/sbopkg/SBo-danix}"
- UPSTREAM="current"
-
- cd "$REPO"
-@@ -47,7 +61,11 @@
- (( ${#to_restore[@]} )) && printf 'unshadow: %s\n' "${to_restore[@]}"
- echo
-
-- read -rp "Proceed? [y/N] " confirm
-+ if [[ -n $answer ]]; then
-+ confirm=$answer
-+ else
-+ read -rp "Proceed? [y/N] " confirm
-+ fi
- if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
- echo "sbshadow: aborted, tree unchanged."
- else
-````
-
-```bash
-cd ~/Programming/GIT/slackrepo_setup && git apply /tmp/slackrepo_setup.patch && bash -n sbshadow && bash -n sbpull.bash
-```
-
-- [ ] **Step 3: Run the throwaway-repo checks**
-
-```bash
-cd "$T"
-SBO_REPO=$T PATH=/usr/bin:/bin bash ~/Programming/GIT/slackrepo_setup/sbshadow --no; echo "rc=$?"; ls cat
-SBO_REPO=$T PATH=/usr/bin:/bin bash ~/Programming/GIT/slackrepo_setup/sbshadow </dev/null; echo "rc=$?"
-SBO_REPO=$T PATH=/usr/bin:/bin bash ~/Programming/GIT/slackrepo_setup/sbshadow --bogus; echo "rc=$?"
-SBO_REPO=$T PATH=/usr/bin:/bin bash ~/Programming/GIT/slackrepo_setup/sbshadow --yes; echo "rc=$?"; ls cat; git log --oneline -1
-```
-
-Expected, in order:
-- `--no`: prints `shadow: cat/foo`, `sbshadow: aborted, tree unchanged.`, the mkhint NOTE, `rc=0`; `ls cat` shows `bar foo`.
-- no flag with stdin `/dev/null`: prints the plan, `rc=1` (read hits EOF, `set -e` exits: nothing hangs, nothing is applied).
-- `--bogus`: `usage: sbshadow [--yes|--no]`, `rc=2`.
-- `--yes`: `sbshadow: shadowed 1, unshadowed 0.`, `rc=0`; `ls cat` shows `bar`; last commit `shadow: reconcile upstream against personal/pentesting`.
-
-- [ ] **Step 4: Check sbpull passes the flag and the exit status**
-
-```bash
-bash -c 'sbshadow() { echo "fake sbshadow [$*]"; return 3; }; git() { return 0; }; pushd() { :; }; popd() { :; };
- . ~/Programming/GIT/slackrepo_setup/sbpull.bash; sbpull personal --no'; echo "rc=$?"
-bash -c 'sbshadow() { echo "fake sbshadow [$*]"; }; git() { return 0; }; pushd() { :; }; popd() { :; };
- . ~/Programming/GIT/slackrepo_setup/sbpull.bash; sbpull pentesting'; echo "rc=$?"
-rm -rf "$T"
-```
-
-Expected: `fake sbshadow [--no]` then `rc=3`; `fake sbshadow []` then `rc=0`.
-
-- [ ] **Step 5: Commit in slackrepo_setup**
-
-```bash
-cd ~/Programming/GIT/slackrepo_setup
-git add sbshadow sbpull.bash README.md
-git commit -F - <<'EOF'
-sbshadow: add --yes/--no, sbpull: pass flag, return sbshadow status
-
-sbshadow --yes applies the plan without asking, --no shows it and
-leaves the tree unchanged, so non-interactive callers (sb-builder runs
-it as a detached job with no stdin) never hit the prompt. No flag keeps
-the interactive prompt. SBO_REPO overrides the repo path for testing.
-
-sbpull passes the flag on, and now returns sbshadow's exit status
-instead of popd's, which made a failed sbshadow look successful.
-
-Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-EOF
-git log --format='%h %G? %s' -1 # expect G
-git push
-```
-
-- [ ] **Step 6: Deploy to the VM (ask the user first)**
-
-The HANDOFF of slackrepo_setup records the deploy locations: scripts in `/usr/local/bin`, `sbpull.bash` in `/usr/local/share/slackrepo_setup/`.
-
-```bash
-cd ~/Programming/GIT/slackrepo_setup
-ssh buildsystem 'ls -l /usr/local/bin/sbshadow /usr/local/share/slackrepo_setup/sbpull.bash'
-scp sbshadow buildsystem:/usr/local/bin/sbshadow
-scp sbpull.bash buildsystem:/usr/local/share/slackrepo_setup/sbpull.bash
-ssh buildsystem 'chmod 755 /usr/local/bin/sbshadow; sbshadow --bogus; echo rc=$?'
-```
-
-Expected last line pair: `usage: sbshadow [--yes|--no]`, `rc=2`.
-
----
-
-### Task 2: VM helper `sbb-remote`
-
-**Files:**
-- Create: `remote/sbb-remote`, `remote/config.example.toml`
-- Test: `tests/test_remote.py`
-
-- [ ] **Step 1: Write the tests**
-
-Create `tests/test_remote.py`:
-
-````python
-# sb-builder - self-checks for the remote helper
-# 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"""
-
-import contextlib
-import fcntl
-import importlib.machinery
-import importlib.util
-import io
-import json
-import os
-import shutil
-import sqlite3
-import tempfile
-import threading
-import time
-import unittest
-from unittest import mock
-
-SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "remote", "sbb-remote")
-_loader = importlib.machinery.SourceFileLoader("sbb_remote", SCRIPT)
-sbb = importlib.util.module_from_spec(importlib.util.spec_from_loader("sbb_remote", _loader))
-_loader.exec_module(sbb)
-
-
-class Base(unittest.TestCase):
- def setUp(self):
- self.tmp = tempfile.mkdtemp()
- self.addCleanup(shutil.rmtree, self.tmp)
- conf = os.path.join(self.tmp, "config.toml")
- with open(conf, "w") as f:
- f.write(f'state_dir = "{self.tmp}"\n'
- f'repo_dir = "{self.tmp}/repo"\n'
- f'slackrepo_db = "{self.tmp}/slackrepo.sqlite3"\n')
- patcher = mock.patch.dict(os.environ, {"SBB_CONFIG": conf})
- patcher.start()
- self.addCleanup(patcher.stop)
- self.cfg = sbb.load_config()
- os.makedirs(os.path.join(self.tmp, "jobs"))
-
- def make_job(self, argv, **meta):
- jid = "20260101-000000-test"
- d = os.path.join(self.tmp, "jobs", jid)
- os.mkdir(d)
- sbb.write_meta(d, {"id": jid, "kind": "test", "args": [], "argv": argv,
- "status": "starting", "created": time.time(), **meta})
- open(os.path.join(d, "log"), "wb").close()
- return jid, d
-
- def hold_lock(self):
- f = open(os.path.join(self.tmp, "lock"), "a")
- fcntl.flock(f, fcntl.LOCK_EX)
- self.addCleanup(f.close)
-
- def run_main(self, *argv):
- out = io.StringIO()
- with contextlib.redirect_stdout(out):
- rc = sbb.main(list(argv))
- return rc, json.loads(out.getvalue())
-
- def wait_status(self, d, statuses):
- for _ in range(100):
- if sbb.read_meta(d)["status"] in statuses:
- break
- time.sleep(0.05)
- return sbb.read_meta(d)
-
-
-class ConfigTest(Base):
- def write(self, text):
- p = os.path.join(self.tmp, "c.toml")
- with open(p, "w") as f:
- f.write(text)
- return p
-
- def test_missing_file_gives_defaults(self):
- self.assertEqual(sbb.load_config(os.path.join(self.tmp, "nope.toml")), sbb.DEFAULTS)
-
- def test_override(self):
- self.assertEqual(sbb.load_config(self.write('overlays = ["a"]\n'))["overlays"], ["a"])
-
- def test_example_matches_defaults(self):
- example = os.path.join(os.path.dirname(SCRIPT), "config.example.toml")
- self.assertEqual(sbb.load_config(example), sbb.DEFAULTS)
-
- def test_unknown_key(self):
- with self.assertRaisesRegex(sbb.Error, "bogus: unknown key"):
- sbb.load_config(self.write("bogus = 1\n"))
-
- def test_wrong_type(self):
- with self.assertRaisesRegex(sbb.Error, "state_dir: expected str"):
- sbb.load_config(self.write("state_dir = 1\n"))
-
-
-class KindsTest(Base):
- def test_sbpull_argv(self):
- self.assertEqual(sbb.KINDS["sbpull"](self.cfg, ["personal", "--no"]),
- ["bash", "-c", '. "$1" && sbpull "$2" "$3"', "_",
- self.cfg["sbpull_script"], "personal", "--no"])
-
- def test_sbshadow_argv(self):
- self.assertEqual(sbb.KINDS["sbshadow"](self.cfg, ["--yes"]), ["sbshadow", "--yes"])
-
- def test_rejects_bad_args(self):
- for kind, args in (("sbpull", ["evil", "--no"]), ("sbpull", ["personal"]),
- ("sbpull", ["personal", "--force"]), ("sbshadow", []),
- ("sbshadow", ["--yes", "--no"])):
- with self.subTest(kind=kind, args=args), self.assertRaises(sbb.Error):
- sbb.KINDS[kind](self.cfg, args)
-
- def test_unknown_kind(self):
- with self.assertRaisesRegex(sbb.Error, "unknown kind"):
- sbb.cmd_job_start(self.cfg, "rm", "-rf", "/")
-
-
-class MainTest(Base):
- def test_unknown_command(self):
- rc, data = self.run_main("nope")
- self.assertEqual(rc, 1)
- self.assertIn("usage", data["error"])
-
- def test_version(self):
- self.assertEqual(self.run_main("version"),
- (0, {"version": sbb.VERSION, "overlays": ["personal", "pentesting"]}))
-
-
-class JobsTest(Base):
- def log(self, d):
- with open(os.path.join(d, "log"), "rb") as f:
- return f.read()
-
- def test_run_ok(self):
- jid, d = self.make_job(["sh", "-c", "echo hello"])
- sbb.cmd_run(self.cfg, jid)
- m = sbb.read_meta(d)
- self.assertEqual((m["status"], m["exit"]), ("ok", 0))
- self.assertEqual(self.log(d), b"hello\n")
-
- def test_run_failed(self):
- jid, d = self.make_job(["sh", "-c", "exit 3"])
- sbb.cmd_run(self.cfg, jid)
- m = sbb.read_meta(d)
- self.assertEqual((m["status"], m["exit"]), ("failed", 3))
-
- def test_run_missing_program(self):
- jid, d = self.make_job(["/nonexistent/prog"])
- sbb.cmd_run(self.cfg, jid)
- self.assertEqual(sbb.read_meta(d)["status"], "failed")
- self.assertIn(b"sbb-remote:", self.log(d))
-
- def test_kill(self):
- jid, d = self.make_job(["sleep", "30"])
- t = threading.Thread(target=sbb.cmd_run, args=(self.cfg, jid))
- t.start()
- self.wait_status(d, ("running",))
- self.assertEqual(sbb.cmd_job_kill(self.cfg, jid), {"ok": True})
- t.join(5)
- self.assertEqual(sbb.read_meta(d)["status"], "killed")
-
- def test_kill_not_running(self):
- jid, _ = self.make_job(["true"], status="ok")
- with self.assertRaisesRegex(sbb.Error, "not running"):
- sbb.cmd_job_kill(self.cfg, jid)
-
- def test_busy(self):
- self.hold_lock()
- jid, d = self.make_job(["true"])
- sbb.cmd_run(self.cfg, jid)
- self.assertEqual(sbb.read_meta(d)["status"], "busy")
-
- def test_lost_when_lock_free(self):
- self.make_job(["true"], status="running", pid=1)
- self.assertEqual(sbb.cmd_jobs(self.cfg)["jobs"][0]["status"], "lost")
-
- def test_running_kept_while_lock_held(self):
- self.hold_lock()
- self.make_job(["true"], status="running", pid=1)
- self.assertEqual(sbb.cmd_jobs(self.cfg)["jobs"][0]["status"], "running")
-
- def test_starting_grace(self):
- _, d = self.make_job(["true"])
- self.assertEqual(sbb.cmd_jobs(self.cfg)["jobs"][0]["status"], "starting")
- sbb.write_meta(d, dict(sbb.read_meta(d), created=time.time() - 60))
- self.assertEqual(sbb.cmd_jobs(self.cfg)["jobs"][0]["status"], "lost")
-
- def test_follow_from_offset(self):
- jid, d = self.make_job(["true"], status="ok")
- with open(os.path.join(d, "log"), "wb") as f:
- f.write(b"hello world")
- out = io.BytesIO()
- sbb.cmd_job_follow(self.cfg, jid, "6", out=out)
- self.assertEqual(out.getvalue(), b"world")
-
- def test_bad_job_id(self):
- with self.assertRaisesRegex(sbb.Error, "bad job id"):
- sbb.cmd_job_follow(self.cfg, "../../etc", "0")
-
- def fake_sbshadow(self):
- bindir = os.path.join(self.tmp, "bin")
- os.mkdir(bindir)
- fake = os.path.join(bindir, "sbshadow")
- with open(fake, "w") as f:
- f.write('#!/bin/sh\necho "shadow: cat/$1"\n')
- os.chmod(fake, 0o755)
- patcher = mock.patch.dict(os.environ, {"PATH": bindir + os.pathsep + os.environ["PATH"]})
- patcher.start()
- self.addCleanup(patcher.stop)
-
- def test_start_end_to_end(self):
- self.fake_sbshadow()
- rc, data = self.run_main("job-start", "sbshadow", "--no")
- self.assertEqual(rc, 0)
- d = os.path.join(self.tmp, "jobs", data["id"])
- self.assertEqual(self.wait_status(d, ("ok", "failed", "lost"))["status"], "ok")
- self.assertEqual(self.log(d), b"shadow: cat/--no\n")
-
- def test_start_busy(self):
- self.fake_sbshadow()
- self.hold_lock()
- jid, _ = self.make_job(["true"], status="running", pid=1)
- self.assertEqual(self.run_main("job-start", "sbshadow", "--no"), (0, {"busy": jid}))
- self.assertEqual(os.listdir(os.path.join(self.tmp, "jobs")), [jid])
-
-
-class StatsTest(Base):
- def test_missing_sources_are_null(self):
- s = sbb.cmd_stats(self.cfg)
- self.assertIsNotNone(s["load"])
- self.assertIsNone(s["packages"])
- self.assertIsNone(s["slackrepo"])
- self.assertEqual(s["sync"], {"personal": None, "pentesting": None})
- json.dumps(s)
-
- def test_counts(self):
- r = self.cfg["repo_dir"]
- for p in ("cat/foo", "cat/bar", "personal/foo", "personal/baz", "pentesting/nmap"):
- os.makedirs(os.path.join(r, p))
- open(os.path.join(r, p, os.path.basename(p) + ".SlackBuild"), "w").close()
- os.makedirs(os.path.join(r, "cat", "notapkg"))
-
- def fake_git(cfg, *args):
- if args[0] == "ls-tree":
- return "cat/foo/foo.SlackBuild\ncat/foo/README\ncat/old/old.SlackBuild\n"
- return "1792351223\n"
-
- with mock.patch.object(sbb, "_git", fake_git):
- s = sbb.cmd_stats(self.cfg)
- self.assertEqual(s["packages"], {"total": 5, "personal": 2, "pentesting": 1, "shadowed": 1})
- self.assertEqual(s["sync"]["personal"], 1792351223)
-
- def test_slackrepo(self):
- db = sqlite3.connect(self.cfg["slackrepo_db"])
- db.executescript("""
- create table buildresults (itemid text primary key, time text, result text);
- create table revisions (itemid text, dep text, deplist text, version text,
- built text, rev text, os text, hintcksum text, primary key (itemid, dep));
- insert into buildresults values ('a', '1', 'ok'), ('b', '1', 'ok'), ('c', '1', 'failed');
- insert into revisions values ('cat/a', '/', '/', '1', '1', 'r', 'o', '/'),
- ('cat/a', 'dep', '/', '1', '1', 'r', 'o', '/');
- """)
- db.close()
- self.assertEqual(sbb.cmd_stats(self.cfg)["slackrepo"],
- {"results": {"ok": 2, "failed": 1}, "built": 1})
-
-
-if __name__ == "__main__":
- unittest.main()
-````
-
-- [ ] **Step 2: Run them to see them fail**
-
-Run: `python3 -m unittest tests.test_remote`
-Expected: ERROR, `FileNotFoundError` for `remote/sbb-remote`.
-
-- [ ] **Step 3: Write the helper**
-
-Create `remote/sbb-remote` and `chmod 755 remote/sbb-remote`:
-
-````python
-#!/usr/bin/env python3
-# sbb-remote - sb-builder helper on the build VM
-# 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.
-
-"""Helper run over ssh by the sb-builder GUI.
-
-Every command prints one JSON object on stdout, except job-follow, which
-streams raw log bytes. Jobs run detached (setsid, stdin=/dev/null), one at a
-time under a flock, from a fixed table of kinds: the GUI sends a kind and
-its arguments, this file validates them and builds the argv itself.
-"""
-
-import fcntl
-import glob
-import json
-import os
-import re
-import shutil
-import signal
-import sqlite3
-import subprocess
-import sys
-import time
-import tomllib
-
-VERSION = "0.1.0"
-DEFAULTS = {
- "state_dir": "/var/lib/sb-builder",
- "repo_dir": "/var/lib/sbopkg/SBo-danix",
- "upstream_branch": "current",
- "slackrepo_db": "/var/lib/slackrepo/SBo-danix/database_SBo-danix_x86_64.sqlite3",
- "sbpull_script": "/usr/local/share/slackrepo_setup/sbpull.bash",
- "overlays": ["personal", "pentesting"],
-}
-FLAGS = ("--yes", "--no")
-JOB_ID = re.compile(r"^\d{8}-\d{6}-[a-z]+(-\d+)?$")
-PKG = re.compile(r"^[^/]+/([^/]+)/\1\.SlackBuild$")
-ACTIVE = ("starting", "running")
-START_GRACE = 10 # seconds a job may sit in "starting" before it counts as lost
-
-
-class Error(Exception):
- pass
-
-
-def load_config(path=None):
- path = path or os.environ.get("SBB_CONFIG", "/etc/sb-builder/config.toml")
- cfg = dict(DEFAULTS)
- try:
- with open(path, "rb") as f:
- data = tomllib.load(f)
- except FileNotFoundError:
- return cfg
- except tomllib.TOMLDecodeError as e:
- raise Error(f"config: {e}")
- for k, v in data.items():
- if k not in DEFAULTS:
- raise Error(f"config: {k}: unknown key")
- 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")
- return cfg
-
-
-# ── job kinds: (cfg, args) -> argv, raising Error on bad args ───────────────
-
-def _flag(args):
- if len(args) != 1 or args[0] not in FLAGS:
- raise Error("expected exactly one of --yes, --no")
- return args[0]
-
-
-def kind_sbpull(cfg, args):
- if not args or args[0] not in cfg["overlays"]:
- raise Error(f"sbpull: overlay must be one of {cfg['overlays']}")
- return ["bash", "-c", '. "$1" && sbpull "$2" "$3"', "_",
- cfg["sbpull_script"], args[0], _flag(args[1:])]
-
-
-def kind_sbshadow(cfg, args):
- return ["sbshadow", _flag(args)]
-
-
-KINDS = {"sbpull": kind_sbpull, "sbshadow": kind_sbshadow}
-
-
-# ── job state ───────────────────────────────────────────────────────────────
-
-def job_dir(cfg, jid):
- if not JOB_ID.match(jid or ""):
- raise Error(f"bad job id: {jid}")
- d = os.path.join(cfg["state_dir"], "jobs", jid)
- if not os.path.isdir(d):
- raise Error(f"no such job: {jid}")
- return d
-
-
-def read_meta(d):
- with open(os.path.join(d, "meta.json")) as f:
- return json.load(f)
-
-
-def write_meta(d, meta):
- tmp = os.path.join(d, "meta.json.tmp")
- with open(tmp, "w") as f:
- json.dump(meta, f)
- os.replace(tmp, os.path.join(d, "meta.json"))
-
-
-def lock_file(cfg):
- return open(os.path.join(cfg["state_dir"], "lock"), "a")
-
-
-def refresh(cfg, d, meta):
- """Mark an active job lost when no runner holds the lock.
-
- _run writes the final status before it releases the lock, so a free lock
- plus a status that is still active (re-read under the lock) means the
- runner died: killed, crashed, or the VM rebooted.
- """
- if meta["status"] not in ACTIVE:
- return meta
- if meta["status"] == "starting" and time.time() - meta["created"] < START_GRACE:
- return meta
- with lock_file(cfg) as lk:
- try:
- fcntl.flock(lk, fcntl.LOCK_EX | fcntl.LOCK_NB)
- except BlockingIOError:
- return meta
- meta = read_meta(d)
- if meta["status"] in ACTIVE:
- meta["status"] = "lost"
- write_meta(d, meta)
- return meta
-
-
-def all_jobs(cfg):
- out = []
- for d in sorted(glob.glob(os.path.join(cfg["state_dir"], "jobs", "*")), reverse=True):
- try:
- out.append(refresh(cfg, d, read_meta(d)))
- except (OSError, ValueError):
- pass # a dir being created or removed right now
- return out
-
-
-# ── commands ────────────────────────────────────────────────────────────────
-
-def cmd_version(cfg):
- return {"version": VERSION, "overlays": cfg["overlays"]}
-
-
-def cmd_jobs(cfg):
- # ponytail: logs and job dirs are kept forever; prune once disk usage shows on the dashboard
- return {"jobs": all_jobs(cfg)}
-
-
-def cmd_job_start(cfg, kind=None, *args):
- if kind not in KINDS:
- raise Error(f"unknown kind: {kind}")
- argv = KINDS[kind](cfg, list(args))
- base = time.strftime("%Y%m%d-%H%M%S-") + kind
- jid, n = base, 1
- while True:
- d = os.path.join(cfg["state_dir"], "jobs", jid)
- try:
- os.mkdir(d)
- break
- except FileExistsError:
- n += 1
- jid = f"{base}-{n}"
- write_meta(d, {"id": jid, "kind": kind, "args": list(args), "argv": argv,
- "status": "starting", "created": time.time()})
- open(os.path.join(d, "log"), "wb").close()
- subprocess.Popen([sys.executable, os.path.abspath(__file__), "_run", jid],
- stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL, start_new_session=True)
- for _ in range(60):
- status = read_meta(d)["status"]
- if status == "busy":
- shutil.rmtree(d)
- return {"busy": next((m["id"] for m in all_jobs(cfg) if m["status"] == "running"), None)}
- if status != "starting":
- break
- time.sleep(0.05)
- return {"id": jid}
-
-
-def cmd_run(cfg, jid=None):
- d = job_dir(cfg, jid)
- meta = read_meta(d)
- with lock_file(cfg) as lk:
- try:
- fcntl.flock(lk, fcntl.LOCK_EX | fcntl.LOCK_NB)
- except BlockingIOError:
- meta["status"] = "busy"
- write_meta(d, meta)
- return None
- with open(os.path.join(d, "log"), "ab") as log:
- meta["start"] = time.time()
- try:
- p = subprocess.Popen(meta["argv"], stdin=subprocess.DEVNULL, stdout=log,
- stderr=subprocess.STDOUT, start_new_session=True)
- except OSError as e:
- log.write(f"sbb-remote: {e}\n".encode())
- rc = 127
- else:
- meta.update(pid=p.pid, status="running")
- write_meta(d, meta)
- rc = p.wait()
- meta.update(end=time.time(), exit=rc,
- status="ok" if rc == 0 else "killed" if rc == -signal.SIGTERM else "failed")
- write_meta(d, meta)
- return None
-
-
-def cmd_job_follow(cfg, jid=None, offset="0", out=None):
- d = job_dir(cfg, jid)
- out = out or sys.stdout.buffer
- with open(os.path.join(d, "log"), "rb") as f:
- f.seek(int(offset))
- while True:
- # status first, then read: the last read after the job ends drains the log
- done = refresh(cfg, d, read_meta(d))["status"] not in ACTIVE
- chunk = f.read()
- if chunk:
- out.write(chunk)
- out.flush()
- elif done:
- return None
- else:
- time.sleep(0.5)
-
-
-def cmd_job_kill(cfg, jid=None):
- meta = read_meta(job_dir(cfg, jid))
- if meta["status"] != "running":
- raise Error(f"job not running: {jid}")
- try:
- os.killpg(meta["pid"], signal.SIGTERM)
- except ProcessLookupError:
- pass # ended on its own meanwhile
- return {"ok": True}
-
-
-# ── stats ───────────────────────────────────────────────────────────────────
-
-def _try(fn):
- try:
- return fn()
- except Exception:
- return None
-
-
-def _git(cfg, *args):
- return subprocess.run(["git", "-C", cfg["repo_dir"], *args],
- capture_output=True, text=True, check=True).stdout
-
-
-def _pkgs(root, pattern):
- """Names of the <pkg>/<pkg>.SlackBuild dirs matching pattern under root."""
- out = []
- for d in glob.glob(os.path.join(root, pattern, "")):
- name = os.path.basename(os.path.dirname(d))
- if os.path.isfile(os.path.join(d, name + ".SlackBuild")):
- out.append(name)
- return out
-
-
-def _packages(cfg):
- r = cfg["repo_dir"]
- mine = {o: _pkgs(r, o + "/*") for o in cfg["overlays"]}
- tree = _git(cfg, "ls-tree", "-r", "--name-only", cfg["upstream_branch"]).splitlines()
- upstream = {m.group(1) for m in map(PKG.match, tree) if m}
- shadowed = upstream & {p for names in mine.values() for p in names}
- return {"total": len(_pkgs(r, "*/*")), **{o: len(n) for o, n in mine.items()},
- "shadowed": len(shadowed)}
-
-
-def _slackrepo(cfg):
- db = sqlite3.connect(f"file:{cfg['slackrepo_db']}?mode=ro", uri=True)
- try:
- results = dict(db.execute("select result, count(*) from buildresults group by result"))
- built = db.execute("select count(distinct itemid) from revisions where dep = '/'").fetchone()[0]
- finally:
- db.close()
- return {"results": results, "built": built}
-
-
-def _uptime():
- with open("/proc/uptime") as f:
- return float(f.read().split()[0])
-
-
-def cmd_stats(cfg):
- dirs = {"repo": cfg["repo_dir"], "state": cfg["state_dir"]}
- return {
- "load": _try(os.getloadavg),
- "uptime": _try(_uptime),
- "disk_free": {k: _try(lambda p=p: shutil.disk_usage(p).free) for k, p in dirs.items()},
- "packages": _try(lambda: _packages(cfg)),
- "sync": {o: _try(lambda o=o: int(_git(cfg, "log", "-1", "--format=%ct", "--", o + "/")))
- for o in cfg["overlays"]},
- "slackrepo": _try(lambda: _slackrepo(cfg)),
- }
-
-
-COMMANDS = {
- "version": cmd_version,
- "stats": cmd_stats,
- "jobs": cmd_jobs,
- "job-start": cmd_job_start,
- "job-follow": cmd_job_follow,
- "job-kill": cmd_job_kill,
- "_run": cmd_run,
-}
-
-
-def main(argv):
- try:
- if not argv or argv[0] not in COMMANDS:
- names = ",".join(c for c in COMMANDS if not c.startswith("_"))
- raise Error(f"usage: sbb-remote {{{names}}} [args]")
- cfg = load_config()
- os.makedirs(os.path.join(cfg["state_dir"], "jobs"), exist_ok=True)
- result = COMMANDS[argv[0]](cfg, *argv[1:])
- except BrokenPipeError:
- # the GUI hung up on job-follow; silence the flush at exit too
- os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
- return 0
- except Exception as e:
- print(json.dumps({"error": str(e) if isinstance(e, Error) else f"{type(e).__name__}: {e}"}))
- return 1
- if result is not None:
- print(json.dumps(result))
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
-````
-
-Create `remote/config.example.toml`:
-
-````toml
-# sbb-remote config on the VM: /etc/sb-builder/config.toml
-# Every key is optional; these are the defaults.
-
-state_dir = "/var/lib/sb-builder"
-repo_dir = "/var/lib/sbopkg/SBo-danix"
-upstream_branch = "current"
-slackrepo_db = "/var/lib/slackrepo/SBo-danix/database_SBo-danix_x86_64.sqlite3"
-sbpull_script = "/usr/local/share/slackrepo_setup/sbpull.bash"
-overlays = ["personal", "pentesting"]
-````
-
-- [ ] **Step 4: Run the tests**
-
-Run: `python3 -m unittest tests.test_remote`
-Expected: `Ran 27 tests`, `OK`. Two `ResourceWarning: subprocess ... is still running` lines are expected: `job-start` deliberately does not wait for the detached `_run`.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add remote/sbb-remote remote/config.example.toml tests/test_remote.py
-git commit -F - <<'EOF'
-Add sbb-remote, the VM helper
-
-Single-file stdlib helper the GUI runs over ssh. JSON commands for
-version, stats and jobs; job-start runs a fixed table of kinds (sbpull,
-sbshadow, each requiring --yes or --no) detached with setsid and no
-stdin, one at a time under a flock; job-follow streams a log from a
-byte offset so the GUI can drop and resume; job-kill sends SIGTERM to
-the job's process group. A job whose runner died is reported lost.
-
-Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-EOF
-git log --format='%h %G? %s' -1
-```
-
----
-
-### Task 3: GUI
-
-**Files:**
-- Create: `sb_builder/__init__.py`, `sb_builder/config.py`, `sb_builder/remote.py`, `sb_builder/fmt.py`, `sb_builder/dashboard.py`, `sb_builder/jobs.py`, `sb_builder/mainwindow.py`, `sb_builder/__main__.py`, `config.example.toml`
-- Test: `tests/test_gui.py`
-
-- [ ] **Step 1: Write the tests**
-
-Create `tests/test_gui.py`:
-
-````python
-# sb-builder - self-checks for the GUI
-# 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 (headless, no VM needed)"""
-
-import os
-os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
-
-import importlib.machinery # noqa: E402
-import importlib.util # noqa: E402
-import sys # noqa: E402
-import tempfile # noqa: E402
-import unittest # noqa: E402
-
-ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
-sys.path.insert(0, ROOT)
-
-from PyQt6.QtCore import QObject, pyqtSignal # noqa: E402
-from PyQt6.QtWidgets import QApplication # noqa: E402
-
-from sb_builder import VERSION, config # noqa: E402
-from sb_builder.mainwindow import MainWindow # noqa: E402
-from sb_builder.remote import DOWN, MISSING, parse # noqa: E402
-
-app = QApplication.instance() or QApplication([])
-
-
-class ConfigTest(unittest.TestCase):
- def load(self, text):
- with tempfile.NamedTemporaryFile("w", suffix=".toml", delete=False) as f:
- f.write(text)
- self.addCleanup(os.unlink, f.name)
- return config.load(f.name)
-
- def test_missing_file(self):
- self.assertEqual(config.load("/nonexistent/config.toml"), (config.DEFAULTS, None))
-
- def test_example_matches_defaults(self):
- self.assertEqual(config.load(os.path.join(ROOT, "config.example.toml")), (config.DEFAULTS, None))
-
- def test_override(self):
- cfg, err = self.load("[refresh]\njobs = 2\n")
- self.assertIsNone(err)
- self.assertEqual(cfg["refresh"], {"dashboard": 30, "jobs": 2})
-
- def test_unknown_key(self):
- cfg, err = self.load("[remote]\nbogus = 1\n")
- self.assertIn("remote.bogus", err)
- self.assertEqual(cfg, config.DEFAULTS)
-
- def test_wrong_type(self):
- cfg, err = self.load('[vm]\nautostart = "yes"\n')
- self.assertIn("vm.autostart: expected bool", err)
- self.assertEqual(cfg, config.DEFAULTS)
-
-
-class ParseTest(unittest.TestCase):
- def test_cases(self):
- self.assertEqual(parse(255, b"", b""), (None, DOWN))
- self.assertEqual(parse(127, b"", b""), (None, MISSING))
- self.assertEqual(parse(0, b'{"a": 1}', b""), ({"a": 1}, None))
- self.assertEqual(parse(1, b'{"error": "boom"}', b""), (None, "boom"))
- self.assertEqual(parse(1, b"", b"bash: x\n"), (None, "bash: x"))
-
-
-class VersionTest(unittest.TestCase):
- def test_gui_matches_helper(self):
- script = os.path.join(ROOT, "remote", "sbb-remote")
- loader = importlib.machinery.SourceFileLoader("sbb_remote", script)
- sbb = importlib.util.module_from_spec(importlib.util.spec_from_loader("sbb_remote", loader))
- loader.exec_module(sbb)
- self.assertEqual(sbb.VERSION, VERSION)
-
-
-class FakeRemote:
- def __init__(self, answers):
- self.answers = answers
- self.calls = []
- self.virsh_answer = (1, "")
-
- def call(self, cb, *args):
- self.calls.append(args)
- cb(*self.answers.get(args[0], (None, DOWN)))
-
- def virsh(self, cb, *args):
- self.calls.append(("virsh", *args))
- cb(*self.virsh_answer)
-
-
-class FakeNotifier(QObject):
- clicked = pyqtSignal(str)
-
- def __init__(self):
- super().__init__()
- self.sent = []
-
- def send(self, title, body, jid=""):
- self.sent.append((title, body, jid))
-
-
-STATS = {"load": [0.5, 0.4, 0.3], "uptime": 3600.0,
- "disk_free": {"repo": 10 * 2**30, "state": None},
- "packages": {"total": 10, "personal": 2, "pentesting": 1, "shadowed": 1},
- "sync": {"personal": None, "pentesting": 1792351223}, "slackrepo": None}
-JOB = {"id": "20260101-000000-sbshadow", "kind": "sbshadow", "args": ["--no"], "argv": [],
- "status": "running", "created": 0, "start": 0, "pid": 5}
-
-
-class SmokeTest(unittest.TestCase):
- def window(self, **answers):
- remote = FakeRemote({"version": ({"version": VERSION, "overlays": ["personal"]}, None),
- "stats": (STATS, None), "jobs": ({"jobs": []}, None), **answers})
- w = MainWindow(config.load("/nonexistent")[0], remote, FakeNotifier())
- self.addCleanup(w.deleteLater)
- return w, remote
-
- def test_tabs_and_dashboard(self):
- w, _ = self.window()
- w.poll_jobs()
- self.assertEqual(w.conn.text(), "● connected")
- self.assertEqual(w.tabs.tabText(0), "Dashboard")
- self.assertIn("total: 10", w.dashboard.labels["Repo"].text())
- self.assertEqual(w.dashboard.labels["slackrepo"].text(), "n/a")
- self.assertIn("10.0 GiB", w.dashboard.labels["VM"].text())
- jobs = w.open_jobs()
- self.assertIs(w.open_jobs(), jobs)
- self.assertEqual(w.tabs.count(), 2)
- self.assertEqual(jobs.start_menu.actions()[0].text(), "sbpull personal (preview)")
-
- def test_end_notification_once(self):
- w, remote = self.window(jobs=({"jobs": [JOB]}, None))
- w.poll_jobs()
- remote.answers["jobs"] = ({"jobs": [dict(JOB, status="failed", end=5, exit=1)]}, None)
- w.poll_jobs()
- w.poll_jobs()
- self.assertEqual(w.notifier.sent, [("sb-builder: failed", "sbshadow --no", JOB["id"])])
-
- def test_down_offers_vm_start(self):
- w, remote = self.window(jobs=(None, DOWN))
- remote.virsh_answer = (0, "shut off")
- w.poll_jobs()
- self.assertIn(("virsh", "domstate", "buildsystem"), remote.calls)
- self.assertEqual(w.conn.text(), "● VM off")
- self.assertFalse(w.vm_btn.isHidden())
-
- def test_log_carriage_return(self):
- w, _ = self.window()
- jobs = w.open_jobs()
- jobs.append_log("10%\r50%\r100%\ndone\r\n")
- self.assertEqual(jobs.log.toPlainText(), "100%\ndone\n")
-
-
-if __name__ == "__main__":
- unittest.main()
-````
-
-- [ ] **Step 2: Run them to see them fail**
-
-Run: `python3 -m unittest tests.test_gui`
-Expected: ERROR, `ModuleNotFoundError: No module named 'sb_builder'`.
-
-- [ ] **Step 3: Package and config**
-
-Create `sb_builder/__init__.py`:
-
-````python
-# sb-builder - GUI package
-# 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.
-
-"""sb-builder: Qt6 GUI driving the SlackBuild tooling on a build VM."""
-
-VERSION = "0.1.0" # must match remote/sbb-remote VERSION
-````
-
-Create `sb_builder/config.py`:
-
-````python
-# sb-builder - GUI configuration
-# 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.
-
-"""GUI configuration: built-in defaults overlaid by the user's TOML file."""
-
-import copy
-import os
-import tomllib
-
-DEFAULTS = {
- "remote": {"target": "buildsystem", "ssh_options": []},
- "refresh": {"dashboard": 30, "jobs": 5},
- "notify": {"enabled": True, "events": ["finished", "failed", "killed", "lost", "vm-up"]},
- "vm": {"libvirt_uri": "qemu:///system", "domain": "buildsystem", "autostart": False},
-}
-
-
-def path():
- base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
- return os.path.join(base, "sb-builder", "config.toml")
-
-
-def load(p=None):
- """Return (config, error). On any problem: the defaults plus the error text."""
- p = p or path()
- try:
- with open(p, "rb") as f:
- data = tomllib.load(f)
- except FileNotFoundError:
- return copy.deepcopy(DEFAULTS), None
- except (OSError, tomllib.TOMLDecodeError) as e:
- return copy.deepcopy(DEFAULTS), f"{p}: {e}"
- cfg = copy.deepcopy(DEFAULTS)
- for sec, keys in data.items():
- if sec not in DEFAULTS or not isinstance(keys, dict):
- return copy.deepcopy(DEFAULTS), f"{p}: unknown section [{sec}]"
- for k, v in keys.items():
- if k not in DEFAULTS[sec]:
- return copy.deepcopy(DEFAULTS), f"{p}: unknown key {sec}.{k}"
- want = type(DEFAULTS[sec][k])
- if type(v) is not want:
- return copy.deepcopy(DEFAULTS), f"{p}: {sec}.{k}: expected {want.__name__}"
- cfg[sec][k] = v
- return cfg, None
-````
-
-Create `config.example.toml`:
-
-````toml
-# sb-builder GUI config: ~/.config/sb-builder/config.toml
-# Every key is optional; these are the defaults.
-
-[remote]
-target = "buildsystem" # any ssh destination: host alias, user@host
-ssh_options = [] # extra ssh args, e.g. ["-p", "2222"]
-
-[refresh]
-dashboard = 30 # seconds, while the Dashboard tab is visible
-jobs = 5 # seconds; doubles up to 30 while the VM is down
-
-[notify]
-enabled = true
-events = ["finished", "failed", "killed", "lost", "vm-up"]
-
-[vm]
-libvirt_uri = "qemu:///system"
-domain = "buildsystem" # libvirt domain of the VM; "" disables VM control
-autostart = false # true: start the VM without asking when it is off
-````
-
-- [ ] **Step 4: Transport**
-
-Create `sb_builder/remote.py`:
-
-````python
-# sb-builder - ssh and virsh transport
-# 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.
-
-"""The only code that talks to the VM: ssh to sbb-remote, and local virsh."""
-
-import json
-import os
-import shlex
-
-from PyQt6.QtCore import QObject, QProcess, QStandardPaths
-
-DOWN = "ssh: connection failed"
-MISSING = "sbb-remote is not installed on the VM"
-
-
-def parse(rc, out, err):
- """Map one sbb-remote call to (data, error)."""
- if rc == 255:
- return None, DOWN
- if rc == 127:
- return None, MISSING
- try:
- data = json.loads(out)
- except ValueError:
- return None, err.decode(errors="replace").strip() or f"exit {rc}"
- if rc != 0 or "error" in data:
- return None, data.get("error", f"exit {rc}")
- return data, None
-
-
-class Remote(QObject):
- def __init__(self, cfg, parent=None):
- super().__init__(parent)
- self.cfg = cfg # shared dict, updated in place on config reload
- run = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.RuntimeLocation)
- self.control = os.path.join(run or "/tmp", "sbb-%C")
-
- def ssh_args(self, *args):
- r = self.cfg["remote"]
- # ssh joins its args into a remote shell command: quote every one
- return ["-o", "ControlMaster=auto", "-o", f"ControlPath={self.control}",
- "-o", "ControlPersist=10m", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
- *r["ssh_options"], r["target"], "sbb-remote", *map(shlex.quote, args)]
-
- def call(self, cb, *args):
- """Run sbb-remote <args>, then cb(data, error)."""
- self._run("ssh", self.ssh_args(*args), lambda rc, out, err: cb(*parse(rc, out, err)))
-
- def virsh(self, cb, *args):
- """Run local virsh against the configured libvirt URI, then cb(rc, text)."""
- argv = ["-c", self.cfg["vm"]["libvirt_uri"], *args]
- self._run("virsh", argv,
- lambda rc, out, err: cb(rc, (out or err).decode(errors="replace").strip()))
-
- def follow(self, jid, offset):
- """Start job-follow; the caller wires readyReadStandardOutput and finished."""
- p = QProcess(self)
- p.start("ssh", self.ssh_args("job-follow", jid, str(offset)))
- return p
-
- def _run(self, prog, argv, cb):
- p = QProcess(self)
-
- def done(rc, _status=None):
- cb(rc, bytes(p.readAllStandardOutput()), bytes(p.readAllStandardError()))
- p.deleteLater()
-
- p.finished.connect(done)
- p.errorOccurred.connect(
- lambda e: e == QProcess.ProcessError.FailedToStart and done(-1))
- p.start(prog, argv)
-````
-
-- [ ] **Step 5: Formatting helpers and Dashboard**
-
-Create `sb_builder/fmt.py`:
-
-````python
-# sb-builder - formatting helpers
-# 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.
-
-"""Text formatting shared by the tabs."""
-
-import time
-
-NA = "n/a"
-ICON = {"starting": "…", "running": "▶", "ok": "✔", "failed": "✘",
- "killed": "■", "lost": "?"}
-
-
-def fmt_bytes(n):
- if n is None:
- return NA
- for unit in ("B", "KiB", "MiB", "GiB"):
- if n < 1024:
- return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
- n /= 1024
- return f"{n:.1f} TiB"
-
-
-def fmt_duration(secs):
- if secs is None:
- return NA
- d, r = divmod(int(secs), 86400)
- h, r = divmod(r, 3600)
- m, s = divmod(r, 60)
- return f"{d}d {h}h" if d else f"{h}h {m}m" if h else f"{m}m {s}s"
-
-
-def fmt_age(ts):
- return NA if ts is None else fmt_duration(time.time() - ts) + " ago"
-
-
-def fmt_time(ts):
- return time.strftime("%m-%d %H:%M", time.localtime(ts))
-
-
-def job_title(j):
- return " ".join([j["kind"], *j["args"]])
-
-
-def describe(j):
- """One-line job summary: icon, kind and args, start time, duration."""
- dur = None if "start" not in j else j.get("end", time.time()) - j["start"]
- return (f"{ICON.get(j['status'], '?')} {job_title(j)} "
- f"{fmt_time(j.get('start', j['created']))} {fmt_duration(dur)}")
-````
-
-Create `sb_builder/dashboard.py`:
-
-````python
-# sb-builder - Dashboard 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.
-
-"""Dashboard tab: tiles filled from sbb-remote stats and jobs."""
-
-import time
-
-from PyQt6.QtWidgets import (QGridLayout, QGroupBox, QHBoxLayout, QLabel,
- QPushButton, QVBoxLayout, QWidget)
-
-from .fmt import NA, describe, fmt_age, fmt_bytes, fmt_duration
-
-TILES = ("VM", "Repo", "slackrepo", "Sync", "Jobs", "Orphans")
-
-
-class Dashboard(QWidget):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.labels = {}
- grid = QGridLayout()
- for i, name in enumerate(TILES):
- box = QGroupBox(name)
- label = QLabel(NA)
- label.setWordWrap(True)
- QVBoxLayout(box).addWidget(label)
- grid.addWidget(box, i // 3, i % 3)
- self.labels[name] = label
- self.labels["Orphans"].setText("coming in phase 5")
- self.stale = QLabel()
- self.refresh_btn = QPushButton("Refresh")
- top = QHBoxLayout()
- top.addWidget(self.stale)
- top.addStretch()
- top.addWidget(self.refresh_btn)
- layout = QVBoxLayout(self)
- layout.addLayout(top)
- layout.addLayout(grid)
- layout.addStretch()
-
- def show_stats(self, s):
- L = self.labels
- load, disk = s.get("load"), s.get("disk_free") or {}
- L["VM"].setText(
- f"load: {NA if load is None else ' '.join(f'{x:.2f}' for x in load)}\n"
- f"uptime: {fmt_duration(s.get('uptime'))}\n"
- f"free: repo {fmt_bytes(disk.get('repo'))}, state {fmt_bytes(disk.get('state'))}")
- p = s.get("packages")
- L["Repo"].setText(NA if p is None else "\n".join(f"{k}: {v}" for k, v in p.items()))
- sr = s.get("slackrepo")
- if sr is None:
- L["slackrepo"].setText(NA)
- else:
- never = NA if p is None else p["total"] - sr["built"]
- L["slackrepo"].setText("\n".join(
- [f"built: {sr['built']}", f"never built: {never}",
- *(f"{k}: {v}" for k, v in sorted(sr["results"].items()))]))
- sync = s.get("sync") or {}
- L["Sync"].setText("\n".join(f"{o}: {fmt_age(t)}" for o, t in sync.items()) or NA)
-
- def show_jobs(self, jobs):
- self.labels["Jobs"].setText("\n".join(describe(j) for j in jobs[:10]) or "no jobs yet")
-
- def set_stale(self, since):
- """Grey the tiles when the VM stopped answering; None clears it."""
- for label in self.labels.values():
- label.setEnabled(since is None)
- self.stale.setText("" if since is None else
- f"stale since {time.strftime('%H:%M', time.localtime(since))}")
-````
-
-- [ ] **Step 6: Jobs tab**
-
-Create `sb_builder/jobs.py`:
-
-````python
-# sb-builder - Jobs 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.
-
-"""Jobs tab: job list, live log through job-follow, start and kill."""
-
-import codecs
-import re
-
-from PyQt6.QtCore import Qt, pyqtSignal
-from PyQt6.QtGui import QFontDatabase, QTextCursor
-from PyQt6.QtWidgets import (QHBoxLayout, QListWidget, QListWidgetItem, QMenu,
- QMessageBox, QPlainTextEdit, QPushButton, QSplitter,
- QToolButton, QVBoxLayout, QWidget)
-
-from .fmt import describe
-
-ANSI = re.compile(rb"\x1b\[[0-9;?]*[A-Za-z]")
-SHADOW_PLAN = re.compile(r"^(un)?shadow: ", re.M)
-ACTIVE = ("starting", "running")
-BAD = ("failed", "killed", "lost")
-
-
-class JobsTab(QWidget):
- refresh_requested = pyqtSignal()
-
- def __init__(self, remote, parent=None):
- super().__init__(parent)
- self.remote = remote
- self.jobs = {}
- self.current = None
- self.offset = 0
- self.proc = None
- self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
-
- self.start_menu = QMenu(self)
- start = QToolButton(text="Start", popupMode=QToolButton.ToolButtonPopupMode.InstantPopup)
- start.setMenu(self.start_menu)
- self.apply_btn = QPushButton("Apply shadowing")
- self.apply_btn.clicked.connect(lambda: self.confirm_start(
- "Apply the shadowing plan to the repo (sbshadow --yes)?", "sbshadow", "--yes"))
- self.apply_btn.hide()
- self.kill_btn = QPushButton("Kill")
- self.kill_btn.clicked.connect(self.kill)
- bar = QHBoxLayout()
- for w in (start, self.apply_btn):
- bar.addWidget(w)
- bar.addStretch()
- bar.addWidget(self.kill_btn)
-
- self.list = QListWidget()
- self.list.currentItemChanged.connect(self._on_select)
- self.log = QPlainTextEdit(readOnly=True)
- self.log.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
- self.log.setMaximumBlockCount(50000) # ponytail: older lines drop off; the full log stays on the VM
- split = QSplitter()
- split.addWidget(self.list)
- split.addWidget(self.log)
- split.setSizes([300, 700])
- layout = QVBoxLayout(self)
- layout.addLayout(bar)
- layout.addWidget(split)
- self.set_overlays([])
-
- def set_overlays(self, overlays):
- m = self.start_menu
- m.clear()
- for o in overlays:
- m.addAction(f"sbpull {o} (preview)", lambda o=o: self.start("sbpull", o, "--no"))
- m.addSeparator()
- m.addAction("sbshadow (preview)", lambda: self.start("sbshadow", "--no"))
- m.addAction("sbshadow (apply)…", lambda: self.confirm_start(
- "Run sbshadow --yes on the repo?", "sbshadow", "--yes"))
-
- def set_jobs(self, jobs):
- self.jobs = {j["id"]: j for j in jobs}
- self.list.blockSignals(True)
- self.list.clear()
- for j in jobs:
- item = QListWidgetItem(describe(j))
- item.setData(Qt.ItemDataRole.UserRole, j["id"])
- if j["status"] in BAD:
- item.setForeground(Qt.GlobalColor.red)
- self.list.addItem(item)
- if j["id"] == self.current:
- self.list.setCurrentItem(item)
- self.list.blockSignals(False)
- j = self.jobs.get(self.current)
- if j and j["status"] in ACTIVE and self.proc is None:
- self._follow() # stream dropped while the job kept running: resume at offset
- self._update_buttons()
-
- def select(self, jid):
- for i in range(self.list.count()):
- if self.list.item(i).data(Qt.ItemDataRole.UserRole) == jid:
- self.list.setCurrentItem(self.list.item(i))
- return
- self._show(jid) # not listed yet: just started
-
- def append_log(self, text):
- cur = self.log.textCursor()
- cur.movePosition(QTextCursor.MoveOperation.End)
- # ponytail: a \r split across two chunks is printed as-is
- for i, part in enumerate(text.replace("\r\n", "\n").split("\r")):
- if i: # carriage return: the next text replaces the current line
- cur.movePosition(QTextCursor.MoveOperation.StartOfBlock,
- QTextCursor.MoveMode.KeepAnchor)
- cur.removeSelectedText()
- cur.insertText(part)
- self.log.setTextCursor(cur)
-
- def stop(self):
- if self.proc:
- p, self.proc = self.proc, None
- p.kill()
-
- def start(self, *args):
- self.remote.call(self._started, "job-start", *args)
-
- def confirm_start(self, question, *args):
- if QMessageBox.question(self, "Confirm", question) == QMessageBox.StandardButton.Yes:
- self.start(*args)
-
- def kill(self):
- if QMessageBox.question(self, "Kill job", f"Send SIGTERM to {self.current}?") \
- == QMessageBox.StandardButton.Yes:
- self.remote.call(lambda _d, err: err and QMessageBox.warning(self, "Kill failed", err),
- "job-kill", self.current)
-
- def _started(self, data, err):
- if err:
- QMessageBox.warning(self, "Start failed", err)
- return
- if "busy" in data:
- box = QMessageBox(QMessageBox.Icon.Information, "Busy",
- f"busy: {data['busy']}", parent=self)
- show = box.addButton("Show job", QMessageBox.ButtonRole.AcceptRole)
- box.addButton(QMessageBox.StandardButton.Close)
- box.exec()
- if box.clickedButton() is show and data["busy"]:
- self.select(data["busy"])
- return
- self.select(data["id"])
- self.refresh_requested.emit()
-
- def _on_select(self, item, _prev):
- jid = item.data(Qt.ItemDataRole.UserRole) if item else None
- if jid != self.current:
- self._show(jid)
-
- def _show(self, jid):
- self.stop()
- self.current, self.offset = jid, 0
- self.decoder.reset()
- self.log.clear()
- if jid:
- self._follow()
- self._update_buttons()
-
- def _follow(self):
- jid = self.current
- p = self.proc = self.remote.follow(jid, self.offset)
- p.readyReadStandardOutput.connect(lambda: self._chunk(p, jid))
- p.finished.connect(lambda *_: self._ended(p))
-
- def _chunk(self, p, jid):
- data = bytes(p.readAllStandardOutput())
- if jid != self.current:
- return
- self.offset += len(data)
- self.append_log(self.decoder.decode(ANSI.sub(b"", data)))
-
- def _ended(self, p):
- if p is self.proc:
- self.proc = None
- p.deleteLater()
- self._update_buttons()
-
- def _update_buttons(self):
- j = self.jobs.get(self.current)
- self.kill_btn.setEnabled(bool(j) and j["status"] == "running")
- self.apply_btn.setVisible(
- bool(j) and j["status"] == "ok" and "--no" in j["args"]
- and bool(SHADOW_PLAN.search(self.log.toPlainText())))
-````
-
-- [ ] **Step 7: Main window and entry point**
-
-Create `sb_builder/mainwindow.py`:
-
-````python
-# sb-builder - main window
-# 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.
-
-"""Main window: tabs, status bar, connection polling, VM control, notifications."""
-
-import time
-
-from PyQt6.QtCore import QMetaType, QObject, QTimer, pyqtSignal, pyqtSlot
-from PyQt6.QtDBus import QDBusArgument, QDBusConnection, QDBusInterface, QDBusMessage
-from PyQt6.QtWidgets import QLabel, QMainWindow, QMessageBox, QPushButton, QTabBar, QTabWidget
-
-from . import VERSION, config
-from .dashboard import Dashboard
-from .fmt import job_title
-from .jobs import JobsTab
-from .remote import DOWN, MISSING
-
-NOTIFY = "org.freedesktop.Notifications"
-NOTIFY_PATH = "/org/freedesktop/Notifications"
-END_EVENT = {"ok": "finished", "failed": "failed", "killed": "killed", "lost": "lost"}
-MAX_RETRY_MS = 30000
-
-
-class Notifier(QObject):
- """freedesktop notifications over D-Bus; clicked(job id) on the default action."""
-
- clicked = pyqtSignal(str)
-
- def __init__(self, parent=None):
- super().__init__(parent)
- bus = QDBusConnection.sessionBus()
- self.iface = QDBusInterface(NOTIFY, NOTIFY_PATH, NOTIFY, bus)
- bus.connect(NOTIFY, NOTIFY_PATH, NOTIFY, "ActionInvoked", self._action)
- self.ids = {} # notification id -> job id
-
- def send(self, title, body, jid=""):
- reply = self.iface.call(
- "Notify", "sb-builder", QDBusArgument(0, QMetaType.Type.UInt.value), "", title, body,
- QDBusArgument(["default", "Open"], QMetaType.Type.QStringList.value), {}, -1)
- if reply.type() == QDBusMessage.MessageType.ReplyMessage:
- self.ids[reply.arguments()[0]] = jid
-
- @pyqtSlot("uint", "QString")
- def _action(self, nid, _key):
- if nid in self.ids:
- self.clicked.emit(self.ids.pop(nid))
-
-
-class MainWindow(QMainWindow):
- def __init__(self, cfg, remote, notifier=None):
- super().__init__()
- self.cfg, self.remote = cfg, remote
- self.notifier = notifier or Notifier(self)
- self.notifier.clicked.connect(self.open_job)
- self.jobs_tab = None
- self.seen = None # job id -> status at the last poll; None before the first
- self.online = None # None until the first answer
- self.overlays = []
- self.vm_action = None # "start" or "resume" offered on the VM button
- self.vm_woken = False # we started the VM: notify once ssh answers
- self.running_id = None
- self.setWindowTitle("sb-builder")
-
- self.tabs = QTabWidget(tabsClosable=True)
- self.tabs.tabCloseRequested.connect(self._close_tab)
- self.tabs.currentChanged.connect(lambda _i: self.poll_stats())
- self.dashboard = Dashboard()
- self.dashboard.refresh_btn.clicked.connect(self.poll_stats)
- self.tabs.addTab(self.dashboard, "Dashboard")
- self.tabs.tabBar().setTabButton(0, QTabBar.ButtonPosition.RightSide, None)
- self.setCentralWidget(self.tabs)
-
- file_menu = self.menuBar().addMenu("&File")
- file_menu.addAction("&Reload config", self.reload_config)
- file_menu.addAction("&Quit", self.close).setShortcut("Ctrl+Q")
- self.menuBar().addMenu("&Open").addAction("&Jobs", self.open_jobs)
-
- self.conn = QLabel("● connecting")
- self.vm_btn = QPushButton()
- self.vm_btn.clicked.connect(lambda: self._vm_do(self.vm_action))
- self.vm_btn.hide()
- self.job_btn = QPushButton(flat=True)
- self.job_btn.clicked.connect(lambda: self.open_job(self.running_id))
- self.job_btn.hide()
- for w in (self.job_btn, self.vm_btn, self.conn):
- self.statusBar().addPermanentWidget(w)
-
- self.jobs_timer = QTimer(self, timeout=self.poll_jobs)
- self.stats_timer = QTimer(self, timeout=self.poll_stats)
- self._start_timers()
- QTimer.singleShot(0, self.poll_jobs)
-
- # ── polling ─────────────────────────────────────────────────────────────
-
- def _start_timers(self):
- self.jobs_timer.start(self.cfg["refresh"]["jobs"] * 1000)
- self.stats_timer.start(self.cfg["refresh"]["dashboard"] * 1000)
-
- def poll_jobs(self):
- self.remote.call(self._got_jobs, "jobs")
-
- def poll_stats(self):
- if self.online and self.tabs.currentWidget() is self.dashboard:
- self.remote.call(self._got_stats, "stats")
-
- def _got_jobs(self, data, err):
- if err:
- return self._failed(err)
- self._up()
- jobs = data["jobs"]
- self._notify_ends(jobs)
- self.dashboard.show_jobs(jobs)
- if self.jobs_tab:
- self.jobs_tab.set_jobs(jobs)
- run = next((j for j in jobs if j["status"] == "running"), None)
- self.running_id = run and run["id"]
- self.job_btn.setVisible(run is not None)
- if run:
- self.job_btn.setText(f"▶ {job_title(run)}")
-
- def _got_stats(self, data, err):
- if err:
- return self._failed(err)
- self.dashboard.show_stats(data)
-
- def _got_version(self, data, err):
- if err:
- return self._failed(err)
- self.overlays = data.get("overlays", [])
- if self.jobs_tab:
- self.jobs_tab.set_overlays(self.overlays)
- if data["version"] != VERSION:
- self.conn.setText(f"● helper {data['version']}, GUI {VERSION}: run ./install.sh --remote")
-
- def _up(self):
- if self.online:
- return
- self.online = True
- self.conn.setText("● connected")
- self.vm_btn.hide()
- self.dashboard.set_stale(None)
- self.jobs_timer.setInterval(self.cfg["refresh"]["jobs"] * 1000)
- self.remote.call(self._got_version, "version")
- self.poll_stats()
- if self.vm_woken:
- self.vm_woken = False
- self.notify("vm-up", f"{self.cfg['vm']['domain']} is up")
-
- def _failed(self, err):
- if err == MISSING:
- self.conn.setText("● sbb-remote missing: run ./install.sh --remote")
- return
- if err != DOWN:
- self.statusBar().showMessage(err, 10000)
- return
- if self.online is not False:
- self.online = False
- self.dashboard.set_stale(time.time())
- base = self.cfg["refresh"]["jobs"] * 1000
- self.jobs_timer.setInterval(min(self.jobs_timer.interval() * 2, max(MAX_RETRY_MS, base)))
- self._check_vm()
-
- # ── VM control ──────────────────────────────────────────────────────────
-
- def _check_vm(self):
- if not self.cfg["vm"]["domain"]:
- self.conn.setText("● down")
- return
- self.remote.virsh(self._got_domstate, "domstate", self.cfg["vm"]["domain"])
-
- def _got_domstate(self, rc, state):
- if self.online:
- return # ssh came back while virsh ran
- self.vm_btn.hide()
- if rc != 0:
- self.conn.setText("● down")
- elif state == "shut off":
- self.conn.setText("● VM off")
- if self.cfg["vm"]["autostart"]:
- self._vm_do("start")
- else:
- self._offer("Start VM", "start")
- elif state == "paused":
- self.conn.setText("● VM paused")
- self._offer("Resume VM", "resume")
- elif state == "running":
- self.conn.setText("● VM running, ssh unreachable")
- else:
- self.conn.setText(f"● VM {state}")
-
- def _offer(self, label, action):
- self.vm_action = action
- self.vm_btn.setText(label)
- self.vm_btn.show()
-
- def _vm_do(self, action):
- self.vm_btn.hide()
- self.vm_woken = True
- self.conn.setText(f"● VM {action} requested")
- self.remote.virsh(
- lambda rc, out: rc and self.statusBar().showMessage(f"virsh {action}: {out}", 10000),
- action, self.cfg["vm"]["domain"])
-
- # ── notifications ───────────────────────────────────────────────────────
-
- def notify(self, event, body, jid=""):
- n = self.cfg["notify"]
- if n["enabled"] and event in n["events"]:
- self.notifier.send(f"sb-builder: {event}", body, jid)
-
- def _notify_ends(self, jobs):
- prev, self.seen = self.seen, {j["id"]: j["status"] for j in jobs}
- if prev is None:
- return # first poll: old jobs are history, not news
- for j in jobs:
- event = END_EVENT.get(j["status"])
- if event and prev.get(j["id"], "running") != j["status"]:
- self.notify(event, job_title(j), j["id"])
-
- # ── tabs ────────────────────────────────────────────────────────────────
-
- def open_jobs(self):
- if self.jobs_tab is None:
- self.jobs_tab = JobsTab(self.remote)
- self.jobs_tab.refresh_requested.connect(self.poll_jobs)
- self.jobs_tab.set_overlays(self.overlays)
- self.tabs.addTab(self.jobs_tab, "Jobs")
- self.poll_jobs()
- self.tabs.setCurrentWidget(self.jobs_tab)
- return self.jobs_tab
-
- def open_job(self, jid):
- self.show()
- self.raise_()
- self.activateWindow()
- if jid:
- self.open_jobs().select(jid)
-
- def _close_tab(self, i):
- w = self.tabs.widget(i)
- if w is self.dashboard:
- return
- self.tabs.removeTab(i)
- if w is self.jobs_tab:
- self.jobs_tab = None
- w.stop()
- w.deleteLater()
-
- def reload_config(self):
- cfg, err = config.load()
- if err:
- QMessageBox.warning(self, "Config error", f"{err}\nUsing defaults.")
- self.cfg.clear()
- self.cfg.update(cfg) # Remote holds the same dict
- self._start_timers()
-````
-
-Create `sb_builder/__main__.py`:
-
-````python
-# sb-builder - entry point
-# 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.
-
-"""Entry point: python3 -m sb_builder."""
-
-import sys
-
-from PyQt6.QtWidgets import QApplication, QMessageBox
-
-from . import config
-from .mainwindow import MainWindow
-from .remote import Remote
-
-
-def main():
- app = QApplication(sys.argv)
- app.setApplicationName("sb-builder")
- cfg, err = config.load()
- w = MainWindow(cfg, Remote(cfg))
- w.resize(1100, 700)
- w.show()
- if err:
- QMessageBox.warning(w, "Config error", f"{err}\nUsing defaults.")
- return app.exec()
-
-
-if __name__ == "__main__":
- sys.exit(main())
-````
-
-- [ ] **Step 8: Run the whole suite**
-
-Run: `python3 -m unittest discover -s tests`
-Expected: `Ran 38 tests`, `OK` (plus the two expected ResourceWarnings from Task 2).
-
-- [ ] **Step 9: Commit**
-
-```bash
-git add sb_builder config.example.toml tests/test_gui.py
-git commit -F - <<'EOF'
-Add the PyQt6 GUI: Dashboard, Jobs, VM control, notifications
-
-All VM access goes through remote.py (ssh with a shared ControlMaster,
-local virsh). The main window polls jobs, backs off while ssh is down,
-checks the libvirt domain and offers Start or Resume, and sends
-freedesktop notifications when a job ends or the VM comes up. The Jobs
-tab streams a job's log with job-follow and resumes at the last offset
-after a drop. Config is TOML, validated against built-in defaults.
-
-Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-EOF
-git log --format='%h %G? %s' -1
-```
-
----
-
-### Task 4: Launcher, install script, docs
-
-**Files:**
-- Create: `sb-builder`, `install.sh`
-- Modify: `README.md` (Requirements, new Install and Usage), `AGENTS.md` (Testing section)
-
-- [ ] **Step 1: Launcher**
-
-Create `sb-builder` and `chmod 755 sb-builder`:
-
-````sh
-#!/bin/sh
-# sb-builder - launcher
-# 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.
-
-here=$(dirname "$(readlink -f "$0")")
-PYTHONPATH="$here${PYTHONPATH:+:$PYTHONPATH}" exec python3 -m sb_builder "$@"
-````
-
-- [ ] **Step 2: Install script**
-
-Create `install.sh` and `chmod 755 install.sh`:
-
-````bash
-#!/bin/bash
-# sb-builder - install the GUI locally, or the helper on the VM
-# 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.
-
-# ./install.sh symlink the launcher into ~/bin (BINDIR overrides),
-# create the GUI config from the example if missing
-# ./install.sh --remote deploy remote/sbb-remote to the VM (SBB_TARGET,
-# default buildsystem) and create its config if missing
-
-set -euo pipefail
-here=$(dirname "$(readlink -f "$0")")
-
-if [[ ${1:-} == --remote ]]; then
- target=${SBB_TARGET:-buildsystem}
- read -rp "Deploy sbb-remote to $target:/usr/local/bin (config created only if missing)? [y/N] " a
- [[ $a =~ ^[Yy]$ ]] || exit 1
- scp -q "$here/remote/sbb-remote" "$target:/usr/local/bin/sbb-remote"
- scp -q "$here/remote/config.example.toml" "$target:/tmp/sbb-config.example.toml"
- ssh "$target" 'chmod 755 /usr/local/bin/sbb-remote &&
- mkdir -p /etc/sb-builder &&
- { [ -e /etc/sb-builder/config.toml ] || cp /tmp/sbb-config.example.toml /etc/sb-builder/config.toml; } &&
- rm -f /tmp/sbb-config.example.toml &&
- sbb-remote version'
- exit
-fi
-
-bindir=${BINDIR:-$HOME/bin}
-cfgdir=${XDG_CONFIG_HOME:-$HOME/.config}/sb-builder
-mkdir -p "$bindir" "$cfgdir"
-ln -sfn "$here/sb-builder" "$bindir/sb-builder"
-[[ -e $cfgdir/config.toml ]] || cp "$here/config.example.toml" "$cfgdir/config.toml"
-echo "installed $bindir/sb-builder -> $here/sb-builder, config in $cfgdir/config.toml"
-````
-
-- [ ] **Step 3: Syntax and headless launch check**
-
-```bash
-bash -n install.sh && sh -n sb-builder && echo syntax ok
-XDG_CONFIG_HOME=$(mktemp -d) QT_QPA_PLATFORM=offscreen timeout 10 ./sb-builder; echo "rc=$?"
-```
-
-Expected: `syntax ok`, then `rc=124` (timeout killed a running GUI: it started without errors).
-
-- [ ] **Step 4: README**
-
-Replace `README.md` with:
-
-````markdown
-# 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
-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.
-
-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
-- 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
-
- ./install.sh # ~/bin/sb-builder -> this repo, GUI config if missing
- ./install.sh --remote # helper to the VM, its config if missing (asks first)
-
-`BINDIR` changes the launcher directory, `SBB_TARGET` the ssh destination for
-`--remote` (default `buildsystem`). Configs:
-
-- GUI: `~/.config/sb-builder/config.toml`, see `config.example.toml`
-- VM: `/etc/sb-builder/config.toml`, see `remote/config.example.toml`
-
-Every key is optional. `File > Reload config` rereads the GUI config.
-
-## Usage
-
- sb-builder
-
-- **Dashboard**: VM load, uptime and free disk, package counts, slackrepo
- build results, last overlay pull, recent jobs. Refreshes while visible.
-- **Open > Jobs**: start a job from the Start menu (`sbpull <overlay>`
- preview, `sbshadow` preview or apply), follow its log live, kill it. After a
- preview that lists `shadow:` or `unshadow:` lines, "Apply shadowing" runs
- `sbshadow --yes`.
-
-Jobs run on the VM detached from the ssh session, one at a time. Closing the
-GUI or losing the connection does not stop them; reopening the job resumes
-the log. Job state and logs live in `/var/lib/sb-builder/jobs/` on the VM.
-
-When ssh fails and `vm.domain` is set, the status bar shows the libvirt state
-of the VM and offers to start or resume it. Desktop notifications report
-finished, failed, killed and lost jobs, and the VM coming up.
-
-## License
-
-Copyright (C) 2026 Danilo M. <danix@danix.xyz>
-
-GPLv2 only. See [LICENSE](LICENSE) for the full text.
-
-## Development Approach
-
-This project is developed using AI-assisted tools. Code is generated with the help of AI based on human-provided specifications, design decisions, and iterative feedback.
-
-All contributions are reviewed, tested, and curated by the maintainer before being included in the codebase. AI is used as a productivity and exploration tool, while human oversight remains central to all decisions.
-
-The goal is to combine the flexibility of AI-assisted development with standard open-source practices such as transparency, review, and accountability.
-````
-
-- [ ] **Step 5: AGENTS.md Testing section**
-
-In `AGENTS.md` replace
-
-```markdown
-No test suite yet. Planned (phase 1 spec): `python3 -m unittest discover
-tests`, with the GUI smoke test under `QT_QPA_PLATFORM=offscreen`.
-```
-
-with
-
-```markdown
- python3 -m unittest discover -s tests
-
-Headless (`test_gui.py` sets `QT_QPA_PLATFORM=offscreen`), no VM needed.
-`test_remote.py` loads `remote/sbb-remote` with `SourceFileLoader` (no `.py`
-extension) and points it at a temp state dir through `SBB_CONFIG`. Two
-`ResourceWarning: subprocess ... is still running` lines are expected: the
-detached `_run` is never waited for. To try the helper on the VM without
-installing it, copy it to `/tmp` there and run it with `SBB_CONFIG` pointing
-at a config whose `state_dir` is under `/tmp`.
-```
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add sb-builder install.sh README.md AGENTS.md
-git commit -F - <<'EOF'
-Add launcher, install script and usage docs
-
-Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-EOF
-git log --format='%h %G? %s' -1
-git push
-```
-
----
-
-### Task 5: Deploy and first real run (with the user)
-
-Ask the user before each VM-touching step.
-
-- [ ] **Step 1: Local install**: `./install.sh`. Expected: `installed ~/bin/sb-builder -> ...`.
-- [ ] **Step 2: Deploy the helper**: `./install.sh --remote`, answer `y`. Expected last line: `{"version": "0.1.0", "overlays": ["personal", "pentesting"]}`.
-- [ ] **Step 3: User runs `sb-builder`** and checks: status bar `● connected`; Dashboard tiles filled (about 9957 packages, 48 personal, 3 pentesting, 13 shadowed at planning time); Open > Jobs.
-- [ ] **Step 4: Preview job**: Start > `sbshadow (preview)`. The log streams, the job ends ✔, a notification appears; clicking it opens the job. "Apply shadowing" shows only if the plan lists changes.
-- [ ] **Step 5: Detach check**: start `sbpull personal (preview)`, quit the GUI while it runs, reopen, select the job: the log continues from the start, the job finishes ✔. `ssh buildsystem ls /var/lib/sb-builder/jobs` shows it.
-- [ ] **Step 6: Busy check**: while a job runs, start another: dialog `busy: <id>` with Show job.
-- [ ] **Step 7: VM check (optional, user decides)**: with the VM shut off (`virsh shutdown buildsystem` by the user), the status bar shows `● VM off` and Start VM; pressing it boots the VM and a `vm-up` notification arrives when ssh answers.
-
-Record anything that misbehaves as a follow-up; do not patch it silently.
diff --git a/docs/superpowers/plans/2026-10-19-sb-builder-phase2.md b/docs/superpowers/plans/2026-10-19-sb-builder-phase2.md
deleted file mode 100644
index b0fb271..0000000
--- a/docs/superpowers/plans/2026-10-19-sb-builder-phase2.md
+++ /dev/null
@@ -1,3883 +0,0 @@
-# sb-builder Phase 2 (Packages and slackrepo builds) 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:** Package browser with a structured hint view, slackrepo build/rebuild/update/remove and update-all as previewed jobs, a publish gate with a separate Publish action, and signing key unlock for jobs without a tty.
-
-**Architecture:** slackrepo_setup becomes the home of the slackrepo config (site values moved to a root-only `.local`), gains `sb-publish` and the hook gates. `sbb-remote` 0.2.0 adds `inventory`, `slackrepo` and `publish` kinds (kinds now return argv plus env), `gpg-status` and `gpg-unlock` (passphrase on stdin only). The GUI adds `hints.py` (pure slackrepo hint rules), the Packages tab, Run it with a publish box and the passphrase dialog in the Jobs tab, the key indicator and tile links. Spec: `docs/superpowers/specs/2026-10-19-sb-builder-phase2-design.md`.
-
-**Tech Stack:** as phase 1: Python 3.11+ stdlib, PyQt6, bash; gpg 2.5 on the VM.
-
-**Verified while planning:** all code below ran in a scratch copy: 68 tests pass, offscreen. Against the real VM (helper copied to `/tmp`, temp state dir): `inventory` returns 9957 packages in 2.1s warm / 6.1s cold, 1.5 MB, 599 with a hint, hint key counts match the census; `gpg-status` reports `{"unlocked": false}` for the configured key; the Packages tab rendered that inventory correctly. The config hook gates were exercised with stubs for every `OPT_DRY_RUN`/`OPT_PREVIEW`/`SBB_PUBLISH` combination. `make-local.py` was run against a copy of the live config (5 keys found, mode 600, refuses to overwrite); that copy was shredded.
-
-**Rules for whoever executes this:**
-- Commits are GPG-signed; never disable signing. Global hooks scan for personal data; a rejection is right until proven otherwise.
-- Never stage `HANDOFF.md`. Never commit or print site values (rsync target, Gotify URL or key, hostnames).
-- Anything touching the VM or deleting files asks the user first: Task 1 Step 5 and Task 5.
-- GUI tests must run offscreen (the test file forces it). If a dialog ever appears on the desktop, stop: a fake remote is missing an answer.
-
----
-
-## File map
-
-| file | change |
-|---|---|
-| slackrepo_setup: `slackrepo_SBo-danix.conf`, `sb-publish`, `SBo-danix.local.example`, `gpg-agent.conf`, `make-local.py`, `README.md`, `AGENTS.md` | Task 1 |
-| `remote/sbb-remote` | 0.2.0: new config keys, kinds return (argv, env), `slackrepo`/`publish` kinds, `inventory`, `gpg-status`, `gpg-unlock`, `stdin=DEVNULL` everywhere |
-| `remote/config.example.toml` | new keys |
-| `tests/test_remote.py` | 37 tests |
-| `sb_builder/__init__.py` | `VERSION = "0.2.0"` |
-| `sb_builder/config.py`, `config.example.toml` | `[builds] publish` |
-| `sb_builder/remote.py` | `call(..., stdin=)` |
-| `sb_builder/hints.py` | new: slackrepo hint rules |
-| `sb_builder/packages.py` | new: Packages tab |
-| `sb_builder/jobs.py` | Run it, publish box, signing gate |
-| `sb_builder/dashboard.py` | clickable cards |
-| `sb_builder/mainwindow.py` | Packages tab, key indicator, inventory reload after jobs |
-| `tests/test_gui.py` | 31 tests |
-| `README.md`, `AGENTS.md` | phase 2 docs |
-
----
-
-### Task 1: slackrepo config, sb-publish and gates (repo slackrepo_setup)
-
-**Files:** create `slackrepo_SBo-danix.conf`, `sb-publish`, `SBo-danix.local.example`, `gpg-agent.conf`, `make-local.py`; modify `README.md`, `AGENTS.md`.
-
-`slackrepo_SBo-danix.conf` is the live VM config of 2026-10-19 with: a header that sources `/etc/slackrepo/SBo-danix.local`; `manage_packages_hook` and `gotify_hook` also checking `OPT_PREVIEW`; `rsync_push_hook` calling `sb-publish` and honouring `SBB_PUBLISH=n` and `OPT_PREVIEW`; Gotify URL and key read from `SB_GOTIFY_URL`/`SB_GOTIFY_KEY`. No site value is in the file.
-
-- [ ] **Step 1: Apply the patch**
-
-Save as `/tmp/slackrepo_setup-p2.patch`, then:
-
-````diff
-diff '--color=auto' -ruN a/AGENTS.md b/AGENTS.md
---- a/AGENTS.md
-+++ b/AGENTS.md
-@@ -43,6 +43,22 @@
- Do **not** add a hintfile generator to this script — it would create a second owner of HINT_DIR and
- fight mkhint's `.bak` handling.
-
-+## slackrepo config and publishing
-+
-+`slackrepo_SBo-danix.conf` is the source of truth for `/etc/slackrepo/slackrepo_SBo-danix.conf`.
-+Never commit site values (rsync target, passwords, Gotify URL or key, hostnames): they live in the
-+root-only `/etc/slackrepo/SBo-danix.local`, which the config sources; `SBo-danix.local.example`
-+documents the keys with placeholders. slackrepo unsets only its own known config names after
-+sourcing (`varnames`, `genrepnames`, `initnames`, `OPTIONLIST` in `slackrepo`), so the `SB_*`
-+variables survive into the hooks, which run as functions in slackrepo's shell.
-+
-+Gates in the hooks: `SBB_PUBLISH=n` holds back `rsync_push_hook` (sb-builder sets it for
-+unpublished runs; `sb-publish` pushes later). `--preview` does not set `OPT_DRY_RUN`
-+(`cmdfunctions.sh`), so hooks check `OPT_PREVIEW` too. To check the gates without a VM, source
-+the config with `/usr/local/bin/` rewritten to a stub dir (stub `sb-publish`,
-+`manage-packages.sh`, `curl`) and call the hooks under each `OPT_DRY_RUN`/`OPT_PREVIEW`/
-+`SBB_PUBLISH` combination.
-+
- ## Run
-
- ```bash
-diff '--color=auto' -ruN a/README.md b/README.md
---- a/README.md
-+++ b/README.md
-@@ -25,6 +25,19 @@
- `--yes` applies without asking, `--no` only shows the plan (for
- non-interactive callers such as sb-builder). `SBO_REPO` overrides the repo
- path.
-+- `slackrepo_SBo-danix.conf`: the slackrepo config for this repo
-+ (`/etc/slackrepo/`). Site values (rsync target, Gotify URL and key) are not
-+ in it: it sources the root-only `/etc/slackrepo/SBo-danix.local`, see
-+ `SBo-danix.local.example`. Hooks honour `SBB_PUBLISH=n` (no rsync push, set by
-+ sb-builder for unpublished runs), and a `--preview` neither notifies Gotify
-+ nor runs `manage-packages.sh` for real.
-+- `sb-publish`: pushes the package repository to the mirror. Called by the
-+ config's `rsync_push_hook`; run it by hand to publish builds made with
-+ `SBB_PUBLISH=n`.
-+- `gpg-agent.conf`: root's gpg-agent config, with `allow-preset-passphrase` so
-+ sb-builder can unlock the package signing key for jobs that have no tty.
-+- `make-local.py`: one-off migration, builds `SBo-danix.local` from the values
-+ in an older config that still had them inline.
- - `sbpull.bash`: `sbpull {personal|pentesting} [--yes|--no]` shell function.
- Pulls one overlay subtree and runs `sbshadow` (passing the flag on), to
- carry overlay changes without a full rebuild. Source it from root's
-@@ -33,8 +46,11 @@
- ## Install
-
- ```bash
--install -m 755 slackrepo_setup_SBo-danix.sh sbshadow /usr/local/bin/
-+install -m 755 slackrepo_setup_SBo-danix.sh sbshadow sb-publish /usr/local/bin/
- echo ". /path/to/sbpull.bash" >> ~/.bashrc
-+install -m 644 slackrepo_SBo-danix.conf /etc/slackrepo/
-+install -m 600 SBo-danix.local.example /etc/slackrepo/SBo-danix.local # then edit
-+install -m 600 gpg-agent.conf /root/.gnupg/ && gpgconf --reload gpg-agent
- ```
-
- Optional: `mkhint` (mkhintfile repo) for phantom-dep hintfile
-diff '--color=auto' -ruN a/SBo-danix.local.example b/SBo-danix.local.example
---- a/SBo-danix.local.example
-+++ b/SBo-danix.local.example
-@@ -0,0 +1,8 @@
-+# /etc/slackrepo/SBo-danix.local: site settings for slackrepo_SBo-danix.conf
-+# and sb-publish. Not in git: it holds the Gotify key. Keep it root-only:
-+# install -m 600 SBo-danix.local.example /etc/slackrepo/SBo-danix.local
-+SB_RSYNC_TARGET='rsync://user@mirror.example.org/packages'
-+SB_RSYNC_PASSWORD_FILE='/root/.rsync-password'
-+SB_RSYNC_EXCLUDES='/root/.repo-excludes.txt'
-+SB_GOTIFY_URL='http://gotify.example.org/message'
-+SB_GOTIFY_KEY='replace-me'
-diff '--color=auto' -ruN a/gpg-agent.conf b/gpg-agent.conf
---- a/gpg-agent.conf
-+++ b/gpg-agent.conf
-@@ -0,0 +1,10 @@
-+pinentry-program /usr/bin/pinentry-curses
-+enable-ssh-support
-+# cache passwords for both gpg and ssh for 4h
-+max-cache-ttl-ssh 14400
-+default-cache-ttl 14400
-+# expire passwords after 6h
-+max-cache-ttl 21600
-+max-cache-ttl-ssh 21600
-+# let sb-builder unlock the signing key: jobs have no tty for pinentry
-+allow-preset-passphrase
-diff '--color=auto' -ruN a/make-local.py b/make-local.py
---- a/make-local.py
-+++ b/make-local.py
-@@ -0,0 +1,31 @@
-+#!/usr/bin/env python3
-+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
-+#
-+# This program is free software; you can redistribute it and/or modify it
-+# under the terms of the GNU General Public License version 2 as published
-+# by the Free Software Foundation. See the LICENSE file for details.
-+
-+# Run on the VM, as root, BEFORE installing the new slackrepo_SBo-danix.conf:
-+# copies the site values out of the old config into SBo-danix.local (mode 600).
-+# Prints only which keys it found, never their values.
-+import os, re, sys
-+old, dest = sys.argv[1], sys.argv[2]
-+if os.path.exists(dest):
-+ sys.exit(f"{dest} exists, left alone")
-+s = open(old).read()
-+found = {
-+ "SB_RSYNC_TARGET": re.search(r"local rsync_target='([^']*)'", s),
-+ "SB_RSYNC_EXCLUDES": re.search(r"local rsync_excludes='([^']*)'", s),
-+ "SB_RSYNC_PASSWORD_FILE": re.search(r"local rsync_password_file='([^']*)'", s),
-+ "SB_GOTIFY_KEY": re.search(r'X-Gotify-Key: ([^"]*)"', s),
-+ "SB_GOTIFY_URL": re.search(r'\n\s*"(https?://[^"]*)" >/dev/null', s),
-+}
-+missing = [k for k, m in found.items() if not m]
-+if missing:
-+ sys.exit(f"not found in {old}: {', '.join(missing)}")
-+fd = os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
-+with os.fdopen(fd, "w") as f:
-+ f.write("# site settings for slackrepo_SBo-danix.conf and sb-publish (root-only)\n")
-+ for k, m in found.items():
-+ f.write(f"{k}='{m.group(1)}'\n")
-+print(f"wrote {dest}: {', '.join(found)}")
-diff '--color=auto' -ruN a/sb-publish b/sb-publish
---- a/sb-publish
-+++ b/sb-publish
-@@ -0,0 +1,35 @@
-+#!/bin/bash
-+# 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. See the LICENSE file for details.
-+
-+# sb-publish: push the local package repository to the public mirror.
-+#
-+# Usage: sb-publish [PKGREPO] (default /repo)
-+#
-+# Called by rsync_push_hook in slackrepo_SBo-danix.conf after a build, unless
-+# the build ran with SBB_PUBLISH=n; run it by hand (or from sb-builder's
-+# Publish action) to push such builds later. Settings come from the root-only
-+# SB_LOCAL file (default /etc/slackrepo/SBo-danix.local):
-+# SB_RSYNC_TARGET rsync destination (required)
-+# SB_RSYNC_PASSWORD_FILE --password-file, if the file exists
-+# SB_RSYNC_EXCLUDES --exclude-from, if the file exists
-+
-+set -euo pipefail
-+
-+SB_LOCAL=${SB_LOCAL:-/etc/slackrepo/SBo-danix.local}
-+[ -r "$SB_LOCAL" ] && . "$SB_LOCAL"
-+: "${SB_RSYNC_TARGET:?SB_RSYNC_TARGET is not set in $SB_LOCAL}"
-+
-+pkgrepo=${1:-/repo}
-+args=( -havz --delete-after --stats )
-+[ -n "${SB_RSYNC_EXCLUDES:-}" ] && [ -f "$SB_RSYNC_EXCLUDES" ] && \
-+ args+=( --exclude-from="$SB_RSYNC_EXCLUDES" )
-+[ -n "${SB_RSYNC_PASSWORD_FILE:-}" ] && [ -f "$SB_RSYNC_PASSWORD_FILE" ] && \
-+ args+=( --password-file="$SB_RSYNC_PASSWORD_FILE" )
-+
-+echo "sb-publish: pushing $pkgrepo to $SB_RSYNC_TARGET ..."
-+rsync "${args[@]}" "$pkgrepo"/ "$SB_RSYNC_TARGET"
-+echo "sb-publish: rsync push complete."
-diff '--color=auto' -ruN a/slackrepo_SBo-danix.conf b/slackrepo_SBo-danix.conf
---- a/slackrepo_SBo-danix.conf
-+++ b/slackrepo_SBo-danix.conf
-@@ -0,0 +1,310 @@
-+# slackrepo configuration file for the SBo-danix personal repository.
-+#
-+# Priority order:
-+# (1) Environment variables
-+# (2) ~/.slackreporc
-+# (3) /etc/slackrepo/slackrepo_SBo-danix.conf
-+#
-+# %REPO%, %SLACKVER% and %ARCH% are replaced at runtime.
-+
-+#-------------------------------------------------------------------------------
-+# Filestore locations
-+
-+# Personal SlackBuilds repo (managed as a git fork of SBo):
-+# Site settings (rsync target, Gotify URL and key) live outside git in a
-+# root-only file, see SBo-danix.local.example in slackrepo_setup.
-+SB_LOCAL='/etc/slackrepo/SBo-danix.local'
-+[ -r "$SB_LOCAL" ] && . "$SB_LOCAL"
-+
-+SBREPO="/var/lib/sbopkg/SBo-danix"
-+
-+# Cached sources:
-+SRCREPO="/var/lib/slackrepo/%REPO%/source"
-+
-+# Built packages:
-+#PKGREPO="/var/lib/slackrepo/%REPO%/packages/%SLACKVER%/%ARCH%"
-+PKGREPO="/repo"
-+
-+# Backups (comment out to disable):
-+#PKGBACKUP="/var/lib/slackrepo/%REPO%/backups/%SLACKVER%/%ARCH%"
-+
-+# Local hintfiles (for overriding SBo packages without touching the SlackBuild):
-+HINTDIR="/etc/slackrepo/%REPO%/hintfiles"
-+
-+# Default hintfiles from slackrepo-hints package (optional):
-+DEFAULT_HINTDIR="/etc/slackrepo/%REPO%/default_hintfiles/%SLACKVER%"
-+
-+# Logs:
-+LOGDIR="/var/log/slackrepo/%REPO%"
-+
-+# Database:
-+DATABASE="/var/lib/slackrepo/%REPO%/database_%REPO%_%ARCH%.sqlite3"
-+
-+# Temporary build space (needs plenty of room):
-+TMP="/tmp/%REPO%"
-+
-+#-------------------------------------------------------------------------------
-+# Package tag
-+# Using _danix instead of _SBo to identify your personal builds.
-+TAG='_danix'
-+
-+# Package compression:
-+PKGTYPE='txz'
-+
-+#-------------------------------------------------------------------------------
-+# Build control
-+
-+SUBSTITUTE=""
-+NUMJOBS=''
-+ARCH=''
-+
-+#-------------------------------------------------------------------------------
-+# Control argument defaults
-+
-+VERBOSE='n'
-+VERY_VERBOSE='n'
-+DRY_RUN='n'
-+INSTALL='n'
-+CHROOT='/'
-+LINT='n'
-+KEEP_TMP='n'
-+COLOR='auto'
-+NICE='5'
-+NOWARNING=''
-+
-+#-------------------------------------------------------------------------------
-+# Git branch setup
-+#
-+# INIT_GITCLONE is only used if SBREPO does not exist yet.
-+# INIT_GITBRANCH must match the branch you have checked out in SBREPO.
-+# Since we use a personal branch, gitfetch_hook is replaced below with a
-+# custom hook that fetches upstream and rebases your branch on top of it.
-+
-+INIT_GITCLONE='https://github.com/Ponce/slackbuilds.git'
-+INIT_GITBRANCH='danix-current'
-+
-+#-------------------------------------------------------------------------------
-+# Hooks
-+
-+function danix_gitfetch_hook
-+{
-+ local upstream_branch='current'
-+ local personal_branch='danix-current'
-+
-+ cd "$SR_SBREPO" || return 1
-+
-+ local currbranch
-+ currbranch=$(git rev-parse --abbrev-ref HEAD)
-+ if [ "$currbranch" != "$personal_branch" ]; then
-+ echo "slackrepo: WARNING: SBREPO is on branch '$currbranch', expected '$personal_branch'. Skipping git update."
-+ return 0
-+ fi
-+
-+ local muck
-+ muck=$(git status -s .)
-+ if [ -n "$muck" ]; then
-+ echo "slackrepo: WARNING: SBREPO has uncommitted changes. Skipping git update."
-+ return 0
-+ fi
-+
-+ echo "slackrepo: Fetching upstream SBo..."
-+ git fetch origin || { echo "slackrepo: WARNING: git fetch failed."; return 1; }
-+
-+ echo "slackrepo: Rebasing $personal_branch onto origin/$upstream_branch..."
-+ git rebase --rebase-merges -X theirs origin/"$upstream_branch" || {
-+ echo "slackrepo: ERROR: rebase failed. Resolve conflicts in $SR_SBREPO and run 'git rebase --continue'."
-+ return 1
-+ }
-+
-+ return 0
-+}
-+
-+HOOK_START=( danix_gitfetch_hook )
-+
-+function manage_packages_hook
-+{
-+ local args=()
-+ # --preview does not set OPT_DRY_RUN (slackrepo cmdfunctions.sh), check both
-+ if [ "$OPT_DRY_RUN" = 'y' ] || [ "$OPT_PREVIEW" = 'y' ]; then
-+ args+=( --dry-run )
-+ fi
-+ REPO="$SR_PKGREPO" /usr/local/bin/manage-packages.sh "${args[@]}"
-+}
-+
-+#-------------------------------------------------------------------------------
-+# generate web files
-+
-+function gen_web_hook
-+{
-+ if [ "$OPT_DRY_RUN" = 'y' ] || [ ! -s "$CHANGELOG" ]; then
-+ return 0
-+ fi
-+ SR_PKGREPO="$SR_PKGREPO" /usr/local/bin/gen_web_hook.sh
-+ local stat=$?
-+ [ $stat -ne 0 ] && log_warning "gen_web_hook failed, status $stat"
-+ return $stat
-+}
-+
-+#-------------------------------------------------------------------------------
-+# dep template generator
-+# Writes <TEMPLATE_DIR>/<prgnam>.template for each successfully built package
-+# that has one or more recursive dependencies.
-+
-+TEMPLATE_DIR='/repo/templates'
-+
-+function dep_template_hook
-+{
-+ local template_dir="${TEMPLATE_DIR:-/repo/templates}"
-+
-+ [ "$OPT_DRY_RUN" = 'y' ] && return 0
-+ [ ${#OKLIST[@]} -eq 0 ] && return 0
-+
-+ mkdir -p "$template_dir" || return 1
-+
-+ local itemid prgnam dep depname outfile
-+ for itemid in "${OKLIST[@]}"; do
-+ [ -z "${FULLDEPS[$itemid]}" ] && continue
-+
-+ prgnam="${ITEMPRGNAM[$itemid]}"
-+ outfile="$template_dir/${prgnam}.template"
-+
-+ {
-+ for dep in ${FULLDEPS[$itemid]}; do
-+ depname="${ITEMPRGNAM[$dep]}"
-+ printf '%s\n' "${depname:-$dep}"
-+ done
-+ printf '%s\n' "$prgnam"
-+ } > "$outfile"
-+
-+ log_normal "dep_template_hook: wrote $outfile"
-+ done
-+
-+ return 0
-+}
-+
-+#-------------------------------------------------------------------------------
-+# rsync push config
-+
-+function rsync_push_hook
-+{
-+ # SBB_PUBLISH=n (set by sb-builder for an unpublished run) holds the push
-+ # back; sb-publish pushes later. Unset means publish, as from a terminal.
-+ if [ "$OPT_DRY_RUN" = 'y' ] || [ "$OPT_PREVIEW" = 'y' ] || [ ! -s "$CHANGELOG" ] \
-+ || [ "${SBB_PUBLISH:-y}" = 'n' ]; then
-+ return 0
-+ fi
-+ /usr/local/bin/sb-publish "$SR_PKGREPO"
-+ local rsyncstat=$?
-+ if [ "$rsyncstat" != 0 ]; then
-+ log_warning "rsync_push_hook failed, status $rsyncstat"
-+ return 1
-+ fi
-+ return 0
-+}
-+
-+function recap_hook
-+{
-+ # TODOLIST is per-item scratch (overwritten on each build_command call), so it
-+ # only ever holds the last item. Build the recap from the cumulative status
-+ # lists that slackrepo dedupes before running the finish hooks.
-+ local recaplist=(
-+ "${OKLIST[@]}" "${FAILEDLIST[@]}" "${ABORTEDLIST[@]}"
-+ "${SKIPPEDLIST[@]}" "${UNSUPPORTEDLIST[@]}"
-+ )
-+ recaplist=( $(printf '%s\n' "${recaplist[@]}" | sort -u) )
-+ [ ${#recaplist[@]} -eq 0 ] && return 0
-+
-+ local col_name=4 col_ver=7 col_status=6
-+ local itemid prgnam ver stat
-+ for itemid in "${recaplist[@]}"; do
-+ prgnam="${ITEMPRGNAM[$itemid]}"
-+ ver="${HINT_VERSION[$itemid]:-${INFOVERSION[$itemid]:-?}}"
-+ stat="${STATUS[$itemid]:-?}"
-+ [ ${#prgnam} -gt $col_name ] && col_name=${#prgnam}
-+ [ ${#ver} -gt $col_ver ] && col_ver=${#ver}
-+ [ ${#stat} -gt $col_status ] && col_status=${#stat}
-+ done
-+
-+ local sep
-+ sep="+-$(printf '%*s' "$col_name" '' | tr ' ' '-')-+-$(printf '%*s' "$col_ver" '' | tr ' ' '-')-+-$(printf '%*s' "$col_status" '' | tr ' ' '-')-+"
-+
-+ echo ""
-+ echo "Build recap:"
-+ echo "$sep"
-+ printf "| %-*s | %-*s | %-*s |\n" "$col_name" "Name" "$col_ver" "Version" "$col_status" "Status"
-+ echo "$sep"
-+ for itemid in "${recaplist[@]}"; do
-+ prgnam="${ITEMPRGNAM[$itemid]}"
-+ ver="${HINT_VERSION[$itemid]:-${INFOVERSION[$itemid]:-?}}"
-+ stat="${STATUS[$itemid]:-?}"
-+ printf "| %-*s | %-*s | %-*s |\n" "$col_name" "$prgnam" "$col_ver" "$ver" "$col_status" "$stat"
-+ done
-+ echo "$sep"
-+ echo ""
-+
-+ return 0
-+}
-+
-+function gotify_hook
-+{
-+ # --preview does not set OPT_DRY_RUN (slackrepo cmdfunctions.sh), check both
-+ if [ "$OPT_DRY_RUN" = 'y' ] || [ "$OPT_PREVIEW" = 'y' ]; then
-+ return 0
-+ fi
-+ if [ -z "${SB_GOTIFY_URL:-}" ] || [ -z "${SB_GOTIFY_KEY:-}" ]; then
-+ log_warning "SB_GOTIFY_URL/SB_GOTIFY_KEY not set in $SB_LOCAL, skipping gotify_hook"
-+ return 0
-+ fi
-+
-+ local curl_bin
-+ curl_bin=$(which curl 2>/dev/null)
-+ if [ ! -x "$curl_bin" ]; then
-+ log_warning "curl not found, skipping gotify_hook"
-+ return 1
-+ fi
-+
-+ local ok="${#OKLIST[@]}"
-+ local failed="${#FAILEDLIST[@]}"
-+ local aborted="${#ABORTEDLIST[@]}"
-+ local skipped="${#SKIPPEDLIST[@]}"
-+
-+ local title msg priority
-+ if [ $(( failed + aborted )) -gt 0 ]; then
-+ title="slackrepo [$CMD]: FAILED"
-+ priority=8
-+ else
-+ title="slackrepo [$CMD]: OK"
-+ priority=5
-+ fi
-+
-+ msg="OK: $ok Failed: $failed Aborted: $aborted Skipped: $skipped"
-+ [ ${#OKLIST[@]} -gt 0 ] && msg+=$'\nOK: '"$(printf '%s ' "${OKLIST[@]}")"
-+ [ ${#FAILEDLIST[@]} -gt 0 ] && msg+=$'\nFailed: '"$(printf '%s ' "${FAILEDLIST[@]}")"
-+ [ ${#ABORTEDLIST[@]} -gt 0 ] && msg+=$'\nAborted: '"$(printf '%s ' "${ABORTEDLIST[@]}")"
-+
-+ "$curl_bin" -s \
-+ -H "X-Gotify-Key: $SB_GOTIFY_KEY" \
-+ -F "title=$title" \
-+ -F "message=$msg" \
-+ -F "priority=$priority" \
-+ "$SB_GOTIFY_URL" >/dev/null
-+ local stat=$?
-+ [ $stat -ne 0 ] && log_warning "gotify_hook failed, status $stat"
-+ return $stat
-+}
-+
-+function bell_hook
-+{
-+ printf '\a'
-+ return 0
-+}
-+
-+HOOK_FINISH=( manage_packages_hook bell_hook genrepos_hook gen_web_hook dep_template_hook rsync_push_hook recap_hook gotify_hook )
-+
-+#-------------------------------------------------------------------------------
-+# gen_repos_files.sh (disabled by default)
-+
-+USE_GENREPOS='1'
-+
-+#-------------------------------------------------------------------------------
-+export XZ_OPT="--threads=0"
-````
-
-```bash
-cd ~/Programming/GIT/slackrepo_setup && git apply /tmp/slackrepo_setup-p2.patch && chmod 755 sb-publish
-bash -n slackrepo_SBo-danix.conf && bash -n sb-publish && python3 -m py_compile make-local.py && echo ok
-grep -cE 'X-Gotify-Key: [A-Za-z0-9]{8,}|rsync://[^$]' slackrepo_SBo-danix.conf # expect 0
-```
-
-- [ ] **Step 2: Check the hook gates with stubs**
-
-```bash
-T=$(mktemp -d); mkdir $T/stub
-for b in sb-publish manage-packages.sh; do printf '#!/bin/sh\necho "%s $*"\n' $b > $T/stub/$b; done
-printf '#!/bin/sh\necho "curl $*" >> %s/curl.log\n' $T > $T/stub/curl; chmod 755 $T/stub/*
-sed "s|/usr/local/bin/|$T/stub/|g; s|^SB_LOCAL=.*|SB_LOCAL=/nonexistent|" \
- ~/Programming/GIT/slackrepo_setup/slackrepo_SBo-danix.conf > $T/test.conf
-for e in "OPT_DRY_RUN=n OPT_PREVIEW=n" "OPT_DRY_RUN=n OPT_PREVIEW=n SBB_PUBLISH=n" \
- "OPT_DRY_RUN=n OPT_PREVIEW=y" "OPT_DRY_RUN=y OPT_PREVIEW=n"; do
- echo "== $e"; : > $T/curl.log
- env $e PATH=$T/stub:$PATH bash -c 'log_warning() { echo "WARN $*"; }; log_normal() { :; }
- . '"$T"'/test.conf; SB_GOTIFY_URL=http://example.org/m SB_GOTIFY_KEY=k
- CHANGELOG='"$T"'/cl; echo x > $CHANGELOG; SR_PKGREPO=/repo; CMD=build; OKLIST=(a)
- rsync_push_hook; manage_packages_hook; gotify_hook'
- grep -o 'X-Gotify-Key: k\|http://example.org/m' $T/curl.log | tr '\n' ' '; echo
-done
-```
-
-Expected:
-
-```
-== OPT_DRY_RUN=n OPT_PREVIEW=n
-sb-publish /repo
-manage-packages.sh
-X-Gotify-Key: k http://example.org/m
-== OPT_DRY_RUN=n OPT_PREVIEW=n SBB_PUBLISH=n
-manage-packages.sh
-X-Gotify-Key: k http://example.org/m
-== OPT_DRY_RUN=n OPT_PREVIEW=y
-manage-packages.sh --dry-run
-
-== OPT_DRY_RUN=y OPT_PREVIEW=n
-manage-packages.sh --dry-run
-
-```
-
-- [ ] **Step 3: Commit and push slackrepo_setup**
-
-```bash
-cd ~/Programming/GIT/slackrepo_setup
-git add slackrepo_SBo-danix.conf sb-publish SBo-danix.local.example gpg-agent.conf make-local.py README.md AGENTS.md
-git commit -F - <<'EOF'
-Track the slackrepo config; add sb-publish and publish gates
-
-slackrepo_SBo-danix.conf is now kept here. Site values (rsync target,
-Gotify URL and key) moved to the root-only SBo-danix.local it sources;
-make-local.py migrates them out of an old inline config. The rsync push
-moved into sb-publish; rsync_push_hook calls it and holds back when
-SBB_PUBLISH=n, so sb-builder can build without publishing and publish
-later. --preview does not set OPT_DRY_RUN, so the hooks now also check
-OPT_PREVIEW: previews no longer notify Gotify or run manage-packages.sh
-for real. gpg-agent.conf adds allow-preset-passphrase so sb-builder can
-unlock the signing key for jobs without a tty.
-
-Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-EOF
-git log --format='%h %G? %s' -1 && git push
-```
-
-- [ ] **Step 4: Remove the stale config copy (ask the user first)**
-
-`~/Programming/GIT/repo-html-structure/slackrepo_SBo-danix.conf` is untracked there and may still hold the Gotify key. With the user's OK: `shred -u ~/Programming/GIT/repo-html-structure/slackrepo_SBo-danix.conf`.
-
-- [ ] **Step 5: VM deploy is Task 5.**
-
----
-
-### Task 2: helper 0.2.0
-
-**Files:** replace `remote/sbb-remote`, `remote/config.example.toml`, `tests/test_remote.py`.
-
-- [ ] **Step 1: Write the tests** (replace `tests/test_remote.py`):
-
-````python
-# sb-builder - self-checks for the remote helper
-# 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"""
-
-import contextlib
-import fcntl
-import importlib.machinery
-import importlib.util
-import io
-import json
-import os
-import shutil
-import sqlite3
-import tempfile
-import threading
-import time
-import unittest
-from unittest import mock
-
-SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "remote", "sbb-remote")
-_loader = importlib.machinery.SourceFileLoader("sbb_remote", SCRIPT)
-sbb = importlib.util.module_from_spec(importlib.util.spec_from_loader("sbb_remote", _loader))
-_loader.exec_module(sbb)
-
-
-class Base(unittest.TestCase):
- def setUp(self):
- self.tmp = tempfile.mkdtemp()
- self.addCleanup(shutil.rmtree, self.tmp)
- conf = os.path.join(self.tmp, "config.toml")
- with open(conf, "w") as f:
- f.write(f'state_dir = "{self.tmp}"\n'
- f'repo_dir = "{self.tmp}/repo"\n'
- f'slackrepo_db = "{self.tmp}/slackrepo.sqlite3"\n'
- f'hintdir = "{self.tmp}/hints"\n'
- f'genreprc = "{self.tmp}/genreprc"\n')
- patcher = mock.patch.dict(os.environ, {"SBB_CONFIG": conf})
- patcher.start()
- self.addCleanup(patcher.stop)
- self.cfg = sbb.load_config()
- os.makedirs(os.path.join(self.tmp, "jobs"))
-
- def make_job(self, argv, **meta):
- jid = "20260101-000000-test"
- d = os.path.join(self.tmp, "jobs", jid)
- os.mkdir(d)
- sbb.write_meta(d, {"id": jid, "kind": "test", "args": [], "argv": argv,
- "status": "starting", "created": time.time(), **meta})
- open(os.path.join(d, "log"), "wb").close()
- return jid, d
-
- def make_pkg(self, path, info="", hint=None, desc=None):
- d = os.path.join(self.cfg["repo_dir"], path)
- pkg = os.path.basename(path)
- os.makedirs(d, exist_ok=True)
- open(os.path.join(d, pkg + ".SlackBuild"), "w").close()
- with open(os.path.join(d, pkg + ".info"), "w") as f:
- f.write(info)
- if desc:
- with open(os.path.join(d, "slack-desc"), "w") as f:
- f.write(f"{pkg}: {pkg} ({desc})\n{pkg}:\n")
- if hint is not None:
- os.makedirs(self.cfg["hintdir"], exist_ok=True)
- with open(os.path.join(self.cfg["hintdir"], pkg + ".hint"), "w") as f:
- f.write(hint)
-
- def fake_bin(self, name, script):
- bindir = os.path.join(self.tmp, "bin")
- os.makedirs(bindir, exist_ok=True)
- path = os.path.join(bindir, name)
- with open(path, "w") as f:
- f.write("#!/bin/sh\n" + script)
- os.chmod(path, 0o755)
- if bindir not in os.environ["PATH"]:
- patcher = mock.patch.dict(os.environ, {"PATH": bindir + os.pathsep + os.environ["PATH"]})
- patcher.start()
- self.addCleanup(patcher.stop)
- return path
-
- def hold_lock(self):
- f = open(os.path.join(self.tmp, "lock"), "a")
- fcntl.flock(f, fcntl.LOCK_EX)
- self.addCleanup(f.close)
-
- def run_main(self, *argv):
- out = io.StringIO()
- with contextlib.redirect_stdout(out):
- rc = sbb.main(list(argv))
- return rc, json.loads(out.getvalue())
-
- def wait_status(self, d, statuses):
- for _ in range(100):
- if sbb.read_meta(d)["status"] in statuses:
- break
- time.sleep(0.05)
- return sbb.read_meta(d)
-
-
-class ConfigTest(Base):
- def write(self, text):
- p = os.path.join(self.tmp, "c.toml")
- with open(p, "w") as f:
- f.write(text)
- return p
-
- def test_missing_file_gives_defaults(self):
- self.assertEqual(sbb.load_config(os.path.join(self.tmp, "nope.toml")), sbb.DEFAULTS)
-
- def test_override(self):
- self.assertEqual(sbb.load_config(self.write('overlays = ["a"]\n'))["overlays"], ["a"])
-
- def test_example_matches_defaults(self):
- example = os.path.join(os.path.dirname(SCRIPT), "config.example.toml")
- self.assertEqual(sbb.load_config(example), sbb.DEFAULTS)
-
- def test_unknown_key(self):
- with self.assertRaisesRegex(sbb.Error, "bogus: unknown key"):
- sbb.load_config(self.write("bogus = 1\n"))
-
- def test_wrong_type(self):
- with self.assertRaisesRegex(sbb.Error, "state_dir: expected str"):
- sbb.load_config(self.write("state_dir = 1\n"))
-
-
-class KindsTest(Base):
- def test_sbpull_argv(self):
- self.assertEqual(sbb.KINDS["sbpull"](self.cfg, ["personal", "--no"]),
- (["bash", "-c", '. "$1" && sbpull "$2" "$3"', "_",
- self.cfg["sbpull_script"], "personal", "--no"], {}))
-
- def test_sbshadow_argv(self):
- self.assertEqual(sbb.KINDS["sbshadow"](self.cfg, ["--yes"]), (["sbshadow", "--yes"], {}))
-
- def test_slackrepo_argv(self):
- self.make_pkg("misc/nuclei")
- self.make_pkg("personal/foo")
- kind = sbb.KINDS["slackrepo"]
- self.assertEqual(kind(self.cfg, ["update", "--preview", "nuclei", "foo", "nuclei"]),
- (["slackrepo", "--repo=SBo-danix", "update", "--preview", "nuclei", "foo"], {}))
- self.assertEqual(kind(self.cfg, ["build", "--no-publish", "foo"]),
- (["slackrepo", "--repo=SBo-danix", "build", "foo"], {"SBB_PUBLISH": "n"}))
- self.assertEqual(kind(self.cfg, ["update"]), (["slackrepo", "--repo=SBo-danix", "update"], {}))
-
- def test_slackrepo_rejects(self):
- self.make_pkg("misc/nuclei")
- for args in (["install", "nuclei"], ["build"], ["remove"], ["build", "nope"],
- ["build", "../etc"], ["build", "--force", "nuclei"]):
- with self.subTest(args=args), self.assertRaises(sbb.Error):
- sbb.KINDS["slackrepo"](self.cfg, args)
-
- def test_publish(self):
- self.assertEqual(sbb.KINDS["publish"](self.cfg, []), (["sb-publish"], {}))
- with self.assertRaises(sbb.Error):
- sbb.KINDS["publish"](self.cfg, ["x"])
-
- def test_rejects_bad_args(self):
- for kind, args in (("sbpull", ["evil", "--no"]), ("sbpull", ["personal"]),
- ("sbpull", ["personal", "--force"]), ("sbshadow", []),
- ("sbshadow", ["--yes", "--no"])):
- with self.subTest(kind=kind, args=args), self.assertRaises(sbb.Error):
- sbb.KINDS[kind](self.cfg, args)
-
- def test_unknown_kind(self):
- with self.assertRaisesRegex(sbb.Error, "unknown kind"):
- sbb.cmd_job_start(self.cfg, "rm", "-rf", "/")
-
-
-class MainTest(Base):
- def test_unknown_command(self):
- rc, data = self.run_main("nope")
- self.assertEqual(rc, 1)
- self.assertIn("usage", data["error"])
-
- def test_version(self):
- self.assertEqual(self.run_main("version"),
- (0, {"version": sbb.VERSION, "overlays": ["personal", "pentesting"]}))
-
-
-class InventoryTest(Base):
- def test_rows(self):
- self.make_pkg("misc/nuclei", 'VERSION="3.11.0"\nMAINTAINER="Some One"\n'
- 'REQUIRES="go \\\n git"\n', desc="Fast scanner")
- self.make_pkg("personal/foo", 'VERSION="1.0"\n',
- hint='VERSION="1.1" # bumped\nDELREQUIRES="a b"\nOPTIONS="X=$(uname -m)"\n')
- os.makedirs(os.path.join(self.cfg["repo_dir"], "misc", "notapkg"))
- db = sqlite3.connect(self.cfg["slackrepo_db"])
- db.executescript("""
- create table buildresults (itemid text primary key, time text, result text);
- create table revisions (itemid text, dep text, deplist text, version text,
- built text, rev text, os text, hintcksum text, primary key (itemid, dep));
- insert into buildresults values ('misc/nuclei', '5', 'ok'), ('nuclei', '1', 'bad');
- insert into revisions values ('misc/nuclei', '/', '/', '3.11.0', '1788978222', 'r', 'o', '/');
- """)
- db.close()
- inv = sbb.cmd_inventory(self.cfg)
- rows = [dict(zip(inv["columns"], r)) for r in inv["rows"]]
- self.assertEqual([r["path"] for r in rows], ["misc/nuclei", "personal/foo"])
- nuclei, foo = rows
- self.assertEqual((nuclei["version"], nuclei["built_version"], nuclei["built_time"],
- nuclei["result"], nuclei["maintainer"], nuclei["requires"],
- nuclei["summary"], nuclei["hint_path"]),
- ("3.11.0", "3.11.0", 1788978222, "ok", "Some One", ["go", "git"],
- "Fast scanner", None))
- self.assertEqual((foo["version"], foo["info_version"], foo["hint_version"], foo["result"]),
- ("1.1", "1.0", "1.1", None))
- self.assertEqual(foo["hints"]["DELREQUIRES"], "a b")
- self.assertEqual(foo["hints"]["OPTIONS"], {"value": '"X=$(uname -m)"', "shell": True})
- json.dumps(inv)
-
- def test_hint_search_order(self):
- self.make_pkg("misc/foo")
- sub = os.path.join(self.cfg["hintdir"], "misc", "foo")
- os.makedirs(sub)
- with open(os.path.join(sub, "foo.hint"), "w") as f:
- f.write('VERSION="2"\n')
- self.assertEqual(sbb.find_hint(self.cfg, "misc/foo"), os.path.join(sub, "foo.hint"))
- with open(os.path.join(self.cfg["hintdir"], "foo.hint"), "w") as f:
- f.write('VERSION="3"\n')
- self.assertEqual(sbb.find_hint(self.cfg, "misc/foo"),
- os.path.join(self.cfg["hintdir"], "foo.hint"))
- in_repo = os.path.join(self.cfg["repo_dir"], "misc", "foo", "foo.hint")
- open(in_repo, "w").close()
- self.assertEqual(sbb.find_hint(self.cfg, "misc/foo"), in_repo)
-
- def test_missing_db(self):
- self.make_pkg("misc/foo", 'VERSION="1"\n')
- row = sbb.cmd_inventory(self.cfg)["rows"][0]
- self.assertEqual(row[4:7], [None, None, None])
-
-
-class GpgTest(Base):
- def setup_key(self, cached):
- with open(self.cfg["genreprc"], "w") as f:
- f.write('REPOSOWNERGPG="ABCD1234"\nUSE_GPGAGENT=1\n')
- self.fake_bin("gpg", 'case "$*" in\n'
- ' *--list-secret-keys*) printf "sec:u:255:22:K:1::::::scESC:\\n'
- 'grp:::::::::GRIPSIGN:\\nssb:u:255:18:S:1::::::e:\\ngrp:::::::::GRIPENC:\\n" ;;\n'
- f' *-bas*) [ -f "{self.tmp}/good" ] ;;\n'
- 'esac\n')
- flag = "1" if cached else "-"
- self.fake_bin("gpg-connect-agent", f'echo "S KEYINFO GRIPSIGN D - - {flag} P - - -"; echo OK\n')
-
- def test_status(self):
- self.assertEqual(sbb.cmd_gpg_status(self.cfg), {"unlocked": None, "key": None})
- self.setup_key(cached=False)
- self.assertEqual(sbb.cmd_gpg_status(self.cfg), {"unlocked": False, "key": "ABCD1234"})
- self.setup_key(cached=True)
- self.assertEqual(sbb.cmd_gpg_status(self.cfg)["unlocked"], True)
-
- def test_locked_refuses_real_run_only(self):
- self.setup_key(cached=False)
- self.make_pkg("misc/foo")
- with self.assertRaisesRegex(sbb.Error, "signing key locked"):
- sbb.cmd_job_start(self.cfg, "slackrepo", "build", "foo")
- self.fake_bin("slackrepo", "exit 0\n")
- self.assertIn("id", sbb.cmd_job_start(self.cfg, "slackrepo", "build", "--preview", "foo"))
-
- def test_unlock(self):
- self.setup_key(cached=False)
- log = os.path.join(self.tmp, "preset.log")
- preset = self.fake_bin("preset", f'echo "$* $(cat)" >> {log}\n')
- with mock.patch.object(sbb, "PRESET", preset):
- with self.assertRaisesRegex(sbb.Error, "bad passphrase"):
- sbb.cmd_gpg_unlock(self.cfg, stdin=io.BytesIO(b"wrong\n"))
- open(os.path.join(self.tmp, "good"), "w").close()
- self.assertEqual(sbb.cmd_gpg_unlock(self.cfg, stdin=io.BytesIO(b"right\n")),
- {"unlocked": True})
- with open(log) as f:
- self.assertEqual(f.read().splitlines(),
- ["--preset GRIPSIGN wrong", "--forget GRIPSIGN ", "--preset GRIPSIGN right"])
-
-
-class JobsTest(Base):
- def log(self, d):
- with open(os.path.join(d, "log"), "rb") as f:
- return f.read()
-
- def test_run_ok(self):
- jid, d = self.make_job(["sh", "-c", "echo hello"])
- sbb.cmd_run(self.cfg, jid)
- m = sbb.read_meta(d)
- self.assertEqual((m["status"], m["exit"]), ("ok", 0))
- self.assertEqual(self.log(d), b"hello\n")
-
- def test_run_failed(self):
- jid, d = self.make_job(["sh", "-c", "exit 3"])
- sbb.cmd_run(self.cfg, jid)
- m = sbb.read_meta(d)
- self.assertEqual((m["status"], m["exit"]), ("failed", 3))
-
- def test_run_missing_program(self):
- jid, d = self.make_job(["/nonexistent/prog"])
- sbb.cmd_run(self.cfg, jid)
- self.assertEqual(sbb.read_meta(d)["status"], "failed")
- self.assertIn(b"sbb-remote:", self.log(d))
-
- def test_kill(self):
- jid, d = self.make_job(["sleep", "30"])
- t = threading.Thread(target=sbb.cmd_run, args=(self.cfg, jid))
- t.start()
- self.wait_status(d, ("running",))
- self.assertEqual(sbb.cmd_job_kill(self.cfg, jid), {"ok": True})
- t.join(5)
- self.assertEqual(sbb.read_meta(d)["status"], "killed")
-
- def test_kill_not_running(self):
- jid, _ = self.make_job(["true"], status="ok")
- with self.assertRaisesRegex(sbb.Error, "not running"):
- sbb.cmd_job_kill(self.cfg, jid)
-
- def test_busy(self):
- self.hold_lock()
- jid, d = self.make_job(["true"])
- sbb.cmd_run(self.cfg, jid)
- self.assertEqual(sbb.read_meta(d)["status"], "busy")
-
- def test_lost_when_lock_free(self):
- self.make_job(["true"], status="running", pid=1)
- self.assertEqual(sbb.cmd_jobs(self.cfg)["jobs"][0]["status"], "lost")
-
- def test_running_kept_while_lock_held(self):
- self.hold_lock()
- self.make_job(["true"], status="running", pid=1)
- self.assertEqual(sbb.cmd_jobs(self.cfg)["jobs"][0]["status"], "running")
-
- def test_starting_grace(self):
- _, d = self.make_job(["true"])
- self.assertEqual(sbb.cmd_jobs(self.cfg)["jobs"][0]["status"], "starting")
- sbb.write_meta(d, dict(sbb.read_meta(d), created=time.time() - 60))
- self.assertEqual(sbb.cmd_jobs(self.cfg)["jobs"][0]["status"], "lost")
-
- def test_follow_from_offset(self):
- jid, d = self.make_job(["true"], status="ok")
- with open(os.path.join(d, "log"), "wb") as f:
- f.write(b"hello world")
- out = io.BytesIO()
- sbb.cmd_job_follow(self.cfg, jid, "6", out=out)
- self.assertEqual(out.getvalue(), b"world")
-
- def test_bad_job_id(self):
- with self.assertRaisesRegex(sbb.Error, "bad job id"):
- sbb.cmd_job_follow(self.cfg, "../../etc", "0")
-
- def fake_sbshadow(self):
- bindir = os.path.join(self.tmp, "bin")
- os.mkdir(bindir)
- fake = os.path.join(bindir, "sbshadow")
- with open(fake, "w") as f:
- f.write('#!/bin/sh\necho "shadow: cat/$1"\n')
- os.chmod(fake, 0o755)
- patcher = mock.patch.dict(os.environ, {"PATH": bindir + os.pathsep + os.environ["PATH"]})
- patcher.start()
- self.addCleanup(patcher.stop)
-
- def test_env_reaches_child(self):
- jid, d = self.make_job(["sh", "-c", 'echo "publish=$SBB_PUBLISH"'], env={"SBB_PUBLISH": "n"})
- sbb.cmd_run(self.cfg, jid)
- self.assertEqual(self.log(d), b"publish=n\n")
-
- def test_start_end_to_end(self):
- self.fake_sbshadow()
- rc, data = self.run_main("job-start", "sbshadow", "--no")
- self.assertEqual(rc, 0)
- d = os.path.join(self.tmp, "jobs", data["id"])
- self.assertEqual(self.wait_status(d, ("ok", "failed", "lost"))["status"], "ok")
- self.assertEqual(self.log(d), b"shadow: cat/--no\n")
-
- def test_start_busy(self):
- self.fake_sbshadow()
- self.hold_lock()
- jid, _ = self.make_job(["true"], status="running", pid=1)
- self.assertEqual(self.run_main("job-start", "sbshadow", "--no"), (0, {"busy": jid}))
- self.assertEqual(os.listdir(os.path.join(self.tmp, "jobs")), [jid])
-
-
-class StatsTest(Base):
- def test_missing_sources_are_null(self):
- s = sbb.cmd_stats(self.cfg)
- self.assertIsNotNone(s["load"])
- self.assertIsNone(s["packages"])
- self.assertIsNone(s["slackrepo"])
- self.assertEqual(s["sync"], {"personal": None, "pentesting": None})
- json.dumps(s)
-
- def test_counts(self):
- r = self.cfg["repo_dir"]
- for p in ("cat/foo", "cat/bar", "personal/foo", "personal/baz", "pentesting/nmap"):
- os.makedirs(os.path.join(r, p))
- open(os.path.join(r, p, os.path.basename(p) + ".SlackBuild"), "w").close()
- os.makedirs(os.path.join(r, "cat", "notapkg"))
-
- def fake_git(cfg, *args):
- if args[0] == "ls-tree":
- return "cat/foo/foo.SlackBuild\ncat/foo/README\ncat/old/old.SlackBuild\n"
- return "1792351223\n"
-
- with mock.patch.object(sbb, "_git", fake_git):
- s = sbb.cmd_stats(self.cfg)
- self.assertEqual(s["packages"], {"total": 5, "personal": 2, "pentesting": 1, "shadowed": 1})
- self.assertEqual(s["sync"]["personal"], 1792351223)
-
- def test_slackrepo(self):
- db = sqlite3.connect(self.cfg["slackrepo_db"])
- db.executescript("""
- create table buildresults (itemid text primary key, time text, result text);
- create table revisions (itemid text, dep text, deplist text, version text,
- built text, rev text, os text, hintcksum text, primary key (itemid, dep));
- insert into buildresults values ('a', '1', 'ok'), ('b', '1', 'ok'), ('c', '1', 'failed');
- insert into revisions values ('cat/a', '/', '/', '1', '1', 'r', 'o', '/'),
- ('cat/a', 'dep', '/', '1', '1', 'r', 'o', '/');
- """)
- db.close()
- self.assertEqual(sbb.cmd_stats(self.cfg)["slackrepo"],
- {"results": {"ok": 2, "failed": 1}, "built": 1})
-
-
-if __name__ == "__main__":
- unittest.main()
-````
-
-- [ ] **Step 2: Run them, expect failures**
-
-Run: `python3 -m unittest tests.test_remote </dev/null`
-Expected: errors and failures in the new tests (`KeyError: 'slackrepo'`, tuples expected from kinds, no `cmd_inventory`, no `find_hint`, no `cmd_gpg_status`).
-
-- [ ] **Step 3: Write the helper** (replace `remote/sbb-remote`, keep mode 755):
-
-````python
-#!/usr/bin/env python3
-# sbb-remote - sb-builder helper on the build VM
-# 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.
-
-"""Helper run over ssh by the sb-builder GUI.
-
-Every command prints one JSON object on stdout, except job-follow, which
-streams raw log bytes. gpg-unlock reads the passphrase from stdin. Jobs run detached (setsid, stdin=/dev/null), one at a
-time under a flock, from a fixed table of kinds: the GUI sends a kind and
-its arguments, this file validates them and builds the argv itself.
-"""
-
-import fcntl
-import glob
-import json
-import os
-import re
-import shlex
-import shutil
-import signal
-import sqlite3
-import subprocess
-import sys
-import tempfile
-import time
-import tomllib
-
-VERSION = "0.2.0"
-DEFAULTS = {
- "state_dir": "/var/lib/sb-builder",
- "repo_dir": "/var/lib/sbopkg/SBo-danix",
- "upstream_branch": "current",
- "slackrepo_db": "/var/lib/slackrepo/SBo-danix/database_SBo-danix_x86_64.sqlite3",
- "sbpull_script": "/usr/local/share/slackrepo_setup/sbpull.bash",
- "overlays": ["personal", "pentesting"],
- "slackrepo_repo": "SBo-danix",
- "hintdir": "/etc/slackrepo/SBo-danix/hintfiles",
- "default_hintdir": "",
- "genreprc": "/root/.genreprc",
-}
-FLAGS = ("--yes", "--no")
-JOB_ID = re.compile(r"^\d{8}-\d{6}-[a-z]+(-\d+)?$")
-PKG = re.compile(r"^[^/]+/([^/]+)/\1\.SlackBuild$")
-PKGNAME = re.compile(r"^[A-Za-z0-9._+-]+$")
-ACTIONS = ("build", "rebuild", "update", "remove")
-PRESET = "/usr/libexec/gpg-preset-passphrase"
-ACTIVE = ("starting", "running")
-START_GRACE = 10 # seconds a job may sit in "starting" before it counts as lost
-
-
-class Error(Exception):
- pass
-
-
-def load_config(path=None):
- path = path or os.environ.get("SBB_CONFIG", "/etc/sb-builder/config.toml")
- cfg = dict(DEFAULTS)
- try:
- with open(path, "rb") as f:
- data = tomllib.load(f)
- except FileNotFoundError:
- return cfg
- except tomllib.TOMLDecodeError as e:
- raise Error(f"config: {e}")
- for k, v in data.items():
- if k not in DEFAULTS:
- raise Error(f"config: {k}: unknown key")
- 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")
- return cfg
-
-
-# ── job kinds: (cfg, args) -> (argv, env), raising Error on bad args ────────
-
-def _flag(args):
- if len(args) != 1 or args[0] not in FLAGS:
- raise Error("expected exactly one of --yes, --no")
- return args[0]
-
-
-def kind_sbpull(cfg, args):
- if not args or args[0] not in cfg["overlays"]:
- raise Error(f"sbpull: overlay must be one of {cfg['overlays']}")
- return ["bash", "-c", '. "$1" && sbpull "$2" "$3"', "_",
- cfg["sbpull_script"], args[0], _flag(args[1:])], {}
-
-
-def kind_sbshadow(cfg, args):
- return ["sbshadow", _flag(args)], {}
-
-
-def kind_slackrepo(cfg, args):
- if not args or args[0] not in ACTIONS:
- raise Error(f"slackrepo: action must be one of {', '.join(ACTIONS)}")
- action, rest = args[0], args[1:]
- opts = [a for a in rest if a.startswith("--")]
- for o in opts:
- if o not in ("--preview", "--no-publish"):
- raise Error(f"slackrepo: unknown option {o}")
- known = {os.path.basename(d) for d in _pkgdirs(cfg["repo_dir"], "*/*")}
- pkgs = []
- for p in (a for a in rest if not a.startswith("--")):
- if not PKGNAME.match(p) or p not in known:
- raise Error(f"slackrepo: no such package: {p}")
- if p not in pkgs:
- pkgs.append(p)
- if not pkgs and action != "update":
- raise Error(f"slackrepo: {action} needs at least one package")
- argv = ["slackrepo", f"--repo={cfg['slackrepo_repo']}", action]
- if "--preview" in opts:
- argv.append("--preview")
- return argv + pkgs, {"SBB_PUBLISH": "n"} if "--no-publish" in opts else {}
-
-
-def kind_publish(cfg, args):
- if args:
- raise Error("publish takes no arguments")
- return ["sb-publish"], {}
-
-
-KINDS = {"sbpull": kind_sbpull, "sbshadow": kind_sbshadow,
- "slackrepo": kind_slackrepo, "publish": kind_publish}
-
-
-def signs(kind, args):
- """A real slackrepo run ends in gen_repos_files.sh, which signs."""
- return kind == "slackrepo" and "--preview" not in args
-
-
-# ── job state ───────────────────────────────────────────────────────────────
-
-def job_dir(cfg, jid):
- if not JOB_ID.match(jid or ""):
- raise Error(f"bad job id: {jid}")
- d = os.path.join(cfg["state_dir"], "jobs", jid)
- if not os.path.isdir(d):
- raise Error(f"no such job: {jid}")
- return d
-
-
-def read_meta(d):
- with open(os.path.join(d, "meta.json")) as f:
- return json.load(f)
-
-
-def write_meta(d, meta):
- tmp = os.path.join(d, "meta.json.tmp")
- with open(tmp, "w") as f:
- json.dump(meta, f)
- os.replace(tmp, os.path.join(d, "meta.json"))
-
-
-def lock_file(cfg):
- return open(os.path.join(cfg["state_dir"], "lock"), "a")
-
-
-def refresh(cfg, d, meta):
- """Mark an active job lost when no runner holds the lock.
-
- _run writes the final status before it releases the lock, so a free lock
- plus a status that is still active (re-read under the lock) means the
- runner died: killed, crashed, or the VM rebooted.
- """
- if meta["status"] not in ACTIVE:
- return meta
- if meta["status"] == "starting" and time.time() - meta["created"] < START_GRACE:
- return meta
- with lock_file(cfg) as lk:
- try:
- fcntl.flock(lk, fcntl.LOCK_EX | fcntl.LOCK_NB)
- except BlockingIOError:
- return meta
- meta = read_meta(d)
- if meta["status"] in ACTIVE:
- meta["status"] = "lost"
- write_meta(d, meta)
- return meta
-
-
-def all_jobs(cfg):
- out = []
- for d in sorted(glob.glob(os.path.join(cfg["state_dir"], "jobs", "*")), reverse=True):
- try:
- out.append(refresh(cfg, d, read_meta(d)))
- except (OSError, ValueError):
- pass # a dir being created or removed right now
- return out
-
-
-# ── commands ────────────────────────────────────────────────────────────────
-
-def cmd_version(cfg):
- return {"version": VERSION, "overlays": cfg["overlays"]}
-
-
-def cmd_jobs(cfg):
- # ponytail: logs and job dirs are kept forever; prune once disk usage shows on the dashboard
- return {"jobs": all_jobs(cfg)}
-
-
-def cmd_job_start(cfg, kind=None, *args):
- if kind not in KINDS:
- raise Error(f"unknown kind: {kind}")
- argv, env = KINDS[kind](cfg, list(args))
- if signs(kind, args) and gpg_unlocked(cfg) is False:
- raise Error("signing key locked")
- base = time.strftime("%Y%m%d-%H%M%S-") + kind
- jid, n = base, 1
- while True:
- d = os.path.join(cfg["state_dir"], "jobs", jid)
- try:
- os.mkdir(d)
- break
- except FileExistsError:
- n += 1
- jid = f"{base}-{n}"
- write_meta(d, {"id": jid, "kind": kind, "args": list(args), "argv": argv,
- "env": env, "status": "starting", "created": time.time()})
- open(os.path.join(d, "log"), "wb").close()
- subprocess.Popen([sys.executable, os.path.abspath(__file__), "_run", jid],
- stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL, start_new_session=True)
- for _ in range(60):
- status = read_meta(d)["status"]
- if status == "busy":
- shutil.rmtree(d)
- return {"busy": next((m["id"] for m in all_jobs(cfg) if m["status"] == "running"), None)}
- if status != "starting":
- break
- time.sleep(0.05)
- return {"id": jid}
-
-
-def cmd_run(cfg, jid=None):
- d = job_dir(cfg, jid)
- meta = read_meta(d)
- with lock_file(cfg) as lk:
- try:
- fcntl.flock(lk, fcntl.LOCK_EX | fcntl.LOCK_NB)
- except BlockingIOError:
- meta["status"] = "busy"
- write_meta(d, meta)
- return None
- with open(os.path.join(d, "log"), "ab") as log:
- meta["start"] = time.time()
- try:
- p = subprocess.Popen(meta["argv"], stdin=subprocess.DEVNULL, stdout=log,
- stderr=subprocess.STDOUT, start_new_session=True,
- env={**os.environ, **meta.get("env", {})})
- except OSError as e:
- log.write(f"sbb-remote: {e}\n".encode())
- rc = 127
- else:
- meta.update(pid=p.pid, status="running")
- write_meta(d, meta)
- rc = p.wait()
- meta.update(end=time.time(), exit=rc,
- status="ok" if rc == 0 else "killed" if rc == -signal.SIGTERM else "failed")
- write_meta(d, meta)
- return None
-
-
-def cmd_job_follow(cfg, jid=None, offset="0", out=None):
- d = job_dir(cfg, jid)
- out = out or sys.stdout.buffer
- with open(os.path.join(d, "log"), "rb") as f:
- f.seek(int(offset))
- while True:
- # status first, then read: the last read after the job ends drains the log
- done = refresh(cfg, d, read_meta(d))["status"] not in ACTIVE
- chunk = f.read()
- if chunk:
- out.write(chunk)
- out.flush()
- elif done:
- return None
- else:
- time.sleep(0.5)
-
-
-def cmd_job_kill(cfg, jid=None):
- meta = read_meta(job_dir(cfg, jid))
- if meta["status"] != "running":
- raise Error(f"job not running: {jid}")
- try:
- os.killpg(meta["pid"], signal.SIGTERM)
- except ProcessLookupError:
- pass # ended on its own meanwhile
- return {"ok": True}
-
-
-# ── stats ───────────────────────────────────────────────────────────────────
-
-def _try(fn):
- try:
- return fn()
- except Exception:
- return None
-
-
-def _git(cfg, *args):
- return subprocess.run(["git", "-C", cfg["repo_dir"], *args], stdin=subprocess.DEVNULL,
- capture_output=True, text=True, check=True).stdout
-
-
-def _pkgdirs(root, pattern):
- """Paths of the <pkg>/<pkg>.SlackBuild dirs matching pattern under root."""
- out = []
- for d in glob.glob(os.path.join(root, pattern, "")):
- d = os.path.dirname(d)
- if os.path.isfile(os.path.join(d, os.path.basename(d) + ".SlackBuild")):
- out.append(d)
- return out
-
-
-def _pkgs(root, pattern):
- return [os.path.basename(d) for d in _pkgdirs(root, pattern)]
-
-
-def _packages(cfg):
- r = cfg["repo_dir"]
- mine = {o: _pkgs(r, o + "/*") for o in cfg["overlays"]}
- tree = _git(cfg, "ls-tree", "-r", "--name-only", cfg["upstream_branch"]).splitlines()
- upstream = {m.group(1) for m in map(PKG.match, tree) if m}
- shadowed = upstream & {p for names in mine.values() for p in names}
- return {"total": len(_pkgs(r, "*/*")), **{o: len(n) for o, n in mine.items()},
- "shadowed": len(shadowed)}
-
-
-def _slackrepo(cfg):
- db = sqlite3.connect(f"file:{cfg['slackrepo_db']}?mode=ro", uri=True)
- try:
- results = dict(db.execute("select result, count(*) from buildresults group by result"))
- built = db.execute("select count(distinct itemid) from revisions where dep = '/'").fetchone()[0]
- finally:
- db.close()
- return {"results": results, "built": built}
-
-
-def _uptime():
- with open("/proc/uptime") as f:
- return float(f.read().split()[0])
-
-
-def cmd_stats(cfg):
- dirs = {"repo": cfg["repo_dir"], "state": cfg["state_dir"]}
- return {
- "load": _try(os.getloadavg),
- "uptime": _try(_uptime),
- "disk_free": {k: _try(lambda p=p: shutil.disk_usage(p).free) for k, p in dirs.items()},
- "packages": _try(lambda: _packages(cfg)),
- "sync": {o: _try(lambda o=o: int(_git(cfg, "log", "-1", "--format=%ct", "--", o + "/")))
- for o in cfg["overlays"]},
- "slackrepo": _try(lambda: _slackrepo(cfg)),
- }
-
-
-# ── inventory ───────────────────────────────────────────────────────────────
-
-def parse_assignments(path):
- """KEY=value assignments of a shell-style file, without running it.
-
- Values with a $ are returned as {"value": raw, "shell": True}: the shell
- would expand them, this parser does not.
- """
- with open(path, errors="replace") as f:
- text = f.read().replace("\\\n", " ")
- out = {}
- for line in text.splitlines():
- try:
- tokens = shlex.split(line, comments=True)
- except ValueError:
- continue # unbalanced quotes: slackrepo would choke too, skip the line
- raw = line.split("=", 1)[1] if "=" in line else ""
- for t in tokens:
- name, eq, value = t.partition("=")
- if eq and re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
- out[name] = {"value": raw.strip(), "shell": True} if "$" in raw else value
- return out
-
-
-def _value(v):
- return v["value"] if isinstance(v, dict) else v
-
-
-def find_hint(cfg, path):
- """slackrepo's search order (parsefunctions.sh): first <pkg>.hint found."""
- pkg = os.path.basename(path)
- dirs = [os.path.join(cfg["repo_dir"], path), cfg["hintdir"],
- os.path.join(cfg["hintdir"], path)]
- if cfg["default_hintdir"]:
- dirs += [cfg["default_hintdir"], os.path.join(cfg["default_hintdir"], path)]
- for d in dirs:
- f = os.path.join(d, pkg + ".hint")
- if os.path.isfile(f):
- return f
- return None
-
-
-def _summary(pkgdir, pkg):
- try:
- with open(os.path.join(pkgdir, "slack-desc"), errors="replace") as f:
- for line in f:
- m = re.match(rf"^{re.escape(pkg)}: {re.escape(pkg)} \((.*)\)\s*$", line)
- if m:
- return m.group(1)
- except OSError:
- pass
- return None
-
-
-def _builds(cfg):
- """{path: (built_version, built_time)}, {path: result} from the slackrepo DB."""
- try:
- db = sqlite3.connect(f"file:{cfg['slackrepo_db']}?mode=ro", uri=True)
- try:
- built = {i: (v, int(t)) for i, v, t in
- db.execute("select itemid, version, built from revisions where dep = '/'")}
- results = dict(db.execute("select itemid, result from buildresults"))
- finally:
- db.close()
- except sqlite3.Error:
- return {}, {}
- return built, results
-
-
-COLUMNS = ["path", "version", "info_version", "hint_version", "built_version", "built_time",
- "result", "maintainer", "requires", "summary", "hint_path", "hints"]
-
-
-def cmd_inventory(cfg):
- built, results = _builds(cfg)
- rows = []
- for d in sorted(_pkgdirs(cfg["repo_dir"], "*/*")):
- path = os.path.relpath(d, cfg["repo_dir"])
- pkg = os.path.basename(d)
- try:
- info = parse_assignments(os.path.join(d, pkg + ".info"))
- except OSError:
- info = {}
- hint_path = find_hint(cfg, path)
- hints = parse_assignments(hint_path) if hint_path else None
- info_version = _value(info.get("VERSION"))
- hint_version = _value(hints.get("VERSION")) if hints else None
- bv, bt = built.get(path, (None, None))
- rows.append([path, hint_version or info_version, info_version, hint_version, bv, bt,
- results.get(path), _value(info.get("MAINTAINER")),
- (_value(info.get("REQUIRES")) or "").split(), _summary(d, pkg),
- hint_path, hints])
- return {"columns": COLUMNS, "rows": rows}
-
-
-# ── signing key ─────────────────────────────────────────────────────────────
-
-def _signing_key(cfg):
- try:
- with open(cfg["genreprc"]) as f:
- m = re.search(r'^\s*REPOSOWNERGPG=["\']?([^"\'\s]+)', f.read(), re.M)
- except OSError:
- return None
- return m and m.group(1)
-
-
-def _keygrips(key):
- """Keygrips of the signing-capable (sub)keys of key."""
- out = subprocess.run(["gpg", "--batch", "--with-colons", "--with-keygrip",
- "--list-secret-keys", key], stdin=subprocess.DEVNULL,
- capture_output=True, text=True).stdout
- grips, can_sign = [], False
- for line in out.splitlines():
- f = line.split(":")
- if f[0] in ("sec", "ssb"):
- can_sign = "s" in f[11]
- elif f[0] == "grp" and can_sign:
- grips.append(f[9])
- return grips
-
-
-def gpg_unlocked(cfg):
- """True/False, or None when no signing key is configured or found."""
- key = _signing_key(cfg)
- grips = _keygrips(key) if key else []
- if not grips:
- return None
- for g in grips:
- out = subprocess.run(["gpg-connect-agent", f"keyinfo {g}", "/bye"],
- stdin=subprocess.DEVNULL, capture_output=True, text=True).stdout
- info = next((ln.split() for ln in out.splitlines() if ln.startswith("S KEYINFO")), [])
- if len(info) < 7 or info[6] != "1":
- return False
- return True
-
-
-def cmd_gpg_status(cfg):
- return {"unlocked": gpg_unlocked(cfg), "key": _signing_key(cfg)}
-
-
-def cmd_gpg_unlock(cfg, stdin=None):
- key = _signing_key(cfg)
- grips = _keygrips(key) if key else []
- if not grips:
- raise Error("no signing key configured")
- passphrase = (stdin or sys.stdin.buffer).read().rstrip(b"\n")
- for g in grips:
- subprocess.run([PRESET, "--preset", g], input=passphrase, check=True,
- capture_output=True)
- with tempfile.NamedTemporaryFile() as t:
- ok = subprocess.run(["gpg", "--batch", "--pinentry-mode", "error", "-u", key,
- "-bas", "-o", "-", t.name], stdin=subprocess.DEVNULL,
- capture_output=True).returncode == 0
- if not ok:
- for g in grips:
- subprocess.run([PRESET, "--forget", g], stdin=subprocess.DEVNULL, capture_output=True)
- raise Error("bad passphrase")
- return {"unlocked": True}
-
-
-COMMANDS = {
- "version": cmd_version,
- "stats": cmd_stats,
- "jobs": cmd_jobs,
- "job-start": cmd_job_start,
- "job-follow": cmd_job_follow,
- "job-kill": cmd_job_kill,
- "inventory": cmd_inventory,
- "gpg-status": cmd_gpg_status,
- "gpg-unlock": cmd_gpg_unlock,
- "_run": cmd_run,
-}
-
-
-def main(argv):
- try:
- if not argv or argv[0] not in COMMANDS:
- names = ",".join(c for c in COMMANDS if not c.startswith("_"))
- raise Error(f"usage: sbb-remote {{{names}}} [args]")
- cfg = load_config()
- os.makedirs(os.path.join(cfg["state_dir"], "jobs"), exist_ok=True)
- result = COMMANDS[argv[0]](cfg, *argv[1:])
- except BrokenPipeError:
- # the GUI hung up on job-follow; silence the flush at exit too
- os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
- return 0
- except Exception as e:
- print(json.dumps({"error": str(e) if isinstance(e, Error) else f"{type(e).__name__}: {e}"}))
- return 1
- if result is not None:
- print(json.dumps(result))
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
-````
-
-Replace `remote/config.example.toml`:
-
-````toml
-# sbb-remote config on the VM: /etc/sb-builder/config.toml
-# Every key is optional; these are the defaults.
-
-state_dir = "/var/lib/sb-builder"
-repo_dir = "/var/lib/sbopkg/SBo-danix"
-upstream_branch = "current"
-slackrepo_db = "/var/lib/slackrepo/SBo-danix/database_SBo-danix_x86_64.sqlite3"
-sbpull_script = "/usr/local/share/slackrepo_setup/sbpull.bash"
-overlays = ["personal", "pentesting"]
-slackrepo_repo = "SBo-danix"
-hintdir = "/etc/slackrepo/SBo-danix/hintfiles"
-default_hintdir = "" # searched after hintdir when set
-genreprc = "/root/.genreprc" # REPOSOWNERGPG names the signing key
-````
-
-- [ ] **Step 4: Run the tests**
-
-Run: `python3 -m unittest tests.test_remote </dev/null`
-Expected: `Ran 37 tests`, `OK` (plus the known ResourceWarnings). `</dev/null` matters: without it a regression that lets a subprocess read stdin hangs instead of failing.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add remote/sbb-remote remote/config.example.toml tests/test_remote.py
-git commit -F - <<'EOF'
-sbb-remote 0.2.0: inventory, slackrepo and publish jobs, signing key
-
-inventory lists every package with its effective version (hint over
-.info), build state from the slackrepo DB, summary, and the parsed
-hint found by slackrepo's search order; hints are parsed with shlex,
-never sourced. Job kinds now return argv plus env: slackrepo
-build/rebuild/update/remove (packages validated against the tree,
---preview, --no-publish sets SBB_PUBLISH=n) and publish (sb-publish).
-gpg-status and gpg-unlock let the GUI unlock the package signing key
-in gpg-agent; the passphrase comes on stdin only, every other
-subprocess gets stdin=DEVNULL. A real slackrepo run is refused while
-the key is locked.
-
-Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-EOF
-git log --format='%h %G? %s' -1
-```
-
----
-
-### Task 3: GUI
-
-**Files:** replace `sb_builder/__init__.py`, `sb_builder/config.py`, `config.example.toml`, `sb_builder/remote.py`, `sb_builder/jobs.py`, `sb_builder/dashboard.py`, `sb_builder/mainwindow.py`, `tests/test_gui.py`; create `sb_builder/hints.py`, `sb_builder/packages.py`.
-
-- [ ] **Step 1: Write the tests** (replace `tests/test_gui.py`):
-
-````python
-# sb-builder - self-checks for the GUI
-# 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 (headless, no VM needed)"""
-
-import os
-os.environ["QT_QPA_PLATFORM"] = "offscreen" # never the real display, even if the session sets one
-
-import importlib.machinery # noqa: E402
-import importlib.util # noqa: E402
-import sys # noqa: E402
-import tempfile # noqa: E402
-import time # noqa: E402
-import unittest # noqa: E402
-
-ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
-sys.path.insert(0, ROOT)
-
-from PyQt6.QtCore import QCoreApplication, QEvent, QObject, QProcess, pyqtSignal # noqa: E402
-from PyQt6.QtWidgets import QApplication # noqa: E402
-
-from sb_builder import VERSION, config # noqa: E402
-from sb_builder import hints as hintlib # noqa: E402
-from sb_builder import packages as pk # noqa: E402
-from sb_builder.mainwindow import MainWindow # noqa: E402
-from sb_builder.remote import DOWN, MISSING, parse # noqa: E402
-
-app = QApplication.instance() or QApplication([])
-
-
-class ConfigTest(unittest.TestCase):
- def load(self, text):
- with tempfile.NamedTemporaryFile("w", suffix=".toml", delete=False) as f:
- f.write(text)
- self.addCleanup(os.unlink, f.name)
- return config.load(f.name)
-
- def test_missing_file(self):
- self.assertEqual(config.load("/nonexistent/config.toml"), (config.DEFAULTS, None))
-
- def test_example_matches_defaults(self):
- self.assertEqual(config.load(os.path.join(ROOT, "config.example.toml")), (config.DEFAULTS, None))
-
- def test_override(self):
- cfg, err = self.load("[refresh]\njobs = 2\n")
- self.assertIsNone(err)
- self.assertEqual(cfg["refresh"], {"dashboard": 30, "jobs": 2})
-
- def test_unknown_key(self):
- cfg, err = self.load("[remote]\nbogus = 1\n")
- self.assertIn("remote.bogus", err)
- self.assertEqual(cfg, config.DEFAULTS)
-
- def test_wrong_type(self):
- cfg, err = self.load('[vm]\nautostart = "yes"\n')
- self.assertIn("vm.autostart: expected bool", err)
- self.assertEqual(cfg, config.DEFAULTS)
-
-
-class ParseTest(unittest.TestCase):
- def test_cases(self):
- self.assertEqual(parse(255, b"", b""), (None, DOWN))
- self.assertEqual(parse(127, b"", b""), (None, MISSING))
- self.assertEqual(parse(0, b'{"a": 1}', b""), ({"a": 1}, None))
- self.assertEqual(parse(1, b'{"error": "boom"}', b""), (None, "boom"))
- self.assertEqual(parse(1, b"", b"bash: x\n"), (None, "bash: x"))
-
-
-class VersionTest(unittest.TestCase):
- def test_gui_matches_helper(self):
- script = os.path.join(ROOT, "remote", "sbb-remote")
- loader = importlib.machinery.SourceFileLoader("sbb_remote", script)
- sbb = importlib.util.module_from_spec(importlib.util.spec_from_loader("sbb_remote", loader))
- loader.exec_module(sbb)
- self.assertEqual(sbb.VERSION, VERSION)
-
-
-class FakeRemote:
- def __init__(self, answers):
- self.answers = answers
- self.calls = []
- self.virsh_answer = (1, "")
-
- def call(self, cb, *args, stdin=None):
- self.calls.append(args if stdin is None else (*args, stdin))
- answer = self.answers.get(args[0], (None, DOWN))
- cb(*(answer(args, stdin) if callable(answer) else answer))
-
- def virsh(self, cb, *args):
- self.calls.append(("virsh", *args))
- cb(*self.virsh_answer)
-
- def follow(self, jid, offset):
- p = QProcess(app) # parented, like Remote's, so it outlives this call
- p.start("true", [])
- return p
-
-
-class FakeNotifier(QObject):
- clicked = pyqtSignal(str)
-
- def __init__(self):
- super().__init__()
- self.sent = []
-
- def send(self, title, body, jid=""):
- self.sent.append((title, body, jid))
-
-
-STATS = {"load": [0.5, 0.4, 0.3], "uptime": 3600.0,
- "disk_free": {"repo": 10 * 2**30, "state": None},
- "packages": {"total": 10, "personal": 2, "pentesting": 1, "shadowed": 1},
- "sync": {"personal": None, "pentesting": 1792351223}, "slackrepo": None}
-JOB = {"id": "20260101-000000-sbshadow", "kind": "sbshadow", "args": ["--no"], "argv": [],
- "status": "running", "created": 0, "start": 0, "pid": 5}
-
-
-def inv_row(path, version="1.0", built=None, result=None, hints=None, requires=(),
- summary=None, maintainer="Some One", info_version=None):
- return [path, version, info_version or version, None, built, 1788978222 if built else None,
- result, maintainer, list(requires), summary, "/h/x.hint" if hints else None, hints]
-
-
-COLS = ["path", "version", "info_version", "hint_version", "built_version", "built_time",
- "result", "maintainer", "requires", "summary", "hint_path", "hints"]
-INVENTORY = {"columns": COLS, "rows": [
- inv_row("misc/nuclei", "3.11.1", built="3.11.0", result="ok", summary="Fast scanner",
- requires=["go"]),
- inv_row("personal/foo", built="1.0", result="ok", hints={"DELREQUIRES": "x"}, requires=["x", "y"]),
- inv_row("pentesting/nmap", built="7.9", result="bad"),
- inv_row("network/curl"),
-]}
-PREVIEW = {"id": "20260101-000000-slackrepo", "kind": "slackrepo", "status": "ok",
- "args": ["update", "--preview", "nuclei"], "argv": [], "created": 0, "start": 0, "end": 1}
-
-
-class HintsTest(unittest.TestCase):
- def view(self, hints, requires=(), info_version="1.0"):
- return hintlib.interpret(hints, "pkg", info_version, requires)
-
- def section(self, view, title):
- return dict(view["sections"]).get(title)
-
- def test_no_hint(self):
- v = self.view(None, requires=["a"])
- self.assertEqual((v["skip"], v["sections"], v["deps"], v["ignored"]),
- (None, [], [("a", "")], []))
-
- def test_version_arch_and_forced_checksum_skip(self):
- v = self.view({"VERSION": "2.0", "ARCH": "x86_64", "DOWNLOAD": "http://a/src.tgz",
- "DOWNLOAD_x86_64": "http://a/bin-x64.tgz http://a/extra.tgz"})
- self.assertEqual(self.section(v, "Version override"), [
- ("version", "1.0 \u2192 2.0"), ("arch", "x86_64"), ("download", "http://a/bin-x64.tgz"),
- ("download", "http://a/extra.tgz"), ("md5sum", "not checked")])
-
- def test_arch_variant_empty_falls_back(self):
- v = self.view({"ARCH": "x86_64", "DOWNLOAD": "http://a/s", "MD5SUM": "abc", "MD5SUM_x86_64": ""})
- self.assertEqual(self.section(v, "Version override")[1:],
- [("download", "http://a/s"), ("md5sum", "abc")])
-
- def test_no_download_forms(self):
- for hints in ({"DOWNLOAD": "no"}, {"NODOWNLOAD": "yes"}):
- with self.subTest(hints=hints):
- self.assertEqual(self.section(self.view(hints), "Version override"),
- [("download", "no download, the source must already be in the cache")])
- self.assertEqual(self.view(hints)["ignored"], [])
-
- def test_dependencies(self):
- v = self.view({"DELREQUIRES": "b %README%", "ADDREQUIRES": "z", "BUILDTIME": "cmake",
- "PRAGMA": "python3 need_net", "CONFLICTS": "old"},
- requires=["a", "b", "%README%"])
- self.assertEqual(v["deps"], [("a", ""), ("b", "del"), ("%README%", "del"),
- ("python3", "pragma"), ("z", "add"), ("cmake", "build")])
- self.assertEqual(v["conflicts"], ["old"])
- self.assertEqual(self.section(v, "Pragmas")[1], ("need_net", "network not blocked during the build"))
-
- def test_skip_forms(self):
- self.assertIsNone(self.view({"SKIP": "no"})["skip"])
- self.assertEqual(self.view({"SKIP": "yes"})["skip"], "always skipped")
- self.assertEqual(self.view({"SKIP": "remove nc first"})["skip"], "remove nc first")
-
- def test_build_settings(self):
- v = self.view({"OPTIONS": "A=yes B=no", "NUMJOBS": "-j1", "INSTALL": "N",
- "ANSWER": "ok\\nyes", "NOWARNINGS": "Nonstandard"})
- self.assertEqual(self.section(v, "Build settings"), [
- ("option", "A=yes"), ("option", "B=no"), ("make jobs", "-j1"),
- ("install after build", "no"), ("answers to the build", "ok \u23ce yes"),
- ("hide warnings matching", "Nonstandard")])
- self.assertEqual(dict(self.section(self.view({"INSTALL": "1"}), "Build settings")),
- {"install after build": "yes"})
-
- def test_users_and_groups(self):
- v = self.view({"GROUPADD": "209:postgres 300", "USERADD": "209:postgres:-d/var/lib/pgsql:-r"})
- self.assertEqual(self.section(v, "Host changes"), [
- ("group", "postgres (gid 209)"), ("group", "pkg (gid 300)"),
- ("user", "postgres (uid 209), group postgres, home /var/lib/pgsql, shell /bin/false, -r")])
-
- def test_pragma_forms(self):
- self.assertEqual(hintlib.pragma_meaning("x86arch=i686"), "on 32 bit x86, build with ARCH=i686")
- self.assertEqual(hintlib.pragma_meaning("unset_DISTCC_HOSTS"), "unset DISTCC_HOSTS for the build")
- self.assertEqual(hintlib.pragma_meaning("bogus"), "unknown pragma")
-
- def test_ignored_and_shell(self):
- v = self.view({"ANSWERS": "x", "FOO": "1", "DOWNLOAD_arm": "u",
- "OPTIONS": {"value": '"X=$(uname -m)"', "shell": True}})
- self.assertEqual(v["ignored"], ["ANSWERS", "FOO"])
- self.assertEqual(v["shell"], ["OPTIONS"])
-
- def test_cleanup(self):
- self.assertEqual(self.section(self.view({"CLEANUP": "depmod -a"}), "On uninstall"),
- [("run", "depmod -a")])
-
-
-class FilterTest(unittest.TestCase):
- def setUp(self):
- self.rows = {r["name"]: r for r in pk.load_rows(INVENTORY)}
-
- def test_status(self):
- self.assertEqual({n: r["status"] for n, r in self.rows.items()},
- {"nuclei": "outdated", "foo": "ok", "nmap": "failed", "curl": "never"})
-
- def test_query(self):
- self.assertEqual(pk.parse_query("Nuc cat:misc bogus:x status:"),
- [(None, "nuc"), ("category", "misc"), (None, "bogus:x"), (None, "status:")])
-
- def names(self, text="", overlays=(), statuses=()):
- terms = pk.parse_query(text)
- return sorted(n for n, r in self.rows.items() if pk.matches(r, terms, overlays, statuses))
-
- def test_matching(self):
- self.assertEqual(self.names("scanner"), ["nuclei"])
- self.assertEqual(self.names("req:go"), ["nuclei"])
- self.assertEqual(self.names("maint:some status:o"), ["foo", "nuclei"])
- self.assertEqual(self.names(overlays={"personal", "pentesting"}), ["foo", "nmap"])
- self.assertEqual(self.names(overlays={"upstream"}, statuses={"never", "failed"}), ["curl"])
- self.assertEqual(self.names("cat:misc", statuses={"ok"}), [])
-
-
-class SmokeTest(unittest.TestCase):
- def window(self, **answers):
- remote = FakeRemote({"version": ({"version": VERSION, "overlays": ["personal"]}, None),
- "stats": (STATS, None), "jobs": ({"jobs": []}, None),
- "gpg-status": ({"unlocked": True, "key": "K"}, None),
- "inventory": (INVENTORY, None), **answers})
- w = MainWindow(config.load("/nonexistent")[0], remote, FakeNotifier())
- self.addCleanup(w.deleteLater)
- self.addCleanup(lambda: w.jobs_tab and w.jobs_tab.stop())
- return w, remote
-
- def test_tabs_and_dashboard(self):
- w, _ = self.window()
- w.poll_jobs()
- self.assertEqual(w.conn.text(), "● connected")
- self.assertEqual(w.tabs.tabText(0), "Dashboard")
- tiles = w.dashboard.tiles
- self.assertEqual(tiles["Packages"].value.text(), "10")
- self.assertEqual(tiles["Packages"].detail.text(), "2\u00a0personal · 1\u00a0pentesting · 1\u00a0shadowed")
- self.assertEqual(tiles["Built"].value.text(), "n/a")
- self.assertIn("10.0 GiB free", tiles["VM load"].detail.text())
- self.assertEqual(w.dashboard.legend.text(), "n/a")
- jobs = w.open_jobs()
- self.assertIs(w.open_jobs(), jobs)
- self.assertEqual(w.tabs.count(), 2)
- self.assertEqual(jobs.start_menu.actions()[0].text(), "sbpull personal (preview)")
-
- def test_end_notification_once(self):
- w, remote = self.window(jobs=({"jobs": [JOB]}, None))
- w.poll_jobs()
- remote.answers["jobs"] = ({"jobs": [dict(JOB, status="failed", end=5, exit=1)]}, None)
- w.poll_jobs()
- w.poll_jobs()
- self.assertEqual(w.notifier.sent, [("sb-builder: failed", "sbshadow --no", JOB["id"])])
-
- def test_down_offers_vm_start(self):
- w, remote = self.window(jobs=(None, DOWN))
- remote.virsh_answer = (0, "shut off")
- w.poll_jobs()
- self.assertIn(("virsh", "domstate", "buildsystem"), remote.calls)
- self.assertEqual(w.conn.text(), "● VM off")
- self.assertFalse(w.vm_btn.isHidden())
-
- def test_results_bar(self):
- w, _ = self.window(stats=(dict(STATS, slackrepo={"results": {"failed": 1, "ok": 3, "removed": 1},
- "built": 4}), None))
- w.poll_jobs()
- self.assertEqual([(label, n) for label, n, _ in w.dashboard.bar.parts],
- [("ok", 3), ("failed", 1), ("removed", 1)])
- self.assertEqual(w.dashboard.tiles["Built"].detail.text(), "of 10 · 6 never built")
-
- def test_close_jobs_tab_while_following(self):
- # regression: the follow process ended after the tab was deleted, its
- # finished slot touched dead widgets and PyQt aborted the app
- w, remote = self.window(jobs=({"jobs": [JOB]}, None))
- procs = QObject()
- self.addCleanup(procs.deleteLater)
-
- def follow(jid, offset):
- p = QProcess(procs)
- p.start("sleep", ["5"])
- return p
-
- remote.follow = follow
- w.poll_jobs()
- jobs = w.open_jobs()
- jobs.select(JOB["id"])
- self.assertIsNotNone(jobs.proc)
- w._close_tab(w.tabs.indexOf(jobs))
- QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value)
- end = time.monotonic() + 1
- while time.monotonic() < end: # let the killed process report in
- app.processEvents()
- self.assertIsNone(w.jobs_tab)
-
- def test_packages_tab(self):
- w, remote = self.window(**{"job-start": ({"id": "20260101-000001-slackrepo"}, None)})
- w.poll_jobs()
- tab = w.open_packages()
- self.assertEqual(tab.proxy.rowCount(), 4)
- tab.chips["failed"].setChecked(True)
- self.assertEqual(tab.proxy.rowCount(), 1)
- tab.chips["failed"].setChecked(False)
- started = []
- tab.start_requested.connect(started.append)
- tab.act("update")
- self.assertEqual(started, []) # nothing checked, no current row
- tab.table.setCurrentIndex(tab.proxy.index(1, 1))
- self.assertIn("<s>x</s>", tab.detail.text())
- tab.act("update")
- tab.model.checked.update({"nuclei", "curl"})
- tab.act("build")
- tab.update_all()
- self.assertEqual(started, [["slackrepo", "update", "--preview", "foo"],
- ["slackrepo", "build", "--preview", "curl", "nuclei"],
- ["slackrepo", "update", "--preview"]])
-
- def test_run_preview_with_signing(self):
- w, remote = self.window(jobs=({"jobs": [PREVIEW]}, None),
- **{"gpg-status": ({"unlocked": False, "key": "K"}, None),
- "gpg-unlock": lambda args, stdin: (
- (None, "bad passphrase") if stdin == b"wrong\n" else ({}, None)),
- "job-start": ({"id": "20260101-000001-slackrepo"}, None)})
- w.poll_jobs()
- jobs = w.open_jobs()
- jobs.select(PREVIEW["id"])
- self.assertTrue(jobs.run_btn.isVisibleTo(jobs))
- answers = iter(["wrong", "right"])
- jobs.ask_passphrase = lambda retry: next(answers)
- jobs.publish_box.setChecked(False)
- jobs.run_preview()
- self.assertIn(("gpg-unlock", b"wrong\n"), remote.calls)
- self.assertIn(("gpg-unlock", b"right\n"), remote.calls)
- self.assertEqual(remote.calls[-2], ("job-start", "slackrepo", "update", "--no-publish", "nuclei"))
-
- def test_signing_cancel_starts_nothing(self):
- w, remote = self.window(jobs=({"jobs": [PREVIEW]}, None),
- **{"gpg-status": ({"unlocked": False, "key": "K"}, None)})
- w.poll_jobs()
- jobs = w.open_jobs()
- jobs.select(PREVIEW["id"])
- jobs.ask_passphrase = lambda retry: None
- jobs.run_preview()
- self.assertFalse(any(c[0] == "job-start" for c in remote.calls))
-
- def test_key_indicator_and_tile_links(self):
- w, _ = self.window(**{"gpg-status": ({"unlocked": False, "key": "K"}, None)})
- w.poll_jobs()
- self.assertEqual(w.key.text(), "🔒 key locked")
- w.dashboard.tiles["Packages"].clicked.emit()
- self.assertIs(w.tabs.currentWidget(), w.packages_tab)
- w.dashboard.jobs.clicked.emit()
- self.assertIs(w.tabs.currentWidget(), w.jobs_tab)
-
- def test_log_carriage_return(self):
- w, _ = self.window()
- jobs = w.open_jobs()
- jobs.append_log("10%\r50%\r100%\ndone\r\n")
- self.assertEqual(jobs.log.toPlainText(), "100%\ndone\n")
-
-
-if __name__ == "__main__":
- unittest.main()
-````
-
-- [ ] **Step 2: Run them, expect failures**
-
-Run: `python3 -m unittest tests.test_gui </dev/null`
-Expected: `ImportError` for `sb_builder.hints` / `sb_builder.packages`. Nothing may appear on the desktop.
-
-- [ ] **Step 3: Hint rules** (create `sb_builder/hints.py`):
-
-````python
-# sb-builder - slackrepo hint interpretation
-# 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.
-
-"""What a slackrepo hint file does, the way slackrepo reads it.
-
-Rules from slackrepo's libexec/functions.d/parsefunctions.sh (hint and fixup
-departments) and slackrepo.hint(5). Pure functions, no Qt.
-"""
-
-import os
-import re
-
-KNOWN = {"SKIP", "VERSION", "OPTIONS", "GROUPADD", "USERADD", "NOWARNING", "NOWARNINGS",
- "ADDREQUIRES", "DELREQUIRES", "BUILDTIME", "CONFLICTS", "INSTALL", "NUMJOBS",
- "ANSWER", "CLEANUP", "PRAGMA", "SPECIAL", "ARCH", "DOWNLOAD", "MD5SUM", "SHA256SUM",
- "NODOWNLOAD"} # NODOWNLOAD: written by mkhint, shown as DOWNLOAD="no" by user decision
-PER_ARCH = re.compile(r"^(DOWNLOAD|MD5SUM|SHA256SUM)_\w+$")
-SHELLS = {"sh", "bash", "zsh", "ksh", "csh", "tcsh", "false", "nologin", "true"}
-
-PRAGMAS = {
- "python3": "force python3 support: adds python3 to the deps, sets PYTHON3=yes",
- "kernelmodule": "rebuild when the host kernel changes; depmod on uninstall",
- "kernel": "rebuild when the host kernel changes",
- "kernelversion": "uname reports $KERNEL as the kernel version",
- "multilib_ldflags": "patch the SlackBuild to set LDFLAGS on a multilib host",
- "wget": "download with a fake wget user agent",
- "curl": "download with a fake curl user agent",
- "download_basename": "symlink downloads to the URL's basename",
- "download_urldecode": "URL-decode DOWNLOAD values before use",
- "need_net": "network not blocked during the build",
- "need_X": "X display not blocked during the build",
- "noexport_ARCH": "set ARCH by patching the SlackBuild, not via the environment",
- "noexport_BUILD": "set BUILD by patching the SlackBuild, not via the environment",
- "noexport_TAG": "set TAG by patching the SlackBuild, not via the environment",
- "noremove": "do not uninstall packages before building",
- "nofakeroot": "build as root with sudo instead of fakeroot",
- "no_make_test": "comment out 'make test' in the SlackBuild",
- "stubs-32": "provide /usr/include/gnu/stubs-32.h during the build",
- "abstar": "patch the SlackBuild to use tar --absolute-names",
-}
-
-
-def _raw(v):
- return v["value"] if isinstance(v, dict) else v
-
-
-def pragma_meaning(p):
- if p in PRAGMAS:
- return PRAGMAS[p]
- if p.startswith("x86arch="):
- return f"on 32 bit x86, build with ARCH={p[8:]}"
- if p.startswith("unset_"):
- return f"unset {p[6:]} for the build"
- return "unknown pragma"
-
-
-def parse_groupadd(value, pkg):
- """[(gid, name)] like slackrepo: numeric field is the gid, other the name."""
- out = []
- for spec in value.split():
- gid, name = None, pkg
- for field in spec.split(":"):
- if field[:1].isdigit():
- gid = field
- elif field:
- name = field
- out.append((gid, name))
- return out
-
-
-def parse_useradd(value, pkg):
- """[dict(uid, name, group, home, shell, extra)] with slackrepo's defaults."""
- out = []
- for spec in value.split():
- u = {"uid": None, "name": pkg, "group": "", "home": "/dev/null",
- "shell": "/bin/false", "extra": []}
- for field in spec.split(":"):
- if field.startswith("-g"):
- u["group"] = field[2:]
- elif field.startswith("-d"):
- u["home"] = field[2:]
- elif field.startswith("-s"):
- u["shell"] = field[2:]
- elif field.startswith("-"):
- u["extra"].append(f"{field[:2]} {field[2:]}".strip())
- elif field.startswith("/"):
- # ponytail: slackrepo tests -x on the build host; guess by name instead
- key = "shell" if os.path.basename(field) in SHELLS else "home"
- u[key] = field
- elif field[:1].isdigit():
- u["uid"] = field
- elif field:
- u["name"] = field
- u["group"] = u["group"] or u["name"]
- out.append(u)
- return out
-
-
-def dependencies(hints, requires):
- """[(dep, mark)] as slackrepo computes them; mark in '', del, pragma, add, build."""
- deleted = set(hints.get("DELREQUIRES", "").split()) | {"%README%"}
- out = [(r, "del" if r in deleted else "") for r in requires]
- if "python3" in hints.get("PRAGMA", hints.get("SPECIAL", "")).split():
- out.append(("python3", "pragma"))
- out += [(r, "add") for r in hints.get("ADDREQUIRES", "").split()]
- out += [(r, "build") for r in hints.get("BUILDTIME", "").split()]
- return out
-
-
-def interpret(hints, pkg, info_version=None, requires=()):
- """Structured view of one package's hints.
-
- hints: {key: value or {"value": raw, "shell": True}} from the helper, or None.
- Returns {"skip", "sections": [(title, [(label, value)])], "deps",
- "conflicts", "ignored", "shell"}.
- """
- raw = hints or {}
- shell = sorted(k for k, v in raw.items() if isinstance(v, dict))
- h = {k: _raw(v) for k, v in raw.items()}
- view = {"skip": None, "sections": [], "deps": dependencies(h, list(requires)),
- "conflicts": h.get("CONFLICTS", "").split(), "shell": shell,
- "ignored": sorted(k for k in h if k not in KNOWN and not PER_ARCH.match(k))}
-
- skip = h.get("SKIP")
- if skip is not None and skip.lower() != "no":
- view["skip"] = "always skipped" if skip.lower() == "yes" else skip
-
- # version, downloads and checksums, resolved together as slackrepo does
- arch = h.get("ARCH")
- resolved = {}
- for var in ("DOWNLOAD", "MD5SUM", "SHA256SUM"):
- resolved[var] = h.get(f"{var}_{arch}") if arch and h.get(f"{var}_{arch}") else h.get(var)
- if h.get("VERSION"):
- resolved["MD5SUM"] = resolved["MD5SUM"] or "no"
- resolved["SHA256SUM"] = resolved["SHA256SUM"] or "no"
- rows = []
- if h.get("VERSION"):
- rows.append(("version", f"{info_version or '?'} → {h['VERSION']}"))
- if arch is not None:
- rows.append(("arch", arch or "(empty)"))
- if resolved["DOWNLOAD"] == "no" or h.get("NODOWNLOAD", "").lower() == "yes":
- rows.append(("download", "no download, the source must already be in the cache"))
- elif resolved["DOWNLOAD"]:
- rows += [("download", url) for url in resolved["DOWNLOAD"].split()]
- if resolved["MD5SUM"] == "no":
- rows.append(("md5sum", "not checked"))
- elif resolved["MD5SUM"]:
- rows.append(("md5sum", resolved["MD5SUM"]))
- if any(k.startswith("SHA256SUM") for k in h):
- rows.append(("sha256sum", "not checked" if resolved["SHA256SUM"] == "no"
- else resolved["SHA256SUM"]))
- if rows:
- view["sections"].append(("Version override", rows))
-
- rows = [("option", o) for o in h.get("OPTIONS", "").split()]
- if "NUMJOBS" in h:
- rows.append(("make jobs", h["NUMJOBS"]))
- if h.get("INSTALL"):
- rows.append(("install after build", "no" if h["INSTALL"][:1] in "Nn0" else "yes"))
- if "ANSWER" in h:
- rows.append(("answers to the build", h["ANSWER"].replace("\\n", " ⏎ ")))
- nowarning = h.get("NOWARNING") or h.get("NOWARNINGS")
- if nowarning:
- rows.append(("hide warnings matching", nowarning))
- if rows:
- view["sections"].append(("Build settings", rows))
-
- pragmas = h.get("PRAGMA") or h.get("SPECIAL") or ""
- if pragmas:
- view["sections"].append(("Pragmas", [(p, pragma_meaning(p)) for p in pragmas.split()]))
-
- rows = [("group", f"{name} (gid {gid or '?'})") for gid, name in parse_groupadd(h.get("GROUPADD", ""), pkg)]
- for u in parse_useradd(h.get("USERADD", ""), pkg):
- extra = f", {' '.join(u['extra'])}" if u["extra"] else ""
- rows.append(("user", f"{u['name']} (uid {u['uid'] or '?'}), group {u['group']}, "
- f"home {u['home']}, shell {u['shell']}{extra}"))
- if rows:
- view["sections"].append(("Host changes", rows))
-
- if h.get("CLEANUP"):
- view["sections"].append(("On uninstall", [("run", h["CLEANUP"])]))
- return view
-````
-
-- [ ] **Step 4: Packages tab** (create `sb_builder/packages.py`):
-
-````python
-# sb-builder - Packages 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.
-
-"""Packages tab: search the inventory, read a package's hints, start slackrepo previews."""
-
-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 . import hints as hintlib
-from .dashboard import STATUS
-from .fmt import NA, fmt_time
-
-OVERLAYS = ("personal", "pentesting")
-STATUSES = ("ok", "failed", "never", "outdated")
-COLUMNS = ("", "name", "category", "version", "built", "status", "maintainer")
-STATUS_COLOR = {"ok": STATUS["ok"], "failed": STATUS["failed"], "outdated": STATUS["aborted"]}
-FIELDS = {"cat": "category", "maint": "maintainer", "req": "requires", "status": "status"}
-
-
-def status(row):
- # ponytail: outdated compares versions only; slackrepo also rebuilds on
- # SlackBuild or dependency changes, which its preview shows
- if not row["built_version"] and not row["result"]:
- return "never"
- if row["result"] in ("failed", "bad", "aborted"):
- return "failed"
- if row["built_version"] != row["version"]:
- return "outdated"
- return "ok"
-
-
-def overlay(row):
- cat = row["path"].split("/", 1)[0]
- return cat if cat in OVERLAYS else "upstream"
-
-
-def parse_query(text):
- """[(field or None, term)], lower-cased; unknown prefixes count as plain text."""
- terms = []
- for word in text.lower().split():
- field, sep, term = word.partition(":")
- if sep and field in FIELDS and term:
- terms.append((FIELDS[field], term))
- else:
- terms.append((None, word))
- return terms
-
-
-def matches(row, terms, overlays=(), statuses=()):
- """All terms match, and the row is in a toggled chip of each non-empty group."""
- if overlays and overlay(row) not in overlays:
- return False
- if statuses and row["status"] not in statuses:
- return False
- for field, term in terms:
- if field is None:
- hay = f"{row['name']} {row['summary'] or ''}"
- elif field == "requires":
- hay = " ".join(row["requires"])
- else:
- hay = row[field] or ""
- if term not in hay.lower():
- return False
- return True
-
-
-def load_rows(inventory):
- rows = []
- for values in inventory["rows"]:
- row = dict(zip(inventory["columns"], values))
- row["category"], row["name"] = row["path"].split("/", 1)
- row["status"] = status(row)
- rows.append(row)
- return rows
-
-
-class Model(QAbstractTableModel):
- def __init__(self):
- super().__init__()
- self.rows = []
- self.checked = set() # package names
-
- def set_rows(self, rows):
- self.beginResetModel()
- self.rows = rows
- names = {r["name"] for r in rows}
- self.checked &= names
- self.endResetModel()
-
- def rowCount(self, parent=QModelIndex()):
- return 0 if parent.isValid() else len(self.rows)
-
- def columnCount(self, parent=QModelIndex()):
- return len(COLUMNS)
-
- def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
- if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
- return COLUMNS[section]
- return None
-
- def flags(self, index):
- f = Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
- return f | Qt.ItemFlag.ItemIsUserCheckable if index.column() == 0 else f
-
- def data(self, index, role=Qt.ItemDataRole.DisplayRole):
- row, col = self.rows[index.row()], index.column()
- if role == Qt.ItemDataRole.CheckStateRole and col == 0:
- return Qt.CheckState.Checked if row["name"] in self.checked else Qt.CheckState.Unchecked
- if role == Qt.ItemDataRole.DisplayRole:
- return (None, row["name"], row["category"], row["version"],
- row["built_version"] or "", row["status"], row["maintainer"] or "")[col]
- if role == Qt.ItemDataRole.ForegroundRole and col == 5 and row["status"] in STATUS_COLOR:
- return QColor(STATUS_COLOR[row["status"]])
- if role == Qt.ItemDataRole.UserRole:
- return row
- return None
-
- def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
- if role != Qt.ItemDataRole.CheckStateRole or index.column() != 0:
- return False
- name = self.rows[index.row()]["name"]
- if Qt.CheckState(value) == Qt.CheckState.Checked:
- self.checked.add(name)
- else:
- self.checked.discard(name)
- self.dataChanged.emit(index, index)
- return True
-
-
-class Filter(QSortFilterProxyModel):
- def __init__(self):
- super().__init__()
- self.terms, self.overlays, self.statuses = [], set(), set()
-
- def update(self, text, overlays, statuses):
- self.terms, self.overlays, self.statuses = parse_query(text), overlays, statuses
- self.invalidateFilter()
-
- def filterAcceptsRow(self, source_row, _parent):
- row = self.sourceModel().rows[source_row]
- return matches(row, self.terms, self.overlays, self.statuses)
-
-
-def chip(text):
- b = QPushButton(text, checkable=True)
- b.setStyleSheet("QPushButton { border: 1px solid palette(mid); border-radius: 10px;"
- " padding: 2px 10px; }"
- "QPushButton:checked { background: palette(highlight);"
- " color: palette(highlighted-text); }")
- return b
-
-
-def detail_html(row):
- """The detail pane for one package: facts, dependencies, then its hints."""
- view = hintlib.interpret(row["hints"], row["name"], row["info_version"], row["requires"])
- e = html.escape
- out = [f"<h3>{e(row['name'])}</h3><p>{e(row['path'])}"
- f"{' · ' + e(row['summary']) if row['summary'] else ''}</p>"]
- if view["skip"]:
- out.append(f'<p style="color:{STATUS["aborted"]}"><b>&#9888; Skipped by slackrepo:</b> '
- f"{e(view['skip'])}</p>")
- facts = [(".info version", row["info_version"]), ("hint version", row["hint_version"]),
- ("built version", row["built_version"]),
- ("built", fmt_time(row["built_time"]) if row["built_time"] else None),
- ("last result", row["result"]), ("maintainer", row["maintainer"])]
- out.append(table([(k, v or NA) for k, v in facts]))
- marks = {"": "", "del": "<s>{}</s> <small>(DELREQUIRES)</small>",
- "add": "+{} <small>(ADDREQUIRES)</small>", "build": "+{} <small>(build time only)</small>",
- "pragma": "+{} <small>(pragma)</small>"}
- deps = [(marks[m] or "{}").format(e(d)) for d, m in view["deps"]]
- out.append(f"<h4>Dependencies</h4><p>{' &nbsp; '.join(deps) or 'none'}</p>")
- if view["conflicts"]:
- out.append(f"<p>Conflicts: {e(' '.join(view['conflicts']))}</p>")
- for title, rows in view["sections"]:
- out.append(f"<h4>{e(title)}</h4>" + table(rows))
- if view["shell"]:
- out.append(f"<p><small>Expanded by the shell at build time, shown as written: "
- f"{e(', '.join(view['shell']))}</small></p>")
- if view["ignored"]:
- out.append(f'<p style="color:{STATUS["aborted"]}">Not read by slackrepo: '
- f"{e(', '.join(view['ignored']))}</p>")
- if row["hint_path"]:
- out.append(f"<p><small>hint: {e(row['hint_path'])}</small></p>")
- return "".join(out)
-
-
-def table(rows):
- cells = "".join(f"<tr><td style='padding-right:12px'><i>{html.escape(str(k))}</i></td>"
- f"<td>{html.escape(str(v))}</td></tr>" for k, v in rows)
- return f"<table>{cells}</table>"
-
-
-class PackagesTab(QWidget):
- start_requested = pyqtSignal(list) # job-start arguments
-
- def __init__(self, remote, parent=None):
- super().__init__(parent)
- self.remote = remote
- self.model = Model()
- self.proxy = Filter()
- self.proxy.setSourceModel(self.model)
-
- self.query = QLineEdit(placeholderText="search (cat: maint: req: status:)")
- self.query.textChanged.connect(self.refilter)
- self.chips = {}
- chips = QHBoxLayout()
- for group in (("personal", "pentesting", "upstream"), STATUSES):
- for name in group:
- self.chips[name] = chip(name)
- self.chips[name].toggled.connect(self.refilter)
- chips.addWidget(self.chips[name])
- chips.addSpacing(16)
- chips.addStretch()
- self.refresh_btn = QPushButton("Refresh")
- self.refresh_btn.clicked.connect(self.reload)
- chips.addWidget(self.refresh_btn)
-
- self.table = QTableView(sortingEnabled=True)
- self.table.setModel(self.proxy)
- self.table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
- self.table.setSelectionMode(QTableView.SelectionMode.SingleSelection)
- self.table.verticalHeader().hide()
- self.table.horizontalHeader().setStretchLastSection(True)
- for col, width in enumerate((28, 220, 110, 120, 120, 80)):
- self.table.setColumnWidth(col, width)
- self.table.selectionModel().currentRowChanged.connect(self.show_detail)
- self.detail = QLabel(textFormat=Qt.TextFormat.RichText, wordWrap=True,
- alignment=Qt.AlignmentFlag.AlignTop)
- self.detail.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
- self.detail.setContentsMargins(12, 8, 12, 8)
- scroll = QScrollArea(widgetResizable=True)
- scroll.setWidget(self.detail)
- split = QSplitter()
- split.addWidget(self.table)
- split.addWidget(scroll)
- split.setSizes([700, 400])
-
- self.count = QLabel()
- bar = QHBoxLayout()
- bar.addWidget(self.count)
- for label, slot in (("Select visible", self.select_visible), ("Clear", self.clear)):
- b = QPushButton(label)
- b.clicked.connect(slot)
- bar.addWidget(b)
- bar.addStretch()
- for action in ("build", "update", "rebuild", "remove"):
- b = QPushButton(action.capitalize())
- b.clicked.connect(lambda _c, a=action: self.act(a))
- bar.addWidget(b)
- for label, slot in (("Update all", self.update_all), ("Publish", self.publish)):
- b = QPushButton(label)
- b.clicked.connect(slot)
- bar.addWidget(b)
-
- layout = QVBoxLayout(self)
- layout.addWidget(self.query)
- layout.addLayout(chips)
- layout.addWidget(split)
- layout.addLayout(bar)
- self.model.dataChanged.connect(lambda *_: self.update_count())
- self.update_count()
-
- def reload(self):
- self.count.setText("loading…")
- self.remote.call(self._loaded, "inventory")
-
- def _loaded(self, data, err):
- if err:
- self.count.setText(f"inventory failed: {err}")
- return
- self.model.set_rows(load_rows(data))
- self.update_count()
-
- def refilter(self):
- on = {n for n, b in self.chips.items() if b.isChecked()}
- self.proxy.update(self.query.text(), on - set(STATUSES), on & set(STATUSES))
- self.update_count()
-
- def update_count(self):
- self.count.setText(f"{self.proxy.rowCount():,} shown · {len(self.model.checked)} selected")
-
- def select_visible(self):
- for i in range(self.proxy.rowCount()):
- self.model.checked.add(self.proxy.index(i, 0).data(Qt.ItemDataRole.UserRole)["name"])
- self.model.layoutChanged.emit()
- self.update_count()
-
- def clear(self):
- self.model.checked.clear()
- self.model.layoutChanged.emit()
- self.update_count()
-
- def targets(self):
- """Checked packages, else the current row; sorted names."""
- if self.model.checked:
- return sorted(self.model.checked)
- current = self.table.currentIndex()
- return [current.data(Qt.ItemDataRole.UserRole)["name"]] if current.isValid() else []
-
- def act(self, action):
- names = self.targets()
- if names:
- self.start_requested.emit(["slackrepo", action, "--preview", *names])
-
- def update_all(self):
- self.start_requested.emit(["slackrepo", "update", "--preview"])
-
- def publish(self):
- if QMessageBox.question(self, "Publish", "Push the package repository to the mirror now?") \
- == QMessageBox.StandardButton.Yes:
- self.start_requested.emit(["publish"])
-
- 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 "")
-````
-
-- [ ] **Step 5: Version, config, transport**
-
-Replace `sb_builder/__init__.py`:
-
-````python
-# sb-builder - GUI package
-# 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.
-
-"""sb-builder: Qt6 GUI driving the SlackBuild tooling on a build VM."""
-
-VERSION = "0.2.0" # must match remote/sbb-remote VERSION
-````
-
-Replace `sb_builder/config.py`:
-
-````python
-# sb-builder - GUI configuration
-# 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.
-
-"""GUI configuration: built-in defaults overlaid by the user's TOML file."""
-
-import copy
-import os
-import tomllib
-
-DEFAULTS = {
- "remote": {"target": "buildsystem", "ssh_options": []},
- "refresh": {"dashboard": 30, "jobs": 5},
- "notify": {"enabled": True, "events": ["finished", "failed", "killed", "lost", "vm-up"]},
- "vm": {"libvirt_uri": "qemu:///system", "domain": "buildsystem", "autostart": False},
- "builds": {"publish": True},
-}
-
-
-def path():
- base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
- return os.path.join(base, "sb-builder", "config.toml")
-
-
-def load(p=None):
- """Return (config, error). On any problem: the defaults plus the error text."""
- p = p or path()
- try:
- with open(p, "rb") as f:
- data = tomllib.load(f)
- except FileNotFoundError:
- return copy.deepcopy(DEFAULTS), None
- except (OSError, tomllib.TOMLDecodeError) as e:
- return copy.deepcopy(DEFAULTS), f"{p}: {e}"
- cfg = copy.deepcopy(DEFAULTS)
- for sec, keys in data.items():
- if sec not in DEFAULTS or not isinstance(keys, dict):
- return copy.deepcopy(DEFAULTS), f"{p}: unknown section [{sec}]"
- for k, v in keys.items():
- if k not in DEFAULTS[sec]:
- return copy.deepcopy(DEFAULTS), f"{p}: unknown key {sec}.{k}"
- want = type(DEFAULTS[sec][k])
- if type(v) is not want:
- return copy.deepcopy(DEFAULTS), f"{p}: {sec}.{k}: expected {want.__name__}"
- cfg[sec][k] = v
- return cfg, None
-````
-
-Replace `config.example.toml`:
-
-````toml
-# sb-builder GUI config: ~/.config/sb-builder/config.toml
-# Every key is optional; these are the defaults.
-
-[remote]
-target = "buildsystem" # any ssh destination: host alias, user@host
-ssh_options = [] # extra ssh args, e.g. ["-p", "2222"]
-
-[refresh]
-dashboard = 30 # seconds, while the Dashboard tab is visible
-jobs = 5 # seconds; doubles up to 30 while the VM is down
-
-[notify]
-enabled = true
-events = ["finished", "failed", "killed", "lost", "vm-up"]
-
-[vm]
-libvirt_uri = "qemu:///system"
-domain = "buildsystem" # libvirt domain of the VM; "" disables VM control
-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
-````
-
-Replace `sb_builder/remote.py`:
-
-````python
-# sb-builder - ssh and virsh transport
-# 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.
-
-"""The only code that talks to the VM: ssh to sbb-remote, and local virsh."""
-
-import json
-import os
-import shlex
-
-from PyQt6.QtCore import QObject, QProcess, QStandardPaths
-
-DOWN = "ssh: connection failed"
-MISSING = "sbb-remote is not installed on the VM"
-
-
-def parse(rc, out, err):
- """Map one sbb-remote call to (data, error)."""
- if rc == 255:
- return None, DOWN
- if rc == 127:
- return None, MISSING
- try:
- data = json.loads(out)
- except ValueError:
- return None, err.decode(errors="replace").strip() or f"exit {rc}"
- if rc != 0 or "error" in data:
- return None, data.get("error", f"exit {rc}")
- return data, None
-
-
-class Remote(QObject):
- def __init__(self, cfg, parent=None):
- super().__init__(parent)
- self.cfg = cfg # shared dict, updated in place on config reload
- run = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.RuntimeLocation)
- self.control = os.path.join(run or "/tmp", "sbb-%C")
-
- def ssh_args(self, *args):
- r = self.cfg["remote"]
- # ssh joins its args into a remote shell command: quote every one
- return ["-o", "ControlMaster=auto", "-o", f"ControlPath={self.control}",
- "-o", "ControlPersist=10m", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
- *r["ssh_options"], r["target"], "sbb-remote", *map(shlex.quote, args)]
-
- def call(self, cb, *args, stdin=None):
- """Run sbb-remote <args>, then cb(data, error). stdin: bytes for the helper."""
- self._run("ssh", self.ssh_args(*args), lambda rc, out, err: cb(*parse(rc, out, err)),
- stdin)
-
- def virsh(self, cb, *args):
- """Run local virsh against the configured libvirt URI, then cb(rc, text)."""
- argv = ["-c", self.cfg["vm"]["libvirt_uri"], *args]
- self._run("virsh", argv,
- lambda rc, out, err: cb(rc, (out or err).decode(errors="replace").strip()))
-
- def follow(self, jid, offset):
- """Start job-follow; the caller wires readyReadStandardOutput and finished."""
- p = QProcess(self)
- p.start("ssh", self.ssh_args("job-follow", jid, str(offset)))
- return p
-
- def _run(self, prog, argv, cb, stdin=None):
- p = QProcess(self)
-
- def done(rc, _status=None):
- cb(rc, bytes(p.readAllStandardOutput()), bytes(p.readAllStandardError()))
- p.deleteLater()
-
- p.finished.connect(done)
- p.errorOccurred.connect(
- lambda e: e == QProcess.ProcessError.FailedToStart and done(-1))
- p.start(prog, argv)
- if stdin is not None:
- p.write(stdin)
- p.closeWriteChannel()
-````
-
-- [ ] **Step 6: Jobs tab, Dashboard, main window**
-
-Replace `sb_builder/jobs.py`:
-
-````python
-# sb-builder - Jobs 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.
-
-"""Jobs tab: job list, live log through job-follow, start and kill."""
-
-import codecs
-import re
-
-from PyQt6.QtCore import Qt, pyqtSignal
-from PyQt6.QtGui import QFontDatabase, QTextCursor
-from PyQt6.QtWidgets import (QCheckBox, QHBoxLayout, QInputDialog, QLineEdit, QListWidget,
- QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit, QPushButton,
- QSplitter, QToolButton, QVBoxLayout, QWidget)
-
-from .fmt import describe
-
-ANSI = re.compile(rb"\x1b\[[0-9;?]*[A-Za-z]")
-SHADOW_PLAN = re.compile(r"^(un)?shadow: ", re.M)
-ACTIVE = ("starting", "running")
-BAD = ("failed", "killed", "lost")
-
-
-class JobsTab(QWidget):
- refresh_requested = pyqtSignal()
-
- def __init__(self, remote, publish=True, parent=None):
- super().__init__(parent)
- self.remote = remote
- self.pending = ()
- self.jobs = {}
- self.current = None
- self.offset = 0
- self.proc = None
- self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
-
- self.start_menu = QMenu(self)
- start = QToolButton(text="Start", popupMode=QToolButton.ToolButtonPopupMode.InstantPopup)
- start.setMenu(self.start_menu)
- self.apply_btn = QPushButton("Apply shadowing")
- self.apply_btn.clicked.connect(lambda: self.confirm_start(
- "Apply the shadowing plan to the repo (sbshadow --yes)?", "sbshadow", "--yes"))
- self.apply_btn.hide()
- self.publish_box = QCheckBox("publish", checked=publish)
- self.publish_box.setToolTip("Push to the mirror when the build ends; "
- "unticked, use Publish later")
- self.run_btn = QPushButton("Run it")
- self.run_btn.clicked.connect(self.run_preview)
- self.kill_btn = QPushButton("Kill")
- self.kill_btn.clicked.connect(self.kill)
- bar = QHBoxLayout()
- for w in (start, self.apply_btn, self.run_btn, self.publish_box):
- bar.addWidget(w)
- bar.addStretch()
- bar.addWidget(self.kill_btn)
-
- self.list = QListWidget()
- self.list.currentItemChanged.connect(self._on_select)
- self.log = QPlainTextEdit(readOnly=True)
- self.log.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
- self.log.setMaximumBlockCount(50000) # ponytail: older lines drop off; the full log stays on the VM
- split = QSplitter()
- split.addWidget(self.list)
- split.addWidget(self.log)
- split.setSizes([300, 700])
- layout = QVBoxLayout(self)
- layout.addLayout(bar)
- layout.addWidget(split)
- self.set_overlays([])
-
- def set_overlays(self, overlays):
- m = self.start_menu
- m.clear()
- for o in overlays:
- m.addAction(f"sbpull {o} (preview)", lambda o=o: self.start("sbpull", o, "--no"))
- m.addSeparator()
- m.addAction("sbshadow (preview)", lambda: self.start("sbshadow", "--no"))
- m.addAction("sbshadow (apply)…", lambda: self.confirm_start(
- "Run sbshadow --yes on the repo?", "sbshadow", "--yes"))
-
- def set_jobs(self, jobs):
- self.jobs = {j["id"]: j for j in jobs}
- self.list.blockSignals(True)
- self.list.clear()
- for j in jobs:
- item = QListWidgetItem(describe(j))
- item.setData(Qt.ItemDataRole.UserRole, j["id"])
- if j["status"] in BAD:
- item.setForeground(Qt.GlobalColor.red)
- self.list.addItem(item)
- if j["id"] == self.current:
- self.list.setCurrentItem(item)
- self.list.blockSignals(False)
- j = self.jobs.get(self.current)
- if j and j["status"] in ACTIVE and self.proc is None:
- self._follow() # stream dropped while the job kept running: resume at offset
- self._update_buttons()
-
- def select(self, jid):
- for i in range(self.list.count()):
- if self.list.item(i).data(Qt.ItemDataRole.UserRole) == jid:
- self.list.setCurrentItem(self.list.item(i))
- return
- self._show(jid) # not listed yet: just started
-
- def append_log(self, text):
- cur = self.log.textCursor()
- cur.movePosition(QTextCursor.MoveOperation.End)
- # ponytail: a \r split across two chunks is printed as-is
- for i, part in enumerate(text.replace("\r\n", "\n").split("\r")):
- if i: # carriage return: the next text replaces the current line
- cur.movePosition(QTextCursor.MoveOperation.StartOfBlock,
- QTextCursor.MoveMode.KeepAnchor)
- cur.removeSelectedText()
- cur.insertText(part)
- self.log.setTextCursor(cur)
-
- def stop(self):
- if self.proc:
- p, self.proc = self.proc, None
- # disconnect first: finished arrives later, maybe after this tab is deleted
- p.readyReadStandardOutput.disconnect()
- p.finished.disconnect()
- p.kill()
- p.deleteLater()
-
- def start(self, *args):
- self.pending = args
- self.remote.call(self._started, "job-start", *args)
-
- def confirm_start(self, question, *args):
- if QMessageBox.question(self, "Confirm", question) == QMessageBox.StandardButton.Yes:
- self.start(*args)
-
- def kill(self):
- if QMessageBox.question(self, "Kill job", f"Send SIGTERM to {self.current}?") \
- == QMessageBox.StandardButton.Yes:
- self.remote.call(lambda _d, err: err and QMessageBox.warning(self, "Kill failed", err),
- "job-kill", self.current)
-
- def run_preview(self):
- """Start the previewed slackrepo run for real."""
- args = [a for a in self.jobs[self.current]["args"] if a != "--preview"]
- if not self.publish_box.isChecked():
- args.insert(1, "--no-publish")
- self.start_signed("slackrepo", *args)
-
- def ask_passphrase(self, retry):
- text, ok = QInputDialog.getText(
- self, "Signing key", ("Wrong passphrase, try again." if retry else
- "Passphrase of the package signing key:"),
- QLineEdit.EchoMode.Password)
- return text if ok else None
-
- def start_signed(self, *args, retry=False):
- """Unlock the signing key first when it is locked, then start the job."""
- def status(data, err):
- if err:
- QMessageBox.warning(self, "Signing key", err)
- elif data["unlocked"] is False:
- self._unlock(args, retry)
- else:
- self.start(*args)
- self.remote.call(status, "gpg-status")
-
- def _unlock(self, args, retry):
- passphrase = self.ask_passphrase(retry)
- if passphrase is None:
- return
-
- def unlocked(_data, err):
- if err == "bad passphrase":
- self._unlock(args, True)
- elif err:
- QMessageBox.warning(self, "Signing key", err)
- else:
- self.start(*args)
- self.remote.call(unlocked, "gpg-unlock", stdin=passphrase.encode() + b"\n")
-
- def _started(self, data, err):
- if err == "signing key locked":
- self.start_signed(*self.pending) # locked since the last check: ask again
- return
- if err:
- QMessageBox.warning(self, "Start failed", err)
- return
- if "busy" in data:
- box = QMessageBox(QMessageBox.Icon.Information, "Busy",
- f"busy: {data['busy']}", parent=self)
- show = box.addButton("Show job", QMessageBox.ButtonRole.AcceptRole)
- box.addButton(QMessageBox.StandardButton.Close)
- box.exec()
- if box.clickedButton() is show and data["busy"]:
- self.select(data["busy"])
- return
- self.select(data["id"])
- self.refresh_requested.emit()
-
- def _on_select(self, item, _prev):
- jid = item.data(Qt.ItemDataRole.UserRole) if item else None
- if jid != self.current:
- self._show(jid)
-
- def _show(self, jid):
- self.stop()
- self.current, self.offset = jid, 0
- self.decoder.reset()
- self.log.clear()
- if jid:
- self._follow()
- self._update_buttons()
-
- def _follow(self):
- jid = self.current
- p = self.proc = self.remote.follow(jid, self.offset)
- p.readyReadStandardOutput.connect(lambda: self._chunk(p, jid))
- p.finished.connect(lambda *_: self._ended(p))
-
- def _chunk(self, p, jid):
- data = bytes(p.readAllStandardOutput())
- if jid != self.current:
- return
- self.offset += len(data)
- self.append_log(self.decoder.decode(ANSI.sub(b"", data)))
-
- def _ended(self, p):
- if p is self.proc:
- self.proc = None
- p.deleteLater()
- self._update_buttons()
-
- def _update_buttons(self):
- j = self.jobs.get(self.current)
- self.kill_btn.setEnabled(bool(j) and j["status"] == "running")
- self.apply_btn.setVisible(
- bool(j) and j["status"] == "ok" and "--no" in j["args"]
- and bool(SHADOW_PLAN.search(self.log.toPlainText())))
- preview = bool(j) and j["kind"] == "slackrepo" and j["status"] == "ok" \
- and "--preview" in j["args"]
- self.run_btn.setVisible(preview)
- self.publish_box.setVisible(preview)
-````
-
-Replace `sb_builder/dashboard.py`:
-
-````python
-# sb-builder - Dashboard 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.
-
-"""Dashboard tab: KPI tiles, slackrepo results bar, recent jobs, from stats and jobs."""
-
-import html
-import time
-
-from PyQt6.QtCore import QEvent, QRectF, Qt, pyqtSignal
-from PyQt6.QtGui import QColor, QFont, QPainter, QPalette
-from PyQt6.QtWidgets import (QFrame, QGridLayout, QHBoxLayout, QLabel, QPushButton,
- QToolTip, QVBoxLayout, QWidget)
-
-from .fmt import ICON, NA, fmt_age, fmt_bytes, fmt_duration, fmt_time, job_title
-
-# reserved status colors (dataviz reference palette); always shown with a text label
-STATUS = {"ok": "#0ca30c", "aborted": "#fab219", "bad": "#ec835a", "failed": "#d03b3b",
- "killed": "#fab219", "lost": "#ec835a"}
-GAP = 2 # px of surface between bar segments
-
-
-def num(n):
- return NA if n is None else f"{n:,}"
-
-
-def muted(label):
- label.setForegroundRole(QPalette.ColorRole.PlaceholderText)
- return label
-
-
-class Card(QFrame):
- """Rounded panel with a muted title; content goes into self.body."""
-
- clicked = pyqtSignal()
-
- def __init__(self, title):
- super().__init__(objectName="card")
- self.body = QVBoxLayout(self)
- self.body.setContentsMargins(16, 12, 16, 14)
- self.body.addWidget(muted(QLabel(title)))
-
- def link(self, slot):
- """Make the whole card open something."""
- self.clicked.connect(slot)
- self.setCursor(Qt.CursorShape.PointingHandCursor)
-
- def mouseReleaseEvent(self, event):
- if event.button() == Qt.MouseButton.LeftButton:
- self.clicked.emit()
- super().mouseReleaseEvent(event)
-
-
-class Tile(Card):
- """Stat tile: title, one large value, a muted detail line."""
-
- def __init__(self, title):
- super().__init__(title)
- self.value = QLabel(NA)
- f = self.value.font()
- f.setPointSizeF(f.pointSizeF() * 2.2)
- f.setWeight(QFont.Weight.DemiBold)
- self.value.setFont(f)
- self.detail = muted(QLabel())
- self.detail.setWordWrap(True)
- self.body.addWidget(self.value)
- self.body.addWidget(self.detail)
- self.body.addStretch()
-
- def set(self, value, detail=""):
- self.value.setText(value)
- self.detail.setText(detail)
-
-
-class StackedBar(QWidget):
- """Horizontal part-to-whole bar; hover a segment for its label and count."""
-
- def __init__(self):
- super().__init__()
- self.parts = [] # (label, count, QColor)
- self.setFixedHeight(14)
-
- def set_parts(self, parts):
- self.parts = [p for p in parts if p[1]]
- self.update()
-
- def rects(self):
- total = sum(c for _, c, _ in self.parts)
- if not total:
- return []
- room = self.width() - GAP * (len(self.parts) - 1)
- out, x = [], 0.0
- for label, count, color in self.parts:
- w = max(2.0, room * count / total)
- out.append((QRectF(x, 0, w, self.height()), label, count, color))
- x += w + GAP
- return out
-
- def paintEvent(self, _event):
- p = QPainter(self)
- p.setRenderHint(QPainter.RenderHint.Antialiasing)
- p.setPen(Qt.PenStyle.NoPen)
- for rect, _label, _count, color in self.rects():
- p.setBrush(color)
- p.drawRoundedRect(rect, 4, 4)
-
- def event(self, e):
- if e.type() == QEvent.Type.ToolTip:
- hit = next((f"{label}: {count:,}" for rect, label, count, _ in self.rects()
- if rect.contains(e.position())), None)
- if hit:
- QToolTip.showText(e.globalPosition().toPoint(), hit, self)
- else:
- QToolTip.hideText()
- return True
- return super().event(e)
-
-
-class Dashboard(QWidget):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setStyleSheet("QFrame#card { background: palette(base);"
- " border: 1px solid palette(mid); border-radius: 10px; }")
- self.tiles = {name: Tile(name) for name in ("Packages", "Built", "Last pull", "VM load")}
-
- self.results = Card("slackrepo results")
- self.bar = StackedBar()
- self.legend = QLabel(NA)
- self.legend.setTextFormat(Qt.TextFormat.RichText)
- self.legend.setWordWrap(True)
- self.results.body.addWidget(self.bar)
- self.results.body.addWidget(self.legend)
- self.results.body.addStretch()
-
- self.orphans = Card("SBo orphans")
- self.orphans.body.addWidget(muted(QLabel("coming in phase 5")))
- self.orphans.body.addStretch()
-
- self.jobs = Card("Recent jobs")
- self.jobs_list = QLabel("no jobs yet")
- self.jobs_list.setTextFormat(Qt.TextFormat.RichText)
- self.jobs.body.addWidget(self.jobs_list)
- self.jobs.body.addStretch()
-
- self.stale = muted(QLabel())
- self.refresh_btn = QPushButton("Refresh")
- top = QHBoxLayout()
- top.addWidget(self.stale)
- top.addStretch()
- top.addWidget(self.refresh_btn)
-
- grid = QGridLayout()
- grid.setSpacing(12)
- for i, tile in enumerate(self.tiles.values()):
- grid.addWidget(tile, 0, i)
- grid.addWidget(self.results, 1, 0, 1, 3)
- grid.addWidget(self.orphans, 1, 3)
- grid.addWidget(self.jobs, 2, 0, 1, 4)
- grid.setRowStretch(2, 1)
- layout = QVBoxLayout(self)
- layout.setContentsMargins(16, 12, 16, 16)
- layout.addLayout(top)
- layout.addLayout(grid)
-
- def cards(self):
- return [*self.tiles.values(), self.results, self.orphans, self.jobs]
-
- def show_stats(self, s):
- p, sr, sync = s.get("packages"), s.get("slackrepo"), s.get("sync") or {}
- t = self.tiles
- if p is None:
- t["Packages"].set(NA)
- else:
- extra = " · ".join(f"{num(v)}\u00a0{k}" for k, v in p.items() if k != "total")
- t["Packages"].set(num(p["total"]), extra)
- if sr is None:
- t["Built"].set(NA, "no slackrepo database")
- else:
- never = "" if p is None else f" · {num(p['total'] - sr['built'])} never built"
- t["Built"].set(num(sr["built"]), f"of {num(p and p['total'])}{never}")
- known = [ts for ts in sync.values() if ts is not None]
- t["Last pull"].set(fmt_age(min(known)) if known else NA,
- " · ".join(f"{o} {fmt_duration(None if ts is None else time.time() - ts)}"
- for o, ts in sync.items()))
- load, disk = s.get("load"), s.get("disk_free") or {}
- t["VM load"].set(NA if load is None else f"{load[0]:.2f}",
- f"up {fmt_duration(s.get('uptime'))} · {fmt_bytes(disk.get('repo'))} free")
- self._show_results(sr)
-
- def _show_results(self, sr):
- if sr is None:
- self.bar.set_parts([])
- self.legend.setText(NA)
- return
- gray = self.palette().color(QPalette.ColorRole.PlaceholderText) # Mid can match the background
- order = sorted(sr["results"].items(),
- key=lambda kv: (list(STATUS).index(kv[0]) if kv[0] in STATUS else 99, kv[0]))
- self.bar.set_parts([(k, v, QColor(STATUS.get(k)) if k in STATUS else gray) for k, v in order])
- self.legend.setText("&nbsp;&nbsp;&nbsp;".join(
- f'<span style="color:{STATUS.get(k, gray.name())}">&#9632;</span> {html.escape(k)} {num(v)}'
- for k, v in order))
-
- def show_jobs(self, jobs):
- rows = []
- for j in jobs[:10]:
- color = STATUS.get(j["status"])
- icon = ICON.get(j["status"], "?")
- if color:
- icon = f'<span style="color:{color}">{icon}</span>'
- dur = None if "start" not in j else j.get("end", time.time()) - j["start"]
- rows.append(f"<tr><td>{icon}&nbsp;</td><td>{html.escape(job_title(j))}&nbsp;&nbsp;</td>"
- f"<td>{j['status']}&nbsp;&nbsp;</td>"
- f"<td>{fmt_time(j.get('start', j['created']))}&nbsp;&nbsp;</td>"
- f"<td>{fmt_duration(dur)}</td></tr>")
- self.jobs_list.setText(f"<table>{''.join(rows)}</table>" if rows else "no jobs yet")
-
- def set_stale(self, since):
- """Grey the cards when the VM stopped answering; None clears it."""
- for card in self.cards():
- card.setEnabled(since is None)
- self.stale.setText("" if since is None else
- f"stale since {time.strftime('%H:%M', time.localtime(since))}")
-````
-
-Replace `sb_builder/mainwindow.py`:
-
-````python
-# sb-builder - main window
-# 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.
-
-"""Main window: tabs, status bar, connection polling, VM control, notifications."""
-
-import time
-
-from PyQt6.QtCore import QMetaType, QObject, QTimer, pyqtSignal, pyqtSlot
-from PyQt6.QtDBus import QDBusArgument, QDBusConnection, QDBusInterface, QDBusMessage
-from PyQt6.QtWidgets import QLabel, QMainWindow, QMessageBox, QPushButton, QTabBar, QTabWidget
-
-from . import VERSION, config
-from .dashboard import Dashboard
-from .fmt import job_title
-from .jobs import JobsTab
-from .packages import PackagesTab
-from .remote import DOWN, MISSING
-
-NOTIFY = "org.freedesktop.Notifications"
-NOTIFY_PATH = "/org/freedesktop/Notifications"
-END_EVENT = {"ok": "finished", "failed": "failed", "killed": "killed", "lost": "lost"}
-MAX_RETRY_MS = 30000
-
-
-class Notifier(QObject):
- """freedesktop notifications over D-Bus; clicked(job id) on the default action."""
-
- clicked = pyqtSignal(str)
-
- def __init__(self, parent=None):
- super().__init__(parent)
- bus = QDBusConnection.sessionBus()
- self.iface = QDBusInterface(NOTIFY, NOTIFY_PATH, NOTIFY, bus)
- bus.connect(NOTIFY, NOTIFY_PATH, NOTIFY, "ActionInvoked", self._action)
- self.ids = {} # notification id -> job id
-
- def send(self, title, body, jid=""):
- reply = self.iface.call(
- "Notify", "sb-builder", QDBusArgument(0, QMetaType.Type.UInt.value), "", title, body,
- QDBusArgument(["default", "Open"], QMetaType.Type.QStringList.value), {}, -1)
- if reply.type() == QDBusMessage.MessageType.ReplyMessage:
- self.ids[reply.arguments()[0]] = jid
-
- @pyqtSlot("uint", "QString")
- def _action(self, nid, _key):
- if nid in self.ids:
- self.clicked.emit(self.ids.pop(nid))
-
-
-class MainWindow(QMainWindow):
- def __init__(self, cfg, remote, notifier=None):
- super().__init__()
- self.cfg, self.remote = cfg, remote
- self.notifier = notifier or Notifier(self)
- self.notifier.clicked.connect(self.open_job)
- self.jobs_tab = None
- self.packages_tab = None
- self.seen = None # job id -> status at the last poll; None before the first
- self.online = None # None until the first answer
- self.overlays = []
- self.vm_action = None # "start" or "resume" offered on the VM button
- self.vm_woken = False # we started the VM: notify once ssh answers
- self.running_id = None
- self.setWindowTitle("sb-builder")
-
- self.tabs = QTabWidget(tabsClosable=True)
- self.tabs.tabCloseRequested.connect(self._close_tab)
- self.tabs.currentChanged.connect(lambda _i: self.poll_stats())
- self.dashboard = Dashboard()
- self.dashboard.refresh_btn.clicked.connect(self.poll_stats)
- self.dashboard.tiles["Packages"].link(self.open_packages)
- self.dashboard.jobs.link(self.open_jobs)
- self.tabs.addTab(self.dashboard, "Dashboard")
- self.tabs.tabBar().setTabButton(0, QTabBar.ButtonPosition.RightSide, None)
- self.setCentralWidget(self.tabs)
-
- file_menu = self.menuBar().addMenu("&File")
- file_menu.addAction("&Reload config", self.reload_config)
- file_menu.addAction("&Quit", self.close).setShortcut("Ctrl+Q")
- open_menu = self.menuBar().addMenu("&Open")
- open_menu.addAction("&Packages", self.open_packages)
- open_menu.addAction("&Jobs", self.open_jobs)
-
- self.conn = QLabel("● connecting")
- self.key = QLabel()
- self.key.hide()
- self.vm_btn = QPushButton()
- self.vm_btn.clicked.connect(lambda: self._vm_do(self.vm_action))
- self.vm_btn.hide()
- self.job_btn = QPushButton(flat=True)
- self.job_btn.clicked.connect(lambda: self.open_job(self.running_id))
- self.job_btn.hide()
- for w in (self.job_btn, self.key, self.vm_btn, self.conn):
- self.statusBar().addPermanentWidget(w)
-
- self.jobs_timer = QTimer(self, timeout=self.poll_jobs)
- self.stats_timer = QTimer(self, timeout=self.poll_slow)
- self._start_timers()
- QTimer.singleShot(0, self.poll_jobs)
-
- # ── polling ─────────────────────────────────────────────────────────────
-
- def _start_timers(self):
- self.jobs_timer.start(self.cfg["refresh"]["jobs"] * 1000)
- self.stats_timer.start(self.cfg["refresh"]["dashboard"] * 1000)
-
- def poll_jobs(self):
- self.remote.call(self._got_jobs, "jobs")
-
- def poll_slow(self):
- self.poll_stats()
- if self.online:
- self.remote.call(self._got_key, "gpg-status")
-
- def _got_key(self, data, err):
- if err:
- return self._failed(err)
- self.key.setVisible(data["unlocked"] is not None)
- self.key.setText("🔓 key unlocked" if data["unlocked"] else "🔒 key locked")
- self.key.setToolTip(f"package signing key {data['key']}")
-
- def poll_stats(self):
- if self.online and self.tabs.currentWidget() is self.dashboard:
- self.remote.call(self._got_stats, "stats")
-
- def _got_jobs(self, data, err):
- if err:
- return self._failed(err)
- self._up()
- jobs = data["jobs"]
- self._notify_ends(jobs)
- self.dashboard.show_jobs(jobs)
- if self.jobs_tab:
- self.jobs_tab.set_jobs(jobs)
- run = next((j for j in jobs if j["status"] == "running"), None)
- self.running_id = run and run["id"]
- self.job_btn.setVisible(run is not None)
- if run:
- self.job_btn.setText(f"▶ {job_title(run)}")
-
- def _got_stats(self, data, err):
- if err:
- return self._failed(err)
- self.dashboard.show_stats(data)
-
- def _got_version(self, data, err):
- if err:
- return self._failed(err)
- self.overlays = data.get("overlays", [])
- if self.jobs_tab:
- self.jobs_tab.set_overlays(self.overlays)
- if data["version"] != VERSION:
- self.conn.setText(f"● helper {data['version']}, GUI {VERSION}: run ./install.sh --remote")
-
- def _up(self):
- if self.online:
- return
- self.online = True
- self.conn.setText("● connected")
- self.vm_btn.hide()
- self.dashboard.set_stale(None)
- self.jobs_timer.setInterval(self.cfg["refresh"]["jobs"] * 1000)
- self.remote.call(self._got_version, "version")
- self.poll_slow()
- if self.vm_woken:
- self.vm_woken = False
- self.notify("vm-up", f"{self.cfg['vm']['domain']} is up")
-
- def _failed(self, err):
- if err == MISSING:
- self.conn.setText("● sbb-remote missing: run ./install.sh --remote")
- return
- if err != DOWN:
- self.statusBar().showMessage(err, 10000)
- return
- if self.online is not False:
- self.online = False
- self.dashboard.set_stale(time.time())
- base = self.cfg["refresh"]["jobs"] * 1000
- self.jobs_timer.setInterval(min(self.jobs_timer.interval() * 2, max(MAX_RETRY_MS, base)))
- self._check_vm()
-
- # ── VM control ──────────────────────────────────────────────────────────
-
- def _check_vm(self):
- if not self.cfg["vm"]["domain"]:
- self.conn.setText("● down")
- return
- self.remote.virsh(self._got_domstate, "domstate", self.cfg["vm"]["domain"])
-
- def _got_domstate(self, rc, state):
- if self.online:
- return # ssh came back while virsh ran
- self.vm_btn.hide()
- if rc != 0:
- self.conn.setText("● down")
- elif state == "shut off":
- self.conn.setText("● VM off")
- if self.cfg["vm"]["autostart"]:
- self._vm_do("start")
- else:
- self._offer("Start VM", "start")
- elif state == "paused":
- self.conn.setText("● VM paused")
- self._offer("Resume VM", "resume")
- elif state == "running":
- self.conn.setText("● VM running, ssh unreachable")
- else:
- self.conn.setText(f"● VM {state}")
-
- def _offer(self, label, action):
- self.vm_action = action
- self.vm_btn.setText(label)
- self.vm_btn.show()
-
- def _vm_do(self, action):
- self.vm_btn.hide()
- self.vm_woken = True
- self.conn.setText(f"● VM {action} requested")
- self.remote.virsh(
- lambda rc, out: rc and self.statusBar().showMessage(f"virsh {action}: {out}", 10000),
- action, self.cfg["vm"]["domain"])
-
- # ── notifications ───────────────────────────────────────────────────────
-
- def notify(self, event, body, jid=""):
- n = self.cfg["notify"]
- if n["enabled"] and event in n["events"]:
- self.notifier.send(f"sb-builder: {event}", body, jid)
-
- def _notify_ends(self, jobs):
- prev, self.seen = self.seen, {j["id"]: j["status"] for j in jobs}
- if prev is None:
- return # first poll: old jobs are history, not news
- for j in jobs:
- event = END_EVENT.get(j["status"])
- if event and prev.get(j["id"], "running") != j["status"]:
- self.notify(event, job_title(j), j["id"])
- if j["kind"] in ("slackrepo", "publish") and self.packages_tab:
- self.packages_tab.reload() # build state changed
-
- # ── tabs ────────────────────────────────────────────────────────────────
-
- def open_jobs(self):
- if self.jobs_tab is None:
- self.jobs_tab = JobsTab(self.remote, self.cfg["builds"]["publish"])
- self.jobs_tab.refresh_requested.connect(self.poll_jobs)
- self.jobs_tab.set_overlays(self.overlays)
- self.tabs.addTab(self.jobs_tab, "Jobs")
- self.poll_jobs()
- self.tabs.setCurrentWidget(self.jobs_tab)
- return self.jobs_tab
-
- def open_packages(self):
- 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.tabs.addTab(self.packages_tab, "Packages")
- self.packages_tab.reload()
- self.tabs.setCurrentWidget(self.packages_tab)
- return self.packages_tab
-
- def open_job(self, jid):
- self.show()
- self.raise_()
- self.activateWindow()
- if jid:
- self.open_jobs().select(jid)
-
- def _close_tab(self, i):
- w = self.tabs.widget(i)
- if w is self.dashboard:
- return
- self.tabs.removeTab(i)
- if w is self.jobs_tab:
- self.jobs_tab = None
- w.stop()
- if w is self.packages_tab:
- self.packages_tab = None
- w.deleteLater()
-
- def reload_config(self):
- cfg, err = config.load()
- if err:
- QMessageBox.warning(self, "Config error", f"{err}\nUsing defaults.")
- self.cfg.clear()
- self.cfg.update(cfg) # Remote holds the same dict
- self._start_timers()
-````
-
-- [ ] **Step 7: Run the whole suite**
-
-Run: `python3 -m unittest discover -s tests </dev/null`
-Expected: `Ran 68 tests`, `OK`. "QProcess: Destroyed while process ("true") is still running" lines are the fake follow processes killed at cleanup.
-
-- [ ] **Step 8: Commit**
-
-```bash
-git add sb_builder config.example.toml tests/test_gui.py
-git commit -F - <<'EOF'
-Add Packages tab, hint view, Run it and signing key unlock
-
-Packages tab over the helper inventory: query bar with cat:/maint:/
-req:/status: prefixes, overlay and status chips, check column, and a
-detail pane with versions, dependencies after hints and a structured
-read-only view of the hint (hints.py mirrors slackrepo's parser).
-Actions start slackrepo previews; the Jobs tab offers Run it with a
-publish box (default from [builds] publish), asking for the signing
-key passphrase first when the key is locked. Status bar shows the key
-state; Dashboard tiles open their tabs; inventory reloads after
-slackrepo and publish jobs.
-
-Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-EOF
-git log --format='%h %G? %s' -1
-```
-
----
-
-### Task 4: docs
-
-- [ ] **Step 1:** Replace `README.md`:
-
-````markdown
-# 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
-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.
-
-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
-- 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
-
- ./install.sh # ~/bin/sb-builder -> this repo, GUI config if missing
- ./install.sh --remote # helper to the VM, its config if missing (asks first)
-
-`BINDIR` changes the launcher directory, `SBB_TARGET` the ssh destination for
-`--remote` (default `buildsystem`). Configs:
-
-- GUI: `~/.config/sb-builder/config.toml`, see `config.example.toml`
-- VM: `/etc/sb-builder/config.toml`, see `remote/config.example.toml`
-
-Every key is optional. `File > Reload config` rereads the GUI config.
-
-## Usage
-
- sb-builder
-
-- **Dashboard**: VM load, uptime and free disk, package counts, slackrepo
- build results, last overlay pull, recent jobs. Refreshes while visible.
-- **Open > Jobs**: start a job from the Start menu (`sbpull <overlay>`
- preview, `sbshadow` preview or apply), follow its log live, kill it. After a
- preview that lists `shadow:` or `unshadow:` lines, "Apply shadowing" runs
- `sbshadow --yes`.
-
-- **Open > Packages** (or click the Packages tile): every package of the
- repository, with built version and status (`ok`, `failed`, `never`,
- `outdated`). Search by name or summary, or with `cat:`, `maint:`, `req:`,
- `status:`; toggle the overlay and status chips. The detail pane shows the
- package's versions, its dependencies after hints (dropped ones struck
- through), and what its hint file does, section by section.
-- Tick packages (or select one row) and press Build, Update, Rebuild or
- Remove; Update all updates everything. Each first runs a slackrepo
- `--preview` job. When the preview is fine, **Run it** in the Jobs tab starts
- the real run; untick **publish** to keep the packages local and press
- **Publish** later to push them.
-- 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.
-
-Jobs run on the VM detached from the ssh session, one at a time. Closing the
-GUI or losing the connection does not stop them; reopening the job resumes
-the log. Job state and logs live in `/var/lib/sb-builder/jobs/` on the VM.
-
-When ssh fails and `vm.domain` is set, the status bar shows the libvirt state
-of the VM and offers to start or resume it. Desktop notifications report
-finished, failed, killed and lost jobs, and the VM coming up.
-
-## License
-
-Copyright (C) 2026 Danilo M. <danix@danix.xyz>
-
-GPLv2 only. See [LICENSE](LICENSE) for the full text.
-
-## Development Approach
-
-This project is developed using AI-assisted tools. Code is generated with the help of AI based on human-provided specifications, design decisions, and iterative feedback.
-
-All contributions are reviewed, tested, and curated by the maintainer before being included in the codebase. AI is used as a productivity and exploration tool, while human oversight remains central to all decisions.
-
-The goal is to combine the flexibility of AI-assisted development with standard open-source practices such as transparency, review, and accountability.
-````
-
-- [ ] **Step 2:** Replace `AGENTS.md`:
-
-````markdown
-# AGENTS.md
-
-Guidance for AI coding agents working in this repository.
-
-## What this is
-
-`sb-builder`: a PyQt6 desktop GUI that drives the SlackBuild tooling on a
-Slackware -current build VM (slackrepo, mkhint, sbpull, sbshadow) over ssh.
-Two programs: the GUI (`sb_builder/`, PyQt6 incl. QtDBus, stdlib otherwise)
-and `remote/sbb-remote`, a single-file stdlib-only Python helper deployed on
-the VM. Do not add third-party dependencies to either.
-
-Design and phasing: `docs/superpowers/specs/`. Phase 1 (core) is
-`2026-10-19-sb-builder-phase1-design.md`.
-
-## How it works
-
-- 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`).
-- 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.
-- Jobs run detached on the VM (`setsid`, `stdin=/dev/null`), one at a time
- under a flock, logging to `<state_dir>/jobs/<id>/`. The GUI can disconnect
- and reattach with `job-follow <id> <offset>`.
-- Exception, by design: `gpg-unlock` reads the signing key passphrase from
- stdin (`remote.call(..., stdin=...)`) and feeds it to
- `gpg-preset-passphrase`. Every other subprocess in the helper runs with
- `stdin=DEVNULL`, so nothing can read the passphrase off the ssh channel.
- Never put the passphrase in argv, env, logs or files.
-- 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.
-- 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.
-
-## Conventions
-
-- GUI config: `$XDG_CONFIG_HOME/sb-builder/config.toml`. Helper config:
- `/etc/sb-builder/config.toml` on the VM. Both read with `tomllib`, both
- have shipped `config.example.toml` files, defaults live in code.
-- One file per GUI tab.
-- Deploying to the VM is outward-facing: confirm before `install.sh --remote`.
-- Placeholders only in code, docs, tests and commits.
-
-## Testing
-
- python3 -m unittest discover -s tests
-
-`test_gui.py` forces `QT_QPA_PLATFORM=offscreen` (the desktop session sets
-its own, and a modal dialog on the real display blocks the run). A fake
-remote must answer every call a test triggers: an unanswered call returns
-"ssh: connection failed", which opens a warning dialog that blocks forever
-offscreen.
-
-Headless (`test_gui.py` sets `QT_QPA_PLATFORM=offscreen`), no VM needed.
-`test_remote.py` loads `remote/sbb-remote` with `SourceFileLoader` (no `.py`
-extension) and points it at a temp state dir through `SBB_CONFIG`. Two
-`ResourceWarning: subprocess ... is still running` lines are expected: the
-detached `_run` is never waited for. To try the helper on the VM without
-installing it, copy it to `/tmp` there and run it with `SBB_CONFIG` pointing
-at a config whose `state_dir` is under `/tmp`.
-
-## Repo facts
-
-- License: GPLv2 only (`LICENSE`, header in each source file).
-- Work directly on master.
-- `.superpowers/` is brainstorming scratch, ignored.
-````
-
-- [ ] **Step 3: Commit and push**
-
-```bash
-git add README.md AGENTS.md
-git commit -F - <<'EOF'
-Document the Packages tab, publishing and signing key unlock
-
-Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-EOF
-git log --format='%h %G? %s' -1 && git push
-```
-
----
-
-### Task 5: deploy and first real run (with the user)
-
-Ask before each VM step. Order matters: the `.local` file must be built from the old config before the new config replaces it.
-
-- [ ] **Step 1: Inspect targets**
-
-```bash
-ssh buildsystem 'ls -l /etc/slackrepo/slackrepo_SBo-danix.conf /etc/slackrepo/SBo-danix.local /usr/local/bin/sb-publish /root/.gnupg/gpg-agent.conf 2>&1'
-```
-
-Expected: config and agent conf exist; `.local` and `sb-publish` do not.
-
-- [ ] **Step 2: Build SBo-danix.local on the VM from the old config** (values never leave the VM)
-
-```bash
-cd ~/Programming/GIT/slackrepo_setup
-scp make-local.py buildsystem:/tmp/make-local.py
-ssh buildsystem 'cp -p /etc/slackrepo/slackrepo_SBo-danix.conf /etc/slackrepo/slackrepo_SBo-danix.conf.pre-sb-builder &&
- python3 /tmp/make-local.py /etc/slackrepo/slackrepo_SBo-danix.conf /etc/slackrepo/SBo-danix.local &&
- rm /tmp/make-local.py && stat -c "%a %n" /etc/slackrepo/SBo-danix.local'
-```
-
-Expected: `wrote /etc/slackrepo/SBo-danix.local: SB_RSYNC_TARGET, SB_RSYNC_EXCLUDES, SB_RSYNC_PASSWORD_FILE, SB_GOTIFY_KEY, SB_GOTIFY_URL` and `600 /etc/slackrepo/SBo-danix.local`.
-
-- [ ] **Step 3: Install config, sb-publish, agent conf**
-
-```bash
-cd ~/Programming/GIT/slackrepo_setup
-scp slackrepo_SBo-danix.conf buildsystem:/etc/slackrepo/slackrepo_SBo-danix.conf
-scp sb-publish buildsystem:/usr/local/bin/sb-publish
-scp gpg-agent.conf buildsystem:/root/.gnupg/gpg-agent.conf
-ssh buildsystem 'chmod 755 /usr/local/bin/sb-publish; chmod 600 /root/.gnupg/gpg-agent.conf;
- gpgconf --reload gpg-agent; bash -n /etc/slackrepo/slackrepo_SBo-danix.conf && echo config ok;
- bash -c ". /etc/slackrepo/SBo-danix.local; [ -n \"\$SB_RSYNC_TARGET\" ] && echo local ok"'
-```
-
-- [ ] **Step 4: Deploy the helper**: `cd ~/Programming/GIT/sb-builder && ./install.sh --remote`. Expected last line: `{"version": "0.2.0", "overlays": ["personal", "pentesting"]}`. Existing `/etc/sb-builder/config.toml` is kept; its defaults cover the new keys.
-
-- [ ] **Step 5: User runs `sb-builder`**, checks: status bar shows 🔒 key locked; Packages tile opens the tab with ~9957 rows; chips and `cat:personal` filter; a hinted package shows its hint sections; DELREQUIRES deps struck through.
-
-- [ ] **Step 6: Preview, run unpublished, publish**: pick one personal package that shows `outdated`, Update. Preview job ends ✔ with [Run it]; untick publish; Run it asks for the passphrase (a wrong one asks again); the job runs and ends ✔; status bar shows 🔓. On the VM: `ls -l /repo/personal/<pkg>/` shows the new package and the mirror is not updated yet. Then Publish: the `publish` job runs `sb-publish` and ends ✔.
-
-- [ ] **Step 7: Note the preset passphrase lifetime**: check the 🔓 indicator again after more than 4h (default-cache-ttl) and after 6h (max-cache-ttl); record in the spec whether presets expire with the TTLs. Either way the GUI asks again when locked.
-
-- [ ] **Step 8: Terminal check**: from a VM shell, `slackrepo --preview update <pkg>`: no Gotify message arrives (was sent before this change).
-
-Record anything that misbehaves as a follow-up; do not patch it silently.
diff --git a/docs/superpowers/plans/2026-10-20-sb-builder-bundled-deps.md b/docs/superpowers/plans/2026-10-20-sb-builder-bundled-deps.md
deleted file mode 100644
index e4f330c..0000000
--- a/docs/superpowers/plans/2026-10-20-sb-builder-bundled-deps.md
+++ /dev/null
@@ -1,456 +0,0 @@
-# sb-builder bundled deps (phase 4b, part 2), 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:** `hint-update` handles packages with several downloads: every URL the bump changed gets a new md5, and a package in the helper's `[manifests]` (neovim) has its extra `DOWNLOAD` URLs moved to the versions of upstream's deps list.
-
-**Architecture:** mkhint's url mode ported into `remote/sbb-remote` as pure text functions (`_url_repo`, `_url_stem`, `_url_version`, `_match`, `reconcile`) called from `apply_version`, which gains a `manifest` argument fed from config `[manifests]`. No GUI change.
-
-**Tech Stack:** Python 3.11+ stdlib (`re`, `urllib`, `hashlib`, `tomllib`).
-
-Spec: `docs/superpowers/specs/2026-10-20-sb-builder-bundled-deps-design.md`.
-
-Every code step below was run before this plan was written: the diffs apply
-to `master` at `891a918` with `git apply` and the suite ends at 128 tests,
-OK. Two `ResourceWarning: subprocess ... is still running` lines are
-expected (detached `_run`). `reconcile` was also run against the live
-neovim deps lists with the 14 real URLs of the neovim `.info`: v0.12.4
-reports all current ("manifest has 7 deps not in the hint"), v0.11.0 moves
-10 deps (luajit, luv, the tree-sitters, utf8proc).
-
-## Files
-
-| File | Change |
-|---|---|
-| `remote/sbb-remote` | `[manifests]` config, `manifest_packages` and its refusal gone, url functions, `reconcile`, `apply_version` md5 for changed URLs and manifest, VERSION 0.8.0 |
-| `remote/config.example.toml` | `[manifests]` in place of `manifest_packages` |
-| `sb_builder/__init__.py` | VERSION 0.8.0 |
-| `tests/test_remote.py` | config, url matching on neovim's real shapes, reconcile, apply with manifest, feroxbuster shape |
-| `README.md`, `AGENTS.md` | neovim no longer refused, mkhint no longer used |
-
----
-
-### Task 1: tests
-
-**Files:**
-- Test: `tests/test_remote.py`
-
-- [ ] **Step 1: Apply the test diff**
-
-```diff
-diff --git i/tests/test_remote.py w/tests/test_remote.py
-index 57d492c..30bdc73 100644
---- i/tests/test_remote.py
-+++ w/tests/test_remote.py
-@@ -135,6 +135,12 @@ class ConfigTest(Base):
- with self.assertRaisesRegex(sbb.Error, "bogus: unknown key"):
- sbb.load_config(self.write("bogus = 1\n"))
-
-+ def test_manifests(self):
-+ self.assertEqual(sbb.load_config(self.write("[manifests]\nx = \"u{VERSION}\"\n"))["manifests"],
-+ {"x": "u{VERSION}"})
-+ with self.assertRaisesRegex(sbb.Error, "manifests: expected strings"):
-+ sbb.load_config(self.write("[manifests]\nx = 1\n"))
-+
- def test_wrong_type(self):
- with self.assertRaisesRegex(sbb.Error, "state_dir: expected str"):
- sbb.load_config(self.write("state_dir = 1\n"))
-@@ -302,6 +308,73 @@ class ApplyVersionTest(Base):
- self.apply(f'VERSION="1.0"\nDOWNLOAD="{self.www.url}/x-1.0.tgz"\nMD5SUM="m"\n', "3.0")
-
-
-+GH = "https://github.com"
-+# neovim 0.12.4: hint URLs (SBo shapes) and their deps.txt counterparts
-+NVIM_HINT = [f"{GH}/neovim/neovim/archive/v0.12.4/neovim-0.12.4.tar.gz",
-+ f"{GH}/luajit/LuaJIT/archive/fbb36bb/LuaJIT-fbb36bb6bfa88716a47c58bcf9ce9f2ef752abac.tar.gz",
-+ f"{GH}/luvit/luv/releases/download/1.52.1-0/luv-1.52.1-0.tar.gz",
-+ f"{GH}/lunarmodules/lua-compat-5.3/archive/v0.13/lua-compat-5.3-0.13.tar.gz",
-+ f"{GH}/tree-sitter/tree-sitter/archive/v0.26.7/tree-sitter-0.26.7.tar.gz",
-+ "https://www.inf.puc-rio.br/~roberto/lpeg/lpeg-1.1.0.tar.gz",
-+ f"{GH}/JuliaStrings/utf8proc/archive/v2.11.3/utf8proc-2.11.3.tar.gz"]
-+NVIM_DEPS = [f"{GH}/luajit/luajit/archive/fbb36bb6bfa88716a47c58bcf9ce9f2ef752abac.tar.gz",
-+ f"{GH}/luvit/luv/archive/1.52.1-0.tar.gz",
-+ f"{GH}/lunarmodules/lua-compat-5.3/archive/v0.13.tar.gz",
-+ f"{GH}/tree-sitter/tree-sitter-c/archive/v0.24.1.tar.gz",
-+ f"{GH}/tree-sitter/tree-sitter/archive/v0.26.7.tar.gz",
-+ f"{GH}/neovim/deps/raw/d495ee6f79e7962a53ad79670cb92488abe0b9b4/opt/lpeg-1.1.0.tar.gz",
-+ f"{GH}/juliastrings/utf8proc/archive/v2.11.3.tar.gz"]
-+
-+
-+class BundledDepsTest(Base):
-+ def test_hint_and_manifest_shapes_match_with_equal_versions(self):
-+ for url in NVIM_HINT[1:]:
-+ m = sbb._match(url, NVIM_DEPS)
-+ with self.subTest(url=url):
-+ self.assertIsNotNone(m)
-+ self.assertEqual(sbb._url_version(url), sbb._url_version(m))
-+ self.assertIsNone(sbb._match(NVIM_HINT[0], NVIM_DEPS))
-+ self.assertEqual([sbb._url_version(u) for u in NVIM_HINT[2:4]], ["1.52.1_0", "0.13"])
-+ self.assertEqual(sbb._dep_name(NVIM_HINT[5]), "lpeg")
-+
-+ def test_reconcile(self):
-+ hint = 'DOWNLOAD="' + " \\\n ".join(NVIM_HINT[:3] + [f"{GH}/a/gone/archive/v1/gone-1.tar.gz"]) + '"\n'
-+ deps = "\n".join(f"X_URL {u}\nX_SHA256 ab" for u in NVIM_DEPS).replace("1.52.1-0", "1.53.0-0")
-+ log = []
-+ out = sbb.reconcile(hint, deps, log.append)
-+ self.assertEqual(sbb.hint_tokens(out, "DOWNLOAD"),
-+ NVIM_HINT[:2] + [f"{GH}/luvit/luv/archive/1.53.0-0.tar.gz",
-+ f"{GH}/a/gone/archive/v1/gone-1.tar.gz"])
-+ self.assertEqual(log, ["luv 1.52.1_0 -> 1.53.0_0", "gone no manifest match, left as is",
-+ "manifest has 5 deps not in the hint"])
-+ self.assertIs(sbb.reconcile(hint, deps.replace("1.53.0-0", "1.52.1-0"), log.append), hint)
-+ with self.assertRaisesRegex(sbb.Error, "no URLs"):
-+ sbb.reconcile(hint, "moved\n", log.append)
-+
-+ def test_apply_version_follows_manifest(self):
-+ www = Served(self, {"x-2.0.tgz": b"two", "dep-0.6.tgz": b"dep11", "keep-3.tgz": b"keep"})
-+ u, log = www.url, []
-+ with open(os.path.join(self.tmp, "www", "deps-2.0.txt"), "w") as f:
-+ f.write(f"DEP_URL {u}/dep-0.6.tgz\nKEEP_URL {u}/keep-3.tgz\n")
-+ text = (f'VERSION="1.0"\nDOWNLOAD="{u}/x-1.0.tgz \\\n {u}/dep-0.5.tgz \\\n {u}/keep-3.tgz"\n'
-+ 'MD5SUM="a \\\n b \\\n c"\n')
-+ out = sbb.apply_version(text, "", "2.0", False, log.append, u + "/deps-{VERSION}.txt")
-+ self.assertEqual(out, f'VERSION="2.0"\nDOWNLOAD="{u}/x-2.0.tgz \\\n {u}/dep-0.6.tgz \\\n'
-+ f' {u}/keep-3.tgz"\nMD5SUM="{md5(b"two")} \\\n {md5(b"dep11")} \\\n c"\n')
-+ self.assertEqual(log, [f"manifest {u}/deps-2.0.txt", "dep 0.5 -> 0.6",
-+ f"downloading {u}/x-2.0.tgz", f"downloading {u}/dep-0.6.tgz"])
-+ with self.assertRaises(OSError): # no deps list at 3.0: the package fails, hint untouched
-+ sbb.apply_version(text, "", "3.0", False, log.append, u + "/deps-{VERSION}.txt")
-+
-+ def test_changed_continuation_url_gets_its_md5(self):
-+ www = Served(self, {"f-2.0.tgz": b"two", "f-2.0-vendor.tgz": b"vendor"})
-+ u, log = www.url, []
-+ text = f'VERSION="1.0"\nDOWNLOAD="{u}/f-1.0.tgz \\\n {u}/f-1.0-vendor.tgz"\nMD5SUM="a \\\n b"\n'
-+ self.assertIn(f'MD5SUM="{md5(b"two")} \\\n {md5(b"vendor")}"',
-+ sbb.apply_version(text, "", "2.0", False, log.append))
-+ self.assertEqual(log, [f"downloading {u}/f-2.0.tgz", f"downloading {u}/f-2.0-vendor.tgz"])
-+
-+
- class HintJobsTest(Base):
- def setUp(self):
- super().setUp()
-@@ -349,14 +422,12 @@ class HintJobsTest(Base):
- ("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", "2.0"], "no such package: 2.0"), # no version any more
- ("hint-new", ["x", "x"], "twice")):
- 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, "y: hint exists"):
- sbb.KINDS["hint-new"](self.cfg, ["x", "y"]) # one with a hint refuses the whole job
-```
-
-- [ ] **Step 2: Run, expect failures**
-
-Run: `python3 -m unittest discover -s tests 2>&1 | tail -3`
-Expected: FAILED, `AttributeError: module 'sbb' has no attribute '_match'` and friends, `manifests: unknown key`.
-
-### Task 2: helper and version
-
-**Files:**
-- Modify: `remote/sbb-remote`, `remote/config.example.toml`, `sb_builder/__init__.py`
-
-- [ ] **Step 1: Apply the helper diff**
-
-```diff
-diff --git i/remote/config.example.toml w/remote/config.example.toml
-index 0fbe801..18ade4c 100644
---- i/remote/config.example.toml
-+++ w/remote/config.example.toml
-@@ -12,5 +12,9 @@ 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"] # bundled deps: hint-update refuses them (phase 4b)
- phantom_deps_file = "/etc/sb-builder/phantom-deps" # deps -current ships; one per line, # comments
-+
-+# bundled deps: package = its upstream deps list URL, {VERSION} = the new version.
-+# hint-update moves the package's extra DOWNLOAD URLs to the versions listed there.
-+[manifests]
-+neovim = "https://raw.githubusercontent.com/neovim/neovim/v{VERSION}/cmake.deps/deps.txt"
-diff --git i/remote/sbb-remote w/remote/sbb-remote
-index c37b44c..07c2a55 100755
---- i/remote/sbb-remote
-+++ w/remote/sbb-remote
-@@ -40,7 +40,7 @@ import time
- import tomllib
- import urllib.request
-
--VERSION = "0.7.0"
-+VERSION = "0.8.0"
- DEFAULTS = {
- "state_dir": "/var/lib/sb-builder",
- "repo_dir": "/var/lib/sbopkg/SBo-danix",
-@@ -53,7 +53,8 @@ DEFAULTS = {
- "default_hintdir": "",
- "genreprc": "/root/.genreprc",
- "slackrepo_logdir": "/var/log/slackrepo/SBo-danix",
-- "manifest_packages": ["neovim"], # bundled deps: left to mkhint until phase 4b
-+ # bundled deps: package -> deps list URL, {VERSION} the new version
-+ "manifests": {"neovim": "https://raw.githubusercontent.com/neovim/neovim/v{VERSION}/cmake.deps/deps.txt"},
- "phantom_deps_file": "/etc/sb-builder/phantom-deps", # deps -current ships: DELREQUIREd
- }
- FLAGS = ("--yes", "--no")
-@@ -90,9 +91,10 @@ 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
-- 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")
-+ if not all(isinstance(o, str) for o in cfg["overlays"]):
-+ raise Error("config: overlays: expected list of strings")
-+ if not all(isinstance(u, str) for u in cfg["manifests"].values()):
-+ raise Error("config: manifests: expected strings")
- return cfg
-
-
-@@ -187,8 +189,6 @@ def hint_args(cfg, kind, args):
- 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
-@@ -710,28 +710,128 @@ def _md5_url(url):
- return h.hexdigest()
-
-
--def apply_version(text, info, new, no_dl, log):
-+def _fetch(url):
-+ req = urllib.request.Request(url, headers={"User-Agent": "sbb-remote"})
-+ with urllib.request.urlopen(req, timeout=DL_TIMEOUT) as r:
-+ return r.read().decode(errors="replace")
-+
-+
-+# ── bundled deps: a hint's extra DOWNLOAD URLs against upstream's deps list ──
-+# mkhint's url mode. Both sides of a pair reduce to "the part that isn't the
-+# name", so one version in two URL shapes (v0.13.tar.gz, lua-compat-5.3-0.13.tar.gz)
-+# compares equal.
-+
-+MANIFEST_URL = re.compile(r"""https?://[^\s"'<>()]+""")
-+ARCHIVE = re.compile(r"\.(?:tar\.(?:gz|xz|bz2|zst)|tgz|zip)$")
-+
-+
-+def _url_repo(url):
-+ """owner/repo of a GitHub URL, lowercased (GitHub ignores case); "" elsewhere.
-+
-+ neovim/deps is a shared blob repo that names the host, not the dep: "".
-+ """
-+ m = re.search(r"github\.com/([^/]+)/([^/]+)", url)
-+ repo = f"{m.group(1)}/{m.group(2).removesuffix('.git')}".lower() if m else ""
-+ return "" if repo.endswith("/deps") else repo
-+
-+
-+def _url_base(url):
-+ return ARCHIVE.sub("", url.rsplit("/", 1)[-1])
-+
-+
-+def _url_stem(url):
-+ """The basename without archive extension and trailing -<version> or -<sha>."""
-+ m = re.match(r"(.+)-(?:[vV]?\d.*|[0-9a-f]{7,})$", _url_base(url))
-+ return m.group(1) if m else _url_base(url)
-+
-+
-+def _url_version(url):
-+ """On GitHub the basename past "<repo>-" (the whole basename when it lacks
-+ it); elsewhere its trailing -<version> or -<sha>. No leading v, '-' as '_'.
-+
-+ ponytail: off GitHub the name is a guess; a "<name-with-digits>-<ver>"
-+ basename there misparses. Add a name hint if such a dep ever appears.
-+ """
-+ base, repo = _url_base(url), _url_repo(url).split("/")[-1]
-+ if repo:
-+ ver = base[len(repo) + 1:] if base.lower().startswith((repo + "-", repo + "_")) else base
-+ else:
-+ m = re.search(r"-([vV]?\d[^/]*)$", base) or re.search(r"-([0-9a-f]{7,})$", base)
-+ ver = m.group(1) if m else base
-+ return ver.removeprefix("v").removeprefix("V").replace("-", "_")
-+
-+
-+def _dep_name(url):
-+ return _url_repo(url).split("/")[-1] or _url_stem(url)
-+
-+
-+def _match(url, found):
-+ """The manifest URL for a hint URL: same GitHub repo, else same stem."""
-+ repo = _url_repo(url)
-+ m = repo and next((f for f in found if _url_repo(f) == repo), None)
-+ return m or next((f for f in found if _url_stem(f) == _url_stem(url)), None)
-+
-+
-+def reconcile(text, manifest, log):
-+ """DOWNLOAD's extra URLs moved to the versions of manifest, a deps list's text.
-+
-+ The first URL is the package itself: the version swap moved it already.
-+ """
-+ found = sorted(set(MANIFEST_URL.findall(manifest)))
-+ if not found:
-+ raise Error("manifest has no URLs")
-+ urls = hint_tokens(text, "DOWNLOAD")
-+ used = {_match(urls[0], found)} if urls else set()
-+ for i, url in enumerate(urls[1:], 1):
-+ m = _match(url, found)
-+ if m is None:
-+ log(f"{_dep_name(url)} no manifest match, left as is")
-+ continue
-+ used.add(m)
-+ old, new = _url_version(url), _url_version(m)
-+ if old != new:
-+ log(f"{_dep_name(url)} {old} -> {new}")
-+ urls[i] = m
-+ if extra := len(set(found) - used):
-+ log(f"manifest has {extra} deps not in the hint")
-+ if urls == hint_tokens(text, "DOWNLOAD"):
-+ return text
-+ return hint_set(text, "DOWNLOAD", " \\\n ".join(urls))
-+
-+
-+def apply_version(text, info, new, no_dl, log, manifest=None):
- """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.
-+ An unpinned hint (DELREQUIRES only) gets the .info pin first. manifest: the
-+ package's deps list URL template; its extra DOWNLOAD URLs follow it.
-+ Downloads the first URL of each DOWNLOAD pair and every URL the bump
-+ changed, for its md5; the others keep theirs. 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
-+ before = {dl: hint_tokens(text, dl) for dl, _ in DL_PAIRS}
- 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
-+ if manifest:
-+ url = manifest.replace("{VERSION}", new)
-+ log(f"manifest {url}")
-+ text = reconcile(text, _fetch(url), log)
- for dl, sums in DL_PAIRS:
-- urls = hint_tokens(text, dl)
-+ urls, old = hint_tokens(text, dl), before[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:]]))
-+ md5s = (hint_tokens(text, sums) + [""] * len(urls))[:len(urls)]
-+ for i, url in enumerate(urls):
-+ if i == 0 or i >= len(old) or url != old[i]:
-+ log(f"downloading {url}")
-+ md5s[i] = _md5_url(url)
-+ text = hint_set(text, sums, " \\\n ".join(md5s))
- return text
-
-
-@@ -864,7 +964,8 @@ def cmd_hint(cfg, kind=None, *args):
- if text is None:
- text = new_hint(info)
- _save(target, apply_version(text, info, ver, no_dl,
-- lambda s, p=pkg: print(f"{p}: {s}", flush=True)))
-+ lambda s, p=pkg: print(f"{p}: {s}", flush=True),
-+ cfg["manifests"].get(pkg)))
- print(f"{pkg}: ok", flush=True)
- ok.append(pkg)
- except (Error, OSError) as e:
-diff --git i/sb_builder/__init__.py w/sb_builder/__init__.py
-index f4a1f98..e4a5697 100644
---- i/sb_builder/__init__.py
-+++ w/sb_builder/__init__.py
-@@ -16,4 +16,4 @@
-
- """sb-builder: Qt6 GUI driving the SlackBuild tooling on a build VM."""
-
--VERSION = "0.7.0" # must match remote/sbb-remote VERSION
-+VERSION = "0.8.0" # must match remote/sbb-remote VERSION
-```
-
-- [ ] **Step 2: Run the suite**
-
-Run: `python3 -m unittest discover -s tests 2>&1 | tail -3`
-Expected: `Ran 128 tests`, `OK`.
-
-- [ ] **Step 3: 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.8.0: bundled deps from upstream's deps list"
-```
-
-### Task 3: docs
-
-**Files:**
-- Modify: `README.md`, `AGENTS.md`
-
-- [ ] **Step 1: Apply the docs diff**
-
-```diff
-diff --git i/AGENTS.md w/AGENTS.md
-index 9f92d71..7e28e0a 100644
---- i/AGENTS.md
-+++ w/AGENTS.md
-@@ -37,8 +37,10 @@ Design and phasing: `docs/superpowers/specs/`. Phase 1 (core) is
- 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`.
- The phantom deps list is the helper's `phantom_deps_file`; sbshadow runs
-- `sbb-remote fix-current-now` after each sync. mkhint stays on the VM only
-- for neovim (bundled deps, `manifest_packages`), until phase 4b part 2.
-+ `sbb-remote fix-current-now` after each sync. Bundled deps: a package in
-+ the helper's `[manifests]` (neovim) has its extra `DOWNLOAD` URLs moved to
-+ the versions of upstream's deps list on `hint-update`. mkhint is no longer
-+ used; removing it from the VM is phase 4b part 3.
- - 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) rather than
-diff --git i/README.md w/README.md
-index a686197..bb1ff56 100644
---- i/README.md
-+++ w/README.md
-@@ -78,8 +78,10 @@ reopens it.
- it is official): add names, or tick suggestions, the required names no
- package of the tree has; **Apply to hints** writes them into the
- `DELREQUIRES` of every package that requires them (sbshadow does the same
-- after each sync). neovim is refused for now: its bundled deps still need
-- mkhint on the VM.
-+ after each sync). An update downloads every URL the bump changed for its
-+ md5; a package with bundled deps (helper config `[manifests]`, neovim by
-+ default) first has its extra downloads moved to the versions upstream's
-+ deps list names.
- - **Results**: slackrepo's last build attempt per package, newest first,
- filterable by result. **Show build log** (or double-click) opens the end of
- the log of the package's last attempt (slackrepo keeps one per command:
-```
-
-- [ ] **Step 2: Commit and push**
-
-```bash
-git add README.md AGENTS.md docs/superpowers/plans/2026-10-20-sb-builder-bundled-deps.md
-git commit -m "Document bundled deps; phase 4b part 2 plan"
-git push
-```
-
-### Task 4: deploy (ask first)
-
-- [ ] **Step 1: Drop the obsolete key on the VM**, else 0.8.0 refuses its config (`manifest_packages: unknown key`):
-
-```bash
-ssh buildsystem "sed -i '/^manifest_packages/d' /etc/sb-builder/config.toml"
-```
-
-- [ ] **Step 2: Deploy**: `./install.sh --remote`, then `ssh buildsystem sbb-remote version` reports `0.8.0`.
-
-- [ ] **Step 3: Manual check (user)**: Hints, tick neovim, update to the current upstream version; the job log lists `manifest ...`, any `<dep> <old> -> <new>` lines and a download per changed URL; the diff dialog shows the moved `DOWNLOAD`/`MD5SUM` lines.
diff --git a/docs/superpowers/plans/2026-10-20-sb-builder-phase4.md b/docs/superpowers/plans/2026-10-20-sb-builder-phase4.md
deleted file mode 100644
index 1d15e1f..0000000
--- a/docs/superpowers/plans/2026-10-20-sb-builder-phase4.md
+++ /dev/null
@@ -1,1994 +0,0 @@
-# sb-builder phase 4: hints, implementation plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Manage slackrepo hints from sb-builder: upstream check on the desktop, hint update/new/delete on the VM through the helper, diff and revert after each.
-
-**Architecture:** mkhint's hint logic is ported into `remote/sbb-remote` as text edits (stdlib: `re`, `urllib`, `hashlib`), run as job kinds whose argv is the helper itself (`_hint`). The GUI runs `nvchecker` locally over a repo-tracked `nvchecker.toml` (`sb_builder/upstream.py`), shows a Hints screen (`sb_builder/hints_tab.py`) and a diff/revert dialog when a hint job ends.
-
-**Tech Stack:** Python 3.11+ stdlib, PyQt6 (GUI only), nvchecker CLI on the desktop.
-
-Spec: `docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md`.
-
-Every code step below was run before this plan was written: the diffs apply
-to `master` at `8ad8b0b` with `git apply` and the suite ends at 111 tests,
-OK. Two `ResourceWarning: subprocess ... is still running` lines are
-expected (detached `_run`).
-
-## Files
-
-| File | Change |
-|---|---|
-| `remote/sbb-remote` | hint text functions, hint job kinds, `_hint`, `hint-diff`, `hint-revert`, `hint-delete`, `job-result`, config key `manifest_packages`, hyphenated job ids, VERSION 0.5.0 |
-| `remote/config.example.toml` | the new key |
-| `sb_builder/__init__.py` | VERSION 0.5.0 |
-| `nvchecker.toml` | new: the VM's 64 stanzas |
-| `sb_builder/upstream.py` | new: nvchecker config, JSON log parsing, states, `Checker` |
-| `sb_builder/config.py`, `config.example.toml` | `[hints] nvchecker_keyfile` |
-| `sb_builder/hints_tab.py` | new: Hints screen, `HintResultDialog` |
-| `sb_builder/mainwindow.py` | Hints ribbon entry and tab, job end -> `job-result` -> dialog |
-| `sb_builder/packages.py` | detail pane: New hint, Open in Hints |
-| `tests/test_remote.py`, `tests/test_upstream.py` (new), `tests/test_gui.py` | tests |
-| `README.md`, `AGENTS.md`, phase 4 spec | docs |
-
----
-
-### Task 1: helper, hint editing and hint jobs
-
-**Files:**
-- Modify: `remote/sbb-remote`, `remote/config.example.toml`, `sb_builder/__init__.py`
-- Test: `tests/test_remote.py`
-
-Key behaviors (see spec Part 2): `swap_version` never touches `MD5SUM*`
-values; `apply_version` pins an unpinned (DELREQUIRES-only) hint from the
-`.info`, downloads only the first URL of each DOWNLOAD pair, and with
-`--no-dl` replaces every `DOWNLOAD*`/`MD5SUM*` line by `DOWNLOAD="no"`;
-`cmd_hint` computes before it backs up, so a failed package keeps its hint
-and old `.bak`; `hint-revert` without `.bak` deletes (reverting a new hint).
-
-- [ ] **Step 1: Write the failing tests.** Save the diff below as `/tmp/t1_test.diff`, then `git apply /tmp/t1_test.diff`.
-
-```diff
-diff --git c/tests/test_remote.py i/tests/test_remote.py
-index db3d0a1..14622b8 100644
---- c/tests/test_remote.py
-+++ i/tests/test_remote.py
-@@ -18,6 +18,9 @@
-
- import contextlib
- import fcntl
-+import functools
-+import hashlib
-+import http.server
- import importlib.machinery
- import importlib.util
- import io
-@@ -25,6 +28,7 @@ import json
- import os
- import shutil
- import sqlite3
-+import sys
- import tempfile
- import threading
- import time
-@@ -179,6 +183,255 @@ class KindsTest(Base):
- sbb.cmd_job_start(self.cfg, "rm", "-rf", "/")
-
-
-+FLATPAK_INFO = ('PRGNAM="flatpak"\nVERSION="1.16.0"\nHOMEPAGE="https://flatpak.org/"\n'
-+ 'DOWNLOAD="https://example.org/flatpak-1.16.0.tar.xz"\n'
-+ 'MD5SUM="bdf866646fb312a341f84669f625488b"\n'
-+ 'DOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\n'
-+ 'REQUIRES="bubblewrap ostree"\nMAINTAINER="Some One"\nEMAIL="one@example.org"\n')
-+
-+
-+class HintTextTest(unittest.TestCase):
-+ def test_swap_forms(self):
-+ text = ('VERSION="1.2.00"\nDOWNLOAD="https://example.org/build_1200/x-1.2.00.tar.gz"\n'
-+ 'MD5SUM="00001200aa"\nDELREQUIRES="lib1200"\n')
-+ self.assertEqual(sbb.swap_version(text, "1.2.00", "1.2.15"),
-+ 'VERSION="1.2.15"\nDOWNLOAD="https://example.org/build_1215/x-1.2.15.tar.gz"\n'
-+ 'MD5SUM="00001200aa"\nDELREQUIRES="lib1200"\n')
-+
-+ def test_swap_underscore_as_dash(self):
-+ text = 'VERSION="2026_07_07"\nDOWNLOAD="https://example.org/2026-07-07.tgz"\n'
-+ self.assertEqual(sbb.swap_version(text, "2026_07_07", "2026_08_01"),
-+ 'VERSION="2026_08_01"\nDOWNLOAD="https://example.org/2026-08-01.tgz"\n')
-+
-+ def test_md5_values_untouched(self):
-+ text = 'VERSION="4215"\nMD5SUM="aa4215bb \\\n cc4215dd"\nMD5SUM_x86_64="4215"\n'
-+ self.assertEqual(sbb.swap_version(text, "4215", "4216"),
-+ 'VERSION="4216"\nMD5SUM="aa4215bb \\\n cc4215dd"\nMD5SUM_x86_64="4215"\n')
-+
-+ def test_tokens_set_drop(self):
-+ text = 'A="x \\\n y"\nB=plain\nDOWNLOAD_x86_64=""\n'
-+ self.assertEqual(sbb.hint_tokens(text, "A"), ["x", "y"])
-+ self.assertEqual(sbb.hint_tokens(text, "B"), ["plain"])
-+ self.assertEqual(sbb.hint_tokens(text, "C"), [])
-+ self.assertEqual(sbb.hint_set(text, "B", "1 2"), 'A="x \\\n y"\nB="1 2"\nDOWNLOAD_x86_64=""\n')
-+ self.assertEqual(sbb.hint_set("Z=1", "B", "2"), 'Z=1\nB="2"\n')
-+ self.assertEqual(sbb.hint_drop(text, r"(?:A|DOWNLOAD\w*)"), "B=plain\n")
-+
-+ def test_new_hint(self):
-+ self.assertEqual(sbb.new_hint(FLATPAK_INFO),
-+ 'VERSION="1.16.0"\nDOWNLOAD="https://example.org/flatpak-1.16.0.tar.xz"\n'
-+ 'MD5SUM="bdf866646fb312a341f84669f625488b"\n'
-+ 'DOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\n'
-+ '#REQUIRES="bubblewrap ostree"\nARCH="x86_64"\n')
-+ with self.assertRaisesRegex(sbb.Error, "no VERSION"):
-+ sbb.new_hint('PRGNAM="x"\n')
-+
-+
-+class Served:
-+ """Files of a temp dir over http on localhost, for md5 downloads."""
-+
-+ def __init__(self, test, files):
-+ root = os.path.join(test.tmp, "www")
-+ os.makedirs(root)
-+ for name, data in files.items():
-+ with open(os.path.join(root, name), "wb") as f:
-+ f.write(data)
-+ handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=root)
-+ handler.log_message = lambda *a: None
-+ self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
-+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
-+ test.addCleanup(self.server.server_close)
-+ test.addCleanup(self.server.shutdown)
-+ self.url = f"http://127.0.0.1:{self.server.server_port}"
-+
-+
-+def md5(data):
-+ return hashlib.md5(data).hexdigest()
-+
-+
-+class ApplyVersionTest(Base):
-+ def setUp(self):
-+ super().setUp()
-+ self.www = Served(self, {"x-2.0.tgz": b"two", "dep.tgz": b"dep"})
-+ self.log = []
-+
-+ def apply(self, text, new, no_dl=False, info=""):
-+ return sbb.apply_version(text, info, new, no_dl, self.log.append)
-+
-+ def test_download_refreshes_first_md5_only(self):
-+ u = self.www.url
-+ text = (f'VERSION="1.0"\nDOWNLOAD="{u}/x-1.0.tgz \\\n {u}/dep.tgz"\n'
-+ 'MD5SUM="old1 \\\n keep2"\nDOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\nARCH="x86_64"\n')
-+ self.assertEqual(self.apply(text, "2.0"),
-+ f'VERSION="2.0"\nDOWNLOAD="{u}/x-2.0.tgz \\\n {u}/dep.tgz"\n'
-+ f'MD5SUM="{md5(b"two")} \\\n keep2"\nDOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\n'
-+ 'ARCH="x86_64"\n')
-+ self.assertEqual(self.log, [f"downloading {u}/x-2.0.tgz"])
-+
-+ def test_arch_pair_and_unsupported(self):
-+ u = self.www.url
-+ text = (f'VERSION="1.0"\nDOWNLOAD="UNSUPPORTED"\nMD5SUM=""\n'
-+ f'DOWNLOAD_x86_64="{u}/x-1.0.tgz"\nMD5SUM_x86_64="old"\n')
-+ self.assertIn(f'MD5SUM_x86_64="{md5(b"two")}"', self.apply(text, "2.0"))
-+ self.assertIn('DOWNLOAD="UNSUPPORTED"\nMD5SUM=""\n', self.apply(text, "2.0"))
-+
-+ def test_unpinned_hint_pins_from_info(self):
-+ u = self.www.url
-+ info = (f'PRGNAM="x"\nVERSION="1.0"\nDOWNLOAD="{u}/x-1.0.tgz"\nMD5SUM="old"\n'
-+ 'DOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\nREQUIRES="a"\n')
-+ self.assertEqual(self.apply('DELREQUIRES="rust-opt"\n', "2.0", info=info),
-+ f'VERSION="2.0"\nDOWNLOAD="{u}/x-2.0.tgz"\nMD5SUM="{md5(b"two")}"\n'
-+ 'DOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\nARCH="x86_64"\nDELREQUIRES="rust-opt"\n')
-+ self.assertEqual(self.apply('DELREQUIRES="rust-opt"\n', "2.0", no_dl=True, info=info),
-+ 'VERSION="2.0"\nARCH="x86_64"\nDELREQUIRES="rust-opt"\nDOWNLOAD="no"\n')
-+
-+ def test_no_dl(self):
-+ text = ('VERSION="1.0"\nDOWNLOAD="https://example.org/x-1.0.tgz \\\n https://example.org/d"\n'
-+ 'MD5SUM="a \\\n b"\nDOWNLOAD_x86_64="https://example.org/y"\nMD5SUM_x86_64="c"\n'
-+ 'ARCH="x86_64"\n')
-+ self.assertEqual(self.apply(text, "2.0", no_dl=True),
-+ 'VERSION="2.0"\nARCH="x86_64"\nDOWNLOAD="no"\n')
-+ self.assertEqual(self.log, [])
-+
-+ def test_no_version_no_download(self):
-+ self.assertEqual(self.apply('VERSION="1.0"\nDOWNLOAD="x"\nMD5SUM="m"\n', None),
-+ 'VERSION="1.0"\nDOWNLOAD="x"\nMD5SUM="m"\n')
-+
-+ def test_download_failure_raises(self):
-+ with self.assertRaises(OSError):
-+ self.apply(f'VERSION="1.0"\nDOWNLOAD="{self.www.url}/x-1.0.tgz"\nMD5SUM="m"\n', "3.0")
-+
-+
-+class HintJobsTest(Base):
-+ def setUp(self):
-+ super().setUp()
-+ self.www = Served(self, {"x-2.0.tgz": b"two"})
-+ self.info = (f'PRGNAM="x"\nVERSION="1.0"\nDOWNLOAD="{self.www.url}/x-1.0.tgz"\nMD5SUM="old"\n'
-+ 'DOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\nREQUIRES="rust-opt a"\n')
-+ self.hint = os.path.join(self.cfg["hintdir"], "x.hint")
-+
-+ def read(self, path):
-+ with open(path) as f:
-+ return f.read()
-+
-+ def run_hint(self, *args):
-+ out = io.StringIO()
-+ with contextlib.redirect_stdout(out):
-+ try:
-+ sbb.cmd_hint(self.cfg, *args)
-+ rc = 0
-+ except sbb.Error:
-+ rc = 1
-+ lines = out.getvalue().splitlines()
-+ self.assertTrue(lines[-1].startswith("SBB-RESULT "))
-+ return rc, json.loads(lines[-1].split(" ", 1)[1]), lines
-+
-+ def test_kinds_argv(self):
-+ self.make_pkg("misc/x", self.info)
-+ me = [sys.executable, os.path.abspath(sbb.__file__), "_hint"]
-+ self.assertEqual(sbb.KINDS["hint-update"](self.cfg, ["x=2.0", "--no-dl"]),
-+ (me + ["hint-update", "x=2.0", "--no-dl"], {}))
-+ self.assertEqual(sbb.KINDS["hint-new"](self.cfg, ["x", "2.0"]), (me + ["hint-new", "x", "2.0"], {}))
-+ self.assertFalse(sbb.signs("hint-update", ["x=2.0"]))
-+
-+ def test_kinds_reject(self):
-+ self.make_pkg("misc/x", self.info)
-+ self.make_pkg("misc/neovim", self.info)
-+ self.make_pkg("misc/shadowed", self.info)
-+ os.makedirs(os.path.join(self.cfg["repo_dir"], "misc/shadowed"), exist_ok=True)
-+ with open(os.path.join(self.cfg["repo_dir"], "misc/shadowed/shadowed.hint"), "w") as f:
-+ f.write("")
-+ for kind, args, why in (("hint-update", [], "at least one"),
-+ ("hint-update", ["x"], "pkg=version"),
-+ ("hint-update", ["x=1;rm"], "bad version"),
-+ ("hint-update", ["x=" + "1" * 65], "bad version"),
-+ ("hint-update", ["nope=1"], "no such package"),
-+ ("hint-update", ["x=1", "x=2"], "twice"),
-+ ("hint-update", ["x=1", "--force"], "unknown option"),
-+ ("hint-update", ["neovim=1"], "bundled deps"),
-+ ("hint-update", ["shadowed=1"], "not managed"),
-+ ("hint-new", [], "hint-new <pkg>"),
-+ ("hint-new", ["x", "1", "2"], "hint-new <pkg>"),
-+ ("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, <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):
-+ 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"]*?)(?<!\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):
-+ """mkhint's hint from an .info: the pin, REQUIRES commented, ARCH.
-+
-+ No phantom deps: a package that has them already has a hint, the
-+ DELREQUIRES one sbshadow's mkhint -F writes after every sync.
-+ """
-+ text = _pin(info)
-+ m = _assignment("REQUIRES").search(info)
-+ if m:
-+ text += "#" + m.group(0).rstrip("\n") + "\n"
-+ return text + 'ARCH="x86_64"\n'
-+
-+
-+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]]}."""
-+ if kind not in ("hint-update", "hint-new"):
-+ raise Error(f"unknown hint job: {kind}")
-+ ok, failed = [], []
-+ pkgs = _pkgmap(cfg)
-+ 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)
-+ _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)])
-+ 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 +896,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` (102 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 and hint-new 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` (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 <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..6be57fc
---- /dev/null
-+++ i/sb_builder/hints_tab.py
-@@ -0,0 +1,277 @@
-+# 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")
-+
-+
-+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 <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..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 <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,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": {"<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; 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 <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
-@@ -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 <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.
-
-@@ -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 <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.
--- `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 <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 +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 <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: 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.
diff --git a/docs/superpowers/plans/2026-10-20-sb-builder-ui.md b/docs/superpowers/plans/2026-10-20-sb-builder-ui.md
deleted file mode 100644
index 4e80b90..0000000
--- a/docs/superpowers/plans/2026-10-20-sb-builder-ui.md
+++ /dev/null
@@ -1,1651 +0,0 @@
-# sb-builder: one UI frame, 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:** Every tab in one frame (permanent tabs, a main ribbon of actions, a small ribbon of view controls), actions on ticked rows, and each job's next step as a dialog where the user is.
-
-**Architecture:** `sb_builder/ui.py` builds ribbon `QAction`s (`action`, `enable`); every tab exposes `actions` and `view_actions` and the main window shows the current tab's lists in two toolbars. `JobsTab` gains `NextStep` (preview log, Run it / Apply shadowing) and the `started`/`preview_requested` signals; the main window keeps the ids it started (`mine`) and opens next steps only for them. The helper's `hint-new` takes several packages.
-
-**Tech Stack:** Python 3.11+ stdlib, PyQt6.
-
-Spec: `docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md`.
-
-Every code step was run before this plan was written: the diffs apply to
-`master` at `f4ee0ce` and the suite ends at 115 tests, OK. Screens were
-checked offscreen (each tab's ribbons, filter rows not stretched, the Jobs
-tab reading "Jobs (running)").
-
----
-
-### Task 1: helper, `hint-new` with several packages
-
-**Files:** `remote/sbb-remote`, `sb_builder/__init__.py`, `tests/test_remote.py`
-
-- [ ] **Step 1: Failing tests.** Save the diff below as `/tmp/u1_test.diff`, then `git apply /tmp/u1_test.diff`.
-
-```diff
-diff --git c/tests/test_remote.py i/tests/test_remote.py
-index 14622b8..54c5614 100644
---- c/tests/test_remote.py
-+++ i/tests/test_remote.py
-@@ -331,7 +331,8 @@ class HintJobsTest(Base):
- 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.make_pkg("misc/y", self.info)
-+ self.assertEqual(sbb.KINDS["hint-new"](self.cfg, ["x", "y"]), (me + ["hint-new", "x", "y"], {}))
- self.assertFalse(sbb.signs("hint-update", ["x=2.0"]))
-
- def test_kinds_reject(self):
-@@ -351,14 +352,14 @@ class HintJobsTest(Base):
- ("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")):
-+ ("hint-new", ["x", "2.0"], "no such package: 2.0"), # no version any more
-+ ("hint-new", ["x", "x"], "twice")):
- 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"])
-+ with self.assertRaisesRegex(sbb.Error, "y: hint exists"):
-+ sbb.KINDS["hint-new"](self.cfg, ["x", "y"]) # one with a hint refuses the whole job
-
- def test_update_backs_up_and_reports(self):
- self.make_pkg("misc/x", self.info, hint='DELREQUIRES="rust-opt"\n')
-@@ -388,8 +389,9 @@ class HintJobsTest(Base):
-
- 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.make_pkg("misc/y", self.info)
-+ rc, result, _ = self.run_hint("hint-new", "x", "y")
-+ self.assertEqual((rc, result), (0, {"ok": ["x", "y"], "failed": []}))
- self.assertIn('#REQUIRES="rust-opt a"', self.read(self.hint))
- self.assertIn('MD5SUM="old"', self.read(self.hint)) # no version given: no download
-
-```
-
-- [ ] **Step 2:** Run `python3 -m unittest tests.test_remote`. Expected: FAILED, `hint-new <pkg>` usage and `y: hint exists` assertions.
-- [ ] **Step 3: Implement.** Save the diff below as `/tmp/u1_code.diff`, then `git apply /tmp/u1_code.diff`.
-
-```diff
-diff --git c/remote/sbb-remote i/remote/sbb-remote
-index 2674ed6..5183b77 100755
---- c/remote/sbb-remote
-+++ i/remote/sbb-remote
-@@ -40,7 +40,7 @@ import time
- import tomllib
- import urllib.request
-
--VERSION = "0.5.0"
-+VERSION = "0.6.0"
- DEFAULTS = {
- "state_dir": "/var/lib/sb-builder",
- "repo_dir": "/var/lib/sbopkg/SBo-danix",
-@@ -168,9 +168,9 @@ def hint_args(cfg, kind, args):
- 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)]
-+ if not rest:
-+ raise Error("usage: hint-new <pkg>... [--no-dl]")
-+ pairs = [(pkg, None) for pkg in rest] # the .info pin; Update sets a version later
- else:
- if not rest:
- raise Error("hint-update needs at least one pkg=version")
-diff --git c/sb_builder/__init__.py i/sb_builder/__init__.py
-index 21a93ab..f9bea08 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.5.0" # must match remote/sbb-remote VERSION
-+VERSION = "0.6.0" # must match remote/sbb-remote VERSION
-```
-
-- [ ] **Step 4:** Run `python3 -m unittest discover -s tests`. Expected: `OK`. (`VersionTest` passes: both files say 0.6.0.)
-- [ ] **Step 5: Commit.**
-
-```bash
-git add remote/sbb-remote sb_builder/__init__.py tests/test_remote.py
-git commit -m "sbb-remote 0.6.0: hint-new takes several packages, no version
-
-Each gets the .info pin; Update selected sets a version later. One
-package that already has a hint refuses the whole job before it starts.
-
-Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
-```
-
-### Task 2: the frame, ribbons, next-step dialogs
-
-**Files:**
-- Create: `sb_builder/ui.py`
-- Modify: `sb_builder/dashboard.py`, `sb_builder/packages.py`, `sb_builder/hints_tab.py`, `sb_builder/results.py`, `sb_builder/jobs.py`, `sb_builder/mainwindow.py`
-- Test: `tests/test_gui.py`
-
-One task: the tabs' ribbon lists and the main window that shows them only
-work together. Key points: tabs are created at startup, not closable,
-`loaded` flags drive the first `reload()`; `_tab_changed` refills both
-toolbars (a menu action gets `InstantPopup`); `_badge` sets the Jobs tab
-icon and its "(running)" text (the tab style sheet would override a text
-color); `show_next_step` keeps one `NextStep` per job, and for an automatic
-shadowing preview shows it only when `loaded(has_plan)` says there is a plan.
-
-- [ ] **Step 1: Failing tests.** Save the diff below as `/tmp/u2_test.diff`, then `git apply /tmp/u2_test.diff`.
-
-```diff
-diff --git c/tests/test_gui.py i/tests/test_gui.py
-index 1f3f7b0..ee568c1 100644
---- c/tests/test_gui.py
-+++ i/tests/test_gui.py
-@@ -29,7 +29,7 @@ import unittest # noqa: E402
- ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
- sys.path.insert(0, ROOT)
-
--from PyQt6.QtCore import QCoreApplication, QEvent, QObject, QPointF, QProcess, Qt, pyqtSignal # noqa: E402
-+from PyQt6.QtCore import QObject, QPointF, QProcess, pyqtSignal # noqa: E402
- from PyQt6.QtGui import QColor # noqa: E402
- from PyQt6.QtWidgets import QApplication # noqa: E402
-
-@@ -309,7 +309,7 @@ class SmokeTest(unittest.TestCase):
- return w, remote
-
- def test_tabs_and_dashboard(self):
-- w, _ = self.window()
-+ w, remote = self.window(**{"job-start": ({"id": "20260101-000001-sbpull"}, None)})
- w.poll_jobs()
- self.assertEqual(w.conn.text(), "● connected")
- self.assertEqual(w.tabs.tabText(0), "Dashboard")
-@@ -328,8 +328,10 @@ class SmokeTest(unittest.TestCase):
- self.assertEqual(w.dashboard.legend.text(), "n/a")
- jobs = w.open_jobs()
- self.assertIs(w.open_jobs(), jobs)
-- self.assertEqual(w.tabs.count(), 2)
-- self.assertEqual(jobs.start_menu.actions()[0].text(), "sbpull personal (preview)")
-+ self.assertEqual(w.tabs.count(), 5)
-+ self.assertEqual([a.text() for a in jobs.pull_menu.actions()], ["personal (preview)"])
-+ jobs.pull_menu.actions()[0].trigger()
-+ self.assertIn(("job-start", "sbpull", "personal", "--no"), remote.calls)
-
- def test_end_notification_once(self):
- w, remote = self.window(jobs=({"jobs": [JOB]}, None))
-@@ -363,30 +365,6 @@ class SmokeTest(unittest.TestCase):
- [("ok", 3), ("failed", 1), ("removed", 1)])
- self.assertEqual(w.dashboard.tiles["Built"].detail.text(), "of 10 · 6 never built")
-
-- def test_close_jobs_tab_while_following(self):
-- # regression: the follow process ended after the tab was deleted, its
-- # finished slot touched dead widgets and PyQt aborted the app
-- w, remote = self.window(jobs=({"jobs": [JOB]}, None))
-- procs = QObject()
-- self.addCleanup(procs.deleteLater)
--
-- def follow(jid, offset):
-- p = QProcess(procs)
-- p.start("sleep", ["5"])
-- return p
--
-- remote.follow = follow
-- w.poll_jobs()
-- jobs = w.open_jobs()
-- jobs.select(JOB["id"])
-- self.assertIsNotNone(jobs.proc)
-- w._close_tab(w.tabs.indexOf(jobs))
-- QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value)
-- end = time.monotonic() + 1
-- while time.monotonic() < end: # let the killed process report in
-- app.processEvents()
-- self.assertIsNone(w.jobs_tab)
--
- def test_packages_tab(self):
- w, remote = self.window(**{"job-start": ({"id": "20260101-000001-slackrepo"}, None)})
- w.poll_jobs()
-@@ -422,34 +400,107 @@ class SmokeTest(unittest.TestCase):
- ["slackrepo", "build", "--preview", "curl", "nuclei"],
- ["slackrepo", "update", "--preview"]])
-
-- def test_run_preview_with_signing(self):
-- w, remote = self.window(jobs=({"jobs": [PREVIEW]}, None),
-- **{"gpg-status": ({"unlocked": False, "key": "K"}, None),
-- "gpg-unlock": lambda args, stdin: (
-- (None, "bad passphrase") if stdin == b"wrong\n" else ({}, None)),
-- "job-start": ({"id": "20260101-000001-slackrepo"}, None)})
-+ def end_preview(self, job, **answers):
-+ """A window where job, started by this session, goes from running to its status."""
-+ w, remote = self.window(jobs=({"jobs": [dict(job, status="running")]}, None), **answers)
-+ w.mine.add(job["id"])
- w.poll_jobs()
-- jobs = w.open_jobs()
-- jobs.select(PREVIEW["id"])
-- self.assertTrue(jobs.run_btn.isVisibleTo(jobs))
-+ remote.answers["jobs"] = ({"jobs": [job]}, None)
-+ w.poll_jobs()
-+ return w, remote
-+
-+ def test_preview_next_step_with_signing(self):
-+ w, remote = self.end_preview(
-+ PREVIEW, **{"gpg-status": ({"unlocked": False, "key": "K"}, None),
-+ "gpg-unlock": lambda args, stdin: (
-+ (None, "bad passphrase") if stdin == b"wrong\n" else ({}, None)),
-+ "job-start": ({"id": "20260101-000001-slackrepo"}, None)})
-+ self.assertIs(w.tabs.currentWidget(), w.dashboard) # nobody was sent to Jobs
-+ d = w.next_steps[PREVIEW["id"]]
-+ self.assertEqual(d.windowTitle(), "Preview: slackrepo update nuclei")
-+ self.assertTrue(d.publish.isChecked())
- answers = iter(["wrong", "right"])
-- jobs.ask_passphrase = lambda retry: next(answers)
-- jobs.publish_box.setChecked(False)
-- jobs.run_preview()
-+ w.jobs_tab.ask_passphrase = lambda retry: next(answers)
-+ d.publish.setChecked(False)
-+ d.run_btn.click()
- self.assertIn(("gpg-unlock", b"wrong\n"), remote.calls)
- self.assertIn(("gpg-unlock", b"right\n"), remote.calls)
-- self.assertEqual(remote.calls[-2], ("job-start", "slackrepo", "update", "--no-publish", "nuclei"))
-+ self.assertIn(("job-start", "slackrepo", "update", "--no-publish", "nuclei"), remote.calls)
-+ self.assertEqual(w.next_steps, {})
-
-- def test_signing_cancel_starts_nothing(self):
-- w, remote = self.window(jobs=({"jobs": [PREVIEW]}, None),
-- **{"gpg-status": ({"unlocked": False, "key": "K"}, None)})
-- w.poll_jobs()
-+ def test_preview_not_now_then_run_it(self):
-+ w, remote = self.end_preview(PREVIEW, **{"job-start": ({"id": "20260101-000001-slackrepo"}, None)})
-+ w.next_steps[PREVIEW["id"]].reject()
-+ self.assertEqual(w.next_steps, {})
- jobs = w.open_jobs()
- jobs.select(PREVIEW["id"])
-- jobs.ask_passphrase = lambda retry: None
-- jobs.run_preview()
-+ self.assertTrue(jobs.act_run.isEnabled())
-+ jobs.act_run.trigger() # Not now loses nothing
-+ w.next_steps[PREVIEW["id"]].run_btn.click()
-+ self.assertIn(("job-start", "slackrepo", "update", "nuclei"), remote.calls)
-+
-+ def test_signing_cancel_starts_nothing(self):
-+ w, remote = self.end_preview(PREVIEW, **{"gpg-status": ({"unlocked": False, "key": "K"}, None)})
-+ w.jobs_tab.ask_passphrase = lambda retry: None
-+ w.next_steps[PREVIEW["id"]].run_btn.click()
- self.assertFalse(any(c[0] == "job-start" for c in remote.calls))
-
-+ def test_preview_from_elsewhere_has_no_dialog(self):
-+ w, remote = self.window(jobs=({"jobs": [dict(PREVIEW, status="running")]}, None))
-+ w.poll_jobs()
-+ remote.answers["jobs"] = ({"jobs": [PREVIEW]}, None)
-+ w.poll_jobs()
-+ self.assertEqual(w.next_steps, {})
-+ self.assertEqual(w.notifier.sent[0][0], "sb-builder: finished")
-+
-+ def test_shadow_preview_dialog_only_with_a_plan(self):
-+ shadow = dict(PREVIEW, id="20260101-000000-sbshadow", kind="sbshadow", args=["--no"])
-+ for log, shown in (("shadow: misc/foo\n", True),
-+ ("sbshadow: nothing to shadow or unshadow.\n", False)):
-+ with self.subTest(log=log):
-+ procs = QObject()
-+ self.addCleanup(procs.deleteLater)
-+
-+ def follow(jid, offset, log=log):
-+ p = QProcess(procs)
-+ p.start("printf", ["%s", log])
-+ return p
-+ w, remote = self.window(jobs=({"jobs": [dict(shadow, status="running")]}, None),
-+ **{"job-start": ({"id": "20260101-000001-sbshadow"}, None)})
-+ remote.follow = follow
-+ w.mine.add(shadow["id"])
-+ w.poll_jobs()
-+ remote.answers["jobs"] = ({"jobs": [shadow]}, None)
-+ w.poll_jobs()
-+ d = w.next_steps.get(shadow["id"])
-+ end = time.monotonic() + 3
-+ while d and not d.isVisible() and shadow["id"] in w.next_steps and time.monotonic() < end:
-+ app.processEvents()
-+ self.assertEqual(shadow["id"] in w.next_steps and d.isVisible(), shown)
-+ if shown:
-+ self.assertEqual(d.run_btn.text(), "Apply shadowing")
-+ self.assertFalse(d.publish.isVisibleTo(d))
-+ d.confirm = lambda q: True
-+ d.run_btn.click()
-+ self.assertIn(("job-start", "sbshadow", "--yes"), remote.calls)
-+
-+ def test_jobs_tab_badge(self):
-+ w, remote = self.window(jobs=({"jobs": [JOB]}, None))
-+ w.poll_jobs()
-+ jobs_i = w.tabs.indexOf(w.jobs_tab)
-+ self.assertEqual(w.badge, "running")
-+ self.assertFalse(w.tabs.tabIcon(jobs_i).isNull())
-+ self.assertEqual(w.tabs.tabText(jobs_i), "Jobs (running)")
-+ remote.answers["jobs"] = ({"jobs": [dict(JOB, status="failed", end=5, exit=1)]}, None)
-+ w.poll_jobs()
-+ self.assertEqual(w.badge, "bad") # stays until Jobs is shown
-+ self.assertEqual(w.tabs.tabText(jobs_i), "Jobs")
-+ w.poll_jobs()
-+ self.assertEqual(w.badge, "bad")
-+ w.open_jobs()
-+ self.assertIsNone(w.badge)
-+ self.assertTrue(w.tabs.tabIcon(jobs_i).isNull())
-+
- def test_key_indicator_and_tile_links(self):
- w, _ = self.window(**{"gpg-status": ({"unlocked": False, "key": "K"}, None)})
- w.poll_jobs()
-@@ -459,16 +510,46 @@ class SmokeTest(unittest.TestCase):
- w.dashboard.jobs.clicked.emit()
- self.assertIs(w.tabs.currentWidget(), w.jobs_tab)
-
-- def test_ribbon_opens_tabs(self):
-- w, _ = self.window()
-+ def test_ribbons_per_tab(self):
-+ w, remote = self.window()
- w.poll_jobs()
-- self.assertEqual([a.text() for a in w.ribbon.actions()],
-+ self.assertEqual([w.tabs.tabText(i) for i in range(w.tabs.count())],
- ["Dashboard", "Packages", "Hints", "Results", "Jobs"])
-+ self.assertFalse(w.tabs.tabsClosable())
-+ self.assertNotIn(("inventory",), remote.calls) # each tab loads on first show
-+ expect = {
-+ "Dashboard": (["Refresh"], []),
-+ "Packages": (["Build", "Update", "Rebuild", "Remove", "Update all", "New hint", "Publish"],
-+ ["Select visible", "Clear", "Refresh"]),
-+ "Hints": (["Check upstream", "Update selected", "Delete hint"],
-+ ["Select visible", "Clear", "No download", "Refresh"]),
-+ "Results": (["Show build log"], ["Refresh"]),
-+ "Jobs": (["Pull overlay", "Check shadowing", "Run it", "Kill"], []),
-+ }
-+ for i in range(w.tabs.count()):
-+ w.tabs.setCurrentIndex(i)
-+ main, view = expect[w.tabs.tabText(i)]
-+ self.assertEqual([a.text() for a in w.ribbon.actions()], main)
-+ self.assertEqual([a.text() for a in w.view_bar.actions()], view)
-+ self.assertEqual(w.view_bar.isVisibleTo(w), bool(view))
-+ self.assertEqual(remote.calls.count(("inventory",)), 2) # Packages and Hints, once each
-+ w.open_packages()
-+ self.assertEqual(remote.calls.count(("inventory",)), 2)
- 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_disabled_actions_say_why(self):
-+ w, _ = self.window()
-+ w.poll_jobs()
-+ p, h, j = w.open_packages(), w.open_hints(), w.open_jobs()
-+ for a, why in ((p.act_build["build"], "tick packages first"),
-+ (p.act_new_hint, "none of the ticked packages is without a hint"),
-+ (h.act_update, "tick outdated packages after Check upstream"),
-+ (h.act_delete, "none of the ticked packages has a hint"),
-+ (j.act_run, "select a finished preview"), (j.act_kill, "select a running job")):
-+ with self.subTest(action=a.text()):
-+ self.assertFalse(a.isEnabled())
-+ self.assertTrue(a.toolTip().endswith(why), a.toolTip())
-+ self.assertEqual(h.view_actions[0].toolTip(), "Select visible (Ctrl+A)")
-
- def test_hints_tab(self):
- w, remote = self.window(**{"job-start": ({"id": "20260101-000001-hint-update"}, None),
-@@ -534,6 +615,7 @@ class SmokeTest(unittest.TestCase):
- None),
- "hint-revert": ({"ok": True}, None),
- "job-start": ({"id": "20260101-000001-slackrepo"}, None)})
-+ w.mine.add(job["id"])
- w.poll_jobs()
- remote.answers["jobs"] = ({"jobs": [dict(job, status="failed", end=5, exit=1)]}, None)
- w.poll_jobs()
-@@ -561,17 +643,17 @@ class SmokeTest(unittest.TestCase):
- 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.assertTrue(tab.open_hint_btn.isVisibleTo(tab))
-+ tab.open_hint_btn.linkActivated.emit("#")
- 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)
-+ tab.model.checked.update({"foo"})
-+ tab.update_count()
-+ self.assertFalse(tab.act_new_hint.isEnabled()) # foo has one
-+ tab.model.checked.update({"curl", "nuclei"})
-+ tab.update_count()
-+ tab.act_new_hint.trigger()
-+ self.assertIn(("job-start", "hint-new", "curl", "nuclei"), remote.calls)
-+ self.assertIs(w.tabs.currentWidget(), w.hints_tab) # starting a job stays put
-
- def test_tab_shortcuts_wrap(self):
- w, _ = self.window()
-@@ -583,7 +665,7 @@ class SmokeTest(unittest.TestCase):
- nxt.activated.emit()
- self.assertEqual(w.tabs.currentIndex(), 0) # wrapped from Jobs to Dashboard
- prev.activated.emit()
-- self.assertEqual(w.tabs.currentIndex(), 2)
-+ self.assertEqual(w.tabs.currentIndex(), 4)
-
- def test_results_tab(self):
- w, remote = self.window()
-@@ -597,10 +679,11 @@ class SmokeTest(unittest.TestCase):
- tab.chips["failed"].setChecked(False)
- self.assertEqual(tab.visible_paths(), ["games/pcsx2", "development/whisper.cpp", "misc/nuclei"])
- tab.table.selectRow(1) # whisper.cpp: no log dir
-- self.assertFalse(tab.log_btn.isEnabled())
-+ self.assertFalse(tab.act_log.isEnabled())
-+ self.assertTrue(tab.act_log.toolTip().endswith("select a result with a log"))
- tab.table.selectRow(0)
-- self.assertTrue(tab.log_btn.isEnabled())
-- tab.show_log()
-+ self.assertTrue(tab.act_log.isEnabled())
-+ tab.act_log.trigger()
- self.assertIn(("build-log", "games/pcsx2"), remote.calls)
- viewer = tab.viewers[-1]
- self.assertIn("ERROR: make failed", viewer.text.toPlainText()) # ANSI stripped
-```
-
-- [ ] **Step 2:** Run `python3 -m unittest tests.test_gui`. Expected: FAILED, e.g. `AttributeError: 'MainWindow' object has no attribute 'mine'`.
-- [ ] **Step 3: Implement.** Save the diff below as `/tmp/u2_code.diff`, then `git apply /tmp/u2_code.diff`.
-
-```diff
-diff --git c/sb_builder/dashboard.py i/sb_builder/dashboard.py
-index 5dee453..3096fb8 100644
---- c/sb_builder/dashboard.py
-+++ i/sb_builder/dashboard.py
-@@ -22,10 +22,11 @@ import time
-
- from PyQt6.QtCore import QEvent, QPointF, QRectF, Qt, pyqtSignal
- from PyQt6.QtGui import QColor, QFont, QPainter, QPalette
--from PyQt6.QtWidgets import (QFrame, QGridLayout, QHBoxLayout, QLabel, QPushButton,
-- QToolTip, QVBoxLayout, QWidget)
-+from PyQt6.QtWidgets import (QFrame, QGridLayout, QHBoxLayout, QLabel, QToolTip, QVBoxLayout,
-+ QWidget)
-
- from .fmt import ICON, NA, fmt_age, fmt_bytes, fmt_duration, fmt_time, job_title
-+from .ui import SP, action
-
- # reserved status colors (dataviz reference palette); always shown with a text label
- STATUS = {"ok": "#0ca30c", "aborted": "#fab219", "bad": "#ec835a", "failed": "#d03b3b",
-@@ -288,11 +289,11 @@ class Dashboard(QWidget):
- self.jobs.body.addStretch()
-
- self.stale = muted(QLabel())
-- self.refresh_btn = QPushButton("Refresh")
-+ self.refresh = action(self, "Refresh", "view-refresh", SP.SP_BrowserReload)
-+ self.actions, self.view_actions = [self.refresh], []
- top = QHBoxLayout()
- top.addWidget(self.stale)
- top.addStretch()
-- top.addWidget(self.refresh_btn)
-
- grid = QGridLayout()
- grid.setSpacing(12)
-diff --git c/sb_builder/hints_tab.py i/sb_builder/hints_tab.py
-index b78ec69..feba94d 100644
---- c/sb_builder/hints_tab.py
-+++ i/sb_builder/hints_tab.py
-@@ -21,13 +21,14 @@ import os
-
- from PyQt6.QtCore import Qt, pyqtSignal
- from PyQt6.QtGui import QColor, QFontDatabase, QKeySequence, QShortcut, QSyntaxHighlighter
--from PyQt6.QtWidgets import (QCheckBox, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
-+from PyQt6.QtWidgets import (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
-+from .ui import SP, action, enable
-
- STATES = ("outdated", "current", "no stanza", "not checked", "nvchecker error")
- STATE_COLOR = {"outdated": STATUS["aborted"], "current": STATUS["ok"],
-@@ -59,9 +60,8 @@ class HintsTab(QWidget):
- 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.status = QLabel()
-+ chips.addWidget(self.status)
-
- self.table = QTableWidget(0, len(COLUMNS))
- self.table.setHorizontalHeaderLabels(COLUMNS)
-@@ -74,37 +74,36 @@ class HintsTab(QWidget):
- self.table.setColumnWidth(col, width)
- self.table.cellClicked.connect(self._toggle)
-
-- 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)
-- self.keys = []
-- for label, on, key in (("Select visible", True, "Ctrl+A"), ("Clear", False, "Ctrl+Shift+A")):
-- b = QPushButton(label, toolTip=key)
-- b.clicked.connect(lambda _c, on=on: self.check_rows(on))
-- bar.addWidget(b)
-- # in the search field Ctrl+A still selects its text: QLineEdit overrides it
-- self.keys.append(QShortcut(QKeySequence(key), self, lambda on=on: self.check_rows(on),
-- context=Qt.ShortcutContext.WidgetWithChildrenShortcut))
-- 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)
-+ self.act_check = action(self, "Check upstream", "system-search",
-+ SP.SP_FileDialogContentsView, self.check)
-+ self.act_update = action(self, "Update selected", "document-save", SP.SP_DialogSaveButton,
-+ self.update_selected)
-+ self.act_delete = action(self, "Delete hint", "edit-delete", SP.SP_TrashIcon, self.delete)
-+ self.actions = [self.act_check, self.act_update, self.act_delete]
-+ self.no_dl = action(self, "No download", "network-offline", SP.SP_DriveNetIcon, checkable=True)
-+ self.no_dl.setToolTip('Update selected writes DOWNLOAD="no": slackrepo uses the source '
-+ "already in its cache")
-+ self.view_actions = [
-+ action(self, "Select visible", "edit-select-all", SP.SP_DialogYesButton,
-+ lambda: self.check_rows(True), shortcut="Ctrl+A"),
-+ action(self, "Clear", "edit-clear", SP.SP_DialogResetButton,
-+ lambda: self.check_rows(False), shortcut="Ctrl+Shift+A"),
-+ self.no_dl,
-+ action(self, "Refresh", "view-refresh", SP.SP_BrowserReload, self.reload)]
-+ # in the search field Ctrl+A still selects its text: QLineEdit overrides it
-+ self.keys = [QShortcut(QKeySequence(key), self, lambda on=on: self.check_rows(on),
-+ context=Qt.ShortcutContext.WidgetWithChildrenShortcut)
-+ for key, on in (("Ctrl+A", True), ("Ctrl+Shift+A", False))]
-+ self.loaded = False
-
- layout = QVBoxLayout(self)
- layout.addWidget(self.query)
- layout.addLayout(chips)
-- layout.addWidget(self.table)
-- layout.addLayout(bar)
-+ layout.addWidget(self.table, 1)
-+ self.refilter()
-
- def reload(self):
-+ self.loaded = True
- self.status.setText("loading…")
- self.remote.call(self._loaded, "inventory")
-
-@@ -152,8 +151,12 @@ class HintsTab(QWidget):
- 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 ""))
-+ ticked = set(self.checked())
-+ self.status.setText(f"{shown} shown · {len(ticked)} selected"
-+ + (f" · {self.failure}" if self.failure else ""))
-+ enable(self.act_update, None if self.pairs() else "tick outdated packages after Check upstream")
-+ enable(self.act_delete, None if any(r["hint_path"] for r in self.rows if r["name"] in ticked)
-+ else "none of the ticked packages has a hint")
-
- def _toggle(self, row, _col):
- """A click anywhere on a row, its box included, ticks or unticks it."""
-@@ -187,20 +190,24 @@ class HintsTab(QWidget):
- return
-
- def check(self):
-- self.check_btn.setEnabled(False)
-+ enable(self.act_check, "checking…")
- 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)
-+ enable(self.act_check, None)
- self.versions, self.errors, self.failure = versions, errors, failure
- self._fill(check_outdated=True)
-
-- def update_selected(self):
-+ def pairs(self):
-+ """pkg=version of the ticked packages Check upstream found a version for."""
- 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 []))
-+ return [f"{n}={versions[n]}" for n in self.checked() if n in versions]
-+
-+ def update_selected(self):
-+ if self.pairs():
-+ self.start_requested.emit(["hint-update", *self.pairs()]
-+ + (["--no-dl"] if self.no_dl.isChecked() else []))
-
- def confirm(self, title, text):
- return QMessageBox.question(self, title, text) == QMessageBox.StandardButton.Yes
-diff --git c/sb_builder/jobs.py i/sb_builder/jobs.py
-index b413597..111d804 100644
---- c/sb_builder/jobs.py
-+++ i/sb_builder/jobs.py
-@@ -14,18 +14,19 @@
- # with this program; if not, write to the Free Software Foundation, Inc.,
- # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-
--"""Jobs tab: job list, live log through job-follow, start and kill."""
-+"""Jobs tab: job list, live log through job-follow, start and kill; the next-step dialog."""
-
- import codecs
- import re
-
- from PyQt6.QtCore import Qt, pyqtSignal
- from PyQt6.QtGui import QFontDatabase, QTextCursor
--from PyQt6.QtWidgets import (QCheckBox, QHBoxLayout, QInputDialog, QLineEdit, QListWidget,
-- QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit, QPushButton,
-- QSplitter, QToolButton, QVBoxLayout, QWidget)
-+from PyQt6.QtWidgets import (QCheckBox, QDialog, QHBoxLayout, QInputDialog, QLineEdit,
-+ QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit,
-+ QPushButton, QSplitter, QVBoxLayout, QWidget)
-
--from .fmt import describe
-+from .fmt import describe, job_title
-+from .ui import SP, action, enable
-
- ANSI = re.compile(rb"\x1b\[[0-9;?]*[A-Za-z]")
- SHADOW_PLAN = re.compile(r"^(un)?shadow: ", re.M)
-@@ -33,10 +34,88 @@ ACTIVE = ("starting", "running")
- BAD = ("failed", "killed", "lost")
-
-
-+def is_preview(j):
-+ """A finished preview whose next step is a real run: slackrepo --preview, sbpull/sbshadow --no."""
-+ return j["status"] == "ok" and (
-+ (j["kind"] == "slackrepo" and "--preview" in j["args"])
-+ or (j["kind"] in ("sbpull", "sbshadow") and "--no" in j["args"]))
-+
-+
-+class NextStep(QDialog):
-+ """After a preview: its whole log, then Run it (or Apply shadowing) / Not now.
-+
-+ run(job-start args) asks for the real run; the caller starts it. For a
-+ shadowing preview, loaded(has_plan) tells whether there is anything to apply.
-+ """
-+
-+ run = pyqtSignal(list)
-+ loaded = pyqtSignal(bool)
-+
-+ def __init__(self, remote, job, publish, parent=None):
-+ super().__init__(parent)
-+ self.job = job
-+ self.shadow = job["kind"] != "slackrepo"
-+ self.setWindowTitle("Preview: " + job_title(dict(job, args=[
-+ a for a in job["args"] if a not in ("--preview", "--no")])))
-+ self.resize(900, 600)
-+ self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
-+ self.log = QPlainTextEdit(readOnly=True)
-+ self.log.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
-+ self.publish = QCheckBox("publish", checked=publish, visible=not self.shadow)
-+ self.publish.setToolTip("Push to the mirror when the build ends; unticked, use Publish later")
-+ self.run_btn = QPushButton("Apply shadowing" if self.shadow else "Run it", default=True)
-+ self.run_btn.clicked.connect(self._run)
-+ later = QPushButton("Not now")
-+ later.clicked.connect(self.reject)
-+ bar = QHBoxLayout()
-+ bar.addWidget(self.publish)
-+ bar.addStretch()
-+ bar.addWidget(later)
-+ bar.addWidget(self.run_btn)
-+ layout = QVBoxLayout(self)
-+ layout.addWidget(self.log)
-+ layout.addLayout(bar)
-+ self.proc = p = remote.follow(job["id"], 0) # the job has ended: the stream closes by itself
-+ p.readyReadStandardOutput.connect(self._chunk)
-+ p.finished.connect(self._ended)
-+
-+ def _chunk(self):
-+ data = bytes(self.proc.readAllStandardOutput())
-+ self.log.appendPlainText(self.decoder.decode(ANSI.sub(b"", data)).rstrip("\n"))
-+
-+ def _ended(self, *_):
-+ self._chunk()
-+ self.proc.deleteLater()
-+ if not self.has_plan():
-+ self.run_btn.setEnabled(False)
-+ self.run_btn.setToolTip("no shadow: or unshadow: lines, nothing to apply")
-+ self.loaded.emit(self.has_plan())
-+
-+ def has_plan(self):
-+ return not self.shadow or bool(SHADOW_PLAN.search(self.log.toPlainText()))
-+
-+ def confirm(self, question):
-+ return QMessageBox.question(self, "Confirm", question) == QMessageBox.StandardButton.Yes
-+
-+ def _run(self):
-+ if self.shadow:
-+ if not self.confirm("Apply the shadowing plan to the repo (sbshadow --yes)?"):
-+ return
-+ self.run.emit(["sbshadow", "--yes"])
-+ else:
-+ args = [a for a in self.job["args"] if a != "--preview"]
-+ if not self.publish.isChecked():
-+ args.insert(1, "--no-publish")
-+ self.run.emit(["slackrepo", *args])
-+ self.accept()
-+
-+
- class JobsTab(QWidget):
- refresh_requested = pyqtSignal()
-+ started = pyqtSignal(str) # id of a job this GUI started
-+ preview_requested = pyqtSignal(dict) # a finished preview to take to its next step
-
-- def __init__(self, remote, publish=True, parent=None):
-+ def __init__(self, remote, parent=None):
- super().__init__(parent)
- self.remote = remote
- self.pending = ()
-@@ -46,25 +125,17 @@ class JobsTab(QWidget):
- self.proc = None
- self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
-
-- self.start_menu = QMenu(self)
-- start = QToolButton(text="Start", popupMode=QToolButton.ToolButtonPopupMode.InstantPopup)
-- start.setMenu(self.start_menu)
-- self.apply_btn = QPushButton("Apply shadowing")
-- self.apply_btn.clicked.connect(lambda: self.confirm_start(
-- "Apply the shadowing plan to the repo (sbshadow --yes)?", "sbshadow", "--yes"))
-- self.apply_btn.hide()
-- self.publish_box = QCheckBox("publish", checked=publish)
-- self.publish_box.setToolTip("Push to the mirror when the build ends; "
-- "unticked, use Publish later")
-- self.run_btn = QPushButton("Run it")
-- self.run_btn.clicked.connect(self.run_preview)
-- self.kill_btn = QPushButton("Kill")
-- self.kill_btn.clicked.connect(self.kill)
-- bar = QHBoxLayout()
-- for w in (start, self.apply_btn, self.run_btn, self.publish_box):
-- bar.addWidget(w)
-- bar.addStretch()
-- bar.addWidget(self.kill_btn)
-+ self.pull_menu = QMenu(self)
-+ self.act_pull = action(self, "Pull overlay", "folder-download", SP.SP_ArrowDown)
-+ self.act_pull.setMenu(self.pull_menu)
-+ self.act_run = action(self, "Run it", "media-playback-start", SP.SP_MediaPlay,
-+ lambda: self.preview_requested.emit(self.jobs[self.current]))
-+ self.act_kill = action(self, "Kill", "process-stop", SP.SP_BrowserStop, self.kill)
-+ self.actions = [self.act_pull,
-+ action(self, "Check shadowing", "edit-find", SP.SP_FileDialogContentsView,
-+ lambda: self.start("sbshadow", "--no")),
-+ self.act_run, self.act_kill]
-+ self.view_actions = []
-
- self.list = QListWidget()
- self.list.currentItemChanged.connect(self._on_select)
-@@ -76,19 +147,14 @@ class JobsTab(QWidget):
- split.addWidget(self.log)
- split.setSizes([300, 700])
- layout = QVBoxLayout(self)
-- layout.addLayout(bar)
- layout.addWidget(split)
- self.set_overlays([])
-
- def set_overlays(self, overlays):
-- m = self.start_menu
-- m.clear()
-+ self.pull_menu.clear()
- for o in overlays:
-- m.addAction(f"sbpull {o} (preview)", lambda o=o: self.start("sbpull", o, "--no"))
-- m.addSeparator()
-- m.addAction("sbshadow (preview)", lambda: self.start("sbshadow", "--no"))
-- m.addAction("sbshadow (apply)…", lambda: self.confirm_start(
-- "Run sbshadow --yes on the repo?", "sbshadow", "--yes"))
-+ self.pull_menu.addAction(f"{o} (preview)", lambda o=o: self.start("sbpull", o, "--no"))
-+ enable(self.act_pull, None if overlays else "no overlays known yet")
-
- def set_jobs(self, jobs):
- self.jobs = {j["id"]: j for j in jobs}
-@@ -140,23 +206,12 @@ class JobsTab(QWidget):
- self.pending = args
- self.remote.call(self._started, "job-start", *args)
-
-- def confirm_start(self, question, *args):
-- if QMessageBox.question(self, "Confirm", question) == QMessageBox.StandardButton.Yes:
-- self.start(*args)
--
- def kill(self):
- if QMessageBox.question(self, "Kill job", f"Send SIGTERM to {self.current}?") \
- == QMessageBox.StandardButton.Yes:
- self.remote.call(lambda _d, err: err and QMessageBox.warning(self, "Kill failed", err),
- "job-kill", self.current)
-
-- def run_preview(self):
-- """Start the previewed slackrepo run for real."""
-- args = [a for a in self.jobs[self.current]["args"] if a != "--preview"]
-- if not self.publish_box.isChecked():
-- args.insert(1, "--no-publish")
-- self.start_signed("slackrepo", *args)
--
- def ask_passphrase(self, retry):
- text, ok = QInputDialog.getText(
- self, "Signing key", ("Wrong passphrase, try again." if retry else
-@@ -206,6 +261,7 @@ class JobsTab(QWidget):
- self.select(data["busy"])
- return
- self.select(data["id"])
-+ self.started.emit(data["id"])
- self.refresh_requested.emit()
-
- def _on_select(self, item, _prev):
-@@ -243,11 +299,5 @@ class JobsTab(QWidget):
-
- def _update_buttons(self):
- j = self.jobs.get(self.current)
-- self.kill_btn.setEnabled(bool(j) and j["status"] == "running")
-- self.apply_btn.setVisible(
-- bool(j) and j["status"] == "ok" and "--no" in j["args"]
-- and bool(SHADOW_PLAN.search(self.log.toPlainText())))
-- preview = bool(j) and j["kind"] == "slackrepo" and j["status"] == "ok" \
-- and "--preview" in j["args"]
-- self.run_btn.setVisible(preview)
-- self.publish_box.setVisible(preview)
-+ enable(self.act_kill, None if j and j["status"] == "running" else "select a running job")
-+ enable(self.act_run, None if j and is_preview(j) else "select a finished preview")
-diff --git c/sb_builder/mainwindow.py i/sb_builder/mainwindow.py
-index eeac61a..c6ea02e 100644
---- c/sb_builder/mainwindow.py
-+++ i/sb_builder/mainwindow.py
-@@ -21,26 +21,24 @@ import time
- from PyQt6.QtCore import QMetaType, QObject, QSize, Qt, QTimer, pyqtSignal, pyqtSlot
- from PyQt6.QtDBus import QDBusArgument, QDBusConnection, QDBusInterface, QDBusMessage
- from PyQt6.QtGui import QIcon, QKeySequence, QShortcut
--from PyQt6.QtWidgets import (QLabel, QMainWindow, QMessageBox, QPushButton, QStyle, QTabBar,
-- QTabWidget, QToolBar)
-+from PyQt6.QtWidgets import (QLabel, QMainWindow, QMessageBox, QPushButton, QStyle, QTabWidget,
-+ QToolBar, QToolButton)
-
- 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 .jobs import JobsTab, NextStep, is_preview
- from .packages import PackagesTab
- from .results import ResultsTab
- from .remote import DOWN, MISSING
-
- NOTIFY = "org.freedesktop.Notifications"
- 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))
-+# Jobs tab icon: running, then the end status until the tab is shown
-+BADGE = {"running": ("system-run", QStyle.StandardPixmap.SP_MediaPlay),
-+ "ok": ("emblem-ok", QStyle.StandardPixmap.SP_DialogApplyButton),
-+ "bad": ("dialog-error", QStyle.StandardPixmap.SP_MessageBoxCritical)}
- # tabs as a distinct, centered row; colors from the palette so the theme applies
- TAB_STYLE = """
- QTabWidget::tab-bar { alignment: center; }
-@@ -86,11 +84,10 @@ class MainWindow(QMainWindow):
- self.cfg, self.remote = cfg, remote
- self.notifier = notifier or Notifier(self)
- self.notifier.clicked.connect(self.open_job)
-- 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.next_steps = {} # job id -> its open NextStep dialog
-+ self.mine = set() # ids of the jobs this session started: only they get next steps
-+ self.badge = None # Jobs tab icon state: None, "running", "ok", "bad"
- self.seen = None # job id -> status at the last poll; None before the first
- self.online = None # None until the first answer
- self.overlays = []
-@@ -99,17 +96,29 @@ class MainWindow(QMainWindow):
- self.running_id = None
- self.setWindowTitle("sb-builder")
-
-- self.tabs = QTabWidget(tabsClosable=True)
-+ self.tabs = QTabWidget()
- self.tabs.setStyleSheet(TAB_STYLE)
-- self.tabs.tabCloseRequested.connect(self._close_tab)
-- self.tabs.currentChanged.connect(lambda _i: self.poll_stats())
- self.dashboard = Dashboard()
-- self.dashboard.refresh_btn.clicked.connect(self.poll_stats)
-+ self.dashboard.refresh.triggered.connect(self.poll_stats)
- self.dashboard.tiles["Packages"].link(self.open_packages)
- self.dashboard.jobs.link(self.open_jobs)
- self.dashboard.result_clicked.connect(self.open_results)
-- self.tabs.addTab(self.dashboard, "Dashboard")
-- self.tabs.tabBar().setTabButton(0, QTabBar.ButtonPosition.RightSide, None)
-+ self.jobs_tab = JobsTab(self.remote)
-+ self.jobs_tab.refresh_requested.connect(self.poll_jobs)
-+ self.jobs_tab.started.connect(self.mine.add)
-+ self.jobs_tab.preview_requested.connect(self.show_next_step)
-+ self.packages_tab = PackagesTab(self.remote)
-+ self.packages_tab.start_requested.connect(lambda args: self.jobs_tab.start(*args))
-+ self.packages_tab.hints_requested.connect(self.open_hints)
-+ self.hints_tab = HintsTab(self.remote, self.cfg)
-+ self.hints_tab.start_requested.connect(lambda args: self.jobs_tab.start(*args))
-+ self.hints_tab.result_ready.connect(self.show_hint_result)
-+ self.results_tab = ResultsTab(self.remote)
-+ # permanent tabs (UI guidelines); each loads on first show
-+ for tab, label in ((self.dashboard, "Dashboard"), (self.packages_tab, "Packages"),
-+ (self.hints_tab, "Hints"), (self.results_tab, "Results"),
-+ (self.jobs_tab, "Jobs")):
-+ self.tabs.addTab(tab, label)
- self.setCentralWidget(self.tabs)
-
- file_menu = self.menuBar().addMenu("&File")
-@@ -118,17 +127,18 @@ class MainWindow(QMainWindow):
- # window-wide: QTabWidget's own Ctrl+PgUp/PgDn needs focus on the tabs
- self.tab_next = QShortcut(QKeySequence("Ctrl+PgDown"), self, lambda: self._cycle_tab(1))
- self.tab_prev = QShortcut(QKeySequence("Ctrl+PgUp"), self, lambda: self._cycle_tab(-1))
-- self.ribbon = QToolBar("Screens", movable=False, floatable=False)
-+ # main ribbon: the current tab's actions; small ribbon under it: its view controls
-+ self.ribbon = QToolBar("Actions", movable=False, floatable=False)
- self.ribbon.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon)
- 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, "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))
-- self.ribbon.addAction(icon, label, slots[label])
-+ self.addToolBarBreak()
-+ self.view_bar = QToolBar("View", movable=False, floatable=False)
-+ self.view_bar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
-+ self.view_bar.setIconSize(QSize(16, 16))
-+ self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.view_bar)
-+ self.tabs.currentChanged.connect(self._tab_changed)
-+ self._tab_changed()
-
- self.conn = QLabel("● connecting")
- self.key = QLabel()
-@@ -179,9 +189,12 @@ class MainWindow(QMainWindow):
- jobs = data["jobs"]
- self._notify_ends(jobs)
- self.dashboard.show_jobs(jobs)
-- if self.jobs_tab:
-- self.jobs_tab.set_jobs(jobs)
-+ self.jobs_tab.set_jobs(jobs)
- run = next((j for j in jobs if j["status"] == "running"), None)
-+ if run:
-+ self._badge("running")
-+ elif self.badge == "running":
-+ self._badge(None) # ended unseen (first poll, reconnect): nothing to report
- self.running_id = run and run["id"]
- self.job_btn.setVisible(run is not None)
- if run:
-@@ -196,8 +209,7 @@ class MainWindow(QMainWindow):
- if err:
- return self._failed(err)
- self.overlays = data.get("overlays", [])
-- if self.jobs_tab:
-- self.jobs_tab.set_overlays(self.overlays)
-+ self.jobs_tab.set_overlays(self.overlays)
- if data["version"] != VERSION:
- self.conn.setText(f"● helper {data['version']}, GUI {VERSION}: run ./install.sh --remote")
-
-@@ -285,24 +297,57 @@ class MainWindow(QMainWindow):
- event = END_EVENT.get(j["status"])
- if event and prev.get(j["id"], "running") != j["status"]:
- self.notify(event, job_title(j), j["id"])
-+ if self.tabs.currentWidget() is not self.jobs_tab:
-+ self._badge("ok" if j["status"] == "ok" else "bad")
- if j["kind"] in ("slackrepo", "publish"): # build state changed
- 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)
-+ if j["id"] not in self.mine:
-+ continue # started elsewhere or before this session: news, not a next step
-+ if j["kind"] in HINT_JOBS and j["status"] in ("ok", "failed"):
- self.remote.call(lambda d, err: d and self.show_hint_result(d), "job-result", j["id"])
-+ elif is_preview(j):
-+ self.show_next_step(j, auto=True)
-
- @staticmethod
- def _reload(*tabs):
- for tab in tabs:
-- if tab:
-+ if tab.loaded: # a tab not shown yet loads fresh when it is
- tab.reload()
-
-+ def show_next_step(self, job, auto=False):
-+ """The preview's next step as a dialog; auto: a shadowing preview without a plan shows none."""
-+ if job["id"] in self.next_steps:
-+ self.next_steps[job["id"]].raise_()
-+ return
-+ d = NextStep(self.remote, job, self.cfg["builds"]["publish"], self)
-+ d.run.connect(lambda args: (self.jobs_tab.start_signed if args[0] == "slackrepo"
-+ else self.jobs_tab.start)(*args))
-+ d.finished.connect(lambda _r: self.next_steps.pop(job["id"], None))
-+ self.next_steps[job["id"]] = d
-+ if d.shadow and auto:
-+ d.loaded.connect(lambda plan: d.show() if plan else d.reject())
-+ else:
-+ d.show()
-+
-+ def _badge(self, state):
-+ self.badge = state
-+ i = self.tabs.indexOf(self.jobs_tab)
-+ # in the text, not a color: TAB_STYLE's tab color would override setTabTextColor
-+ self.tabs.setTabText(i, "Jobs (running)" if state == "running" else "Jobs")
-+ if state is None:
-+ self.tabs.setTabIcon(i, QIcon())
-+ return
-+ name, fallback = BADGE[state]
-+ self.tabs.setTabIcon(i, QIcon.fromTheme(name, self.style().standardIcon(fallback)))
-+
- 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))
-+ lambda pkgs: self.jobs_tab.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)
-@@ -310,49 +355,42 @@ class MainWindow(QMainWindow):
-
- # ── tabs ────────────────────────────────────────────────────────────────
-
-- def open_jobs(self):
-- if self.jobs_tab is None:
-- self.jobs_tab = JobsTab(self.remote, self.cfg["builds"]["publish"])
-- self.jobs_tab.refresh_requested.connect(self.poll_jobs)
-- self.jobs_tab.set_overlays(self.overlays)
-- self.tabs.addTab(self.jobs_tab, "Jobs")
-- self.poll_jobs()
-- self.tabs.setCurrentWidget(self.jobs_tab)
-- return self.jobs_tab
-+ def _tab_changed(self, _i=None):
-+ tab = self.tabs.currentWidget()
-+ for bar, acts in ((self.ribbon, tab.actions), (self.view_bar, tab.view_actions)):
-+ bar.clear()
-+ bar.addActions(acts)
-+ for a in acts:
-+ if a.menu(): # a menu action opens its menu on a plain click
-+ bar.widgetForAction(a).setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
-+ self.view_bar.setVisible(bool(tab.view_actions))
-+ if not getattr(tab, "loaded", True):
-+ tab.reload()
-+ if tab is self.jobs_tab and self.badge != "running":
-+ self._badge(None)
-+ self.poll_stats()
-
- def _cycle_tab(self, step):
- self.tabs.setCurrentIndex((self.tabs.currentIndex() + step) % self.tabs.count())
-
-+ def open_jobs(self):
-+ self.tabs.setCurrentWidget(self.jobs_tab)
-+ return self.jobs_tab
-+
- def open_packages(self):
-- 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.hints_tab.result_ready.connect(self.show_hint_result)
-- self.tabs.addTab(self.hints_tab, "Hints")
-- self.hints_tab.reload()
-+ self.tabs.setCurrentWidget(self.hints_tab)
- 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)
-- self.tabs.addTab(self.results_tab, "Results")
-- self.results_tab.reload()
-+ self.tabs.setCurrentWidget(self.results_tab)
- if result:
- self.results_tab.filter_to(result)
-- self.tabs.setCurrentWidget(self.results_tab)
- return self.results_tab
-
- def open_job(self, jid):
-@@ -362,22 +400,6 @@ class MainWindow(QMainWindow):
- if jid:
- self.open_jobs().select(jid)
-
-- def _close_tab(self, i):
-- w = self.tabs.widget(i)
-- if w is self.dashboard:
-- return
-- self.tabs.removeTab(i)
-- if w is self.jobs_tab:
-- self.jobs_tab = None
-- w.stop()
-- if w is self.packages_tab:
-- 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):
- cfg, err = config.load()
- if err:
-diff --git c/sb_builder/packages.py i/sb_builder/packages.py
-index 64032a2..30215e0 100644
---- c/sb_builder/packages.py
-+++ i/sb_builder/packages.py
-@@ -20,13 +20,13 @@ import html
-
- from PyQt6.QtCore import QAbstractTableModel, QModelIndex, QSortFilterProxyModel, Qt, pyqtSignal
- from PyQt6.QtGui import QColor, QKeySequence, QShortcut
--from PyQt6.QtWidgets import (QCheckBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout,
-- QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea, QSplitter,
-- QTableView, QVBoxLayout, QWidget)
-+from PyQt6.QtWidgets import (QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea,
-+ QSplitter, QTableView, QVBoxLayout, QWidget)
-
- from . import hints as hintlib
- from .dashboard import STATUS
- from .fmt import NA, fmt_time
-+from .ui import SP, action, enable
-
- OVERLAYS = ("personal", "pentesting")
- STATUSES = ("ok", "failed", "never", "outdated")
-@@ -230,9 +230,8 @@ class PackagesTab(QWidget):
- chips.addWidget(self.chips[name])
- chips.addSpacing(16)
- chips.addStretch()
-- self.refresh_btn = QPushButton("Refresh")
-- self.refresh_btn.clicked.connect(self.reload)
-- chips.addWidget(self.refresh_btn)
-+ self.count = QLabel()
-+ chips.addWidget(self.count)
-
- self.table = QTableView(sortingEnabled=True)
- self.table.setModel(self.proxy)
-@@ -248,18 +247,14 @@ 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()
-+ # navigation, not an action: a link in the pane (UI guidelines)
-+ self.open_hint_btn = QLabel('<a href="#">Open in Hints</a>')
-+ self.open_hint_btn.setContentsMargins(12, 0, 12, 8)
-+ self.open_hint_btn.linkActivated.connect(lambda _l: self.hints_requested.emit(self.current()["name"]))
- pane = QWidget()
- pane_layout = QVBoxLayout(pane)
- pane_layout.addWidget(self.detail)
-- pane_layout.addLayout(detail_bar)
-+ pane_layout.addWidget(self.open_hint_btn)
- pane_layout.addStretch()
- scroll = QScrollArea(widgetResizable=True)
- scroll.setWidget(pane)
-@@ -269,37 +264,38 @@ class PackagesTab(QWidget):
- split.addWidget(scroll)
- split.setSizes([700, 400])
-
-- self.count = QLabel()
-- bar = QHBoxLayout()
-- bar.addWidget(self.count)
-- self.keys = []
-- for label, slot, key in (("Select visible", self.select_visible, "Ctrl+A"),
-- ("Clear", self.clear, "Ctrl+Shift+A")):
-- b = QPushButton(label, toolTip=key)
-- b.clicked.connect(slot)
-- bar.addWidget(b)
-- # in the search field Ctrl+A still selects its text: QLineEdit overrides it
-- self.keys.append(QShortcut(QKeySequence(key), self, slot,
-- context=Qt.ShortcutContext.WidgetWithChildrenShortcut))
-- bar.addStretch()
-- for action in ("build", "update", "rebuild", "remove"):
-- b = QPushButton(action.capitalize())
-- b.clicked.connect(lambda _c, a=action: self.act(a))
-- bar.addWidget(b)
-- for label, slot in (("Update all", self.update_all), ("Publish", self.publish)):
-- b = QPushButton(label)
-- b.clicked.connect(slot)
-- bar.addWidget(b)
-+ self.act_build = {
-+ a: action(self, a.capitalize(), icon, fb, lambda a=a: self.act(a))
-+ for a, icon, fb in (("build", "run-build", SP.SP_MediaPlay),
-+ ("update", "system-software-update", SP.SP_BrowserReload),
-+ ("rebuild", "view-refresh", SP.SP_BrowserReload),
-+ ("remove", "edit-delete", SP.SP_TrashIcon))}
-+ self.act_new_hint = action(self, "New hint", "document-new", SP.SP_FileIcon, self.new_hint)
-+ self.actions = [*self.act_build.values(),
-+ action(self, "Update all", "system-upgrade", SP.SP_ArrowUp, self.update_all),
-+ self.act_new_hint,
-+ action(self, "Publish", "document-send", SP.SP_DialogSaveButton, self.publish)]
-+ self.view_actions = [
-+ action(self, "Select visible", "edit-select-all", SP.SP_DialogYesButton,
-+ self.select_visible, shortcut="Ctrl+A"),
-+ action(self, "Clear", "edit-clear", SP.SP_DialogResetButton, self.clear,
-+ shortcut="Ctrl+Shift+A"),
-+ action(self, "Refresh", "view-refresh", SP.SP_BrowserReload, self.reload)]
-+ # in the search field Ctrl+A still selects its text: QLineEdit overrides it
-+ self.keys = [QShortcut(QKeySequence(key), self, slot,
-+ context=Qt.ShortcutContext.WidgetWithChildrenShortcut)
-+ for key, slot in (("Ctrl+A", self.select_visible), ("Ctrl+Shift+A", self.clear))]
-+ self.loaded = False
-
- layout = QVBoxLayout(self)
- layout.addWidget(self.query)
- layout.addLayout(chips)
-- layout.addWidget(split)
-- layout.addLayout(bar)
-+ layout.addWidget(split, 1)
- self.model.dataChanged.connect(lambda *_: self.update_count())
- self.update_count()
-
- def reload(self):
-+ self.loaded = True
- self.count.setText("loading…")
- self.remote.call(self._loaded, "inventory")
-
-@@ -317,6 +313,15 @@ class PackagesTab(QWidget):
-
- def update_count(self):
- self.count.setText(f"{self.proxy.rowCount():,} shown · {len(self.model.checked)} selected")
-+ for a in self.act_build.values():
-+ enable(a, None if self.model.checked else "tick packages first")
-+ enable(self.act_new_hint, None if self.without_hint() else
-+ "none of the ticked packages is without a hint")
-+
-+ def without_hint(self):
-+ """Ticked packages that have no hint, sorted."""
-+ return sorted(r["name"] for r in self.model.rows
-+ if r["name"] in self.model.checked and not r["hint_path"])
-
- def _toggle(self, index):
- """A click anywhere on a row ticks or unticks it."""
-@@ -360,30 +365,9 @@ class PackagesTab(QWidget):
- 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 [])])
-+ names = self.without_hint()
-+ if names:
-+ self.start_requested.emit(["hint-new", *names])
-diff --git c/sb_builder/results.py i/sb_builder/results.py
-index 1fc6b9a..21fdcc3 100644
---- c/sb_builder/results.py
-+++ i/sb_builder/results.py
-@@ -21,12 +21,13 @@ import re
-
- from PyQt6.QtCore import Qt
- from PyQt6.QtGui import QColor, QFontDatabase, QTextCursor
--from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton,
-- QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget)
-+from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QPlainTextEdit, QSplitter,
-+ QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget)
-
- from .dashboard import STATUS
- from .fmt import NA, fmt_time
- from .packages import chip
-+from .ui import SP, action, enable
-
- KINDS = ("ok", "failed", "bad", "aborted", "removed")
- ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
-@@ -75,9 +76,10 @@ class ResultsTab(QWidget):
- bar.addStretch()
- self.count = QLabel()
- bar.addWidget(self.count)
-- refresh = QPushButton("Refresh")
-- refresh.clicked.connect(self.reload)
-- bar.addWidget(refresh)
-+ self.act_log = action(self, "Show build log", "text-x-generic", SP.SP_FileIcon, self.show_log)
-+ self.actions = [self.act_log]
-+ self.view_actions = [action(self, "Refresh", "view-refresh", SP.SP_BrowserReload, self.reload)]
-+ self.loaded = False
-
- self.table = QTableWidget(0, 4, sortingEnabled=True)
- self.table.setHorizontalHeaderLabels(["result", "package", "built version", "attempt"])
-@@ -94,13 +96,9 @@ class ResultsTab(QWidget):
- self.detail = QLabel(textFormat=Qt.TextFormat.RichText, wordWrap=True,
- alignment=Qt.AlignmentFlag.AlignTop)
- self.detail.setContentsMargins(12, 8, 12, 8)
-- self.log_btn = QPushButton("Show build log")
-- self.log_btn.clicked.connect(self.show_log)
-- self.log_btn.setEnabled(False)
- side = QWidget()
- col = QVBoxLayout(side)
- col.addWidget(self.detail)
-- col.addWidget(self.log_btn)
- col.addStretch()
- split = QSplitter()
- split.addWidget(self.table)
-@@ -109,9 +107,11 @@ class ResultsTab(QWidget):
-
- layout = QVBoxLayout(self)
- layout.addLayout(bar)
-- layout.addWidget(split)
-+ layout.addWidget(split, 1)
-+ self.show_detail()
-
- def reload(self):
-+ self.loaded = True
- self.count.setText("loading…")
- self.remote.call(self._loaded, "results")
-
-@@ -166,7 +166,7 @@ class ResultsTab(QWidget):
-
- def show_detail(self):
- row = self.current()
-- self.log_btn.setEnabled(bool(row and row[4]))
-+ enable(self.act_log, None if row and row[4] else "select a result with a log")
- if not row:
- self.detail.setText("")
- return
-diff --git c/sb_builder/ui.py i/sb_builder/ui.py
-new file mode 100644
-index 0000000..087d1a7
---- /dev/null
-+++ i/sb_builder/ui.py
-@@ -0,0 +1,46 @@
-+# sb-builder - ribbon actions
-+# 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.
-+
-+"""Ribbon actions shared by the tabs (UI guidelines in AGENTS.md).
-+
-+Each tab owns two lists of QActions: `actions` for the main ribbon (what it
-+does, in workflow order) and `view_actions` for the small one (selection and
-+view controls). The main window shows the current tab's lists.
-+"""
-+
-+from PyQt6.QtGui import QAction, QIcon
-+from PyQt6.QtWidgets import QStyle
-+
-+
-+def action(parent, text, icon, fallback, slot=None, checkable=False, shortcut=None):
-+ """A ribbon action: freedesktop icon with a QStyle fallback."""
-+ a = QAction(QIcon.fromTheme(icon, parent.style().standardIcon(fallback)), text, parent,
-+ checkable=checkable)
-+ if slot:
-+ a.triggered.connect(lambda _checked=False: slot())
-+ a.setData(shortcut) # shown in the tooltip; the tab's QShortcut does the work
-+ enable(a, None)
-+ return a
-+
-+
-+def enable(a, reason):
-+ """Enable a, or disable it with reason as its tooltip: disabled, never hidden."""
-+ a.setEnabled(not reason)
-+ tip = a.text() + (f" ({a.data()})" if a.data() else "")
-+ a.setToolTip(f"{tip}: {reason}" if reason else tip)
-+
-+
-+SP = QStyle.StandardPixmap
-```
-
-- [ ] **Step 4:** Run `python3 -m unittest discover -s tests`. Expected: `OK` (115 tests).
-- [ ] **Step 5: Look at it** offscreen: each tab's grab shows the main ribbon, the small ribbon (not on Dashboard and Jobs), the tab bar, the filter rows directly above the table.
-- [ ] **Step 6: Commit.**
-
-```bash
-git add sb_builder tests/test_gui.py
-git commit -m "One frame for every tab: ribbons of actions, next steps as dialogs
-
-Tabs are permanent and load on first show. The ribbon shows the current
-tab's actions in workflow order, a small ribbon under it the view
-controls; inapplicable actions are disabled with the reason as tooltip.
-Starting a job stays on the tab; the Jobs tab reads (running), then shows
-the end status. When a preview this session started ends, its log opens
-with Run it (publish box) or Apply shadowing; Not now loses nothing, Run
-it in Jobs reopens it. New hint moves to the Packages ribbon and acts on
-the ticked packages without a hint.
-
-Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
-```
-
-### Task 3: docs
-
-**Files:** `README.md`, `AGENTS.md` (new "UI guidelines" section), the UI and phase 4 specs.
-
-- [ ] **Step 1:** Save the diff below as `/tmp/u3.diff`, then `git apply /tmp/u3.diff`.
-
-```diff
-diff --git c/AGENTS.md i/AGENTS.md
-index 9a8918c..f55d952 100644
---- c/AGENTS.md
-+++ i/AGENTS.md
-@@ -44,6 +44,27 @@ Design and phasing: `docs/superpowers/specs/`. Phase 1 (core) is
- that tool's own repo (e.g. `slackrepo_setup` for sbshadow) rather than
- working around it here.
-
-+## UI guidelines
-+
-+Every tab follows these (design: `docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md`).
-+
-+1. Tabs are permanent and load their data lazily on first show (`loaded`,
-+ `reload()`); the ribbon never opens or closes tabs.
-+2. Main ribbon: the tab's actions (`tab.actions`) in workflow order. Small
-+ ribbon: view controls (`tab.view_actions`). Build them with
-+ `sb_builder/ui.py` `action()`. Nothing actionable lives inside a view
-+ except navigation links.
-+3. Change actions act on ticked rows, view actions on the highlighted row. A
-+ row click toggles its tick; Ctrl+A / Ctrl+Shift+A select visible / clear.
-+4. An inapplicable action is disabled with `ui.enable(action, reason)`, the
-+ reason in its tooltip; never hidden.
-+5. When a job this session started ends, its next step opens as a non-modal
-+ dialog where the user is (`MainWindow.mine`, `NextStep`,
-+ `HintResultDialog`); starting a job never switches tab; the Jobs tab shows
-+ activity; a dismissed step can be taken again from the ribbon.
-+6. A modal confirm only for destructive or outward steps (delete hint,
-+ publish, apply shadowing).
-+
- ## Conventions
-
- - GUI config: `$XDG_CONFIG_HOME/sb-builder/config.toml`. Helper config:
-diff --git c/README.md i/README.md
-index 366b740..ccf5edd 100644
---- c/README.md
-+++ i/README.md
-@@ -33,45 +33,54 @@ Every key is optional. `File > Reload config` rereads the GUI config.
-
- sb-builder
-
-+The window has five tabs, always open: Dashboard, Packages, Hints, Results,
-+Jobs (Ctrl+PgUp/PgDn cycle them). The ribbon at the top holds the current
-+tab's actions, in the order a task uses them; the smaller bar under it holds
-+its view controls (select, clear, refresh). An action that does not apply is
-+greyed out, its tooltip says why. Actions act on the **ticked** rows: a click
-+anywhere on a row ticks or unticks it, Ctrl+A ticks every visible row,
-+Ctrl+Shift+A unticks all.
-+
-+Starting a job keeps you where you are; the Jobs tab shows "(running)" and
-+then the end status. When a job you started ends, its next step comes to you
-+as a dialog: after a preview, its log with **Run it** (and a **publish** box);
-+after a hint change, the diff with **Revert** and **Build these**. **Not
-+now** loses nothing: **Run it** in the Jobs tab, on a finished preview,
-+reopens it.
-+
- - **Dashboard**: VM load, uptime and free disk, package counts, slackrepo
- build results, last overlay pull, recent jobs. Refreshes while visible.
--- **Open > Jobs**: start a job from the Start menu (`sbpull <overlay>`
-- preview, `sbshadow` preview or apply), follow its log live, kill it. After a
-- preview that lists `shadow:` or `unshadow:` lines, "Apply shadowing" runs
-- `sbshadow --yes`.
--
--- **Open > Packages** (or click the Packages tile): every package of the
-- repository, with built version and status (`ok`, `failed`, `never`,
-- `outdated`). Search by name or summary, or with `cat:`, `maint:`, `req:`,
-- `status:`; toggle the overlay and status chips. Ctrl+A ticks every visible
-- package, Ctrl+Shift+A unticks all (here and in Hints). The detail pane shows the
-- package's versions, its dependencies after hints (dropped ones struck
-- through), and what its hint file does, section by section.
--- Tick packages and press Build, Update, Rebuild or Remove (they act on the
-- ticked packages only); Update all updates everything. Each first runs a slackrepo
-- `--preview` job. When the preview is fine, **Run it** in the Jobs tab starts
-- the real run; untick **publish** to keep the packages local and press
-- **Publish** later to push them.
--- **Results** (ribbon, or click a result in the Dashboard's slackrepo bar or
-- legend): slackrepo's last build attempt per package, newest first, filterable
-- by result. **Show build log** (or double-click) opens the end of the log of
-- 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** removes the ticked
-- packages' hints, each kept as a `.bak`. The Packages detail pane has **New hint** for a package
-- without one.
--- After a hint job or a delete 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.
-+ The tiles open Packages, Results (one result kind) and Jobs.
-+- **Packages**: every package of the repository, with built version and
-+ status (`ok`, `failed`, `never`, `outdated`). Search by name or summary, or
-+ with `cat:`, `maint:`, `req:`, `status:`; toggle the overlay and status
-+ chips. The detail pane shows the package's versions, its dependencies after
-+ hints (dropped ones struck through), what its hint file does, and a link
-+ to it in Hints. **Build**, **Update**, **Rebuild**, **Remove** and **Update
-+ all** run a slackrepo `--preview` first; **New hint** creates hints, pinned
-+ at the `.info` version, for the ticked packages without one; **Publish**
-+ pushes the package repository (untick **publish** at Run it to hold a run
-+ back and publish later).
-+- **Hints**: 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; with
-+ **No download** on, `DOWNLOAD="no"` instead; a hint sbshadow wrote,
-+ `DELREQUIRES` only, gets the `.info` version lines added). **Delete hint**
-+ removes the ticked packages' hints, each kept as a `.bak`, revertable from
-+ the dialog that follows. neovim and openvino are refused for now: their
-+ bundled deps still need mkhint on the VM.
-+- **Results**: slackrepo's last build attempt per package, newest first,
-+ filterable by result. **Show build log** (or double-click) opens the end of
-+ the log of 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).
-+- **Jobs**: every job with its live log. **Pull overlay** (`sbpull <overlay>`)
-+ and **Check shadowing** (`sbshadow`) run as previews; when one finds
-+ `shadow:` or `unshadow:` lines, its dialog offers **Apply shadowing**.
-+ **Kill** stops the selected running job.
- - 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 775b0c4..b746044 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
-@@ -136,7 +136,8 @@ New key `manifest_packages` (below). `remote/config.example.toml` shows it.
- 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
-+- `hint-new <pkg> [<ver>] [--no-dl]` (since 0.6.0 `hint-new <pkg>... [--no-dl]`,
-+ no version, see the UI design): refuses when the hint exists. Builds
- 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.
-diff --git c/docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md i/docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md
-index 7c7b254..8a6844d 100644
---- c/docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md
-+++ i/docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md
-@@ -70,7 +70,7 @@ Disabled reasons (tooltips):
- - Delete hint: "none of the ticked packages has a hint".
- - Show build log: "select a result with a log".
- - Run it: "select a finished preview".
--- Kill: "no job running".
-+- Kill: "select a running job".
- - Check upstream while running: "checking…".
-
- Update all, Publish, Pull overlay and Check shadowing are always enabled
-@@ -120,7 +120,8 @@ dialog, Build these -> preview dialog, Run it. No tab switch.
-
- - Starting a job no longer switches to Jobs; the user stays on the tab.
- - While a job runs, the Jobs tab shows a running icon (`system-run`) and
-- accent-colored text (palette highlight).
-+ reads "Jobs (running)": the tab style sheet's text color would override a
-+ per-tab color.
- - When it ends, the icon becomes the end status (`✔` ok, `✘` failed,
- killed, lost) and stays until the Jobs tab is shown, then clears.
- - The status bar job button stays.
-```
-
-- [ ] **Step 2: Commit and push.**
-
-```bash
-git add README.md AGENTS.md docs/superpowers/specs
-git commit -m "UI guidelines in AGENTS.md; README for the new frame
-
-Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
-git push
-```
-
-### Task 4: deploy (ask first)
-
-- [ ] `./install.sh --remote`, expected `{"version": "0.6.0", ...}`.
-- [ ] The user restarts sb-builder and runs the hugo-style flow: Hints, Check upstream, Update selected, Build these, Run it, all without switching tab.