@bensandee/tooling 0.68.0 → 0.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,14 +13,14 @@ pnpm dlx @bensandee/tooling repo:sync
13
13
 
14
14
  ## Conventions
15
15
 
16
- The tool auto-detects project structure, CI platform, project type, and Docker packages from the filesystem. `.tooling.json` stores **overrides only** — omitted fields use detected defaults. Runtime commands (`docker:build`, `checks:run`, `release:changesets`) work without running `repo:sync` first.
16
+ The tool auto-detects project structure, CI platform, project type, and Docker packages from the filesystem. `.tooling.json` stores **overrides only** -- omitted fields use detected defaults. Runtime commands (`docker:build`, `checks:run`, `release:changesets`) work without running `repo:sync` first.
17
17
 
18
18
  | Convention | Detection | Default | Override via |
19
19
  | ----------------- | ----------------------------------------------------- | ---------------------------------------- | ------------------------------------ |
20
20
  | Project structure | `pnpm-workspace.yaml` present | `single` | `structure` in `.tooling.json` |
21
21
  | CI platform | `.github/workflows/` or `.forgejo/workflows/` dir | `none` | `ci` in `.tooling.json` |
22
22
  | Project type | Dependencies in `package.json` (react, node, library) | `default` | `projectType` in `.tooling.json` |
23
- | Docker packages | `Dockerfile` or `docker/Dockerfile` in package dirs | — | `docker` map in `.tooling.json` |
23
+ | Docker packages | `Dockerfile` or `docker/Dockerfile` in package dirs | -- | `docker` map in `.tooling.json` |
24
24
  | Formatter | Existing prettier config detected | `oxfmt` | `formatter` in `.tooling.json` |
25
25
  | Release strategy | Existing release config detected | monorepo: `changesets`, single: `simple` | `releaseStrategy` in `.tooling.json` |
26
26
 
@@ -56,24 +56,24 @@ The `--add` flag appends extra checks (must be defined in `package.json`):
56
56
  tooling checks:run --add e2e
57
57
  ```
58
58
 
59
- **Coverage is branch-aware by default.** With neither coverage flag, the `test` step runs via `pnpm run test:coverage` (producing `coverage/coverage-summary.json` for the `coverage:*` commands) **only on the repo's default branch** — PR and feature-branch runs skip the v8 instrumentation cost automatically. The current branch is read from `GITHUB_REF` / `GITHUB_REF_NAME` (set by GitHub _and_ Forgejo Actions), falling back to `git rev-parse --abbrev-ref HEAD` for local runs; tag and pull-request refs count as non-default. The default branch is `.tooling.json#defaultBranch` if set, otherwise detected via `git symbolic-ref refs/remotes/origin/HEAD`, otherwise `main`.
59
+ **Coverage is branch-aware by default.** With neither coverage flag, the `test` step runs via `pnpm run test:coverage` (producing `coverage/coverage-summary.json` for the `coverage:*` commands) **only on the repo's default branch** -- PR and feature-branch runs skip the v8 instrumentation cost automatically. The current branch is read from `GITHUB_REF` / `GITHUB_REF_NAME` (set by GitHub _and_ Forgejo Actions), falling back to `git rev-parse --abbrev-ref HEAD` for local runs; tag and pull-request refs count as non-default. The default branch is `.tooling.json#defaultBranch` if set, otherwise detected via `git symbolic-ref refs/remotes/origin/HEAD`, otherwise `main`.
60
60
 
61
61
  Explicit flags always win, regardless of branch:
62
62
 
63
- - `--with-coverage` — force the `test` step to run with coverage.
64
- - `--no-coverage` — force it to run without coverage.
63
+ - `--with-coverage` -- force the `test` step to run with coverage.
64
+ - `--no-coverage` -- force it to run without coverage.
65
65
 
66
66
  If both are passed, `--no-coverage` wins.
67
67
 
68
- Because the decision is branch-aware, the generated CI workflow runs plain `pnpm ci:check` (no coverage flag) and lets `checks:run` decide — except when coverage is disabled in `.tooling.json`, where it passes `--no-coverage` to force it off everywhere.
68
+ Because the decision is branch-aware, the generated CI workflow runs plain `pnpm ci:check` (no coverage flag) and lets `checks:run` decide -- except when coverage is disabled in `.tooling.json`, where it passes `--no-coverage` to force it off everywhere.
69
69
 
70
- **Extend the `check` script with `--add`, never a shell chain.** `repo:sync` generates `check` as a single rooted command (`bst checks:run`) and `ci:check` as `pnpm check --skip 'docker:*'`. When CI runs `pnpm ci:check`, pnpm appends the resolved `--skip 'docker:*'` (and any flags you add, e.g. `--no-coverage`) to the _end_ of the resolved script — so they reach `bst checks:run` only if that is the last command. Adding extra checks via an `&&` chain silently drops `--skip 'docker:*'` and any forwarded flag:
70
+ **Extend the `check` script with `--add`, never a shell chain.** `repo:sync` generates `check` as a single rooted command (`bst checks:run`) and `ci:check` as `pnpm check --skip 'docker:*'`. When CI runs `pnpm ci:check`, pnpm appends the resolved `--skip 'docker:*'` (and any flags you add, e.g. `--no-coverage`) to the _end_ of the resolved script -- so they reach `bst checks:run` only if that is the last command. Adding extra checks via an `&&` chain silently drops `--skip 'docker:*'` and any forwarded flag:
71
71
 
72
72
  ```jsonc
73
- // ✓ forwarded flags reach bst checks:run
73
+ // [ok] forwarded flags reach bst checks:run
74
74
  "check": "bst checks:run --add check:env-drift,check:custom"
75
75
 
76
- // ✗ --skip / --no-coverage land on check:custom instead
76
+ // [x] --skip / --no-coverage land on check:custom instead
77
77
  "check": "bst checks:run && pnpm check:env-drift && pnpm check:custom"
78
78
  ```
79
79
 
