Skip to content

Add a Deploy Adapter

Get a task done. Add support for a new deploy target (a stack or hosting platform) to the pluggable deploy-adapter framework. (Back to how-to home.)

The framework lives at scripts/deploy/ and is invoked through /deploy (or scripts/deploy.sh). It resolves an adapter for the project and runs the deploy contract — check → build → migrate → deploy → verify — parameterized by environment (--env test|prod). Adding an adapter is a one-file change: drop adapters/<name>.sh; resolution and dispatch pick it up automatically.

An adapter is a bash file sourced by the dispatcher. It sets ADAPTER_NAME and implements either the five ordered step functions or a single monolithic adapter_run:

Function Step Notes
adapter_check <env> check pre-flight gates (clean tree, types, tests)
adapter_build <env> build produce the deployable artifact
adapter_migrate <env> migrate apply schema/data migrations to <env>
adapter_deploy <env> deploy ship to <env>
adapter_verify <env> verify health-check the deployed <env>
adapter_run <env> (all) escape hatch: run the whole pipeline yourself
adapter_prepare <env> (before all) optional: resolve and validate the target; never skipped by --skip-tests/--skip-build; return 3 to refuse

Each function receives the target environment (test or prod) as $1. Any undefined step is a no-op (a static site has no migrate). If adapter_run is defined it takes precedence over the step functions — use it to wrap a proven, indivisible pipeline.

What “clean tree” means. A check that refuses a dirty tree should ignore the files BoB’s own tooling writes into a project: the session ledger, the decision log, .dash/ and the rest of scripts/lib/derived-paths.json. Ask node scripts/lib/derived-paths.js undeclared-dirt <dir>, which prints only the dirty paths that are not declared there, the same rule the segmentation checkpoint applies. The cloudflare adapter does this, and it names the paths it refuses over (#3257).

A step that returns 3 means refused: the dispatcher reports "status":"refused" and exits 3 instead of 1. The cloudflare adapter uses adapter_prepare for this — it plans the deploy from the project’s wrangler config (lib/wrangler-config.js: wrangler.json / wrangler.jsonc / wrangler.toml) and refuses any target it cannot map to a declared environment, so --env test never runs a bare, production wrangler deploy (#3068).

The dispatcher exports to every adapter: DEPLOY_ENV, DEPLOY_DRY_RUN, DEPLOY_SKIP_TESTS, DEPLOY_SKIP_BUILD, DEPLOY_PROJECT_DIR.

lib/contract.sh provides helpers so adapters stay lean — use them:

  • deploy_cfg '<jq filter>' — read a value from the project’s dev.json (empty if absent).
  • run_cmd '<label>' '<command>' — run a command, honoring --dry-run (echo intent, don’t execute).
  • http_health '<url>' [tries] — curl a URL with backoff; 0 on first HTTP 200.
  • pass / fail / warn / info / header — consistent logging.

Create scripts/deploy/adapters/<name>.sh. Minimal discrete-step example:

#!/usr/bin/env bash
# myhost.sh — deploy adapter for MyHost.
ADAPTER_NAME="myhost"
adapter_check() { local env="$1"; run_cmd "test" "npm test"; }
adapter_build() { local env="$1"; run_cmd "build" "npm run build"; }
adapter_migrate() { local env="$1"; run_cmd "migrate ($env)" "$(deploy_cfg '.deploy.migrate')"; }
adapter_deploy() { local env="$1"; run_cmd "deploy ($env)" "myhost push --env $env"; }
adapter_verify() { local env="$1"; http_health "$(deploy_cfg ".access.\"$env\"")"; }

Do not call set -e or exit inside step functions — return non-zero to signal failure; the dispatcher aborts the pipeline on the first failing step and reports status:error. Honor --dry-run by routing real side effects through run_cmd (or guarding on DEPLOY_DRY_RUN).

Auto-detection lives in lib/resolve.sh::_autodetect_adapter, which maps a detected stack → adapter name using scripts/fleet/readiness.js (the single source of truth for stack detection). Add a branch there if your adapter should be picked automatically:

Terminal window
if [[ "$stack" == *" myhost "* ]]; then echo "myhost"; return 0; fi

A project can always select an adapter explicitly without touching resolution — via dev.json .deploy.adapter or the --adapter <name> flag. An adapter name is valid iff adapters/<name>.sh exists.

Extend tests/test-deploy-adapters.sh with a fixture + a --dry-run dispatch assertion (no real deploys), then confirm it is wired into the make test target:

Terminal window
out="$(bash "$DEPLOY" --env prod --dry-run --adapter myhost --project "$TMP/myhost" 2>&1)"
contains "myhost runs deploy step" "myhost push --env prod" "$out"

Run the suite first:

Terminal:

Terminal window
make test # full suite, including test-deploy-adapters

Terminal only — the test suite has no slash command.

Then confirm the adapter resolves and smoke-test the contract. Both actions have a slash command (in a Claude Code session) and a terminal form (the dispatcher directly):

Action Slash command Terminal
List adapters (yours should appear) /deploy --list scripts/deploy/deploy.sh --list
Smoke-test the contract (dry-run) /deploy --adapter myhost --env test --dry-run scripts/deploy/deploy.sh --adapter myhost --env test --dry-run

Then sync to BOB_HOME with the BoB source→runtime sync so the runtime picks up the new adapter (this is not the deploy dispatcher above):

Terminal:

Terminal window
scripts/deploy.sh # the BoB deploy (source → ~/.claude)

Terminal only — the BoB sync runs outside any Claude session.

  • Framework: scripts/deploy/ (deploy.sh, lib/contract.sh, lib/resolve.sh, adapters/)
  • Command: /deploy
  • Environment targets come from scripts/promotion/promotion.js (decide() → deploy: test|prod).