mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
chore(testbox): add Blacksmith runner setup
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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/*
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user