@@ -99,29 +99,29 @@ Generated workflow files are regenerated on each `repo:sync` run. To preserve cu
99
99
 
100
100
  Custom blocks are anchored to the nearest preceding non-blank line. When `repo:sync` regenerates the workflow, it extracts custom blocks from the existing file, generates fresh content, then re-inserts each block after its anchor line. If the anchor line is no longer present (e.g. a step was renamed), the block is appended at the end of the file.
101
101
 
102
- To skip a workflow file entirely, add `# @bensandee/tooling:ignore` as the first line — `repo:sync` will leave the file untouched.
102
+ To skip a workflow file entirely, add `# @bensandee/tooling:ignore` as the first line -- `repo:sync` will leave the file untouched.
103
103
 
104
104
  ### pnpm-workspace.yaml
105
105
 
106
- `repo:sync` manages `pnpm-workspace.yaml` with a narrow contract — mandated keys, a few seeded-on-creation keys, and everything else passed through verbatim.
106
+ `repo:sync` manages `pnpm-workspace.yaml` with a narrow contract -- mandated keys, a few seeded-on-creation keys, and everything else passed through verbatim.
107
107
 
108
108
  **Mandated** (drift fails `repo:sync --check`):
109
109
 
110
110
  - `blockExoticSubdeps: true`
111
- - `minimumReleaseAge` — any non-negative number (`0` is valid)
111
+ - `minimumReleaseAge` -- any non-negative number (`0` is valid)
112
112
 
113
113
  **Seeded on creation only** (free to change or remove afterward):
114
114
 
115
115
  - `minimumReleaseAgeExclude: ["@bensandee/*"]`
116
116
  - `packages: ["packages/*"]` (monorepos only)
117
117
 
118
- All other keys — `allowBuilds`, custom entries — are preserved verbatim through `repo:sync`. The generator emits the file for single-package repos too, not just monorepos.
118
+ All other keys -- `allowBuilds`, custom entries -- are preserved verbatim through `repo:sync`. The generator emits the file for single-package repos too, not just monorepos.
119
119
 
120
120
  Opt out entirely with `"pnpmWorkspace": false` in `.tooling.json`; the generator then never inspects or writes the file.
121
121
 
122
122
  ### packageManager force-bump
123
123
 
124
- `repo:sync` rewrites `package.json#packageManager` when an existing value is `pnpm@<N>.x` with `N < 11`, setting it to the tooling's pinned pnpm version (currently `11.1.1`). This is delivered through `repo:sync` rather than Renovate because the mandated `pnpm-workspace.yaml` keys (`blockExoticSubdeps`) require pnpm 11+. Within-major bumps (`pnpm@11.x → 11.y`) remain Renovate's job; non-pnpm package managers (yarn, npm) are out of scope.
124
+ `repo:sync` rewrites `package.json#packageManager` when an existing value is `pnpm@<N>.x` with `N < 11`, setting it to the tooling's pinned pnpm version (currently `11.1.1`). This is delivered through `repo:sync` rather than Renovate because the mandated `pnpm-workspace.yaml` keys (`blockExoticSubdeps`) require pnpm 11+. Within-major bumps (`pnpm@11.x -> 11.y`) remain Renovate's job; non-pnpm package managers (yarn, npm) are out of scope.
125
125
 
126
126
  ### Repository setup
127
127
 
@@ -141,7 +141,7 @@ Opt out entirely with `"pnpmWorkspace": false` in `.tooling.json`; the generator
141
141
  | `tooling release:trigger` | Trigger a release workflow. |
142
142
  | `tooling workflow:dispatch` | Dispatch a `workflow_dispatch` event for any workflow in any repo on the same git host. Flags: `--repo`, `--workflow` (required), `--ref`, `--input key=value` (repeatable). |
143
143
  | `tooling forgejo:create-release` | Create a Forgejo release from a tag. |
144
- | `tooling changesets:merge` | Merge a changesets version PR. |
144
+ | `tooling changesets:merge` | Merge a changesets version PR. Gated on a live read of the PR; refuses an empty version PR. |
145
145
  | `tooling webhook:send <url>` | POST a structured JSON release payload with HMAC-SHA256 auth. Flags: `--tag`, `--repository`, `--server-url`, `--actor`, `--prerelease`. Secret from `WEBHOOK_SECRET` env var. |
146
146
 
147
147
  #### `release:simple`
@@ -165,18 +165,18 @@ tooling release:simple --release-as major
165
165
  tooling release:simple --release-as 2.0.0
166
166
 
167
167
  # Create a prerelease
168
- tooling release:simple --release-as major --prerelease beta # → 2.0.0-beta.0
168
+ tooling release:simple --release-as major --prerelease beta # -> 2.0.0-beta.0
169
169
  ```
170
170
 
171
171
  The generated release workflow exposes these as optional `workflow_dispatch` inputs (`bump` and `prerelease`), so bumps can also be controlled from the CI UI.
172
172
 
173
- **Authentication:** `release:simple` requires `RELEASE_TOKEN` (a Personal Access Token with write access on the target forge) on every platform — Forgejo, GitHub, and GitHub Enterprise alike. The runner's auto-injected token (`github.token` / Forgejo Actions' internal auth) is never used: `actions/checkout@v6` writes that token to git config as an `http.<serverUrl>/.extraheader`, which silently overrides URL-embedded credentials and authenticates pushes as the runner pseudo-user. On Forgejo that pseudo-user is `uid=-2`, which fails the pre-receive hook (`Internal Server Error`); on GitHub Enterprise the same shape can mask custom hook rejections. `release:simple` actively unsets the extraheader and rewrites the remote URL with `RELEASE_TOKEN` before pushing.
173
+ **Authentication:** `release:simple` requires `RELEASE_TOKEN` (a Personal Access Token with write access on the target forge) on every platform -- Forgejo, GitHub, and GitHub Enterprise alike. The runner's auto-injected token (`github.token` / Forgejo Actions' internal auth) is never used: `actions/checkout@v6` writes that token to git config as an `http.<serverUrl>/.extraheader`, which silently overrides URL-embedded credentials and authenticates pushes as the runner pseudo-user. On Forgejo that pseudo-user is `uid=-2`, which fails the pre-receive hook (`Internal Server Error`); on GitHub Enterprise the same shape can mask custom hook rejections. `release:simple` actively unsets the extraheader and rewrites the remote URL with `RELEASE_TOKEN` before pushing.
174
174
 
175
175
  GitHub consumers that previously relied on `github.token` must mint a PAT (scope: `contents: write`) and store it as the `RELEASE_TOKEN` secret on the repo or org.
176
176
 
177
177
  #### `workflow:dispatch`
178
178
 
179
- Triggers a `workflow_dispatch` event for an arbitrary workflow in an arbitrary repo on the same git host — useful for release→deploy patterns where one repo's CI kicks off a workflow in another (e.g. an infra repo). `release:trigger` is a special case of this command, pinned to `release.yml` in the current repo.
179
+ Triggers a `workflow_dispatch` event for an arbitrary workflow in an arbitrary repo on the same git host -- useful for release->deploy patterns where one repo's CI kicks off a workflow in another (e.g. an infra repo). `release:trigger` is a special case of this command, pinned to `release.yml` in the current repo.
180
180
 
181
181
  ```bash
