@bensandee/tooling 0.68.0 → 0.70.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
 
@@ -28,13 +28,13 @@ The tool auto-detects project structure, CI platform, project type, and Docker p
28
28
 
29
29
  ### Project management
30
30
 
31
- | Command | Description |
32
- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
33
- | `tooling repo:sync [dir]` | Detect, generate, and sync project tooling (idempotent). First run prompts for release strategy, CI platform (if not detected), and formatter (if Prettier found). Subsequent runs are non-interactive. |
34
- | `tooling repo:sync --check [dir]` | Dry-run drift detection. Exits 1 if files would change. CI-friendly. |
35
- | `tooling checks:run` | Run project checks (build, docker:build, typecheck, lint, test, format, knip, tooling:check, docker:check). Flags: `--skip`, `--add`, `--fail-fast`, `--verbose`, `--with-coverage`, `--no-coverage`. |
31
+ | Command | Description |
32
+ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
33
+ | `tooling repo:sync [dir]` | Detect, generate, and sync project tooling (idempotent). First run prompts for release strategy, CI platform (if not detected), and formatter (if Prettier found). Subsequent runs are non-interactive. |
34
+ | `tooling repo:sync --check [dir]` | Dry-run drift detection. Exits 1 if files would change. CI-friendly. |
35
+ | `tooling checks:run` | Run project checks (build, docker:build, typecheck, lint, test, format, knip, tooling:check, docker:check). Flags: `--skip`, `--add`, `--fail-fast`, `--verbose`, `--with-coverage`, `--coverage`, `--no-coverage`. |
36
36
 
37
- **Flags:** `--yes` (accept all defaults), `--no-ci`, `--no-prompt`, `--eslint-plugin`
37
+ **Flags:** `--yes` (accept all defaults), `--no-ci` (negates `--ci`), `--no-prompt` (negates `--prompt`), `--eslint-plugin`
38
38
 
39
39
  #### `checks:run`
40
40
 
@@ -56,24 +56,25 @@ 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
+ - `--coverage` -- the same thing, spelled as the positive of `--no-coverage`.
65
+ - `--no-coverage` -- force it to run without coverage.
65
66
 
66
67
  If both are passed, `--no-coverage` wins.
67
68
 
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
+ 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
70
 
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
+ **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
72
 
72
73
  ```jsonc
73
- // ✓ forwarded flags reach bst checks:run
74
+ // [ok] forwarded flags reach bst checks:run
74
75
  "check": "bst checks:run --add check:env-drift,check:custom"
75
76
 
76
- // ✗ --skip / --no-coverage land on check:custom instead
77
+ // [x] --skip / --no-coverage land on check:custom instead
77
78
  "check": "bst checks:run && pnpm check:env-drift && pnpm check:custom"
78
79
  ```
79
80
 
@@ -99,50 +100,50 @@ Generated workflow files are regenerated on each `repo:sync` run. To preserve cu
99
100
 
100
101
  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
102
 
102
- To skip a workflow file entirely, add `# @bensandee/tooling:ignore` as the first line — `repo:sync` will leave the file untouched.
103
+ To skip a workflow file entirely, add `# @bensandee/tooling:ignore` as the first line -- `repo:sync` will leave the file untouched.
103
104
 
104
105
  ### pnpm-workspace.yaml
105
106
 
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
+ `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
108
 
108
109
  **Mandated** (drift fails `repo:sync --check`):
109
110
 
110
111
  - `blockExoticSubdeps: true`
111
- - `minimumReleaseAge` — any non-negative number (`0` is valid)
112
+ - `minimumReleaseAge` -- any non-negative number (`0` is valid)
112
113
 
113
114
  **Seeded on creation only** (free to change or remove afterward):
114
115
 
115
116
  - `minimumReleaseAgeExclude: ["@bensandee/*"]`
116
117
  - `packages: ["packages/*"]` (monorepos only)
117
118
 
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
+ 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
120
 
120
121
  Opt out entirely with `"pnpmWorkspace": false` in `.tooling.json`; the generator then never inspects or writes the file.
121
122
 
122
123
  ### packageManager force-bump
123
124
 
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
+ `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
126
 
