diff options
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.md | 2259 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-10-19-sb-builder-phase2.md | 3883 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-10-20-sb-builder-bundled-deps.md | 456 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-10-20-sb-builder-phase4.md | 1994 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-10-20-sb-builder-ui.md | 1651 |
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>⚠ 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>{' '.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(" ".join( - f'<span style="color:{STATUS.get(k, gray.name())}">■</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} </td><td>{html.escape(job_title(j))} </td>" - f"<td>{j['status']} </td>" - f"<td>{fmt_time(j.get('start', j['created']))} </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. |