182
182
  tooling workflow:dispatch \
@@ -191,22 +191,22 @@ On Forgejo it POSTs to the `actions/workflows/{workflow}/dispatches` API; on Git
191
191
 
192
192
  ##### Generated deploy step
193
193
 
194
- For the common release→deploy case, `repo:sync` can generate the dispatch step for you. Add a `deploy` block to `.tooling.json`:
194
+ For the common release->deploy case, `repo:sync` can generate the dispatch step for you. Add a `deploy` block to `.tooling.json`:
195
195
 
196
196
  ```jsonc
197
197
  {
198
198
  "deploy": {
199
- "dispatchRepo": "bensandee/ansible_infra", // required — repo whose workflow to trigger
200
- "stack": "dirty_draft", // optional — defaults to the package name with '-' → '_'
201
- "workflow": "deploy.yml", // optional — default "deploy.yml"
202
- "ref": "main", // optional — default "main"
199
+ "dispatchRepo": "bensandee/ansible_infra", // required -- repo whose workflow to trigger
200
+ "stack": "dirty_draft", // optional -- defaults to the package name with '-' -> '_'
201
+ "workflow": "deploy.yml", // optional -- default "deploy.yml"
202
+ "ref": "main", // optional -- default "main"
203
203
  },
204
204
  }
205
205
  ```
206
206
 
207
- When `deploy` is set, `repo:sync` appends a **"Dispatch deploy"** step to the release workflow (`release.yml` for the `simple` strategy, `ci.yml` for `changesets`). The step runs `bst workflow:dispatch` only after a successful release — gated on `steps.release.outputs.pushed` (simple) or `steps.release.outputs.published` (changesets) — and forwards the release tag as the `release_tag` input. `dispatchRepo` is deliberately explicit: a deploy target is never inferred. The `release-it` strategy does not support deploy dispatch (`repo:sync` warns and skips the step). The resolved release token must have rights on `dispatchRepo`; supply a separate cross-repo token by adding its secret name to `postReleaseHookSecrets`.
207
+ When `deploy` is set, `repo:sync` appends a **"Dispatch deploy"** step to the release workflow (`release.yml` for the `simple` strategy, `ci.yml` for `changesets`). The step runs `bst workflow:dispatch` only after a successful release -- gated on `steps.release.outputs.pushed` (simple) or `steps.release.outputs.published` (changesets) -- and forwards the release tag as the `release_tag` input. `dispatchRepo` is deliberately explicit: a deploy target is never inferred. The `release-it` strategy does not support deploy dispatch (`repo:sync` warns and skips the step). The resolved release token must have rights on `dispatchRepo`; supply a separate cross-repo token by adding its secret name to `postReleaseHookSecrets`.
208
208
 
209
- > **Note:** the `release_tag` input is sourced from the release step's `tag` output. On GitHub with the `changesets` strategy the release runs via `changesets/action`, which exposes no `tag` output — the deploy still fires, but `release_tag` is dispatched empty. The `simple` strategy and Forgejo `changesets` both populate it.
209
+ > **Note:** the `release_tag` input is sourced from the release step's `tag` output. On GitHub with the `changesets` strategy the release runs via `changesets/action`, which exposes no `tag` output -- the deploy still fires, but `release_tag` is dispatched empty. The `simple` strategy and Forgejo `changesets` both populate it.
210
210
 
211
211
  #### Release assets
212
212
 
@@ -242,7 +242,7 @@ If the script runs but writes no files to `release-assets/`, `release:assets` fa
242
242
  | `command` | argv run before upload (optional; omit for pre-built files). Not interpreted by a shell. |
