chore(testbox): add Blacksmith runner setup

This commit is contained in:
Vincent Koc
2026-04-29 22:21:18 -07:00
parent 9ebf7d7bde
commit 3bfdbfc004
11 changed files with 1289 additions and 127 deletions
+347
View File
@@ -0,0 +1,347 @@
---
name: blacksmith-testbox
description: Run Blacksmith Testbox for ClawHub CI-parity checks, hosted services, broad Bun gates, or builds local cannot reproduce without hurting developer machines.
---
# Blacksmith Testbox
## Scope
Use Testbox when you need remote CI parity, injected secrets, hosted services,
or an OS/runtime image that your local machine cannot provide cheaply.
Do not default to Testbox for every local test/build loop. If the repo has
documented local commands for normal iteration, use those first so you keep
warm caches, local build state, and fast feedback.
Testbox is the expensive path. Reach for it deliberately.
ClawHub maintainers can opt into Testbox-first validation by setting
`CLAWHUB_TESTBOX=1` in their environment or standing agent rules. This mode is
maintainers-only and requires Blacksmith access.
When `CLAWHUB_TESTBOX=1` is set in ClawHub:
- Pre-warm a Testbox early for longer, wider, or uncertain work.
- Prefer Testbox for broad Bun gates, e2e, Convex-ish deploy parity, package
proof, and expensive validation.
- Reuse the same Testbox ID for every run command in the same task/session.
- Use local commands only when the task explicitly sets
`CLAWHUB_LOCAL_CHECK_MODE=throttled|full`, or when the user asks for local
proof.
## Install The CLI
If `blacksmith` is not installed, install it:
```bash
curl -fsSL https://get.blacksmith.sh | sh
```
For the canary channel:
```bash
BLACKSMITH_CHANNEL=canary sh -c 'curl -fsSL https://get.blacksmith.sh | sh'
```
Then authenticate:
```bash
blacksmith auth login
```
## Agent-Triggered Browser Auth
When an agent needs to ensure the user is authenticated before running Testbox
commands, use browser-based auth with non-interactive mode. This opens the
browser for the user to sign in; the agent does not interact with the browser.
`--organization` is required with `--non-interactive`:
```bash
blacksmith auth login --non-interactive --organization <org-slug>
```
The org slug can come from `BLACKSMITH_ORG` or the `--org` global flag. Do not
use `--api-token` for this browser flow; that is for headless/token auth.
## Decide First: Local Or Testbox
Before warming anything up, check the repo's own instructions.
Prefer local commands when:
- the repo documents a supported local test/build workflow
- you are iterating on unit tests, lint, typecheck, formatting, or other
local-only validation
- the value comes from warm local caches and fast repeat runs
- the command does not need remote secrets, hosted services, or CI-only images
Prefer Testbox when:
- `CLAWHUB_TESTBOX=1` is set by the user, agent environment, or standing rules
- the repo explicitly requires CI-parity or remote validation
- the command needs secrets, service containers, or provisioned infra
- you are reproducing CI-only failures
- you need the exact workflow image/job environment from GitHub Actions
For ClawHub specifically, normal local iteration stays local unless maintainer
Testbox mode is enabled with `CLAWHUB_TESTBOX=1`:
- `bun run format:check`
- `bun run lint`
- `bun run test`
- `bun run coverage`
- `bunx tsc --noEmit`
- `bun run build`
If `CLAWHUB_TESTBOX=1` is enabled, run those same repo commands inside the warm
Testbox. If the user wants laptop-friendly local proof for one command, use the
explicit escape hatch `CLAWHUB_LOCAL_CHECK_MODE=throttled`.
In `.codex` worktrees without a `node_modules` symlink, do not run
`bun install` just to validate locally. Use syntax checks or Testbox.
## Setup: Warmup Before Coding
If you decided Testbox is warranted, warm one up early. This returns an ID
instantly and boots the CI environment in the background while you work:
```bash
blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90
# -> tbx_01jkz5b3t9...
```
Save this ID in the current session. You need it for every `run` command.
Treat `blacksmith testbox list` as diagnostics, not a reusable work queue.
Listed boxes can be visible at the org/repo level while still being unusable or
stale for the current local agent lane.
For ClawHub maintainer Testbox mode, claim the ID in the current checkout:
```bash
bun run testbox:claim -- --id <ID>
```
Warmup dispatches `.github/workflows/ci-check-testbox.yml`, which provisions a
VM with Bun, Node, dependency install/cache, and a clean checkout of the repo at
the chosen ref.
Bootstrap note: GitHub only exposes `workflow_dispatch` workflows through the
Actions API after the workflow file exists on the default branch. If a brand-new
Testbox workflow exists only on a feature branch, `blacksmith testbox warmup
ci-check-testbox.yml --ref <branch>` can return a GitHub 404 even though the
file exists on that branch. Land the workflow bootstrap first, then dispatch
branch refs normally.
Options:
```text
--ref <branch|tag> Git ref to dispatch against
--job <name> Specific job within the workflow, if it has multiple
--idle-timeout <min> Idle timeout in minutes
```
## Critical: Always Run From The Repo Root
Always invoke `blacksmith testbox` commands from the root of the git
repository. The CLI syncs the current working directory to the testbox using
rsync with `--delete`. If you run from a subdirectory, rsync mirrors only that
subdirectory and can delete everything else on the testbox.
Correct:
```bash
blacksmith testbox run --id <ID> "bun run test"
blacksmith testbox run --id <ID> "cd packages/clawhub && bun run verify"
```
Wrong:
```bash
cd packages/clawhub && blacksmith testbox run --id <ID> "bun run verify"
```
If your shell is in a subdirectory, move back first:
```bash
cd "$(git rev-parse --show-toplevel)"
```
## Running Commands
Raw Blacksmith form:
```bash
blacksmith testbox run --id <ID> "<command>"
```
The `run` command waits for the testbox to become ready if it is still booting,
so you can call `run` immediately after warmup.
In ClawHub, prefer the guarded runner wrapper so stale/reused ids fail before
the Blacksmith CLI spends time syncing or emits a confusing missing-key error:
```bash
bun run testbox:run -- --id <ID> -- bun run lint
bun run testbox:run -- --id <ID> -- bun run test
bun run testbox:run -- --id <ID> -- bun run build
```
The wrapper refuses to run when the local per-Testbox key is missing or when
the id was not claimed by this ClawHub checkout with:
```bash
bun run testbox:claim -- --id <ID>
```
Treat that as the expected remediation, not as a GitHub account or normal
SSH-key problem. A local key alone is not enough; a ready box may still carry
stale rsync state from another lane.
If the agent crashes, the remote box relies on Blacksmith's idle timeout. The
local ClawHub claim marker is not deleted automatically, so the wrapper treats
claims older than 12 hours as stale. Override only for intentional long-running
work with:
```bash
CLAWHUB_TESTBOX_CLAIM_TTL_MINUTES=<minutes>
```
Before spending a broad gate on a manually assembled command, run:
```bash
bun run testbox:sanity -- --id <ID>
```
## Downloading Files From A Testbox
Use the `download` command to retrieve files or directories from a running
testbox to your local machine. This is useful for fetching build artifacts,
test results, coverage reports, or any output generated on the testbox.
```bash
blacksmith testbox download --id <ID> <remote-path> [local-path]
```
The remote path is relative to the testbox working directory. If no local path
is specified, the file is saved to the current directory using the same base
name.
Examples:
```bash
blacksmith testbox download --id <ID> coverage/lcov-report/ ./coverage/
blacksmith testbox download --id <ID> test-results/ ./test-results/
blacksmith testbox download --id <ID> dist/ ./dist/
```
## How File Sync Works
Understanding this model is critical for using Testbox correctly.
When you call `run`, the CLI performs a delta sync of your local changes to the
remote testbox before executing your command:
1. The testbox VM starts from a clean checkout at the warmup ref. The workflow
setup steps run during warmup and populate dependency directories on the
remote VM.
2. On each `run`, the CLI uses git to detect which files changed locally since
the last sync. It syncs only tracked files and untracked non-ignored files.
3. `.gitignore`'d directories are never synced. `node_modules/`, `.bun/`,
`.vite/`, `dist/`, `.output/`, `.nitro/`, and coverage outputs stay local.
The testbox uses its own copies populated by the warmup workflow.
4. If nothing has changed since the last sync, the sync is skipped.
Why this matters:
- If you modify `package.json` or `bun.lock`, re-run install on the testbox:
```bash
bun run testbox:run -- --id <ID> -- bun install --frozen-lockfile
```
- If tests depend on generated/build output, re-run the build on the testbox.
- New untracked files sync as long as they are not gitignored.
- Deleted files are also deleted on the remote testbox.
## Critical: Do Not Ban Local Tests
Do not assume local validation is forbidden. Many repos intentionally invest in
fast, warm local loops, and forcing every run through Testbox destroys that
advantage.
Use Testbox for checks that actually need it: remote parity, secrets, services,
CI-only runners, expensive broad gates, or reproducibility against the workflow
image.
ClawHub maintainer exception: if `CLAWHUB_TESTBOX=1` is set by the user or
agent environment, treat Testbox as the normal validation path for this repo.
Use `CLAWHUB_LOCAL_CHECK_MODE=throttled|full` as the explicit local escape
hatch.
## Workflow
1. Decide whether the repo's local loop is the right default. For ClawHub,
`CLAWHUB_TESTBOX=1` makes Testbox the maintainer default.
2. If Testbox is warranted, warm up early:
`blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90`.
3. Save the ID, then claim it:
`bun run testbox:claim -- --id <ID>`.
4. Write code while the testbox boots in the background.
5. Run sanity before broad checks:
`bun run testbox:sanity -- --id <ID>`.
6. Run the remote command:
`bun run testbox:run -- --id <ID> -- bun run lint`.
7. If tests fail, fix code and re-run against the same warm box.
8. If dependency manifests changed, run install in the box before testing.
9. If you need artifacts, download them with `blacksmith testbox download`.
10. Stop the box when done if it is no longer needed:
`blacksmith testbox stop --id <ID>`.
## ClawHub Broad Gate
For a broad ClawHub proof in maintainer Testbox mode, use the repo package
manager and keep the commands explicit:
```bash
bun run testbox:run -- --id <ID> -- bun run format:check
bun run testbox:run -- --id <ID> -- bun run lint
bun run testbox:run -- --id <ID> -- bun run test
bun run testbox:run -- --id <ID> -- bunx tsc --noEmit
bun run testbox:run -- --id <ID> -- bunx tsc -p packages/schema/tsconfig.json --noEmit
bun run testbox:run -- --id <ID> -- bunx tsc -p packages/clawhub/tsconfig.json --noEmit
bun run testbox:run -- --id <ID> -- bun run build
```
For e2e:
```bash
bun run testbox:run -- --id <ID> -- bun run test:e2e
bun run testbox:run -- --id <ID> -- bun run test:pw
```
## Waiting For Readiness
The `run` command automatically waits for the testbox, so explicit waiting is
usually unnecessary. If you do need to check readiness separately, use
`--wait`. Do not use a sleep-and-recheck loop.
```bash
blacksmith testbox status --id <ID> --wait --wait-timeout 5m
```
## Managing Testboxes
```bash
blacksmith testbox status --id <ID>
blacksmith testbox list
blacksmith testbox stop --id <ID>
```
Testboxes automatically shut down after being idle. For ClawHub maintainer
work, use 90 minutes for long-running sessions:
```bash
blacksmith testbox warmup ci-check-testbox.yml --idle-timeout 90
```
+10
View File
@@ -0,0 +1,10 @@
# actionlint configuration
# https://github.com/rhysd/actionlint/blob/main/docs/config.md
self-hosted-runner:
labels:
# Blacksmith CI runners
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-8vcpu-ubuntu-2404
- blacksmith-16vcpu-ubuntu-2404
- blacksmith-32vcpu-ubuntu-2404
+80
View File
@@ -0,0 +1,80 @@
name: Blacksmith Testbox
on:
workflow_dispatch:
inputs:
testbox_id:
type: string
description: "Testbox session ID"
required: true
permissions:
contents: read
env:
BUN_VERSION: "1.3.10"
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
check:
name: "check"
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
with:
testbox_id: ${{ inputs.testbox_id }}
- uses: actions/checkout@v6
with:
fetch-depth: 50
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Restore Bun install cache
id: bun-cache
uses: actions/cache/restore@v5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ env.BUN_VERSION }}-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-${{ env.BUN_VERSION }}-
- name: Install
run: bun install --frozen-lockfile
- name: Save Bun install cache
if: steps.bun-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
continue-on-error: true
with:
path: ~/.bun/install/cache
key: ${{ steps.bun-cache.outputs.cache-primary-key }}
- name: Prepare Testbox shell
shell: bash
run: |
set -euo pipefail
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
bun_bin="$(command -v bun)"
sudo ln -sf "$bun_bin" /usr/local/bin/bun
if command -v bunx >/dev/null 2>&1; then
sudo ln -sf "$(command -v bunx)" /usr/local/bin/bunx
fi
node_bin="$(dirname "$(node -p 'process.execPath')")"
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
- name: Run Testbox
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
if: always()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
+2
View File
@@ -37,5 +37,7 @@ skills-lock.json
!.agents/skills/
!.agents/skills/convex*/
!.agents/skills/convex*/**
!.agents/skills/blacksmith-testbox/
!.agents/skills/blacksmith-testbox/**
skills/*
.codex/*
+24
View File
@@ -153,6 +153,30 @@ bun run --cwd packages/clawhub verify
These are the same checks that run in CI (`.github/workflows/ci.yml`).
### Blacksmith Testbox checks
Maintainers with Blacksmith access can run the same checks in a warmed Testbox
instead of spending local CPU:
```bash
export CLAWHUB_TESTBOX=1
blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90
bun run testbox:claim -- --id <tbx_id>
bun run testbox:sanity -- --id <tbx_id>
bun run testbox:run -- --id <tbx_id> -- bun run lint
bun run testbox:run -- --id <tbx_id> -- bun run test
bun run testbox:run -- --id <tbx_id> -- bun run build
```
Use the `tbx_...` id from the current warmup output. The wrapper refuses ids
that are missing the local SSH key or were claimed by a different checkout.
Use `CLAWHUB_LOCAL_CHECK_MODE=throttled` or `CLAWHUB_LOCAL_CHECK_MODE=full` as
the explicit local escape hatch when you intentionally want laptop-side proof.
If Blacksmith auth/org access is missing, report that instead of falling back
to a broad local gate that can bog down a dev machine.
For the initial bootstrap only, the Testbox workflow must land on `main` before
`blacksmith testbox warmup ci-check-testbox.yml --ref <branch>` can dispatch it.
**PR guidelines:**
- Keep PRs focused — one concern per PR.
+130 -127
View File
@@ -1,129 +1,132 @@
{
"name": "clawhub",
"private": true,
"workspaces": [
"packages/*"
],
"type": "module",
"scripts": {
"build": "vite build && bun scripts/copy-og-assets.ts",
"check": "bun run lint",
"check:peers": "bun scripts/check-peer-deps.ts",
"check:secrets": "bun scripts/check-staged-secrets.mjs",
"convex:deploy": "bunx convex deploy --typecheck=disable --yes",
"coverage": "vitest run --coverage",
"dataset:eval": "bun scripts/security-dataset/run-eval.ts",
"dataset:snapshot": "bun scripts/security-dataset/export-snapshot.ts",
"dataset:snapshot:prod:dry-run": "bun scripts/security-dataset/export-snapshot.ts --prod --limit 10 --dry-run",
"dev": "bun --bun vite dev --port 3000",
"docs:list": "bun scripts/docs-list.ts",
"format": "oxfmt --write",
"format:check": "oxfmt --check",
"install:local-hooks": "bun scripts/install-git-hooks.mjs",
"lint": "bun run lint:oxlint",
"lint:fix": "oxlint --type-aware --tsconfig ./tsconfig.oxlint.json ./src ./convex ./packages/clawhub/src ./packages/schema/src --fix && bun run format",
"lint:oxlint": "oxlint --type-aware --tsconfig ./tsconfig.oxlint.json ./src ./convex ./packages/clawhub/src ./packages/schema/src",
"preinstall": "bunx only-allow bun",
"preview": "bun --bun vite preview",
"release:clawhub:cli:npm:check": "node scripts/clawhub-cli-npm-release-check.mjs",
"test": "vitest run",
"test:e2e": "vitest run -c vitest.e2e.config.ts",
"test:e2e:local": "bash scripts/run-playwright-local.sh",
"test:e2e:prod-http": "vitest run -c vitest.e2e.config.ts e2e/prod-http-smoke.e2e.test.ts",
"test:pw": "playwright test",
"test:ui-contract": "vitest run src/__tests__/ui-design-contract.test.ts src/__tests__/header.test.tsx src/__tests__/home-route.test.tsx src/components/Footer.test.tsx src/lib/theme.test.tsx src/routes/-settings.test.tsx",
"test:watch": "vitest",
"verify:convex-contract": "bun scripts/verify-convex-contract.ts"
},
"dependencies": {
"@auth/core": "^0.37.4",
"@convex-dev/auth": "0.0.92",
"@create-markdown/core": "^2.0.2",
"@create-markdown/preview": "^2.0.2",
"@fontsource/bricolage-grotesque": "^5.2.10",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/manrope": "^5.2.8",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@resvg/resvg-wasm": "^2.6.2",
"@shikijs/rehype": "^4.0.2",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-devtools": "0.10.2",
"@tanstack/react-router": "1.168.26",
"@tanstack/react-router-devtools": "1.166.13",
"@tanstack/react-start": "1.167.52",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "1.167.29",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clawhub-schema": "workspace:*",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"convex": "^1.36.1",
"convex-helpers": "^0.1.115",
"fflate": "^0.8.2",
"h3": "2.0.1-rc.21",
"ignore": "^7.0.5",
"lucide-react": "1.14.0",
"monaco-editor": "^0.55.1",
"next-themes": "^0.4.6",
"nitro": "3.0.260429-beta",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"semver": "^7.7.4",
"shiki": "^4.0.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"tw-animate-css": "^1.4.0",
"unist-util-visit": "^5.1.0",
"vite-tsconfig-paths": "^6.1.1",
"yaml": "^2.8.3",
"zod": "^4.4.1"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@tanstack/devtools-vite": "0.6.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"@vitejs/plugin-react": "6.0.1",
"@vitest/coverage-v8": "^4.1.5",
"jsdom": "^29.1.0",
"only-allow": "^1.2.2",
"oxfmt": "0.47.0",
"oxlint": "^1.62.0",
"oxlint-tsgolint": "0.22.1",
"typescript": "6.0.3",
"undici": "7.25.0",
"vite": "8.0.10",
"vitest": "^4.1.5"
},
"overrides": {
"dompurify": "3.4.1",
"postcss": "8.5.12"
}
"name": "clawhub",
"private": true,
"workspaces": [
"packages/*"
],
"type": "module",
"scripts": {
"build": "vite build && bun scripts/copy-og-assets.ts",
"check": "bun run lint",
"check:peers": "bun scripts/check-peer-deps.ts",
"check:secrets": "bun scripts/check-staged-secrets.mjs",
"convex:deploy": "bunx convex deploy --typecheck=disable --yes",
"coverage": "vitest run --coverage",
"dataset:eval": "bun scripts/security-dataset/run-eval.ts",
"dataset:snapshot": "bun scripts/security-dataset/export-snapshot.ts",
"dataset:snapshot:prod:dry-run": "bun scripts/security-dataset/export-snapshot.ts --prod --limit 10 --dry-run",
"dev": "bun --bun vite dev --port 3000",
"docs:list": "bun scripts/docs-list.ts",
"format": "oxfmt --write",
"format:check": "oxfmt --check",
"install:local-hooks": "bun scripts/install-git-hooks.mjs",
"lint": "bun run lint:oxlint",
"lint:fix": "oxlint --type-aware --tsconfig ./tsconfig.oxlint.json ./src ./convex ./packages/clawhub/src ./packages/schema/src --fix && bun run format",
"lint:oxlint": "oxlint --type-aware --tsconfig ./tsconfig.oxlint.json ./src ./convex ./packages/clawhub/src ./packages/schema/src",
"preinstall": "bunx only-allow bun",
"preview": "bun --bun vite preview",
"release:clawhub:cli:npm:check": "node scripts/clawhub-cli-npm-release-check.mjs",
"test": "vitest run",
"test:e2e": "vitest run -c vitest.e2e.config.ts",
"test:e2e:local": "bash scripts/run-playwright-local.sh",
"test:e2e:prod-http": "vitest run -c vitest.e2e.config.ts e2e/prod-http-smoke.e2e.test.ts",
"test:pw": "playwright test",
"test:ui-contract": "vitest run src/__tests__/ui-design-contract.test.ts src/__tests__/header.test.tsx src/__tests__/home-route.test.tsx src/components/Footer.test.tsx src/lib/theme.test.tsx src/routes/-settings.test.tsx",
"test:watch": "vitest",
"testbox:claim": "node scripts/blacksmith-testbox-runner.mjs --claim",
"testbox:run": "node scripts/blacksmith-testbox-runner.mjs",
"testbox:sanity": "node scripts/testbox-sync-sanity.mjs",
"verify:convex-contract": "bun scripts/verify-convex-contract.ts"
},
"dependencies": {
"@auth/core": "^0.37.4",
"@convex-dev/auth": "0.0.92",
"@create-markdown/core": "^2.0.2",
"@create-markdown/preview": "^2.0.2",
"@fontsource/bricolage-grotesque": "^5.2.10",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/manrope": "^5.2.8",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@resvg/resvg-wasm": "^2.6.2",
"@shikijs/rehype": "^4.0.2",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-devtools": "0.10.2",
"@tanstack/react-router": "1.168.26",
"@tanstack/react-router-devtools": "1.166.13",
"@tanstack/react-start": "1.167.52",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "1.167.29",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clawhub-schema": "workspace:*",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"convex": "^1.36.1",
"convex-helpers": "^0.1.115",
"fflate": "^0.8.2",
"h3": "2.0.1-rc.21",
"ignore": "^7.0.5",
"lucide-react": "1.14.0",
"monaco-editor": "^0.55.1",
"next-themes": "^0.4.6",
"nitro": "3.0.260429-beta",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"semver": "^7.7.4",
"shiki": "^4.0.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"tw-animate-css": "^1.4.0",
"unist-util-visit": "^5.1.0",
"vite-tsconfig-paths": "^6.1.1",
"yaml": "^2.8.3",
"zod": "^4.4.1"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@tanstack/devtools-vite": "0.6.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"@vitejs/plugin-react": "6.0.1",
"@vitest/coverage-v8": "^4.1.5",
"jsdom": "^29.1.0",
"only-allow": "^1.2.2",
"oxfmt": "0.47.0",
"oxlint": "^1.62.0",
"oxlint-tsgolint": "0.22.1",
"typescript": "6.0.3",
"undici": "7.25.0",
"vite": "8.0.10",
"vitest": "^4.1.5"
},
"overrides": {
"dompurify": "3.4.1",
"postcss": "8.5.12"
}
}
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env node
import { execFileSync, spawn as nodeSpawn } from "node:child_process";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
evaluateClawHubTestboxClaim,
evaluateLocalTestboxKey,
resolveTestboxId,
writeClawHubTestboxClaim,
} from "./blacksmith-testbox-state.mjs";
function git(args, cwd) {
return execFileSync("git", args, { cwd, encoding: "utf8" });
}
export function splitRunnerArgs(argv = []) {
const normalizedArgv = stripPackageManagerSeparator(argv);
const separatorIndex = normalizedArgv.indexOf("--");
if (separatorIndex === -1) {
return { runnerArgs: normalizedArgv, commandArgs: [] };
}
return {
runnerArgs: normalizedArgv.slice(0, separatorIndex),
commandArgs: normalizedArgv.slice(separatorIndex + 1),
};
}
function stripPackageManagerSeparator(argv) {
if (argv[0] === "--") return argv.slice(1);
const separatorIndex = argv.indexOf("--");
const next = argv[separatorIndex + 1];
if (
separatorIndex > 0 &&
(next === "--id" ||
next === "--testbox-id" ||
next?.startsWith("--id=") ||
next?.startsWith("--testbox-id="))
) {
return [...argv.slice(0, separatorIndex), ...argv.slice(separatorIndex + 1)];
}
return argv;
}
export function buildBlacksmithRunArgs({ commandArgs, testboxId }) {
const command = commandArgs.join(" ").trim();
if (!command) return [];
return ["testbox", "run", "--id", testboxId, command];
}
export function resolveTestboxSyncTimeoutMs(env = process.env) {
const raw = env.CLAWHUB_TESTBOX_SYNC_TIMEOUT_MS;
if (raw === undefined || raw === "") return 5 * 60 * 1000;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 5 * 60 * 1000;
}
function hasClaimFlag(runnerArgs) {
return runnerArgs.includes("--claim") || runnerArgs.includes("--claim-fresh");
}
function stripRunnerOnlyFlags(runnerArgs) {
return runnerArgs.filter((arg) => arg !== "--claim" && arg !== "--claim-fresh");
}
function pipeChunk(stream, chunk) {
if (chunk) stream.write(chunk);
}
function runBlacksmithWithSyncGuard({ args, cwd, env, spawn, stderr, stdout, syncTimeoutMs }) {
return new Promise((resolve) => {
const child = spawn("blacksmith", args, {
cwd,
env,
stdio: ["inherit", "pipe", "pipe"],
});
let settled = false;
let syncingSince = 0;
let timedOut = false;
let timer;
const finish = (code) => {
if (settled) return;
settled = true;
clearInterval(timer);
resolve(timedOut ? 124 : typeof code === "number" ? code : 1);
};
const handleOutput = (stream, chunk) => {
const text = String(chunk);
pipeChunk(stream, chunk);
if (text.includes("Syncing...")) {
syncingSince ||= Date.now();
} else if (syncingSince && /\b(running|executing|command|pnpm|npm|yarn|bun)\b/iu.test(text)) {
syncingSince = 0;
}
};
child.stdout?.on("data", (chunk) => handleOutput(stdout, chunk));
child.stderr?.on("data", (chunk) => handleOutput(stderr, chunk));
child.on("error", (error) => {
stderr.write(`Failed to start blacksmith: ${error.message}\n`);
finish(1);
});
child.on("close", (code) => finish(code));
timer = setInterval(
() => {
if (!syncingSince || syncTimeoutMs <= 0) return;
if (Date.now() - syncingSince < syncTimeoutMs) return;
stderr.write(
`Blacksmith Testbox sync produced no post-sync output for ${syncTimeoutMs}ms; terminating local runner. ` +
"Rerun with CLAWHUB_TESTBOX_SYNC_TIMEOUT_MS=0 to disable this guard.\n",
);
timedOut = true;
syncingSince = 0;
child.kill?.("SIGTERM");
},
Math.min(Math.max(syncTimeoutMs, 1), 1000),
);
});
}
export async function runBlacksmithTestboxRunner({
argv = process.argv.slice(2),
cwd = process.cwd(),
env = process.env,
spawn = nodeSpawn,
stderr = process.stderr,
stdout = process.stdout,
} = {}) {
const { runnerArgs, commandArgs } = splitRunnerArgs(argv);
const shouldClaim = hasClaimFlag(runnerArgs);
const testboxId = resolveTestboxId({ argv: stripRunnerOnlyFlags(runnerArgs), env });
if (!testboxId) {
stderr.write(
"Missing Testbox id. Pass `--id <tbx_id>` or set CLAWHUB_TESTBOX_ID from this session's warmup output.\n",
);
return 2;
}
const keyResult = evaluateLocalTestboxKey({ env, testboxId });
if (!keyResult.ok) {
stderr.write(`${keyResult.problems.join("\n")}\n`);
stderr.write(
"Refusing to reuse a remote-visible Testbox without the local private key. Run:\n" +
" blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90\n",
);
return 2;
}
const root = git(["rev-parse", "--show-toplevel"], cwd).trim();
if (path.resolve(cwd) !== path.resolve(root)) {
stderr.write(
`Refusing to run Testbox sync from ${cwd}; run from repo root ${root} so rsync does not mirror a subdirectory.\n`,
);
return 2;
}
if (shouldClaim) {
const claim = writeClawHubTestboxClaim({ cwd: root, env, testboxId });
stdout.write(`ClawHub Testbox claim written: ${testboxId} -> ${claim.claimPath}\n`);
} else {
const claimResult = evaluateClawHubTestboxClaim({ cwd: root, env, testboxId });
if (!claimResult.ok) {
stderr.write(`${claimResult.problems.join("\n")}\n`);
stderr.write(
"Refusing to run a Testbox that was not claimed by this ClawHub checkout. Run:\n" +
" blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90\n" +
" bun run testbox:claim -- --id <new_tbx_id>\n",
);
return 2;
}
}
const blacksmithArgs = buildBlacksmithRunArgs({ commandArgs, testboxId });
if (blacksmithArgs.length === 0) {
stdout.write(`Testbox local key and ClawHub claim ok: ${testboxId}\n`);
return 0;
}
return await runBlacksmithWithSyncGuard({
args: blacksmithArgs,
cwd,
env,
spawn,
stderr,
stdout,
syncTimeoutMs: resolveTestboxSyncTimeoutMs(env),
});
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
process.exitCode = await runBlacksmithTestboxRunner();
}
@@ -0,0 +1,76 @@
/* @vitest-environment node */
import { describe, expect, it } from "vitest";
import {
buildBlacksmithRunArgs,
resolveTestboxSyncTimeoutMs,
splitRunnerArgs,
} from "./blacksmith-testbox-runner.mjs";
import { evaluateTestboxSyncSanity, parseGitShortStatus } from "./testbox-sync-sanity.mjs";
describe("blacksmith-testbox-runner", () => {
it("splits runner args from remote command args", () => {
expect(splitRunnerArgs(["--id", "tbx_abc", "--", "bun", "run", "lint"])).toEqual({
runnerArgs: ["--id", "tbx_abc"],
commandArgs: ["bun", "run", "lint"],
});
});
it("tolerates package-manager separators before runner args", () => {
expect(splitRunnerArgs(["--", "--id", "tbx_abc", "--", "bun", "run", "lint"])).toEqual({
runnerArgs: ["--id", "tbx_abc"],
commandArgs: ["bun", "run", "lint"],
});
expect(splitRunnerArgs(["--claim", "--", "--id", "tbx_abc"])).toEqual({
runnerArgs: ["--claim", "--id", "tbx_abc"],
commandArgs: [],
});
});
it("builds blacksmith run args from command args", () => {
expect(
buildBlacksmithRunArgs({
commandArgs: ["bun", "run", "test", "--", "convex/lib/skills.test.ts"],
testboxId: "tbx_abc",
}),
).toEqual(["testbox", "run", "--id", "tbx_abc", "bun run test -- convex/lib/skills.test.ts"]);
});
it("uses a five minute sync timeout by default", () => {
expect(resolveTestboxSyncTimeoutMs({})).toBe(300_000);
expect(resolveTestboxSyncTimeoutMs({ CLAWHUB_TESTBOX_SYNC_TIMEOUT_MS: "0" })).toBe(0);
});
});
describe("testbox-sync-sanity", () => {
it("parses tracked deletions from git status", () => {
expect(parseGitShortStatus(" D src/a.ts\n?? scratch.txt\nR old.ts -> new.ts\n")).toEqual([
{ line: " D src/a.ts", path: "src/a.ts", status: " D", trackedDeletion: true },
{ line: "?? scratch.txt", path: "scratch.txt", status: "??", trackedDeletion: false },
{ line: "R old.ts -> new.ts", path: "new.ts", status: "R ", trackedDeletion: false },
]);
});
it("fails when required root files are missing", () => {
const result = evaluateTestboxSyncSanity({
cwd: "/repo",
statusRaw: "",
exists: () => false,
});
expect(result.ok).toBe(false);
expect(result.problems.join("\n")).toContain("missing required root files");
});
it("fails on mass tracked deletions", () => {
const result = evaluateTestboxSyncSanity({
cwd: "/repo",
statusRaw: " D a.ts\n D b.ts\n",
exists: () => true,
deletionThreshold: 2,
});
expect(result.ok).toBe(false);
expect(result.trackedDeletionCount).toBe(2);
});
});
+181
View File
@@ -0,0 +1,181 @@
import fs from "node:fs";
import path from "node:path";
const DEFAULT_CLAWHUB_TESTBOX_CLAIM_TTL_MINUTES = 12 * 60;
const TESTBOX_ID_PATTERN = /^tbx_[a-z0-9]+$/u;
const CLAWHUB_TESTBOX_CLAIM_FILE = "clawhub-runner.json";
function parsePositiveInteger(value, fallback) {
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export function parseTestboxIdArg(argv = []) {
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (value === "--id" || value === "--testbox-id") {
return argv[index + 1] ?? "";
}
if (value?.startsWith("--id=")) {
return value.slice("--id=".length);
}
if (value?.startsWith("--testbox-id=")) {
return value.slice("--testbox-id=".length);
}
}
return "";
}
export function resolveTestboxId({ argv = [], env = process.env } = {}) {
return (
parseTestboxIdArg(argv) ||
env.CLAWHUB_TESTBOX_ID ||
env.BLACKSMITH_TESTBOX_ID ||
env.TESTBOX_ID ||
""
).trim();
}
export function resolveBlacksmithTestboxStateDir({ env = process.env, homeDir } = {}) {
if (env.CLAWHUB_BLACKSMITH_TESTBOX_STATE_DIR) {
return env.CLAWHUB_BLACKSMITH_TESTBOX_STATE_DIR;
}
const blacksmithHome =
env.BLACKSMITH_HOME || path.join(homeDir || env.HOME || process.cwd(), ".blacksmith");
return path.join(blacksmithHome, "testboxes");
}
export function evaluateLocalTestboxKey({
testboxId,
env = process.env,
exists = fs.existsSync,
homeDir,
} = {}) {
if (!testboxId) {
return { ok: true, checked: false, problems: [] };
}
const problems = [];
if (!TESTBOX_ID_PATTERN.test(testboxId)) {
problems.push(`invalid Testbox id: ${testboxId}`);
return { ok: false, checked: true, keyPath: "", problems, testboxId };
}
const stateDir = resolveBlacksmithTestboxStateDir({ env, homeDir });
const testboxDir = path.join(stateDir, testboxId);
const keyPath = path.join(testboxDir, "id_ed25519");
if (!exists(keyPath)) {
problems.push(
`local Testbox SSH key missing for ${testboxId}: expected ${keyPath}. ` +
"This id may be visible in `blacksmith testbox list` but unusable by this operator; warm a fresh box instead.",
);
}
return {
ok: problems.length === 0,
checked: true,
keyPath,
problems,
testboxDir,
testboxId,
};
}
export function resolveClawHubTestboxClaimPath({ testboxId, env = process.env, homeDir } = {}) {
const stateDir = resolveBlacksmithTestboxStateDir({ env, homeDir });
return path.join(stateDir, testboxId, CLAWHUB_TESTBOX_CLAIM_FILE);
}
export function evaluateClawHubTestboxClaim({
testboxId,
cwd,
env = process.env,
exists = fs.existsSync,
now = () => new Date(),
readFile = fs.readFileSync,
homeDir,
} = {}) {
if (!testboxId) {
return { ok: true, checked: false, problems: [] };
}
const claimPath = resolveClawHubTestboxClaimPath({ testboxId, env, homeDir });
const expectedRepoRoot = path.resolve(cwd || process.cwd());
const maxAgeMinutes = parsePositiveInteger(
env.CLAWHUB_TESTBOX_CLAIM_TTL_MINUTES,
DEFAULT_CLAWHUB_TESTBOX_CLAIM_TTL_MINUTES,
);
const problems = [];
if (!exists(claimPath)) {
problems.push(
`ClawHub Testbox claim missing for ${testboxId}: expected ${claimPath}. ` +
"Do not reuse ids from `blacksmith testbox list`; warm a fresh box and claim it with " +
"`bun run testbox:claim -- --id <id>`.",
);
return { ok: false, checked: true, claimPath, expectedRepoRoot, problems, testboxId };
}
let claim;
try {
claim = JSON.parse(readFile(claimPath, "utf8"));
} catch (error) {
problems.push(`ClawHub Testbox claim is unreadable for ${testboxId}: ${error.message}`);
}
const claimedRepoRoot = claim?.repoRoot ? path.resolve(claim.repoRoot) : "";
if (!claimedRepoRoot) {
problems.push(`ClawHub Testbox claim is missing repoRoot for ${testboxId}: ${claimPath}`);
} else if (claimedRepoRoot !== expectedRepoRoot) {
problems.push(
`ClawHub Testbox claim repo mismatch for ${testboxId}: ` +
`claimed ${claimedRepoRoot}, current ${expectedRepoRoot}. ` +
"Warm and claim a fresh box for this checkout.",
);
}
const claimedAtMs = Date.parse(claim?.claimedAt ?? "");
if (!Number.isFinite(claimedAtMs)) {
problems.push(`ClawHub Testbox claim is missing claimedAt for ${testboxId}: ${claimPath}`);
} else {
const ageMinutes = Math.floor((now().getTime() - claimedAtMs) / 60000);
if (ageMinutes > maxAgeMinutes) {
problems.push(
`ClawHub Testbox claim is stale for ${testboxId}: ${ageMinutes}m old, limit ${maxAgeMinutes}m. ` +
"Warm and claim a fresh box after crashes or long pauses.",
);
}
}
return {
ok: problems.length === 0,
checked: true,
claim,
claimPath,
expectedRepoRoot,
problems,
testboxId,
};
}
export function writeClawHubTestboxClaim({
testboxId,
cwd,
env = process.env,
homeDir,
mkdir = fs.mkdirSync,
writeFile = fs.writeFileSync,
now = () => new Date(),
} = {}) {
const claimPath = resolveClawHubTestboxClaimPath({ testboxId, env, homeDir });
const repoRoot = path.resolve(cwd || process.cwd());
const payload = {
claimedAt: now().toISOString(),
repoRoot,
runnerVersion: 1,
};
mkdir(path.dirname(claimPath), { recursive: true });
writeFile(claimPath, `${JSON.stringify(payload, null, 2)}\n`);
return { claimPath, payload, testboxId };
}
+116
View File
@@ -0,0 +1,116 @@
/* @vitest-environment node */
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
evaluateClawHubTestboxClaim,
evaluateLocalTestboxKey,
parseTestboxIdArg,
resolveBlacksmithTestboxStateDir,
resolveClawHubTestboxClaimPath,
resolveTestboxId,
writeClawHubTestboxClaim,
} from "./blacksmith-testbox-state.mjs";
describe("blacksmith-testbox-state", () => {
it("parses testbox ids from runner args", () => {
expect(parseTestboxIdArg(["--id", "tbx_abc123"])).toBe("tbx_abc123");
expect(parseTestboxIdArg(["--testbox-id=tbx_def456"])).toBe("tbx_def456");
expect(parseTestboxIdArg(["--other"])).toBe("");
});
it("prefers CLI id over env ids", () => {
expect(
resolveTestboxId({
argv: ["--id", "tbx_cli"],
env: { CLAWHUB_TESTBOX_ID: "tbx_env", TESTBOX_ID: "tbx_fallback" },
}),
).toBe("tbx_cli");
});
it("builds state paths from BLACKSMITH_HOME", () => {
expect(resolveBlacksmithTestboxStateDir({ env: { BLACKSMITH_HOME: "/tmp/bs" } })).toBe(
path.join("/tmp/bs", "testboxes"),
);
expect(
resolveClawHubTestboxClaimPath({
testboxId: "tbx_abc",
env: { BLACKSMITH_HOME: "/tmp/bs" },
}),
).toBe(path.join("/tmp/bs", "testboxes", "tbx_abc", "clawhub-runner.json"));
});
it("rejects ids without a local private key", () => {
const result = evaluateLocalTestboxKey({
testboxId: "tbx_missing",
env: { BLACKSMITH_HOME: "/tmp/bs" },
exists: () => false,
});
expect(result.ok).toBe(false);
expect(result.problems.join("\n")).toContain("local Testbox SSH key missing");
});
it("validates claim repo root and freshness", () => {
const claim = JSON.stringify({
claimedAt: "2026-04-30T00:00:00.000Z",
repoRoot: "/repo",
runnerVersion: 1,
});
const result = evaluateClawHubTestboxClaim({
testboxId: "tbx_claimed",
cwd: "/repo",
env: { BLACKSMITH_HOME: "/tmp/bs", CLAWHUB_TESTBOX_CLAIM_TTL_MINUTES: "60" },
exists: () => true,
readFile: () => claim,
now: () => new Date("2026-04-30T00:30:00.000Z"),
});
expect(result.ok).toBe(true);
});
it("flags stale claims", () => {
const claim = JSON.stringify({
claimedAt: "2026-04-30T00:00:00.000Z",
repoRoot: "/repo",
runnerVersion: 1,
});
const result = evaluateClawHubTestboxClaim({
testboxId: "tbx_stale",
cwd: "/repo",
env: { BLACKSMITH_HOME: "/tmp/bs", CLAWHUB_TESTBOX_CLAIM_TTL_MINUTES: "10" },
exists: () => true,
readFile: () => claim,
now: () => new Date("2026-04-30T00:30:00.000Z"),
});
expect(result.ok).toBe(false);
expect(result.problems.join("\n")).toContain("claim is stale");
});
it("writes a claim payload", () => {
let writtenPath = "";
let writtenBody = "";
const result = writeClawHubTestboxClaim({
testboxId: "tbx_write",
cwd: "/repo",
env: { BLACKSMITH_HOME: "/tmp/bs" },
mkdir: () => {},
writeFile: (target, body) => {
writtenPath = target;
writtenBody = body;
},
now: () => new Date("2026-04-30T00:00:00.000Z"),
});
expect(result.claimPath).toBe(
path.join("/tmp/bs", "testboxes", "tbx_write", "clawhub-runner.json"),
);
expect(writtenPath).toBe(result.claimPath);
expect(JSON.parse(writtenBody)).toMatchObject({
claimedAt: "2026-04-30T00:00:00.000Z",
repoRoot: "/repo",
runnerVersion: 1,
});
});
});
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
evaluateClawHubTestboxClaim,
evaluateLocalTestboxKey,
resolveTestboxId,
} from "./blacksmith-testbox-state.mjs";
const DEFAULT_DELETION_THRESHOLD = 200;
const REQUIRED_ROOT_FILES = ["package.json", "bun.lock", ".gitignore"];
function parseBooleanEnv(value) {
return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? "");
}
function parsePositiveInteger(value, fallback) {
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export function parseGitShortStatus(raw) {
return raw
.split(/\r?\n/u)
.map((line) => line.trimEnd())
.filter(Boolean)
.map((line) => {
const status = line.slice(0, 2);
const rawPath = line.slice(3);
return {
line,
path: rawPath.includes(" -> ") ? (rawPath.split(" -> ").at(-1) ?? rawPath) : rawPath,
status,
trackedDeletion: status.includes("D") && status !== "??",
};
});
}
export function evaluateTestboxSyncSanity({
cwd,
statusRaw,
exists = fs.existsSync,
deletionThreshold = DEFAULT_DELETION_THRESHOLD,
allowMassDeletions = false,
}) {
const missingRootFiles = REQUIRED_ROOT_FILES.filter((file) => !exists(path.join(cwd, file)));
const statusEntries = parseGitShortStatus(statusRaw);
const trackedDeletions = statusEntries.filter((entry) => entry.trackedDeletion);
const problems = [];
if (missingRootFiles.length > 0) {
problems.push(`missing required root files: ${missingRootFiles.join(", ")}`);
}
if (!allowMassDeletions && trackedDeletions.length >= deletionThreshold) {
const examples = trackedDeletions
.slice(0, 8)
.map((entry) => entry.path)
.join(", ");
problems.push(
`remote git status has ${trackedDeletions.length} tracked deletions ` +
`(threshold ${deletionThreshold}); examples: ${examples}`,
);
}
return {
ok: problems.length === 0,
missingRootFiles,
problems,
statusEntryCount: statusEntries.length,
trackedDeletionCount: trackedDeletions.length,
};
}
function git(args, cwd) {
return execFileSync("git", args, { cwd, encoding: "utf8" });
}
export function runTestboxSyncSanity({
cwd = process.cwd(),
env = process.env,
argv = process.argv.slice(2),
stdout = process.stdout,
stderr = process.stderr,
} = {}) {
const root = git(["rev-parse", "--show-toplevel"], cwd).trim();
const statusRaw = git(["status", "--short", "--untracked-files=all"], root);
const testboxId = resolveTestboxId({ argv, env });
const keyResult = evaluateLocalTestboxKey({ env, testboxId });
const claimResult = evaluateClawHubTestboxClaim({ cwd: root, env, testboxId });
const result = evaluateTestboxSyncSanity({
cwd: root,
statusRaw,
deletionThreshold: parsePositiveInteger(
env.CLAWHUB_TESTBOX_DELETION_THRESHOLD,
DEFAULT_DELETION_THRESHOLD,
),
allowMassDeletions: parseBooleanEnv(env.CLAWHUB_TESTBOX_ALLOW_MASS_DELETIONS),
});
result.problems.push(...keyResult.problems);
result.problems.push(...claimResult.problems);
result.ok = result.problems.length === 0;
if (!result.ok) {
stderr.write(`Testbox sync sanity failed:\n- ${result.problems.join("\n- ")}\n`);
stderr.write(
"Warm a fresh box, keep using the id from this session, " +
"or rerun from a clean repo root before spending a gate.\n",
);
return 1;
}
if (keyResult.checked) {
stdout.write(`Testbox local key and ClawHub claim ok: ${keyResult.testboxId}\n`);
}
stdout.write(
`Testbox sync sanity ok: ${result.statusEntryCount} changed entries, ` +
`${result.trackedDeletionCount} tracked deletions.\n`,
);
return 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
process.exitCode = runTestboxSyncSanity();
}