126
127
  ### Repository setup
127
128
 
128
- | Command | Description |
129
- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
130
- | `tooling setup:secrets` | Configure CI secrets (Forgejo or GitHub). Auto-detects platform from `package.json` repository URL. Flags: `--dry-run`, `--no-docker`. |
131
- | `tooling forgejo:secrets` | Manage Forgejo Actions secrets directly. Subcommands: `set`, `list`, `delete`. |
129
+ | Command | Description |
130
+ | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
131
+ | `tooling setup:secrets` | Configure CI secrets (Forgejo or GitHub). Auto-detects platform from `package.json` repository URL. Flags: `--dry-run`, `--no-docker` (negates `--docker`). |
132
+ | `tooling forgejo:secrets` | Manage Forgejo Actions secrets directly. Subcommands: `set`, `list`, `delete`. |
132
133
 
133
134
  `setup:secrets` detects the hosting platform from the `repository` field in `package.json`. For Forgejo repositories, it prompts for username and password, creates an access token with appropriate scopes, then sets `RELEASE_TOKEN` (and Docker secrets if Docker packages are detected). For GitHub repositories, it prompts for an existing Personal Access Token and uses `gh secret set` to configure secrets.
134
135
 
135
136
  ### Release management
136
137
 
137
- | Command | Description |
138
- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
139
- | `tooling release:changesets` | Changesets version/publish for Forgejo CI. Flags: `--dry-run`, `--verbose`. Env: `FORGEJO_SERVER_URL`, `FORGEJO_REPOSITORY`, `RELEASE_TOKEN`. |
140
- | `tooling release:simple` | Streamlined release using commit-and-tag-version. Flags: `--release-as`, `--first-release`, `--prerelease`, `--verbose`. Env: `RELEASE_TOKEN` (required on every platform). |
141
- | `tooling release:trigger` | Trigger a release workflow. |
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
- | `tooling forgejo:create-release` | Create a Forgejo release from a tag. |
144
- | `tooling changesets:merge` | Merge a changesets version PR. |
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. |
138
+ | Command | Description |
139
+ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
140
+ | `tooling release:changesets` | Changesets version/publish for Forgejo CI. Flags: `--dry-run`, `--verbose`. Env: `FORGEJO_SERVER_URL`, `FORGEJO_REPOSITORY`, `RELEASE_TOKEN`. |
141
+ | `tooling release:simple` | Streamlined release using commit-and-tag-version. Flags: `--release-as`, `--first-release`, `--prerelease`, `--verbose`. Env: `RELEASE_TOKEN` (required on every platform). |
142
+ | `tooling release:trigger` | Trigger a release workflow. |
143
+ | `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). |
144
+ | `tooling forgejo:create-release` | Create a Forgejo release from a tag. |
145
+ | `tooling changesets:merge` | Merge a changesets version PR. Gated on a live read of the PR; refuses an empty version PR. On Forgejo, a first `mergeable: false` re-reads once after a short delay before refusing, since the forge reports `false` both for a conflict and while still checking. |
146
+ | `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
147
 
147
148
  #### `release:simple`
148
149
 
@@ -165,18 +166,18 @@ tooling release:simple --release-as major
165
166
  tooling release:simple --release-as 2.0.0
166
167
 
167
168
  # Create a prerelease
168
- tooling release:simple --release-as major --prerelease beta # → 2.0.0-beta.0
169
+ tooling release:simple --release-as major --prerelease beta # -> 2.0.0-beta.0
169
170
  ```
170
171
 
171
172
  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
173
 
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
+ **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
175
 
175
176
  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
177
 
177
178
  #### `workflow:dispatch`
178
179
 
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
+ 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
181
 
181
182
  ```bash
182
183
  tooling workflow:dispatch \
@@ -191,22 +192,22 @@ On Forgejo it POSTs to the `actions/workflows/{workflow}/dispatches` API; on Git
191
192
 
192
193
  ##### Generated deploy step
193
194
 