243
243
  | `name` | Asset name in the release (optional; defaults to the file's basename). |
244
244
 
245
- Convention and overrides coexist — a repo can use both.
245
+ Convention and overrides coexist -- a repo can use both.
246
246
 
247
247
  Works for both `changesets` and `simple` release strategies, on Forgejo (via API upload) and GitHub (via `gh release upload --clobber`). The asset step in the workflow is gated on the release itself having published/pushed, so failed releases never upload stale artifacts.
248
248
 
@@ -253,7 +253,7 @@ Works for both `changesets` and `simple` release strategies, on Forgejo (via API
253
253
  | `tooling coverage:summary` | Print a labeled per-package summary of the latest run. `--json` for machine-readable form. |
254
254
  | `tooling coverage:check` | Compare the latest run against `coverage-baseline.json`; exit non-zero on regression. |
255
255
  | `tooling coverage:record` | Append the latest run to a `bst/coverage-metrics` orphan branch (CI: push to default branch). |
256
- | `tooling coverage:status` | Show recent coverage trend. Read-only — fetches the orphan branch, no checkout. |
256
+ | `tooling coverage:status` | Show recent coverage trend. Read-only -- fetches the orphan branch, no checkout. |
257
257
 
258
258
  The feature has two halves: a **CI history** that captures one record per commit on the default branch (visible to anyone with the repo), and a **local ratchet** that the agent (or developer) consults before declaring a task complete.
259
259
 
@@ -262,12 +262,12 @@ The feature has two halves: a **CI history** that captures one record per commit
262
262
  `repo:sync` enables coverage tracking automatically when a `vitest.config.ts` is present. The generated `vitest.config.ts` includes the `json-summary` reporter so each `pnpm test:coverage` writes `coverage/coverage-summary.json` alongside the human-readable HTML report. A generated `coverage.yml` workflow runs on push to the default branch and pipes:
263
263
 
264
264
  ```text
265
- pnpm test:coverage → coverage:summary → coverage:record → coverage:status
265
+ pnpm test:coverage -> coverage:summary -> coverage:record -> coverage:status
266
266
  ```
267
267
 
268
268
  so every CI log ends with the trend table for the last 10 records.
269
269
 
270
- The history file (`coverage-history.jsonl`) lives on a dedicated `bst/coverage-metrics` orphan branch — never on `main`. This sidesteps branch protection (no privileged token needed beyond `contents: write`) and keeps `git log main` clean. `coverage:record` uses pure git plumbing (`hash-object` → `mktree` → `commit-tree` → `push --force-with-lease`) so the working tree is never touched, and concurrent CI runs are handled safely via compare-and-swap with retry.
270
+ The history file (`coverage-history.jsonl`) lives on a dedicated `bst/coverage-metrics` orphan branch -- never on `main`. This sidesteps branch protection (no privileged token needed beyond `contents: write`) and keeps `git log main` clean. `coverage:record` uses pure git plumbing (`hash-object` -> `mktree` -> `commit-tree` -> `push --force-with-lease`) so the working tree is never touched, and concurrent CI runs are handled safely via compare-and-swap with retry.
271
271
 
272
272
  #### The local ratchet
273
273
 
@@ -275,7 +275,7 @@ A tracked `coverage-baseline.json` at the repo root captures the four overall to
275
275
 
276
276
  `repo:sync` also inserts a `<!-- @tooling:coverage-rule -->` managed block into `CLAUDE.md` describing the workflow:
277
277
 
278
- > Before declaring a task complete that touched files under `packages/*/src/`, run `pnpm test:coverage` then `pnpm exec bst coverage:check`. If any total dropped, add tests until parity is restored — or, if a touched file is genuinely not worth covering, exclude it via `coverage.exclude` in `vitest.config.ts` and explain why in the commit message. If all four are flat-or-up, run `pnpm exec bst coverage:check --update-baseline` and stage `coverage-baseline.json`.
278
+ > Before declaring a task complete that touched files under `packages/*/src/`, run `pnpm test:coverage` then `pnpm exec bst coverage:check`. If any total dropped, add tests until parity is restored -- or, if a touched file is genuinely not worth covering, exclude it via `coverage.exclude` in `vitest.config.ts` and explain why in the commit message. If all four are flat-or-up, run `pnpm exec bst coverage:check --update-baseline` and stage `coverage-baseline.json`.
279
279
 
280
280
  Skip the rule for docs-only, config-only, dependency-bump, or test-only changes.
281
281
 
@@ -309,16 +309,16 @@ Or keep the local ratchet but skip the CI history append:
309
309
  { "coverage": { "history": "none" } }
310
310
  ```
311
311
 
312
- | Override field | Default | Description |
313
- | -------------------- | ------------------------ | ---------------------------------------------------------------------- |
314
- | `history` | `"orphan-branch"` | `"none"` disables CI history; the local ratchet still works |
315
- | `historyBranch` | `"bst/coverage-metrics"` | Override the orphan branch name |
316
- | `tolerancePp` | `0.1` | Tolerance in percentage points before `coverage:check` flags a drop |
317
- | `gateOnPullRequests` | `false` | Run coverage on PR builds too (deferred follow-up — currently a no-op) |
312
+ | Override field | Default | Description |
313
+ | -------------------- | ------------------------ | ----------------------------------------------------------------------- |
314
+ | `history` | `"orphan-branch"` | `"none"` disables CI history; the local ratchet still works |
315
+ | `historyBranch` | `"bst/coverage-metrics"` | Override the orphan branch name |
316
+ | `tolerancePp` | `0.1` | Tolerance in percentage points before `coverage:check` flags a drop |
317
+ | `gateOnPullRequests` | `false` | Run coverage on PR builds too (deferred follow-up -- currently a no-op) |
318
318
 
319
319
  #### Per-package re-sum
320
320
 
321
- The trimmed record's `packages` map is computed by aggregating files whose path starts with `packages/<name>/`, so monorepo packages get their own line/branch percentages without per-package vitest configs. Single-package repos collapse to one entry keyed by the directory basename. Per-package numbers are recorded but **not gated** — only the four overall totals control the ratchet.
321
+ The trimmed record's `packages` map is computed by aggregating files whose path starts with `packages/<name>/`, so monorepo packages get their own line/branch percentages without per-package vitest configs. Single-package repos collapse to one entry keyed by the directory basename. Per-package numbers are recorded but **not gated** -- only the four overall totals control the ratchet.
322
322
 
323
323
  ### Docker
324
324
 
@@ -351,7 +351,7 @@ The `context` field defaults to `"."` (project root) when omitted. Versions for
351
351
 
352
352
  #### Per-package build args
353
353
 
354
- Each `docker` entry may declare `buildArgs` — a map of `ARG_NAME` to value that is passed to that package's `docker build` as `--build-arg ARG_NAME=<value>`. Args flow to the configured package only, so they don't trigger "unused build arg" warnings on sibling images. They apply to both `docker:build` and `docker:publish`, so CI-published images match local builds.
354
+ Each `docker` entry may declare `buildArgs` -- a map of `ARG_NAME` to value that is passed to that package's `docker build` as `--build-arg ARG_NAME=<value>`. Args flow to the configured package only, so they don't trigger "unused build arg" warnings on sibling images. They apply to both `docker:build` and `docker:publish`, so CI-published images match local builds.
355
355
 
356
356
  ```json
357
357
  {
@@ -412,8 +412,8 @@ Each package is tagged independently using its own version, so packages in a mon
412
412
  | --------------------------- | -------- | --------------------------------------------------------------------- |
413
413
  | `DOCKER_REGISTRY_HOST` | variable | Registry hostname (e.g. `code.orangebikelabs.com`) |
414
414
  | `DOCKER_REGISTRY_NAMESPACE` | variable | Full namespace for tagging (e.g. `code.orangebikelabs.com/bensandee`) |
415
- | `<REGISTRY>_USERNAME` | secret | Registry username — name depends on registry host (see below) |
416
- | `<REGISTRY>_PASSWORD` | secret | Registry password — name depends on registry host (see below) |
415
+ | `<REGISTRY>_USERNAME` | secret | Registry username -- name depends on registry host (see below) |
416
+ | `<REGISTRY>_PASSWORD` | secret | Registry password -- name depends on registry host (see below) |
417
417
 
418
418
  **Registry-specific secret names.** The workflow generator picks the secret reference at gen time from the registry host. The env-var name inside the step is always `DOCKER_USERNAME` / `DOCKER_PASSWORD` (so the CLI reads them under stable names), but the right-hand side of `${{ secrets.X }}` is registry-specific:
419
419
 
@@ -433,7 +433,7 @@ This rename exists because Forgejo's [act fork auto-injects](https://code.forgej
433
433
 
434
434
  ## Config file
435
435
 
436
- `.tooling.json` stores **overrides only** — fields where the project differs from what convention/detection produces. A fully conventional project has `{}` or no `.tooling.json` at all.
436
+ `.tooling.json` stores **overrides only** -- fields where the project differs from what convention/detection produces. A fully conventional project has `{}` or no `.tooling.json` at all.
437
437
 
438
438
  Available override fields:
439
439
 
@@ -452,7 +452,7 @@ Available override fields:
452
452
 
453
453
  ## Debug logging
454
454
 
455
- All CLI commands support a `--verbose` flag for detailed debug output. Alternatively, set `TOOLING_DEBUG=true` as an environment variable — useful in CI workflows:
455
+ All CLI commands support a `--verbose` flag for detailed debug output. Alternatively, set `TOOLING_DEBUG=true` as an environment variable -- useful in CI workflows:
456
456
 
457
457
  ```yaml
458
458
  env:
@@ -461,29 +461,161 @@ env:
461
461
 
462
462
  Debug output is prefixed with `[debug]` and includes exec results (exit codes, stdout/stderr), compose configuration details, container health statuses, and retry attempts.
463
463
 
464
- ## Library API
464
+ ## The agent daemon's web control panel
465
465
 
466
- The `"."` export provides type-only exports for programmatic use:
466
+ `agent:daemon --ui` serves an opt-in board that renders the `agent/<stage>[-<status>]` label state
467
+ machine, shows the daemon's own live state, and lets you drive the transitions you would otherwise
468
+ apply by hand with `bst issue:set-labels` -- plus read a branch's plan and reply on its pull request
469
+ without leaving the page. It is off by default.
467
470
 
468
- ```ts
469
- import type {
470
- ProjectConfig,
471
- GeneratorResult,
472
- GeneratorContext,
473
- Generator,
474
- DetectedProjectState,
475
- LegacyConfig,
476
- } from "@bensandee/tooling";
471
+ The problem it solves is that a parked terminal is silent: `agent/plan-ready`,
472
+ `agent/implement-mergeable`, `agent/review-ready` and every `-failed` are states where the machine
473
+ has stopped and is waiting on a person, and nothing announces them. The board's lanes are ordered by
474
+ what needs you, not by pipeline position.
475
+
476
+ It also closes a real gap: **the board can kill a wedged run on a headless daemon.** The interactive
477
+ `k` keypress needs a TTY, so under systemd there was previously no way to stop a stuck container
478
+ short of a hand-run `docker kill`.
479
+
480
+ ```bash
481
+ # Loopback, port 4600, token minted at startup and logged once.
482
+ bst agent:daemon --ui
483
+ ```
484
+
485
+ The startup log carries one clickable line -- open it and the token is exchanged for a session
486
+ cookie, so it never sits in your history:
487
+
488
+ ```
489
+ board: http://127.0.0.1:4600/?t=<token>
490
+ board listening on http://127.0.0.1:4600
491
+ ```
492
+
493
+ ### Reaching it from a phone
494
+
495
+ The documented deployment binds **loopback only** and publishes the board through a tailnet TLS
496
+ terminator, so nothing is listening on your LAN at all and no phone is asked to trust a self-signed
497
+ certificate:
498
+
499
+ ```bash
500
+ bst agent:daemon --ui \
501
+ --ui-public-url https://box.tail1234.ts.net \
502
+ --ui-behind-tls
503
+
504
+ # and, separately, whatever your terminator wants:
505
+ tailscale serve --bg 4600
506
+ ```
507
+
508
+ **Both flags are required, and each fails closed in a way that looks like a bug if you omit it:**
509
+
510
+ - `--ui-public-url` adds the external hostname to the Host-header allowlist. Your browser sends
511
+ `Host: box.tail1234.ts.net`, not `127.0.0.1:4600`, so without it **every** proxied request is
512
+ rejected with a bare `403`. It is also what makes the startup line print the URL you can actually
513
+ open.
514
+ - `--ui-behind-tls` marks the session cookie `Secure`. It has to be explicit: the daemon serves plain
515
+ HTTP and cannot see that something in front terminated TLS, and `X-Forwarded-Proto` is
516
+ attacker-supplied unless a trusted proxy is guaranteed -- which nothing here can guarantee.
517
+
518
+ A non-loopback `--ui-bind` still works, but **pass `--ui-public-url` with it too.** The Host-header
519
+ allowlist is built from the bind string literally, so `--ui-bind 0.0.0.0` allows the useless
520
+ `Host: 0.0.0.0:4600` while your browser sends the machine's actual address -- and every request comes
521
+ back a bare `403` with nothing to explain it. `--ui-public-url http://<host-or-ip>:4600` names the host
522
+ you will really reach it at. Binding a specific address you then type verbatim needs nothing extra.
523
+
524
+ ### The board token, and the session it mints
525
+
526
+ There is exactly one credential to be careful with, and its lifetime is the part people get wrong:
527
+
528
+ | | Board token | Session cookie it mints |
529
+ | --------- | ----------------------------------------------------------------- | ----------------------- |
530
+ | Lifetime | the daemon **process** -- a service, so weeks | 12 hours |
531
+ | Scope | label transitions + comments + kill, every repo the bot can write | the board, nothing else |
532
+ | Holders | one, shared | one browser |
533
+ | Persisted | never | never |
534
+
535
+ The board token gates reaching the board at all, and everything it grants happens under the daemon's
536
+ bot token. It is "short-lived" only relative to a config file; against the process it gates it is
537
+ long-lived, and rotating it means restarting the daemon -- at which point a phone bookmark goes dead
538
+ and you return to the log for a fresh link. That is the accepted cost of never persisting it.
539
+
540
+ Session cookies are `HttpOnly`, `SameSite=Strict`, and expire after 12 hours. Every request is
541
+ checked against a Host-header allowlist **and** an `Origin` check; neither replaces the other,
542
+ because an `Origin` check alone does not defeat DNS rebinding.
543
+
544
+ There is no second forge credential. **Everything the board writes -- labels and comments alike --
545
+ is written under the daemon's own bot token**, so there is no operator PAT to mint and no account to
546
+ connect. What that costs is authorship, and the next section is about why paying it is safe.
547
+
548
+ ### Reading a plan, and replying to one
549
+
550
+ Three things a parked PR row offers beyond its label transitions:
551
+
552
+ **`View doc`** shows that branch's plan or spec without a clone. The doc is located by the same
553
+ `<kind>-<n>` naming convention the runs themselves use, and it is fetched **at the PR's head ref** --
554
+ what you read is the plan as the run left it, not as the default branch has it. The page reports
555
+ checkbox progress (`14 of 21 tasks`) and links out to the forge's own rendered view. The body itself
556
+ is shown as **plain escaped text, never rendered markdown**: branch content is written by anyone who
557
+ can push to it, and the board will not render it.
558
+
559
+ **`Comment`** opens a form on the PR's own thread (never a backing issue, whose comments are replayed
560
+ to every later run rather than windowed). Posting takes the same two-step confirm as kill, because
561
+ the forge has no comment-edit API and nothing you post can be retracted from here.
562
+
563
+ The comment is **authored by the daemon's bot account**, with a plain-prose suffix saying so:
564
+
565
+ ```
566
+ Looks right, but rebase on main first.
567
+
568
+ -- posted from the agent daemon control panel
477
569
  ```
478
570
 
479
- | Type | Description |
480
- | ---------------------- | ----------------------------------------------------------------------------------------------- |
481
- | `ProjectConfig` | User config shape (persisted in `.tooling.json`) |
482
- | `GeneratorContext` | Context passed to generator functions (`exists`, `read`, `write`, `remove`, `confirmOverwrite`) |
483
- | `GeneratorResult` | Result from a generator (created/updated/skipped files) |
484
- | `Generator` | Generator function signature: `(ctx: GeneratorContext) => Promise<GeneratorResult>` |
485
- | `DetectedProjectState` | Detected existing project state (package manager, CI, etc.) |
486
- | `LegacyConfig` | Legacy config detection for migration |
571
+ That is safe because the boundary the agent uses to tell its own words from yours is **marker-based,
572
+ not author-based**: a run's own comments start with a reserved marker (`**agent:run**` and friends),
573
+ and everything without one is treated as human feedback no matter who wrote it. So a bot-authored
574
+ board comment reaches the next run exactly like one you typed in the forge's web UI. The attribution
575
+ is a **suffix** for the same reason -- markers are matched at the start, so trailing prose can never
576
+ be mistaken for one. The flip side: **the board refuses a comment whose text starts with a reserved
577
+ agent marker.** Typing `**agent:run**` yourself would move the run's own watermark and silently hide
578
+ every human comment written before it, so the form rejects it by name rather than posting something
579
+ that lies to the next run.
580
+
581
+ **The `N new comments` badge** on a parked row means: human comments on that PR newer than the last
582
+ agent operation on it -- the same window the next run will actually read, computed from the same two
583
+ functions, so the badge cannot promise the run something different. It appears on **pull request rows
584
+ only**; an issue's comments are replayed to every run whole, so "new since the last run" has no
585
+ meaning there and a number would be a lie. The counts are fetched in the daemon's poll cycle and
586
+ memoized, so a row nothing has touched costs nothing and `GET /` still makes no forge call.
587
+
588
+ ### What it costs, and what it does not
589
+
590
+ Reads are cheap, not free: the publishers write a snapshot into memory and `GET /` is a synchronous
591
+ render off it, so **a board page load makes no forge call of its own**. What it does do is record
592
+ that someone is watching, which arms a refresher that drains queued actions and re-searches the
593
+ forge every few seconds until nobody is. That traffic is bounded by one refresher per daemon rather
594
+ than by how many tabs are open, and it stops an idle window after the last reader's last request --
595
+ a window derived from the poll interval, never shorter than two minutes. Closing the tab does not
596
+ cut that short: the stream ends at once, but presence also counts that reader's last request, and
597
+ nothing clears it early. The page renders its own
598
+ staleness (`as of 12s ago`) rather than implying it is live. Opening a doc view or posting a comment
599
+ hits the forge from the request itself -- those are explicit navigations you asked for, and they are
600
+ the only two that do.
601
+
602
+ **One honest caveat:** a request can still be delayed by a single in-flight _synchronous_ git `exec`
603
+ in the run pipeline -- `clone`, `rev-parse`, `diff`, `status`, `commit`, `ls-remote` -- each of which
604
+ briefly blocks the event loop, a `git clone --depth 1` being the longest. The container run itself
605
+ does not block. Making those git calls async is out of scope, so the board is usually instant and
606
+ occasionally waits out one clone.
607
+
608
+ An action is **queued, not applied**, when the page comes back. The board never writes a label
609
+ itself; the daemon drains queued actions -- at the top of its next claim cycle, and on the board
610
+ refresher's own timer while a viewer is present -- which keeps it the only writer of agent labels in
611
+ the process. A row says `queued` in either state, because the refresher applies it within a tick or
612
+ two whatever the claim cycle is doing. Each action is re-checked against live labels when it is
613
+ applied, and one whose target has since been claimed is skipped with the reason shown on the row
614
+ rather than silently dropped. Kill is the exception: it writes no label, so it takes effect
615
+ immediately rather than at a drain.
616
+
617
+ `--ui` is refused together with `--once`, which runs a single cycle and returns -- the board would
618
+ serve one page and then be a dead surface whose every control did nothing.
487
619
 
488
620
  ## Docker check
489
621
 
@@ -493,7 +625,7 @@ import type {
493
625
 
494
626
  A smoketest is a command that runs against the live Docker stack after all containers are healthy and HTTP health checks pass. This is useful for running integration tests, API contract checks, or any validation that requires the full stack to be running.
495
627
 
496
- **Auto-detection:** If the root `package.json` has a `"test:smoke"` script, it is automatically used as the smoketest command — no configuration needed.
628
+ **Auto-detection:** If the root `package.json` has a `"test:smoke"` script, it is automatically used as the smoketest command -- no configuration needed.
497
629
 
498
630
  **Manual configuration:** For non-conventional command names, configure via `.tooling.json`:
499
631
 
@@ -519,82 +651,23 @@ services:
519
651
  - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
520
652
  ```
521
653
 
522
- **Unset variables are filled with an empty string, automatically.** Compose interpolates each file _before_ merging them, so on a host where `ANTHROPIC_API_KEY` is unset it prints `The "ANTHROPIC_API_KEY" variable is not set. Defaulting to a blank string.` on every invocation — including teardown, after the check has already passed. Before each stack comes up, `docker:check` asks compose which variables are still unset and sets each to the empty string compose was substituting anyway: the warnings go away and the value every container sees is unchanged. One line names what was filled.
654
+ **Unset variables are filled with an empty string, automatically.** Compose interpolates each file _before_ merging them, so on a host where `ANTHROPIC_API_KEY` is unset it prints `The "ANTHROPIC_API_KEY" variable is not set. Defaulting to a blank string.` on every invocation -- including teardown, after the check has already passed. Before each stack comes up, `docker:check` asks compose which variables are still unset and sets each to the empty string compose was substituting anyway: the warnings go away and the value every container sees is unchanged. One line names what was filled.
523
655
 
524
656
  Two things follow from this that are worth knowing:
525
657
 
526
658
  - **Setting the value in the check overlay does not silence the warning.** The overlay overrides the _merged_ value, but the warning comes from interpolating the base file, which happens per-file beforehand. Nothing you can write in the overlay reaches it.
527
659
  - **`docker-compose.check.env` is the file that does feed interpolation.** Drop it beside the base compose file and `docker:check` auto-detects it and passes it to compose as `--env-file`. Use it when the check needs _real_ values rather than blanks. Override the location with `dockerCheck.envFile` in `.tooling.json`.
528
660
 
529
- **The `PORT` trap:** when a service publishes a `${PORT}`-parameterized port, `docker:check` allocates a free host port and injects it, so a check run never collides with something already bound on the host. Assigning `PORT` in `docker-compose.check.env` (or in the ambient environment) opts out of that — your value is used verbatim, collisions included.
661
+ **The `PORT` trap:** when a service publishes a `${PORT}`-parameterized port, `docker:check` allocates a free host port and injects it, so a check run never collides with something already bound on the host. Assigning `PORT` in `docker-compose.check.env` (or in the ambient environment) opts out of that -- your value is used verbatim, collisions included.
530
662
 
531
- `docker-compose.check.env` is exempt from the bst-hook's env-file deny rules, so an agent working in the repo can create and read it. It is a committed, placeholder-holding file for a throwaway stack — do not put real secrets in it.
663
+ `docker-compose.check.env` is exempt from the bst-hook's env-file deny rules, so an agent working in the repo can create and read it. It is a committed, placeholder-holding file for a throwaway stack -- do not put real secrets in it.
532
664
 
533
665
  ### Derived HTTP health checks
534
666
 
535
- A service with a published port but no compose-level `healthcheck:` automatically gets an HTTP health check — no configuration needed. That check is probed **from inside the service's own container network namespace** (`docker run --network container:<id> curlimages/curl ... http://localhost:<containerPort>/`), not from the process running `bst docker:check`.
536
-
537
- This matters because compose publishes a port in the _host's_ network namespace, not the caller's. On a normal developer machine those are the same thing, so probing `localhost:<hostPort>` directly works fine — but on a CI runner that shares the host's docker daemon over a mounted socket (the standard Forgejo/GitHub Actions runner shape), the job container's `localhost` is its own netns, and the published port is unreachable from it. Probing from inside the target's own namespace works in every topology, including that one.
538
-
539
- The probe image (`curlimages/curl`) is pinned by digest, and a `docker run` exit code of `125` (Docker's convention for "the daemon failed to start the container at all" — missing image, pull denied, daemon unreachable) fails the check immediately instead of waiting out the full timeout.
540
-
541
- **This only applies to auto-derived checks.** A health check you configure yourself via `.tooling.json`'s `dockerCheck.healthChecks` is still probed with a direct `fetch()` from wherever `bst docker:check` runs — an arbitrary user-supplied URL isn't necessarily compose-related at all, so making it reachable from the caller remains your responsibility.
542
-
543
- ### Library API
667
+ A service with a published port but no compose-level `healthcheck:` automatically gets an HTTP health check -- no configuration needed. That check is probed **from inside the service's own container network namespace** (`docker run --network container:<id> curlimages/curl ... http://localhost:<containerPort>/`), not from the process running `bst docker:check`.
544
668
 
545
- The `@bensandee/tooling/docker-check` export provides utilities for checking Docker Compose stacks programmatically.
669
+ This matters because compose publishes a port in the _host's_ network namespace, not the caller's. On a normal developer machine those are the same thing, so probing `localhost:<hostPort>` directly works fine -- but on a CI runner that shares the host's docker daemon over a mounted socket (the standard Forgejo/GitHub Actions runner shape), the job container's `localhost` is its own netns, and the published port is unreachable from it. Probing from inside the target's own namespace works in every topology, including that one.
546
670
 
547
- #### Quick start
548
-
549
- ```ts
550
- import { createRealExecutor, runDockerCheck } from "@bensandee/tooling/docker-check";
551
- import type { CheckConfig } from "@bensandee/tooling/docker-check";
552
-
553
- const config: CheckConfig = {
554
- compose: {
555
- cwd: "./deploy",
556
- composeFiles: ["docker-compose.yaml"],
557
- services: ["api", "db"],
558
- },
559
- buildCommand: ["pnpm", "image:build"],
560
- healthChecks: [
561
- {
562
- name: "API",
563
- url: "http://localhost:3000/health",
564
- validate: async (res) => res.ok,
565
- },
566
- ],
567
- smoketest: ["pnpm", "test:smoke"],
568
- timeoutMs: 120_000,
569
- pollIntervalMs: 5_000,
570
- };
571
-
572
- const result = await runDockerCheck(createRealExecutor(), config);
573
- if (!result.success) {
574
- console.error(result.reason, result.message);
575
- }
576
- ```
671
+ The probe image (`curlimages/curl`) is pinned by digest, and a `docker run` exit code of `125` (Docker's convention for "the daemon failed to start the container at all" -- missing image, pull denied, daemon unreachable) fails the check immediately instead of waiting out the full timeout.
577
672
 
578
- #### Exports
579
-
580
- | Export | Description |
581
- | -------------------------------------- | ---------------------------------------------------------------------------- |
582
- | `runDockerCheck(executor, config)` | Full lifecycle: build, compose up, health check polling, smoketest, teardown |
583
- | `createRealExecutor()` | Production executor (real shell, fetch, timers) |
584
- | `composeUp(executor, config)` | Start compose services |
585
- | `composeDown(executor, config)` | Stop and remove compose services |
586
- | `composeLogs(executor, config)` | Stream compose logs |
587
- | `composePs(executor, config)` | List running containers |
588
- | `checkHttpHealth(executor, check)` | Run a single HTTP health check |
589
- | `getContainerHealth(executor, config)` | Check container-level health status |
590
-
591
- #### Types
592
-
593
- | Type | Description |
594
- | --------------------- | ------------------------------------------------------------------------------------------ |
595
- | `CheckConfig` | Full check config (compose settings, build command, health checks, smoketest, timeouts) |
596
- | `ComposeConfig` | Docker Compose settings (cwd, compose files, env file, services) |
597
- | `HttpHealthCheck` | Health check definition (name, URL, validate function) |
598
- | `CheckResult` | Result: `{ success: true, elapsedMs }` or `{ success: false, reason, message, elapsedMs }` |
599
- | `DockerCheckExecutor` | Side-effect abstraction (exec, fetch, timers) for testability |
600
- | `ContainerInfo` | Container status info from `composePs` |
673
+ **This only applies to auto-derived checks.** A health check you configure yourself via `.tooling.json`'s `dockerCheck.healthChecks` is still probed with a direct `fetch()` from wherever `bst docker:check` runs -- an arbitrary user-supplied URL isn't necessarily compose-related at all, so making it reachable from the caller remains your responsibility.
package/bin/bst.mjs ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Stable bin target for `bst`.
4
+ *
5
+ * This file exists only so that `node_modules/.bin/bst` can be created, and it is committed
6
+ * rather than built for exactly that reason. pnpm links bins **during install**, and skips one
7
+ * whose target is missing -- so pointing `bin` straight at `dist/bin.mjs` works only in a tree
8
+ * that already happens to be built. A fresh checkout installs before it builds, pnpm warns
9
+ * (`Failed to create bin ... ENOENT`) and moves on, and building afterwards does not go back and
10
+ * create the link. Every later `pnpm exec bst` then fails with `Command "bst" not found`, which
11
+ * is what broke CI on this repo's own `check` script.
12
+ *
13
+ * The indirection costs one module load and makes the bin link independent of build state: the
14
+ * link always exists, and `dist/bin.mjs` only has to be there by the time someone runs it. When
15
+ * it is not, the failure is an explicit ERR_MODULE_NOT_FOUND naming the missing build output --
16
+ * a far better error than a command that appears not to exist.
17
+ *
18
+ * Consumers installing from the registry get both files (`files` ships `bin` and `dist`) and are
19
+ * unaffected either way, since a published tarball is always built.
20
+ *
21
+ * Loaded with a dynamic `await import` rather than a bare `import "../dist/bin.mjs"`, which is an
22
+ * unassigned import and trips `import(no-unassigned-import)`. Suppressing that rule would be the
23
+ * wrong trade for a file whose entire purpose is a side effect: the dynamic form says "run this"
24
+ * rather than "bring in these bindings", which is what is actually meant here.
25
+ */
26
+ await import("../dist/bin.mjs");