diff --git a/onboarding-harness/.gitignore b/onboarding-harness/.gitignore new file mode 100644 index 0000000..5de90e8 --- /dev/null +++ b/onboarding-harness/.gitignore @@ -0,0 +1,3 @@ +# Per-run scratch: live daemon DBs, logs, tokens, the symlink to the active run. +# Disposable and may contain (worthless, post-teardown) fixture tokens. +runs/ diff --git a/onboarding-harness/README.md b/onboarding-harness/README.md new file mode 100644 index 0000000..cadce96 --- /dev/null +++ b/onboarding-harness/README.md @@ -0,0 +1,58 @@ +# Keysat onboarding harness + +A disposable test rig that runs the global **`onboarding-tester`** agent against +Keysat's developer SDK-integration journey, to find every place the *published +docs* leave a newcomer stuck — and, on a clean run, to harvest a publishable +"all it took was X, Y, Z" walkthrough. + +The premise (from `~/Projects/standards/guides/onboarding-tester.md`): the agent +is a fresh adopter who may use **only the published docs corpus**, never Keysat +source. The harness builder (you) may read Keysat freely; the agent may not. + +## What a run sets up + +| Piece | What it is | Disposable via | +|-------|------------|----------------| +| Fixture daemon | a fresh `keysat` release binary on `127.0.0.1:`, throwaway SQLite, fresh issuer keypair | `teardown.sh` | +| Provisioning | a **merchant-onboard** scoped key minted with the fixture's master key (the operator's job, not the agent's) | — | +| Docs corpus | `keysat-docs/` served over HTTP — the only how-to source the agent may read | `teardown.sh` | +| Sandbox | a pristine Next.js/TS proof-of-work (`sandbox-template/`) copied to `/tmp/onboarding-tester/`, with one ungated "Pro export" to gate | `teardown.sh` | + +The fixture's dummy `BTCPAY_URL` is never dialed in this path: **Stage 1 is +license issuance + SDK integration, no payments.** + +## Usage + +```sh +./run.sh # boot + provision + serve docs + sandbox; writes AGENT_BRIEF.md +# → feed runs//AGENT_BRIEF.md to the onboarding-tester agent +./teardown.sh runs/ # stop daemon + docs server, remove sandbox +./teardown.sh runs/ --purge # also delete the run dir +``` + +Individual stages (`boot-fixture.sh`, `provision.sh`, `serve-docs.sh`, +`make-sandbox.sh`) can be run on their own; each reads/writes +`runs//state.env` and `runs/current` points at the active run. + +## The loop + +1. `./run.sh`, then run the `onboarding-tester` agent on the brief. +2. Read `runs//reports/friction.md`. If `completed-clean`, harvest the + walkthrough into `keysat-docs/agent.html`. Otherwise fix the highest-severity + **doc** gaps (additively — document missing API/how-to; don't rewrite + marketing copy), tear down, and re-run on a fresh fixture. +3. Repeat until `completed-clean`. + +## Stage 2 (gated, not built yet) + +The buyer-pays-on-regtest path needs Keysat to ship `payment_providers:write` + +the sandbox-mode daemon flag + the network gate (slices 3–5, in progress). It +adds a Dockerized BTCPay regtest stack and grants the agent +`merchant-onboard` + `payment_providers:write` so it can connect BTCPay +(regtest) and drive a test buyer payment end to end. Connecting a *mainnet* +wallet stays operator-only by design — that boundary is a feature, not a gap. + +## Requirements + +`cargo`, `node`/`npm`, `python3`, `curl`, `jq`, `openssl`. (Docker is only +needed for Stage 2.) diff --git a/onboarding-harness/STAGE1-RESULT.md b/onboarding-harness/STAGE1-RESULT.md new file mode 100644 index 0000000..154b868 --- /dev/null +++ b/onboarding-harness/STAGE1-RESULT.md @@ -0,0 +1,62 @@ +# Stage 1 result — developer SDK-integration journey (no payments) + +**Verdict: `completed-clean` on run 3.** A fresh adopter, using only the published +docs, can stand up a product, issue a license under a non-master `merchant-onboard` +key, integrate the TypeScript SDK into a Next.js app, and gate a feature so a valid +license unlocks it and an absent/invalid one blocks it. + +## Method + +The harness (`./run.sh`) boots a disposable `keysat` fixture (fresh SQLite, fresh +issuer keypair), mints a `merchant-onboard` scoped key with the fixture's master +key, serves `keysat-docs/` as the published corpus, and materializes a pristine +Next.js/TS proof-of-work (`sandbox-template/` → `/tmp/onboarding-tester/`). The +global `onboarding-tester` agent then drives the journey **docs-only** — it never +reads Keysat source. Corpus declared in-scope: the docs site, the daemon's +`/v1/openapi.json`, and the npm `@keysat/licensing-client` README. + +## Convergence + +| Run | Verdict | Findings | +|-----|---------|----------| +| 1 | completed-with-stumbles (5) + 1 nit | SDK `verify()` shape wrong in integrate.html; product `price_value` vs `price_sats`; licenses filter param; `merchant-onboard` role undocumented; issuer-pubkey response shape; phantom `GET /v1/admin/products`. | +| 2 | completed-with-stumbles (1) + 1 nit | "Find a license by email" pointed at the wrong endpoint; server-side key transport unstated. | +| 3 | **completed-clean** | none. Walkthrough harvested to `agent.html`. | + +Each finding was verified against Keysat source before the doc was changed (the +agent can't read source; the harness builder can). + +## Doc fixes shipped this loop + +**`keysat-docs/` (static site — deploys independently):** +- `integrate.html`: rewrote the verify/error examples (TS/Rust/Python) to the real + v0.3 SDK — `verify()` throws/returns `Err` and yields `VerifyOk{payload,…}`; no + `valid` boolean; entitlements at `payload.entitlements`; errors are `LicensingError` + (`.code` in TS, `.kind` in Python; Rust `Error::BadSignature`/`BadFormat`). Replaced the + result-fields table; added an offline-expiry note (`isExpiredAt`/`is_expired_at`; TS/Rust + `verifyWithTime`) and server-side key-transport guidance. +- `agent.html`: added the `merchant-onboard` role row; added "Create a product" and + "Add a tier (policy)" workflows with the `price_value`/`price_sats` distinction; + fixed the comp-license field name (`buyer_note` → `note`); pointed "Find a license + by email" at `/v1/admin/licenses/search`; **added the publishable worked example** + (the harvested walkthrough). +- `wire-format.html`: corrected the `GET /v1/issuer/public-key` response shape. + +**`licensing-service/src/api/openapi.rs` (served spec — ships with the next daemon +release; the local fixture was rebuilt so the agent saw the fixes):** +- `GET /v1/admin/licenses` description: requires `product_id=`, not a slug. +- Removed the phantom `GET /v1/admin/products` (only POST exists; list is the public + `GET /v1/products`). +- Added the `/v1/admin/licenses/search` path (was referenced but undefined). +- Product schema: marked `price_value` as the write field, `price_sats` as derived. + +## Reproduce + +```sh +./run.sh # prints the fixture URL, docs URL, merchant key, sandbox path +# feed runs//AGENT_BRIEF.md to the onboarding-tester agent +./teardown.sh runs/ # leaves nothing running +``` + +Per-run logs and the three friction reports live under `runs/` (gitignored; the +tokens there are worthless after teardown). diff --git a/onboarding-harness/boot-fixture.sh b/onboarding-harness/boot-fixture.sh new file mode 100755 index 0000000..89020bf --- /dev/null +++ b/onboarding-harness/boot-fixture.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Boot a fresh, disposable Keysat daemon on a throwaway SQLite DB. +# Creates a new run dir, writes its state file, points runs/current at it. +# Echoes the run id on success. +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +require curl; require openssl; require node + +# Build the daemon if the release binary is missing. +if [[ ! -x "$DAEMON_BIN" ]]; then + log "release binary missing; building (cargo build --release)…" + ( cd "$DAEMON_DIR" && cargo build --release >/dev/null ) || die "daemon build failed" +fi + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +RUN_DIR="$RUNS_DIR/$RUN_ID" +mkdir -p "$RUN_DIR" +STATE="$RUN_DIR/state.env" +: > "$STATE" + +PORT="$(free_port)" +MASTER="$(openssl rand -hex 32)" +DB_DIR="$RUN_DIR/data" +mkdir -p "$DB_DIR" + +state_set "$STATE" RUN_ID "$RUN_ID" +state_set "$STATE" RUN_DIR "$RUN_DIR" +state_set "$STATE" PORT "$PORT" +state_set "$STATE" BASE_URL "http://127.0.0.1:$PORT" +state_set "$STATE" MASTER_KEY "$MASTER" + +log "booting keysat fixture on 127.0.0.1:$PORT (db: $DB_DIR/keysat.db)" +KEYSAT_BIND="127.0.0.1:$PORT" \ +KEYSAT_DB_PATH="$DB_DIR/keysat.db" \ +KEYSAT_ADMIN_API_KEY="$MASTER" \ +BTCPAY_URL="http://127.0.0.1:1" \ +KEYSAT_PUBLIC_URL="http://127.0.0.1:$PORT" \ +KEYSAT_OPERATOR_NAME="Onboarding Fixture" \ + nohup "$DAEMON_BIN" >"$RUN_DIR/daemon.log" 2>&1 & +DAEMON_PID=$! +state_set "$STATE" DAEMON_PID "$DAEMON_PID" + +if ! wait_http "http://127.0.0.1:$PORT/healthz" 75; then + warn "daemon did not become healthy; last log lines:" + tail -20 "$RUN_DIR/daemon.log" >&2 || true + kill "$DAEMON_PID" 2>/dev/null || true + die "fixture failed to start" +fi + +ln -sfn "$RUN_DIR" "$CURRENT_LINK" +ok "fixture healthy (pid $DAEMON_PID) at http://127.0.0.1:$PORT" +echo "$RUN_ID" diff --git a/onboarding-harness/lib.sh b/onboarding-harness/lib.sh new file mode 100755 index 0000000..1a4548f --- /dev/null +++ b/onboarding-harness/lib.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Shared config + helpers for the Keysat onboarding harness. +# Sourced by the stage scripts; not run directly. + +set -euo pipefail + +HARNESS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# onboarding-harness/ -> licensing-service-startos/ -> workspace root +WORKSPACE="$(cd "$HARNESS_DIR/../.." && pwd)" +DAEMON_DIR="$WORKSPACE/licensing-service-startos/licensing-service" +DAEMON_BIN="$DAEMON_DIR/target/release/keysat" +DOCS_DIR="$WORKSPACE/keysat-docs" +TEMPLATE_DIR="$HARNESS_DIR/sandbox-template" + +# Per-run scratch lives under runs/ (gitignored). The agent's sandbox copy +# lives under /tmp/onboarding-tester/ per the onboarding-tester guide. +RUNS_DIR="$HARNESS_DIR/runs" +SANDBOX_BASE="/tmp/onboarding-tester" + +# The active run's state file is pointed to by runs/current. +CURRENT_LINK="$RUNS_DIR/current" + +log() { printf '\033[1;34m[harness]\033[0m %s\n' "$*" >&2; } +ok() { printf '\033[1;32m[ ok ]\033[0m %s\n' "$*" >&2; } +warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; } + +# state_set KEY VALUE — append/update a KEY=VALUE line in the run state file. +# Not concurrency-safe (uses a fixed temp suffix); the stages call it serially. +state_set() { + local f="$1" k="$2" v="$3" + touch "$f" + # strip any existing line for this key, then append + grep -v "^${k}=" "$f" > "$f.tmp" 2>/dev/null || true + mv "$f.tmp" "$f" + printf '%s=%s\n' "$k" "$v" >> "$f" +} + +# state_get FILE KEY +state_get() { grep "^${2}=" "$1" | head -1 | cut -d= -f2-; } + +# free_port — echo an unused TCP port on 127.0.0.1. +free_port() { + node -e 'const s=require("net").createServer();s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close();});' +} + +# wait_http URL TRIES — poll until URL returns 2xx/3xx, or die. +wait_http() { + local url="$1" tries="${2:-50}" i + for i in $(seq 1 "$tries"); do + if curl -fsS -o /dev/null "$url" 2>/dev/null; then return 0; fi + sleep 0.2 + done + return 1 +} + +require() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; } diff --git a/onboarding-harness/make-sandbox.sh b/onboarding-harness/make-sandbox.sh new file mode 100755 index 0000000..1a7b7cd --- /dev/null +++ b/onboarding-harness/make-sandbox.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Materialize a fresh, pristine proof-of-work app for the agent to integrate +# into. Copies sandbox-template/ to /tmp/onboarding-tester/sandbox-/ and +# runs `npm install` so the app is known-good before the agent touches it. +# The agent mutates ONLY this copy. Usage: make-sandbox.sh [RUN_DIR] +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +require node; require npm + +RUN_DIR="${1:-$(readlink "$CURRENT_LINK")}" +[[ -d "$RUN_DIR" ]] || die "no run dir (boot a fixture first)" +STATE="$RUN_DIR/state.env" +RUN_ID="$(state_get "$STATE" RUN_ID)" + +mkdir -p "$SANDBOX_BASE" +SANDBOX="$SANDBOX_BASE/sandbox-$RUN_ID" +rm -rf "$SANDBOX" +log "copying pristine proof-of-work to $SANDBOX" +# copy template without any stray build artifacts +( cd "$TEMPLATE_DIR" && find . -type d \( -name node_modules -o -name .next \) -prune -o -type f -print \ + | while IFS= read -r f; do mkdir -p "$SANDBOX/$(dirname "$f")"; cp "$f" "$SANDBOX/$f"; done ) + +log "installing base app dependencies (npm install)…" +( cd "$SANDBOX" && npm install --no-audit --no-fund >"$RUN_DIR/sandbox-npm.log" 2>&1 ) \ + || { tail -20 "$RUN_DIR/sandbox-npm.log" >&2; die "sandbox npm install failed"; } + +state_set "$STATE" SANDBOX "$SANDBOX" +ok "pristine sandbox ready at $SANDBOX" +echo "$SANDBOX" diff --git a/onboarding-harness/provision.sh b/onboarding-harness/provision.sh new file mode 100755 index 0000000..0416fcf --- /dev/null +++ b/onboarding-harness/provision.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Provisioner step (the human operator's job, NOT the agent's): with the +# fixture's master key, mint a merchant-onboard scoped key and capture the +# issuer public key. Writes both into the run state file. +# Usage: provision.sh [RUN_DIR] (defaults to runs/current) +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +require curl; require jq + +RUN_DIR="${1:-$(readlink "$CURRENT_LINK")}" +[[ -d "$RUN_DIR" ]] || die "no run dir (boot a fixture first)" +STATE="$RUN_DIR/state.env" +BASE_URL="$(state_get "$STATE" BASE_URL)" +MASTER="$(state_get "$STATE" MASTER_KEY)" + +log "minting merchant-onboard scoped key via master key" +RESP="$(curl -fsS -X POST "$BASE_URL/v1/admin/api-keys" \ + -H "Authorization: Bearer $MASTER" -H "Content-Type: application/json" \ + -d '{"label":"onboarding-agent","role":"merchant-onboard","scopes":[]}')" \ + || die "key mint failed" +TOKEN="$(echo "$RESP" | jq -r '.token')" +[[ "$TOKEN" == ks_* ]] || die "unexpected mint response: $RESP" +state_set "$STATE" MERCHANT_KEY "$TOKEN" + +log "fetching issuer public key" +PUBKEY_PEM="$(curl -fsS "$BASE_URL/v1/issuer/public-key" | jq -r '.public_key_pem')" +[[ "$PUBKEY_PEM" == *"BEGIN PUBLIC KEY"* ]] || die "could not fetch issuer public key" +printf '%s' "$PUBKEY_PEM" > "$RUN_DIR/issuer.pub" +state_set "$STATE" ISSUER_PUBKEY_FILE "$RUN_DIR/issuer.pub" + +ok "merchant-onboard key minted; issuer pubkey saved to $RUN_DIR/issuer.pub" +echo "$TOKEN" diff --git a/onboarding-harness/run.sh b/onboarding-harness/run.sh new file mode 100755 index 0000000..f2aff1c --- /dev/null +++ b/onboarding-harness/run.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# One-shot Stage 1 setup: boot fixture, provision the merchant-onboard key, +# serve the docs corpus, materialize a pristine sandbox, then emit the agent +# brief (AGENT_BRIEF.md) with the live URLs + credentials interpolated in. +# +# This script sets the stage; it does NOT run the agent (the orchestrator does +# that with the global onboarding-tester agent, feeding it AGENT_BRIEF.md). +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +RUN_ID="$("$HARNESS_DIR/boot-fixture.sh")" +RUN_DIR="$RUNS_DIR/$RUN_ID" +STATE="$RUN_DIR/state.env" +"$HARNESS_DIR/provision.sh" "$RUN_DIR" >/dev/null +"$HARNESS_DIR/serve-docs.sh" "$RUN_DIR" >/dev/null +"$HARNESS_DIR/make-sandbox.sh" "$RUN_DIR" >/dev/null + +BASE_URL="$(state_get "$STATE" BASE_URL)" +DOCS_URL="$(state_get "$STATE" DOCS_URL)" +MERCHANT_KEY="$(state_get "$STATE" MERCHANT_KEY)" +SANDBOX="$(state_get "$STATE" SANDBOX)" +mkdir -p "$RUN_DIR/reports" + +cat > "$RUN_DIR/AGENT_BRIEF.md" <&2 < + + {children} + + + ); +} diff --git a/onboarding-harness/sandbox-template/app/page.tsx b/onboarding-harness/sandbox-template/app/page.tsx new file mode 100644 index 0000000..f166328 --- /dev/null +++ b/onboarding-harness/sandbox-template/app/page.tsx @@ -0,0 +1,34 @@ +import { ROWS } from "@/lib/reports"; + +export default function Home() { + return ( +
+

