@bensandee/tooling
v0.76.0
Published
CLI tool to bootstrap and maintain standardized TypeScript project tooling
Readme
@bensandee/tooling
CLI to bootstrap and maintain standardized TypeScript project tooling.
Installation
pnpm add -D @bensandee/tooling
# Or run directly
pnpm dlx @bensandee/tooling repo:syncConventions
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.
| Convention | Detection | Default | Override via |
| ----------------- | ----------------------------------------------------- | ---------------------------------------- | ------------------------------------ |
| Project structure | pnpm-workspace.yaml present | single | structure in .tooling.json |
| CI platform | .github/workflows/ or .forgejo/workflows/ dir | none | ci in .tooling.json |
| Project type | Dependencies in package.json (react, node, library) | default | projectType in .tooling.json |
| Docker packages | Dockerfile or docker/Dockerfile in package dirs | -- | docker map in .tooling.json |
| Formatter | Existing prettier config detected | oxfmt | formatter in .tooling.json |
| Release strategy | Existing release config detected | monorepo: changesets, single: simple | releaseStrategy in .tooling.json |
CLI commands
Project management
| Command | Description |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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. |
| tooling repo:sync --check [dir] | Dry-run drift detection. Exits 1 if files would change, except drift confined to .forgejo/workflows//.github/workflows/, which is reported on the ::bst-checks:: warn marker at exit 0. CI-friendly. |
| tooling checks:run | Run project checks -- bst checks:run --help lists the ordered steps. Flags: --skip, --add, --fail-fast, --verbose, --with-coverage, --coverage, --no-coverage. |
| tooling lint:types | Type-aware linting (oxlint --type-aware, backed by oxlint-tsgolint). Flags: --json. See Type-aware linting below. |
Flags: --yes (accept all defaults), --no-ci (negates --ci), --no-prompt (negates --prompt), --eslint-plugin
checks:run
Runs checks in order: build, typecheck, lint, ascii:check (opt-in), lint:types, test, format (--check), knip, tooling:check, health:check (opt-in), docs:check, docker:build, docker:check. Checks without a matching script in package.json are silently skipped.
The --skip flag supports glob patterns via picomatch:
# Skip all docker steps
tooling checks:run --skip 'docker:*'
# Skip specific checks
tooling checks:run --skip build,knipThe --add flag appends extra checks (must be defined in package.json):
tooling checks:run --add e2eCoverage 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.
Explicit flags always win, regardless of branch:
--with-coverage-- force theteststep to run with coverage.--coverage-- the same thing, spelled as the positive of--no-coverage.--no-coverage-- force it to run without coverage.
If both are passed, --no-coverage wins.
Set .tooling.json#coverage.gateOnPullRequests: true to turn coverage collection on for every
branch, including refs that resolve to no branch at all (a detached HEAD, a tag, a
refs/pull/N/merge build) -- auto mode then always runs pnpm run test:coverage, regardless of
branch. An explicit --no-coverage still wins. This changes collection only: coverage recording
and history stay default-branch-only either way.
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.
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:
// [ok] forwarded flags reach bst checks:run
"check": "bst checks:run --add check:env-drift,check:custom"
// [x] --skip / --no-coverage land on check:custom instead
"check": "bst checks:run && pnpm check:env-drift && pnpm check:custom"repo:sync warns when it detects the broken shape, and coverage:summary / coverage:check name it as the likely cause if the summary file is missing.
The generated ci:check script defaults to pnpm check --skip 'docker:*' since CI environments typically lack Docker support.
Type-aware linting
For stack: "typescript" repos, repo:sync generates a lint:types script (bst lint:types, backed by a managed oxlint-tsgolint devDependency) and inserts a lint:types step into checks:run right after lint. It runs oxlint --type-aware and selects its findings by subtracting a plain oxlint run from it -- there is no maintained list of type-aware rule names, so a rule oxlint adds lands the day it ships.
Two phases, and the tooling's own version carries them. The report phase prints every finding, states plainly that they will block once a later tooling release flips to enforce, and exits 0. The flip is one constant in the tooling's source, so it reaches every consumer as an ordinary @bensandee/tooling version bump -- no coordinated migration, nothing to remember. .tooling.json#lint.strictTypeAware (default false) lets a repo opt into blocking during the report phase, ahead of the fleet-wide flip.
The whole default rule set ships, and the tooling excludes nothing. A repo's own oxlint.config.ts is the per-rule control -- turning a rule off there ("typescript/<rule>": "off") is honored by the type-aware run, and since plain oxlint ignores type-aware rules entirely, such an entry never weakens pnpm lint. In increasing order of bluntness: turn off one rule in oxlint.config.ts, or skip the whole step with .tooling.json#checks.skip: ["lint:types"].
A missing oxlint-tsgolint binary degrades to a notice at exit 0 in the report phase, and fails only when lint.strictTypeAware is set -- a repo that has not armed enforcement should not have CI broken by a platform gap, and a repo that has asked for the gate should not silently lose it.
Custom blocks in workflows
Generated workflow files are regenerated on each repo:sync run. To preserve custom additions (extra steps, environment variables, etc.), wrap them in custom block markers:
- name: Run checks
run: pnpm ci:check
# @tooling:custom
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
# @tooling:endcustomCustom 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.
To skip a workflow file entirely, add # @bensandee/tooling:ignore as the first line -- repo:sync will leave the file untouched.
pnpm-workspace.yaml
repo:sync manages pnpm-workspace.yaml with a narrow contract -- mandated keys, a few seeded-on-creation keys, and everything else passed through verbatim.
Mandated (drift fails repo:sync --check):
blockExoticSubdeps: trueminimumReleaseAge-- any non-negative number (0is valid)
Seeded on creation only (free to change or remove afterward):
minimumReleaseAgeExclude: ["@bensandee/*"]packages: ["packages/*"](monorepos only)
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.
Opt out entirely with "pnpmWorkspace": false in .tooling.json; the generator then never inspects or writes the file.
packageManager force-bump
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 ([email protected] -> 11.y) remain Renovate's job; non-pnpm package managers (yarn, npm) are out of scope.
Repository setup
| Command | Description |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| tooling setup:secrets | Configure CI secrets (Forgejo or GitHub). Auto-detects platform from package.json repository URL. Flags: --dry-run, --no-docker (negates --docker). |
| tooling forgejo:secrets | Manage Forgejo Actions secrets directly. Subcommands: set, list, delete. |
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.
Release management
| Command | Description |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| tooling release:changesets | Changesets version/publish for Forgejo CI. Flags: --dry-run, --verbose. Env: FORGEJO_SERVER_URL, FORGEJO_REPOSITORY, RELEASE_TOKEN. |
| tooling release:simple | Streamlined release using commit-and-tag-version. Flags: --release-as, --first-release, --prerelease, --verbose. Env: RELEASE_TOKEN (required on every platform). |
| tooling release:trigger | Trigger a release workflow. |
| 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). |
| tooling forgejo:create-release | Create a Forgejo release from a tag. |
| 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. |
| tooling webhook:send <url> | POST a structured JSON release payload with HMAC-SHA256 auth. Best-effort by default: a delivery failure is logged, annotated ::error:: under CI, and the command exits 0; --strict restores a non-zero exit. Flags: --tag, --repository, --server-url, --actor, --prerelease, --strict. Secret from WEBHOOK_SECRET env var. |
release:simple
Uses commit-and-tag-version under the hood. Version bumps are auto-detected from Conventional Commits:
| Commit prefix | Bump | Example |
| ----------------------- | ----- | ------------------------------------ |
| fix: | patch | fix: handle null response |
| feat: | minor | feat: add retry logic |
| feat!: / fix!: etc | major | feat!: drop v1 API |
| BREAKING CHANGE: body | major | Any type with breaking change footer |
Override auto-detection with CLI flags:
# Force a major bump
tooling release:simple --release-as major
# Force a specific version
tooling release:simple --release-as 2.0.0
# Create a prerelease
tooling release:simple --release-as major --prerelease beta # -> 2.0.0-beta.0The generated release workflow exposes these as optional workflow_dispatch inputs (bump and prerelease), so bumps can also be controlled from the CI UI.
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.
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.
workflow:dispatch
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.
tooling workflow:dispatch \
--repo bensandee/ansible_infra \
--workflow deploy.yml \
--ref main \
--input stack=dirty_draft \
--input release_tag=v1.2.3On Forgejo it POSTs to the actions/workflows/{workflow}/dispatches API; on GitHub it shells out to gh workflow run. The token resolved from the environment / package.json must have rights on --repo when it differs from the current repo.
Generated deploy step
For the common release->deploy case, repo:sync can generate the dispatch step for you. Add a deploy block to .tooling.json:
{
"deploy": {
"dispatchRepo": "bensandee/ansible_infra", // required -- repo whose workflow to trigger
"stack": "dirty_draft", // optional -- defaults to the package name with '-' -> '_'
"workflow": "deploy.yml", // optional -- default "deploy.yml"
"ref": "main", // optional -- default "main"
},
}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.
Note: the
release_taginput is sourced from the release step'stagoutput. On GitHub with thechangesetsstrategy the release runs viachangesets/action, which exposes notagoutput -- the deploy still fires, butrelease_tagis dispatched empty. Thesimplestrategy and Forgejochangesetsboth populate it.
Release assets
Upload arbitrary build artifacts (tarballs, binaries, zips, etc.) to each release.
Convention (zero config): Add a build:release-assets script to any package.json and have it write artifacts into a release-assets/ directory alongside the script:
{
"scripts": {
"build:release-assets": "mkdir -p release-assets && tar -czf release-assets/app.tar.gz dist"
}
}On repo:sync, release-assets/ is added to .gitignore and a notice is printed confirming the convention was detected. In CI, the generated release workflow runs tooling release:assets, which builds every detected package and uploads every file in each release-assets/ directory to the current tag's release. The script runs from the package directory, so monorepo packages can each emit their own set of artifacts.
If the script runs but writes no files to release-assets/, release:assets fails with an actionable error rather than silently skipping the upload.
Override (non-conventional layouts): For artifacts that don't fit the convention, declare them explicitly in .tooling.json:
{
"releaseAssets": [
{ "file": "dist/app.tar.gz", "command": ["pnpm", "build"], "name": "app.tar.gz" }
]
}| Field | Description |
| --------- | ---------------------------------------------------------------------------------------- |
| file | Path to the artifact, relative to the project root (required). |
| command | argv run before upload (optional; omit for pre-built files). Not interpreted by a shell. |
| name | Asset name in the release (optional; defaults to the file's basename). |
Convention and overrides coexist -- a repo can use both.
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.
Version stamping
For a repo whose released artifact carries its version somewhere other than package.json -- a WordPress theme's style.css header, a plugin banner, a template file -- declare where the version goes and let release:assets write it in:
{
"versionStamp": [{ "file": "style.css", "prefix": "Version: " }]
}| Field | Description |
| -------- | ------------------------------------------------------------------------------------- |
| file | Path to the file whose version line gets stamped, relative to the project root (required). |
| prefix | Literal string (never a pattern) the target line must start with (required). |
| suffix | Literal string (never a pattern) the target line must end with (optional; default: none). |
prefix and suffix are literal strings, not regexes -- the target line is read straight off prefix + version + suffix:
| Target | prefix | suffix |
| ------------------------------------ | ---------------------------- | -------------- |
| Version: 1.2.3 (WordPress theme) | "Version: " | -- |
| * Version: 1.2.3 (plugin header) | " * Version: " | -- |
| Version: v1.2.3 | "Version: v" | -- |
| __version__ = "1.2.3" | "__version__ = \"" | "\"" |
| <version>1.2.3</version> | "<version>" | "</version>" |
| define('FOO_VERSION', '1.2.3'); | "define('FOO_VERSION', '" | "');" |
release:assets stamps every configured file before building assets, then verifies the stamp survived the build. Exactly one line in the file must match prefix/suffix or the release fails loudly -- zero matches means the config is broken, and more than one is an ambiguous config that needs a more specific prefix or a second entry. The stamp is never committed: the tracked copy of the file keeps its placeholder version on purpose, so there is only ever one source of truth (package.json#version) and nothing to drift. Run bst release:assets --dry-run to rehearse a versionStamp config locally, with no forge access and no release -- the rehearsal does still write the stamp, so it leaves the tracked file modified in your working tree. Discard it with git checkout -- <file>; committing it is exactly the drift this design exists to prevent.
repo:sync --check validates that every versionStamp[].file exists and still has exactly one matching line -- it never compares the stamped value to package.json#version, since the tracked value is deliberately stale.
Coverage tracking
| Command | Description |
| -------------------------- | --------------------------------------------------------------------------------------------- |
| tooling coverage:summary | Print a labeled per-package summary of the latest run. --json for machine-readable form. |
| tooling coverage:check | Compare the latest run against coverage-baseline.json; exit non-zero on regression. |
| tooling coverage:record | Append the latest run to a bst/coverage-metrics orphan branch (CI: push to default branch). |
| tooling coverage:status | Show recent coverage trend. Read-only -- fetches the orphan branch, no checkout. |
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.
How it works
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:
pnpm test:coverage -> coverage:summary -> coverage:record -> coverage:statusso every CI log ends with the trend table for the last 10 records.
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. The contents: write workflow permission is a GitHub concern: on Forgejo the generated workflow declares no permissions block at all, and the push is authorized by the runner token's default access (or an Authorized Integration, where an instance restricts it). 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.
The local ratchet
A tracked coverage-baseline.json at the repo root captures the four overall totals (lines/statements/branches/functions) plus per-package numbers. coverage:check compares the latest run against it and exits non-zero if any total drops more than the configured tolerancePp (default 0.1). When all four are flat-or-up, --update-baseline rewrites the file so it can be staged with the change.
repo:sync also inserts a <!-- @tooling:coverage-rule --> managed block into CLAUDE.md describing the workflow:
Before declaring a task complete that touched files under
packages/*/src/, runpnpm test:coveragethenpnpm 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 viacoverage.excludeinvitest.config.tsand explain why in the commit message. If all four are flat-or-up, runpnpm exec bst coverage:check --update-baselineand stagecoverage-baseline.json.
Skip the rule for docs-only, config-only, dependency-bump, or test-only changes.
Bootstrap
On a clean default branch, generate the initial baseline:
pnpm test:coverage
pnpm exec bst coverage:check --update-baseline --no-fail-on-missing-baseline
git add coverage-baseline.jsonThe first CI run on the default branch creates the orphan bst/coverage-metrics branch automatically. From any clone, inspect the trend with:
pnpm exec bst coverage:status --limit 20Opt-out
Disable the feature entirely:
{ "coverage": false }Or keep the local ratchet but skip the CI history append:
{ "coverage": { "history": "none" } }| Override field | Default | Description |
| -------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| history | "orphan-branch" | "none" disables CI history; the local ratchet still works |
| historyBranch | "bst/coverage-metrics" | Override the orphan branch name |
| tolerancePp | 0.1 | Tolerance in percentage points before coverage:check flags a drop |
| gateOnPullRequests | false | true makes checks:run's auto mode collect coverage on every branch, not only the default one; recording is unaffected |
Per-package re-sum
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.
Docker
| Command | Description |
| ------------------------ | ------------------------------------------------------------------- |
| tooling docker:build | Build Docker images for discovered Docker packages. |
| tooling docker:publish | Build, tag, and push Docker images to a registry. |
| tooling docker:check | Start a Compose stack, run health checks, run smoketest, tear down. |
Docker packages are discovered automatically. Any package with a Dockerfile or docker/Dockerfile is a Docker package. Image names are derived as {root-package-name}-{package-name}, build context defaults to . (project root). For single-package repos, Dockerfile or docker/Dockerfile at the project root is checked.
When Docker packages are present, repo:sync generates a publish workflow (.forgejo/workflows/publish.yml or .github/workflows/publish.yml) triggered via workflow_dispatch for manual runs. For the simple release strategy, docker publishing is also added as a step in the release workflow so it runs automatically after each release.
Overrides
To override defaults, add a docker entry to .tooling.json:
{
"docker": {
"server": {
"dockerfile": "packages/server/docker/Dockerfile",
"context": "."
}
}
}The context field defaults to "." (project root) when omitted. Versions for tagging are read from each package's own package.json.
Per-package build args
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.
{
"docker": {
"frontend": {
"dockerfile": "packages/frontend/docker/Dockerfile",
"context": ".",
"buildArgs": {
"VITE_BASE_PATH": "${BASE_PATH:-/}"
}
}
}
}Values support ${VAR} and ${VAR:-default} expansion against process.env. Empty strings are treated as unset (POSIX :- semantics). Unset variables with no default expand to "" and log a warning. When the CLI also receives --build-arg via -- pass-through, the CLI value comes after the configured value and wins on duplicate ARG_NAME.
docker:build
Builds all discovered packages, or a single package with --package:
# Build all packages with docker config
tooling docker:build
# Build a single package (useful as an image:build script)
tooling docker:build --package packages/server
# Pass extra args to docker build
tooling docker:build -- --no-cache --build-arg FOO=barTo give individual packages a standalone image:build script for local testing:
{
"scripts": {
"image:build": "pnpm exec tooling docker:build --package ."
}
}Flags: --package <dir> (build a single package), --verbose
docker:publish
Runs docker:build for all packages, then logs in to the registry, tags each image with semver variants from its own version field, pushes all tags, and logs out.
Tags generated per package: latest, vX.Y.Z, vX.Y, vX
Each package is tagged independently using its own version, so packages in a monorepo can have different release cadences. Packages without a version field are rejected at publish time.
Flags: --dry-run (build and tag only, skip login/push/logout), --verbose
Required CI variables:
| Variable | Type | Description |
| --------------------------- | -------- | --------------------------------------------------------------------- |
| DOCKER_REGISTRY_HOST | variable | Registry hostname (e.g. code.orangebikelabs.com) |
| DOCKER_REGISTRY_NAMESPACE | variable | Full namespace for tagging (e.g. code.orangebikelabs.com/bensandee) |
| <REGISTRY>_USERNAME | secret | Registry username -- name depends on registry host (see below) |
| <REGISTRY>_PASSWORD | secret | Registry password -- name depends on registry host (see below) |
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:
| Host | Username secret | Password secret |
| ------------------------- | --------------------------------- | --------------------------------- |
| code.orangebikelabs.com | OBL_REGISTRY_USERNAME | OBL_REGISTRY_PASSWORD |
| docker.io | DOCKERHUB_USERNAME | DOCKERHUB_PASSWORD |
| quay.io | QUAY_USERNAME | QUAY_PASSWORD |
| ghcr.io | GHCR_USERNAME | GHCR_PASSWORD |
| anything else | <UPPER_SNAKE_HOSTNAME>_USERNAME | <UPPER_SNAKE_HOSTNAME>_PASSWORD |
When package.json#repository and .tooling.json#registryHost are both absent the generator falls back to neutral REGISTRY_USERNAME / REGISTRY_PASSWORD.
Registry-specific names keep one credential per registry, so a repo publishing to more than one never has to decide which registry the generic pair belongs to.
Names a forge will not accept. A secret name must match [A-Za-z_][A-Za-z0-9_]* and must not start with FORGEJO_, GITEA_, GITHUB_, or a digit -- Forgejo answers invalid secret name and refuses to create it. The generator sanitizes every derived name against those rules, which is why code.orangebikelabs.com is OBL_REGISTRY_* rather than the FORGEJO_* the hostname would suggest, and why a host like 10.0.0.5:5000 or forgejo.example.com derives a REGISTRY_-prefixed name. bst forgejo:secrets set refuses an invalid name up front rather than passing it to the API.
Forgejo setup: On Forgejo, the username secret is your Forgejo account username, and the password secret can reuse the same token as RELEASE_TOKEN. The token needs write permissions on the org, package, and repository scopes. These permissions should be set for the user if the package is under a user namespace (e.g. bensandee), or the organization if it's under an org namespace (e.g. orangebikelabs). After migrating, the old DOCKER_USERNAME / DOCKER_PASSWORD user/org-level secrets can be deleted once every consumer has been regenerated.
Config file
.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.
Available override fields:
| Field | Type | Default (detected) |
| -------------------- | ------- | -------------------------------------------------------------------------------- |
| structure | string | "monorepo" if pnpm-workspace.yaml present, else "single" |
| useEslintPlugin | boolean | true |
| formatter | string | "prettier" if config found, else "oxfmt" |
| setupVitest | boolean | true |
| ci | string | Detected from workflow dirs, else "none" |
| setupRenovate | boolean | true |
| releaseStrategy | string | Detected from existing config, else monorepo: "changesets", single: "simple" |
| projectType | string | Auto-detected from package.json deps |
| detectPackageTypes | boolean | true |
| pnpmWorkspace | false | Set to false to opt out of pnpm-workspace.yaml management |
Debug logging
All CLI commands support a --verbose flag for detailed debug output. Alternatively, set TOOLING_DEBUG=true as an environment variable -- useful in CI workflows:
env:
TOOLING_DEBUG: "true"Debug output is prefixed with [debug] and includes exec results (exit codes, stdout/stderr), compose configuration details, container health statuses, and retry attempts.
The agent's web control panel
agent:start --ui serves an opt-in board that renders the agent/<stage>[-<status>] label state
machine, shows the agent's own live state, and lets you drive the transitions you would otherwise
apply by hand with bst issue:set-labels -- plus read a branch's plan and reply on its pull request
without leaving the page. It is off by default.
The problem it solves is that a parked terminal is silent: agent/plan-ready,
agent/implement-mergeable, agent/review-ready and every -failed are states where the machine
has stopped and is waiting on a person, and nothing announces them. The board's lanes are ordered by
what needs you, not by pipeline position.
It also closes a real gap: the board can kill a wedged run on a headless agent. The interactive
k keypress needs a TTY, so under systemd there was previously no way to stop a stuck container
short of a hand-run docker kill.
# Loopback, port 4600. The access token comes from BST_AGENT_UI_TOKEN, or is minted once into
# the state home when that is unset.
bst agent:start --uiThe startup log names the board's URL and, when a file backs the token, that file's path. The
token itself is logged nowhere -- read it out of the file, or set BST_AGENT_UI_TOKEN yourself:
board: http://127.0.0.1:4600
board token: stored at ~/.local/state/bst-agent/ui-token (set BST_AGENT_UI_TOKEN to override)
board listening on http://127.0.0.1:4600Opening the board gives you a login form; the token is the password, and a successful sign-in stores a 30-day HttpOnly cookie, so an agent restart no longer signs you out. Rotate by changing the env value or deleting the file, then restarting.
Reaching it from a phone
The documented deployment binds loopback only and publishes the board through a tailnet TLS terminator, so nothing is listening on your LAN at all and no phone is asked to trust a self-signed certificate:
bst agent:start --ui \
--ui-public-url https://box.tail1234.ts.net \
--ui-behind-tls
# and, separately, whatever your terminator wants:
tailscale serve --bg 4600Both flags are required, and each fails closed in a way that looks like a bug if you omit it:
--ui-public-urladds the external hostname to the Host-header allowlist. Your browser sendsHost: box.tail1234.ts.net, not127.0.0.1:4600, so without it every proxied request is rejected with a bare403. It is also what makes the startup line print the URL you can actually open.--ui-behind-tlsmarks the session cookieSecure. It has to be explicit: the agent serves plain HTTP and cannot see that something in front terminated TLS, andX-Forwarded-Protois attacker-supplied unless a trusted proxy is guaranteed -- which nothing here can guarantee.
A non-loopback --ui-bind still works, but pass --ui-public-url with it too. The Host-header
allowlist is built from the bind string literally, so --ui-bind 0.0.0.0 allows the useless
Host: 0.0.0.0:4600 while your browser sends the machine's actual address -- and every request comes
back a bare 403 with nothing to explain it. --ui-public-url http://<host-or-ip>:4600 names the host
you will really reach it at. Binding a specific address you then type verbatim needs nothing extra.
The board token, and the session it mints
There is exactly one credential to be careful with, and its lifetime is the part people get wrong:
| | Board token | Session cookie it mints | | --------- | ----------------------------------------------------------------- | ----------------------- | | Lifetime | the agent process -- a service, so weeks | 12 hours | | Scope | label transitions + comments + kill, every repo the bot can write | the board, nothing else | | Holders | one, shared | one browser | | Persisted | never | never |
The board token gates reaching the board at all, and everything it grants happens under the agent's bot token. It is "short-lived" only relative to a config file; against the process it gates it is long-lived, and rotating it means restarting the agent -- at which point a phone bookmark goes dead and you return to the log for a fresh link. That is the accepted cost of never persisting it.
Session cookies are HttpOnly, SameSite=Strict, and expire after 12 hours. Every request is
checked against a Host-header allowlist and an Origin check; neither replaces the other,
because an Origin check alone does not defeat DNS rebinding.
There is no second forge credential. Everything the board writes -- labels and comments alike -- is written under the agent's own bot token, so there is no operator PAT to mint and no account to connect. What that costs is authorship, and the next section is about why paying it is safe.
Reading a plan, and replying to one
Three things a parked PR row offers beyond its label transitions:
View doc shows that branch's plan or spec without a clone. The doc is located by the same
<kind>-<n> naming convention the runs themselves use, and it is fetched at the PR's head ref --
what you read is the plan as the run left it, not as the default branch has it. The page reports
checkbox progress (14 of 21 tasks) and links out to the forge's own rendered view. The body itself
is shown as plain escaped text, never rendered markdown: branch content is written by anyone who
can push to it, and the board will not render it.
Comment opens a form on the PR's own thread (never a backing issue, whose comments are replayed
to every later run rather than windowed). Posting takes the same two-step confirm as kill, because
the forge has no comment-edit API and nothing you post can be retracted from here.
The comment is authored by the agent's bot account, with a plain-prose suffix saying so:
Looks right, but rebase on main first.
-- posted from the agent control panelThat is safe because the boundary the agent uses to tell its own words from yours is marker-based,
not author-based: a run's own comments start with a reserved marker (**agent:run** and friends),
and everything without one is treated as human feedback no matter who wrote it. So a bot-authored
board comment reaches the next run exactly like one you typed in the forge's web UI. The attribution
is a suffix for the same reason -- markers are matched at the start, so trailing prose can never
be mistaken for one. The flip side: the board refuses a comment whose text starts with a reserved
agent marker. Typing **agent:run** yourself would move the run's own watermark and silently hide
every human comment written before it, so the form rejects it by name rather than posting something
that lies to the next run.
The N new comments badge on a parked row means: human comments on that PR newer than the last
agent operation on it -- the same window the next run will actually read, computed from the same two
functions, so the badge cannot promise the run something different. It appears on pull request rows
only; an issue's comments are replayed to every run whole, so "new since the last run" has no
meaning there and a number would be a lie. The counts are fetched in the agent's poll cycle and
memoized, so a row nothing has touched costs nothing and GET / still makes no forge call.
What it costs, and what it does not
Reads are cheap, not free: the publishers write a snapshot into memory and GET / is a synchronous
render off it, so a board page load makes no forge call of its own. What it does do is record
that someone is watching, which arms a refresher that drains queued actions and re-searches the
forge every few seconds until nobody is. That traffic is bounded by one refresher per agent rather
than by how many tabs are open, and it stops an idle window after the last reader's last request --
a window derived from the poll interval, never shorter than two minutes. Closing the tab does not
cut that short: the stream ends at once, but presence also counts that reader's last request, and
nothing clears it early. The page renders its own
staleness (as of 12s ago) rather than implying it is live. Opening a doc view or posting a comment
hits the forge from the request itself -- those are explicit navigations you asked for, and they are
the only two that do.
One honest caveat: a request can still be delayed by a single in-flight synchronous git exec
in the run pipeline -- clone, rev-parse, diff, status, commit, ls-remote -- each of which
briefly blocks the event loop, a git clone --depth 1 being the longest. The container run itself
does not block. Making those git calls async is out of scope, so the board is usually instant and
occasionally waits out one clone.
An action is queued, not applied, when the page comes back. The board never writes a label
itself; the agent drains queued actions -- at the top of its next claim cycle, and on the board
refresher's own timer while a viewer is present -- which keeps it the only writer of agent labels in
the process. A row says queued in either state, because the refresher applies it within a tick or
two whatever the claim cycle is doing. Each action is re-checked against live labels when it is
applied, and one whose target has since been claimed is skipped with the reason shown on the row
rather than silently dropped. Kill is the exception: it writes no label, so it takes effect
immediately rather than at a drain.
--ui is refused together with --once, which runs a single cycle and returns -- the board would
serve one page and then be a dead surface whose every control did nothing.
Docker check
docker:check verifies that a Docker Compose stack starts correctly, passes health checks, and (optionally) survives a smoketest command. The lifecycle is: build images, compose up, poll health checks, run smoketest, compose down.
Smoketests
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.
Auto-detection: If the root package.json has a "test:smoke" script, it is automatically used as the smoketest command -- no configuration needed.
Manual configuration: For non-conventional command names, configure via .tooling.json:
{
"dockerCheck": {
"smoketest": ["pnpm", "test:integration"],
"smoketestCwd": "."
}
}If the smoketest command exits non-zero, docker:check dumps container logs and fails with reason "smoketest-failed".
Environment variables and compose interpolation
A base compose file usually interpolates values it refuses to give a fallback for:
services:
app:
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}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.
Two things follow from this that are worth knowing:
- 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.
docker-compose.check.envis the file that does feed interpolation. Drop it beside the base compose file anddocker:checkauto-detects it and passes it to compose as--env-file. Use it when the check needs real values rather than blanks. Override the location withdockerCheck.envFilein.tooling.json.
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.
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.
Derived HTTP health checks
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.
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.
The probe image (curlimages/curl) is pinned by digest, and a docker run exit code of 125 (Docker's convention for "the agent failed to start the container at all" -- missing image, pull denied, agent unreachable) fails the check immediately instead of waiting out the full timeout.
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.
