@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 +201 -128
- package/bin/bst.mjs +26 -0
- package/dist/bin.mjs +17752 -10294
- package/package.json +16 -18
- package/tooling.schema.json +176 -4
- package/dist/bin.d.mts +0 -1
- package/dist/index.d.mts +0 -146
- package/dist/index.mjs +0 -1
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**
|
|
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 |
|
|
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**
|
|
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`
|
|
64
|
-
- `--no-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
|
|
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
|
|
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
|
-
//
|
|
73
|
+
// [ok] forwarded flags reach bst checks:run
|
|
74
74
|
"check": "bst checks:run --add check:env-drift,check:custom"
|
|
75
75
|
|
|
76
|
-
//
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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 #
|
|
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
|
|
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
|
|
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
|
|
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
|
|
200
|
-
"stack": "dirty_draft", // optional
|
|
201
|
-
"workflow": "deploy.yml", // optional
|
|
202
|
-
"ref": "main", // optional
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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**
|
|
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`
|
|
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
|
|
416
|
-
| `<REGISTRY>_PASSWORD` | secret | Registry password
|
|
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**
|
|
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
|
|
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
|
-
##
|
|
464
|
+
## The agent daemon's web control panel
|
|
465
465
|
|
|
466
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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");
|