194
- For the common release→deploy case, `repo:sync` can generate the dispatch step for you. Add a `deploy` block to `.tooling.json`:
195
+ For the common release->deploy case, `repo:sync` can generate the dispatch step for you. Add a `deploy` block to `.tooling.json`:
195
196
 
196
197
  ```jsonc
197
198
  {
198
199
  "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"
200
+ "dispatchRepo": "bensandee/ansible_infra", // required -- repo whose workflow to trigger
201
+ "stack": "dirty_draft", // optional -- defaults to the package name with '-' -> '_'
202
+ "workflow": "deploy.yml", // optional -- default "deploy.yml"
203
+ "ref": "main", // optional -- default "main"
203
204
  },
204
205
  }
205
206
  ```
206
207
 
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
+ 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
209
 
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
+ > **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
211
 
211
212
  #### Release assets
212
213
 
@@ -242,7 +243,7 @@ If the script runs but writes no files to `release-assets/`, `release:assets` fa
242
243
  | `command` | argv run before upload (optional; omit for pre-built files). Not interpreted by a shell. |
243
244
  | `name` | Asset name in the release (optional; defaults to the file's basename). |
244
245
 
245
- Convention and overrides coexist — a repo can use both.
246
+ Convention and overrides coexist -- a repo can use both.
246
247
 
247
248
  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
249
 
@@ -253,7 +254,7 @@ Works for both `changesets` and `simple` release strategies, on Forgejo (via API
253
254
  | `tooling coverage:summary` | Print a labeled per-package summary of the latest run. `--json` for machine-readable form. |
254
255
  | `tooling coverage:check` | Compare the latest run against `coverage-baseline.json`; exit non-zero on regression. |
255
256
  | `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. |
257
+ | `tooling coverage:status` | Show recent coverage trend. Read-only -- fetches the orphan branch, no checkout. |
257
258
 
258
259
  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
260
 
@@ -262,12 +263,12 @@ The feature has two halves: a **CI history** that captures one record per commit
262
263
  `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
264
 
264
265
  ```text
265
- pnpm test:coverage → coverage:summary → coverage:record → coverage:status
266
+ pnpm test:coverage -> coverage:summary -> coverage:record -> coverage:status
266
267
  ```
267
268
 
268
269
  so every CI log ends with the trend table for the last 10 records.
269
270
 
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
+ 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
272
 
272
273
  #### The local ratchet
273
274
 
@@ -275,7 +276,7 @@ A tracked `coverage-baseline.json` at the repo root captures the four overall to
275
276
 
276
277
  `repo:sync` also inserts a `<!-- @tooling:coverage-rule -->` managed block into `CLAUDE.md` describing the workflow:
277
278
 
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
+ > 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
280
 
280
281
  Skip the rule for docs-only, config-only, dependency-bump, or test-only changes.
281
282
 
@@ -309,16 +310,16 @@ Or keep the local ratchet but skip the CI history append:
309
310
  { "coverage": { "history": "none" } }
310
311
  ```
311
312
 
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) |
313
+ | Override field | Default | Description |
314
+ | -------------------- | ------------------------ | ----------------------------------------------------------------------- |
315
+ | `history` | `"orphan-branch"` | `"none"` disables CI history; the local ratchet still works |
316
+ | `historyBranch` | `"bst/coverage-metrics"` | Override the orphan branch name |
317
+ | `tolerancePp` | `0.1` | Tolerance in percentage points before `coverage:check` flags a drop |
318
+ | `gateOnPullRequests` | `false` | Run coverage on PR builds too (deferred follow-up -- currently a no-op) |
318
319
 
319
320
  #### Per-package re-sum
320
321
 
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
+ 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
323
 
323
324
  ### Docker
324
325
 
@@ -351,7 +352,7 @@ The `context` field defaults to `"."` (project root) when omitted. Versions for
351
352
 
352
353
  #### Per-package build args
353
354
 
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
+ 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
356
 
356
357
  ```json
357
358
  {
@@ -412,8 +413,8 @@ Each package is tagged independently using its own version, so packages in a mon
412
413
  | --------------------------- | -------- | --------------------------------------------------------------------- |
413
414
  | `DOCKER_REGISTRY_HOST` | variable | Registry hostname (e.g. `code.orangebikelabs.com`) |
414
415
  | `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) |
416
+ | `<REGISTRY>_USERNAME` | secret | Registry username -- name depends on registry host (see below) |
417
+ | `<REGISTRY>_PASSWORD` | secret | Registry password -- name depends on registry host (see below) |
417
418
 
418
419
  **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
420
 
@@ -433,7 +434,7 @@ This rename exists because Forgejo's [act fork auto-injects](https://code.forgej
433
434
 
434
435
  ## Config file
435
436
 
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
+ `.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
438
 
438
439
  Available override fields:
439
440
 
@@ -452,7 +453,7 @@ Available override fields:
452
453
 
453
454
  ## Debug logging
454
455
 
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
+ 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
457
 
457
458
  ```yaml
458
459
  env:
@@ -461,29 +462,161 @@ env:
461
462
 
462
463
  Debug output is prefixed with `[debug]` and includes exec results (exit codes, stdout/stderr), compose configuration details, container health statuses, and retry attempts.
463
464
 
464
- ## Library API
465
+ ## The agent daemon's web control panel
465
466
 
466
- The `"."` export provides type-only exports for programmatic use:
467
+ `agent:daemon --ui` serves an opt-in board that renders the `agent/<stage>[-<status>]` label state
468
+ machine, shows the daemon's own live state, and lets you drive the transitions you would otherwise
469
+ apply by hand with `bst issue:set-labels` -- plus read a branch's plan and reply on its pull request
470
+ without leaving the page. It is off by default.
467
471
 
468
- ```ts
469
- import type {
470
- ProjectConfig,
471
- GeneratorResult,
472
- GeneratorContext,
473
- Generator,
474
- DetectedProjectState,
475
- LegacyConfig,
476
- } from "@bensandee/tooling";
472
+ The problem it solves is that a parked terminal is silent: `agent/plan-ready`,
473
+ `agent/implement-mergeable`, `agent/review-ready` and every `-failed` are states where the machine
474
+ has stopped and is waiting on a person, and nothing announces them. The board's lanes are ordered by
475
+ what needs you, not by pipeline position.
476
+
477
+ It also closes a real gap: **the board can kill a wedged run on a headless daemon.** The interactive
478
+ `k` keypress needs a TTY, so under systemd there was previously no way to stop a stuck container
479
+ short of a hand-run `docker kill`.
480
+
481
+ ```bash
482
+ # Loopback, port 4600, token minted at startup and logged once.
483
+ bst agent:daemon --ui
484
+ ```
485
+
486
+ The startup log carries one clickable line -- open it and the token is exchanged for a session
487
+ cookie, so it never sits in your history:
488
+
489
+ ```
490
+ board: http://127.0.0.1:4600/?t=<token>
491
+ board listening on http://127.0.0.1:4600
492
+ ```
493
+
494
+ ### Reaching it from a phone
495
+
496
+ The documented deployment binds **loopback only** and publishes the board through a tailnet TLS
497
+ terminator, so nothing is listening on your LAN at all and no phone is asked to trust a self-signed
498
+ certificate:
499
+
500
+ ```bash
501
+ bst agent:daemon --ui \
502
+ --ui-public-url https://box.tail1234.ts.net \
503
+ --ui-behind-tls
504
+
505
+ # and, separately, whatever your terminator wants:
506
+ tailscale serve --bg 4600
507
+ ```
508
+
509
+ **Both flags are required, and each fails closed in a way that looks like a bug if you omit it:**
510
+
511
+ - `--ui-public-url` adds the external hostname to the Host-header allowlist. Your browser sends
512
+ `Host: box.tail1234.ts.net`, not `127.0.0.1:4600`, so without it **every** proxied request is
513
+ rejected with a bare `403`. It is also what makes the startup line print the URL you can actually
514
+ open.
515
+ - `--ui-behind-tls` marks the session cookie `Secure`. It has to be explicit: the daemon serves plain
516
+ HTTP and cannot see that something in front terminated TLS, and `X-Forwarded-Proto` is
517
+ attacker-supplied unless a trusted proxy is guaranteed -- which nothing here can guarantee.
518
+
519
+ A non-loopback `--ui-bind` still works, but **pass `--ui-public-url` with it too.** The Host-header
520
+ allowlist is built from the bind string literally, so `--ui-bind 0.0.0.0` allows the useless
521
+ `Host: 0.0.0.0:4600` while your browser sends the machine's actual address -- and every request comes
522
+ back a bare `403` with nothing to explain it. `--ui-public-url http://<host-or-ip>:4600` names the host
523
+ you will really reach it at. Binding a specific address you then type verbatim needs nothing extra.
524
+
525
+ ### The board token, and the session it mints
526
+
527
+ There is exactly one credential to be careful with, and its lifetime is the part people get wrong:
528
+
529
+ | | Board token | Session cookie it mints |
530
+ | --------- | ----------------------------------------------------------------- | ----------------------- |
531
+ | Lifetime | the daemon **process** -- a service, so weeks | 12 hours |
532
+ | Scope | label transitions + comments + kill, every repo the bot can write | the board, nothing else |
533
+ | Holders | one, shared | one browser |
534
+ | Persisted | never | never |
535
+
536
+ The board token gates reaching the board at all, and everything it grants happens under the daemon's
537
+ bot token. It is "short-lived" only relative to a config file; against the process it gates it is
538
+ long-lived, and rotating it means restarting the daemon -- at which point a phone bookmark goes dead
539
+ and you return to the log for a fresh link. That is the accepted cost of never persisting it.
540
+
541
+ Session cookies are `HttpOnly`, `SameSite=Strict`, and expire after 12 hours. Every request is
542
+ checked against a Host-header allowlist **and** an `Origin` check; neither replaces the other,
543
+ because an `Origin` check alone does not defeat DNS rebinding.
544
+
545
+ There is no second forge credential. **Everything the board writes -- labels and comments alike --
546
+ is written under the daemon's own bot token**, so there is no operator PAT to mint and no account to
547
+ connect. What that costs is authorship, and the next section is about why paying it is safe.
548
+
549
+ ### Reading a plan, and replying to one
550
+
551
+ Three things a parked PR row offers beyond its label transitions:
552
+
553
+ **`View doc`** shows that branch's plan or spec without a clone. The doc is located by the same
554
+ `<kind>-<n>` naming convention the runs themselves use, and it is fetched **at the PR's head ref** --
555
+ what you read is the plan as the run left it, not as the default branch has it. The page reports
556
+ checkbox progress (`14 of 21 tasks`) and links out to the forge's own rendered view. The body itself
557
+ is shown as **plain escaped text, never rendered markdown**: branch content is written by anyone who
558
+ can push to it, and the board will not render it.
559
+
560
+ **`Comment`** opens a form on the PR's own thread (never a backing issue, whose comments are replayed
561
+ to every later run rather than windowed). Posting takes the same two-step confirm as kill, because
562
+ the forge has no comment-edit API and nothing you post can be retracted from here.
563
+
564
+ The comment is **authored by the daemon's bot account**, with a plain-prose suffix saying so:
565
+
566
+ ```
567
+ Looks right, but rebase on main first.
568
+
569
+ -- posted from the agent daemon control panel
477
570
  ```
478
571
 
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 |
572
+ That is safe because the boundary the agent uses to tell its own words from yours is **marker-based,
573
+ not author-based**: a run's own comments start with a reserved marker (`**agent:run**` and friends),
574
+ and everything without one is treated as human feedback no matter who wrote it. So a bot-authored
575
+ board comment reaches the next run exactly like one you typed in the forge's web UI. The attribution
576
+ is a **suffix** for the same reason -- markers are matched at the start, so trailing prose can never
577
+ be mistaken for one. The flip side: **the board refuses a comment whose text starts with a reserved
578
+ agent marker.** Typing `**agent:run**` yourself would move the run's own watermark and silently hide
579
+ every human comment written before it, so the form rejects it by name rather than posting something
580
+ that lies to the next run.
581
+
582
+ **The `N new comments` badge** on a parked row means: human comments on that PR newer than the last
583
+ agent operation on it -- the same window the next run will actually read, computed from the same two
584
+ functions, so the badge cannot promise the run something different. It appears on **pull request rows
585
+ only**; an issue's comments are replayed to every run whole, so "new since the last run" has no
586
+ meaning there and a number would be a lie. The counts are fetched in the daemon's poll cycle and
587
+ memoized, so a row nothing has touched costs nothing and `GET /` still makes no forge call.
588
+
589
+ ### What it costs, and what it does not
590
+
591
+ Reads are cheap, not free: the publishers write a snapshot into memory and `GET /` is a synchronous
592
+ render off it, so **a board page load makes no forge call of its own**. What it does do is record
593
+ that someone is watching, which arms a refresher that drains queued actions and re-searches the
594
+ forge every few seconds until nobody is. That traffic is bounded by one refresher per daemon rather
595
+ than by how many tabs are open, and it stops an idle window after the last reader's last request --
596
+ a window derived from the poll interval, never shorter than two minutes. Closing the tab does not
597
+ cut that short: the stream ends at once, but presence also counts that reader's last request, and
598
+ nothing clears it early. The page renders its own
599
+ staleness (`as of 12s ago`) rather than implying it is live. Opening a doc view or posting a comment
600
+ hits the forge from the request itself -- those are explicit navigations you asked for, and they are
601
+ the only two that do.
602
+
603
+ **One honest caveat:** a request can still be delayed by a single in-flight _synchronous_ git `exec`
604
+ in the run pipeline -- `clone`, `rev-parse`, `diff`, `status`, `commit`, `ls-remote` -- each of which
605
+ briefly blocks the event loop, a `git clone --depth 1` being the longest. The container run itself
606
+ does not block. Making those git calls async is out of scope, so the board is usually instant and
607
+ occasionally waits out one clone.
608
+
609
+ An action is **queued, not applied**, when the page comes back. The board never writes a label
610
+ itself; the daemon drains queued actions -- at the top of its next claim cycle, and on the board
611
+ refresher's own timer while a viewer is present -- which keeps it the only writer of agent labels in
612
+ the process. A row says `queued` in either state, because the refresher applies it within a tick or
613
+ two whatever the claim cycle is doing. Each action is re-checked against live labels when it is
614
+ applied, and one whose target has since been claimed is skipped with the reason shown on the row
615
+ rather than silently dropped. Kill is the exception: it writes no label, so it takes effect
616
+ immediately rather than at a drain.
617
+
618
+ `--ui` is refused together with `--once`, which runs a single cycle and returns -- the board would
619
+ serve one page and then be a dead surface whose every control did nothing.
487
620
 
488
621
  ## Docker check
489
622
 
@@ -493,7 +626,7 @@ import type {
493
626
 
494
627
  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
628
 
496
- **Auto-detection:** If the root `package.json` has a `"test:smoke"` script, it is automatically used as the smoketest command — no configuration needed.
629
+ **Auto-detection:** If the root `package.json` has a `"test:smoke"` script, it is automatically used as the smoketest command -- no configuration needed.
497
630
 
498
631
  **Manual configuration:** For non-conventional command names, configure via `.tooling.json`:
499
632
 
@@ -519,82 +652,23 @@ services:
519
652
  - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
520
653
  ```
521
654
 
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.
655
+ **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
656
 
524
657
  Two things follow from this that are worth knowing:
525
658
 
526
659
  - **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
660
  - **`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
661
 
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.
662
+ **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
663
 
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.
664
+ `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
665
 
533
666
  ### Derived HTTP health checks
534
667
 
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
668
+ 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
669
 
545
- The `@bensandee/tooling/docker-check` export provides utilities for checking Docker Compose stacks programmatically.
670
+ 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
671
 
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
- ```
672
+ 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
673
 
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` |
674
+ **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.