Acme Reports

+

Your signups and revenue by region. Viewing is free.

+ + + + + + + + + + {ROWS.map((r) => ( + + + + + + ))} + +
RegionSignupsRevenue (sats)
{r.region}{r.signups}{r.revenueSats.toLocaleString()}
+ +

Pro export

+

+ Download the full dataset as CSV. This is a paid feature:{" "} + /api/export. +

+
+ ); +} diff --git a/onboarding-harness/sandbox-template/lib/reports.ts b/onboarding-harness/sandbox-template/lib/reports.ts new file mode 100644 index 0000000..95d80d4 --- /dev/null +++ b/onboarding-harness/sandbox-template/lib/reports.ts @@ -0,0 +1,18 @@ +// The "data" behind Acme Reports. The free tier lets you view it on screen; +// the paid "Pro export" feature lets you download it as CSV. That export is +// the feature we want to gate behind a Keysat license. + +export type Row = { region: string; signups: number; revenueSats: number }; + +export const ROWS: Row[] = [ + { region: "North", signups: 412, revenueSats: 1_240_000 }, + { region: "South", signups: 318, revenueSats: 980_500 }, + { region: "East", signups: 521, revenueSats: 1_702_300 }, + { region: "West", signups: 274, revenueSats: 731_900 }, +]; + +export function toCsv(rows: Row[]): string { + const header = "region,signups,revenue_sats"; + const body = rows.map((r) => `${r.region},${r.signups},${r.revenueSats}`); + return [header, ...body].join("\n") + "\n"; +} diff --git a/onboarding-harness/sandbox-template/next.config.mjs b/onboarding-harness/sandbox-template/next.config.mjs new file mode 100644 index 0000000..ca35456 --- /dev/null +++ b/onboarding-harness/sandbox-template/next.config.mjs @@ -0,0 +1,7 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + // Keep the proof-of-work app deliberately boring: no experimental flags, + // so any onboarding friction is attributable to Keysat, not to Next.js. +}; + +export default nextConfig; diff --git a/onboarding-harness/sandbox-template/package.json b/onboarding-harness/sandbox-template/package.json new file mode 100644 index 0000000..f47af2c --- /dev/null +++ b/onboarding-harness/sandbox-template/package.json @@ -0,0 +1,22 @@ +{ + "name": "acme-reports", + "version": "0.1.0", + "private": true, + "description": "Pristine proof-of-work app for the Keysat onboarding harness. A tiny Next.js report tool whose 'Pro export' feature is meant to be gated behind a Keysat license.", + "scripts": { + "dev": "next dev -p 4311", + "build": "next build", + "start": "next start -p 4311" + }, + "dependencies": { + "next": "15.1.6", + "react": "19.0.0", + "react-dom": "19.0.0" + }, + "devDependencies": { + "@types/node": "22.10.7", + "@types/react": "19.0.7", + "@types/react-dom": "19.0.3", + "typescript": "5.7.3" + } +} diff --git a/onboarding-harness/sandbox-template/tsconfig.json b/onboarding-harness/sandbox-template/tsconfig.json new file mode 100644 index 0000000..afedc74 --- /dev/null +++ b/onboarding-harness/sandbox-template/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/onboarding-harness/serve-docs.sh b/onboarding-harness/serve-docs.sh new file mode 100755 index 0000000..a98fff1 --- /dev/null +++ b/onboarding-harness/serve-docs.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Serve the keysat-docs/ site over HTTP as the "published docs corpus" the +# agent is allowed to read. Writes the docs URL + server pid into state. +# Usage: serve-docs.sh [RUN_DIR] +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +RUN_DIR="${1:-$(readlink "$CURRENT_LINK")}" +[[ -d "$RUN_DIR" ]] || die "no run dir (boot a fixture first)" +STATE="$RUN_DIR/state.env" +[[ -d "$DOCS_DIR" ]] || die "keysat-docs not found at $DOCS_DIR" + +PORT="$(free_port)" +log "serving published docs corpus from $DOCS_DIR on 127.0.0.1:$PORT" +# --directory avoids a `cd` subshell, so $! is the real python PID (not a +# wrapper shell that would orphan the server on teardown). nohup survives the +# SIGHUP when this script exits. +nohup python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$DOCS_DIR" \ + >"$RUN_DIR/docs-server.log" 2>&1 & +DOCS_PID=$! +state_set "$STATE" DOCS_PID "$DOCS_PID" +state_set "$STATE" DOCS_PORT "$PORT" +state_set "$STATE" DOCS_URL "http://127.0.0.1:$PORT" + +if ! wait_http "http://127.0.0.1:$PORT/" 25; then + die "docs server failed to come up" +fi +ok "docs corpus served at http://127.0.0.1:$PORT (pid $DOCS_PID)" +echo "http://127.0.0.1:$PORT" diff --git a/onboarding-harness/teardown.sh b/onboarding-harness/teardown.sh new file mode 100755 index 0000000..955696b --- /dev/null +++ b/onboarding-harness/teardown.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Tear down a run: stop the daemon + docs server, remove the agent's sandbox +# copy. Keeps the run dir (logs + reports) unless --purge is given. +# Usage: teardown.sh [RUN_DIR] [--purge] +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +PURGE=0; RUN_DIR="" +for a in "$@"; do + case "$a" in + --purge) PURGE=1 ;; + *) RUN_DIR="$a" ;; + esac +done +RUN_DIR="${RUN_DIR:-$(readlink "$CURRENT_LINK" 2>/dev/null || true)}" +[[ -n "$RUN_DIR" && -d "$RUN_DIR" ]] || { warn "no run dir to tear down"; exit 0; } +STATE="$RUN_DIR/state.env" + +for key in DAEMON_PID DOCS_PID; do + pid="$(state_get "$STATE" "$key" 2>/dev/null || true)" + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + log "stopped $key ($pid)" + fi +done + +# Belt-and-suspenders: free the recorded ports in case a PID drifted. +for portkey in PORT DOCS_PORT; do + port="$(state_get "$STATE" "$portkey" 2>/dev/null || true)" + [[ -z "$port" ]] && continue + for lpid in $(lsof -ti "tcp:$port" -sTCP:LISTEN 2>/dev/null || true); do + kill "$lpid" 2>/dev/null && log "freed port $port (pid $lpid)" || true + done +done + +SANDBOX="$(state_get "$STATE" SANDBOX 2>/dev/null || true)" +if [[ -n "$SANDBOX" && -d "$SANDBOX" ]]; then rm -rf "$SANDBOX"; log "removed sandbox $SANDBOX"; fi + +if [[ "$PURGE" == 1 ]]; then + rm -rf "$RUN_DIR"; log "purged run dir $RUN_DIR" + [[ "$(readlink "$CURRENT_LINK" 2>/dev/null)" == "$RUN_DIR" ]] && rm -f "$CURRENT_LINK" +fi +ok "teardown complete"