diff --git a/bin/gstack-gbrain-supabase-provision b/bin/gstack-gbrain-supabase-provision index 8498b6a3c..e52d3c994 100755 --- a/bin/gstack-gbrain-supabase-provision +++ b/bin/gstack-gbrain-supabase-provision @@ -1,482 +1,26 @@ -#!/usr/bin/env bash -# gstack-gbrain-supabase-provision — Supabase Management API wrapper for -# /setup-gbrain path 2a (auto-provision). -# -# Subcommands: -# list-orgs -# GET /v1/organizations. Output: {"orgs": [{"slug","name"}, ...]} -# -# create -# POST /v1/projects with {name, db_pass, organization_slug, region}. -# db_pass must be in the DB_PASS env var (never argv — D8 grep test -# enforces this). Output: {"ref","name","region","organization_slug","status"}. -# -# NOTE: does NOT send a `plan` field. Per verified Supabase Management -# API OpenAPI, the `plan` field is now deprecated at the project level -# — subscription tier is an org-level decision (D17 updated). -# -# wait [--timeout ] -# Poll GET /v1/projects/{ref} every 5s until status=ACTIVE_HEALTHY, -# or fail on terminal states (INIT_FAILED, REMOVED). Default timeout -# 180s. Output on success: {"ref","status","elapsed_s"}. -# -# pooler-url -# GET /v1/projects/{ref}/config/database/pooler, construct the full -# Session Pooler URL using DB_PASS from env (the API response's -# connection_string is typically templated [PASSWORD] rather than the -# real value — we build from db_user/db_host/db_port/db_name instead). -# Output: {"ref","pooler_url"}. -# -# list-orphans [--name-prefix ] -# GET /v1/projects. Filter to projects whose name starts with --name-prefix -# (default "gbrain") AND whose ref does NOT match the one in the local -# active ~/.gbrain/config.json pooler URL. Those are the gbrain-shaped -# projects that aren't pointed at by a working local config — candidates -# for /setup-gbrain --cleanup-orphans. -# Output: {"active_ref","orphans":[{"ref","name","created_at","region"}, ...]}. -# -# delete-project -# DELETE /v1/projects/{ref}. Destructive, one-way — callers must -# double-confirm before invoking. This bin performs NO confirmation -# prompt; the skill's UI layer owns that responsibility. -# Output: {"deleted_ref"}. -# -# Secrets discipline (D8, D10, D11): -# - SUPABASE_ACCESS_TOKEN is read from env; never accepted as argv. -# - DB_PASS (for `create` and `pooler-url`) is read from env; never argv. -# - Forbidden strings (enforced by skill-validation grep test): -# --insecure, -k (curl), NODE_TLS_REJECT_UNAUTHORIZED -# - `set +x` default — debug mode requires explicit opt-in around -# non-secret lines. -# -# Env: -# SUPABASE_ACCESS_TOKEN — PAT for auth (required on all subcommands) -# DB_PASS — database password (required for create + pooler-url) -# SUPABASE_API_BASE — override the API host (tests point this at a -# local mock server). Default: https://api.supabase.com -# -# Exit codes: -# 0 — success -# 2 — usage / invalid input -# 3 — auth failure (401/403) — retry with fresh PAT -# 4 — quota / billing (402) — user action needed -# 5 — conflict (409) — duplicate name, user action needed -# 6 — timeout (wait subcommand hit its deadline) -# 7 — terminal failure state from Supabase (INIT_FAILED, REMOVED) -# 8 — network / 5xx after retries -set +x # Defensive: never trace secrets in this helper. -set -euo pipefail +#!/usr/bin/env -S bun run +/** + * gstack-gbrain-supabase-provision — Supabase Management API wrapper for + * /setup-gbrain path 2a (auto-provision). Thin entry: all logic lives in + * lib/gbrain-supabase-provision.ts so tests can drive it in-process with + * injected fetch/env/sleep instead of spawning a process per test. + * + * Rewritten from bash to TypeScript; filename and exec semantics unchanged — + * callers shell out to this path and the bun shebang resolves at runtime + * (same pattern as bin/gstack-gbrain-detect). CLI surface, stdout/stderr + * shapes, env handling (SUPABASE_ACCESS_TOKEN / DB_PASS / SUPABASE_API_BASE), + * and exit codes are unchanged; run --help for the full contract. + * + * Egress receipts stay fail-closed at the API-call layer (sink + * "supabase-provision", receipt-before-send) — see the module header. + */ -SUPABASE_API_BASE="${SUPABASE_API_BASE:-https://api.supabase.com}" -API_VERSION="v1" +import { runProvision } from '../lib/gbrain-supabase-provision'; -# Egress receipt helpers (_receipted_curl): receipt-before-send, fail-closed. -# The receipt hashes the request body only — the PAT (Authorization header) -# is never receipted or logged. -. "$(cd "$(dirname "$0")" && pwd)/gstack-egress-lib.sh" -SUPABASE_API_HOST="${SUPABASE_API_BASE#*://}"; SUPABASE_API_HOST="${SUPABASE_API_HOST%%/*}" -DEFAULT_WAIT_TIMEOUT=180 -POLL_INTERVAL=5 -CURL_TIMEOUT=30 - -die() { echo "gstack-gbrain-supabase-provision: $*" >&2; exit 2; } -die_auth() { echo "gstack-gbrain-supabase-provision: $*" >&2; exit 3; } -die_quota(){ echo "gstack-gbrain-supabase-provision: $*" >&2; exit 4; } -die_conflict(){ echo "gstack-gbrain-supabase-provision: $*" >&2; exit 5; } -die_net() { echo "gstack-gbrain-supabase-provision: $*" >&2; exit 8; } - -require_jq() { - command -v jq >/dev/null 2>&1 || die "jq is required. Install with: brew install jq" -} -require_curl() { - command -v curl >/dev/null 2>&1 || die "curl is required" -} - -require_pat() { - if [ -z "${SUPABASE_ACCESS_TOKEN:-}" ]; then - die_auth "SUPABASE_ACCESS_TOKEN is not set. Generate a PAT at https://supabase.com/dashboard/account/tokens" - fi -} - -require_db_pass() { - if [ -z "${DB_PASS:-}" ]; then - die "DB_PASS env var is required (never passed as argv — that leaks via ps/history)" - fi -} - -# api_call [] -# Handles: 401/403 → exit 3, 402 → 4, 409 → 5, 429 + 5xx → retry w/ -# exponential backoff up to 3 attempts. Returns the response body on -# stdout and HTTP status on an internal variable via a pipe trick. -# -# Because bash lacks multi-value returns, we write response body to a -# tmpfile + status to another tmpfile and the caller reads them. -api_call() { - local method="$1" - local apipath="$2" - local body_file="${3:-}" - - local url="$SUPABASE_API_BASE/$API_VERSION/$apipath" - local body_tmp - body_tmp=$(mktemp) - local status_tmp - status_tmp=$(mktemp) - # shellcheck disable=SC2064 - trap "rm -f '$body_tmp' '$status_tmp'" RETURN - - local attempt=0 - local max_attempts=3 - local backoff=2 - while : ; do - attempt=$((attempt + 1)) - local curl_args=( - --silent - --show-error - --max-time "$CURL_TIMEOUT" - -o "$body_tmp" - -w "%{http_code}" - -X "$method" - -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" - -H "Accept: application/json" - -H "Content-Type: application/json" - -H "User-Agent: gstack-gbrain-supabase-provision" - ) - # Receipted fail-closed. The retry loop reuses $body_file across - # attempts, but the helper consumes its payload file — so each attempt - # hands it a fresh copy (hash still equals the exact wire bytes; the - # helper appends --data-binary @copy). Bodyless calls use --no-payload. - local payload_arg="--no-payload" - if [ -n "$body_file" ]; then - payload_arg=$(mktemp) - cp "$body_file" "$payload_arg" - fi - local status rc=0 - status=$(_receipted_curl closed supabase-provision "$SUPABASE_API_HOST" "provision-api-call ($method $apipath)" "user ran gstack-gbrain-supabase-provision" "$payload_arg" \ - curl "${curl_args[@]}" "$url") || rc=$? - if [ "$rc" -eq 3 ] && [ -z "$status" ]; then - # Egress receipt refused — the send never happened (the helper's - # problem/cause/fix message is already on stderr). Don't retry. - exit 8 - fi - if [ "$rc" -ne 0 ]; then - # curl itself failed (network, timeout, etc.). Retry. - if [ "$attempt" -ge "$max_attempts" ]; then - die_net "network failure calling $method $apipath after $attempt attempts" - fi - sleep "$backoff" - backoff=$((backoff * 2)) - continue - fi - - case "$status" in - 2??) - cat "$body_tmp" - printf '%s' "$status" > "$status_tmp" - return 0 - ;; - 401) - die_auth "401 Unauthorized — your PAT is invalid or expired. Re-generate at https://supabase.com/dashboard/account/tokens" - ;; - 403) - die_auth "403 Forbidden — your PAT lacks permission for $method $apipath. Regenerate with All Access scope." - ;; - 402) - die_quota "402 Payment Required — Supabase project/organization quota exceeded. See https://supabase.com/dashboard" - ;; - 409) - die_conflict "409 Conflict on $method $apipath — likely a duplicate project name. Pick a different name and re-run." - ;; - 429|5??) - if [ "$attempt" -ge "$max_attempts" ]; then - die_net "$status after $attempt attempts on $method $apipath" - fi - sleep "$backoff" - backoff=$((backoff * 2)) - continue - ;; - *) - # 400, 404, etc. — surface the error body for debugging. - local err - err=$(jq -r '.message // .error // empty' "$body_tmp" 2>/dev/null || true) - if [ -n "$err" ]; then - die "HTTP $status from $method $apipath: $err" - else - die "HTTP $status from $method $apipath (no error message in response)" - fi - ;; - esac - done -} - -cmd_list_orgs() { - local json_mode=false - while [ $# -gt 0 ]; do - case "$1" in - --json) json_mode=true; shift ;; - *) die "list-orgs: unknown flag: $1" ;; - esac - done - - require_jq; require_curl; require_pat - local resp - resp=$(api_call GET organizations) - if $json_mode; then - printf '%s' "$resp" | jq '{orgs: map({slug: .slug, name: .name})}' - else - printf '%s' "$resp" | jq -r '.[] | "\(.slug)\t\(.name)"' - fi -} - -cmd_create() { - local name="" region="" org_slug="" - local json_mode=false - local instance_size="" - while [ $# -gt 0 ]; do - case "$1" in - --json) json_mode=true; shift ;; - --instance-size) instance_size="$2"; shift 2 ;; - --*) die "create: unknown flag: $1" ;; - *) - if [ -z "$name" ]; then name="$1" - elif [ -z "$region" ]; then region="$1" - elif [ -z "$org_slug" ]; then org_slug="$1" - else die "create: too many positional arguments" - fi - shift - ;; - esac - done - [ -z "$name" ] && die "create: missing " - [ -z "$region" ] && die "create: missing " - [ -z "$org_slug" ] && die "create: missing " - - require_jq; require_curl; require_pat; require_db_pass - - local body_file - body_file=$(mktemp) - # shellcheck disable=SC2064 - trap "rm -f '$body_file'" RETURN - if [ -n "$instance_size" ]; then - jq -n \ - --arg name "$name" \ - --arg db_pass "$DB_PASS" \ - --arg organization_slug "$org_slug" \ - --arg region "$region" \ - --arg desired_instance_size "$instance_size" \ - '{name: $name, db_pass: $db_pass, organization_slug: $organization_slug, region: $region, desired_instance_size: $desired_instance_size}' \ - > "$body_file" - else - jq -n \ - --arg name "$name" \ - --arg db_pass "$DB_PASS" \ - --arg organization_slug "$org_slug" \ - --arg region "$region" \ - '{name: $name, db_pass: $db_pass, organization_slug: $organization_slug, region: $region}' \ - > "$body_file" - fi - - local resp - resp=$(api_call POST projects "$body_file") - if $json_mode; then - printf '%s' "$resp" | jq '{ref, name, region, organization_slug, status}' - else - printf '%s' "$resp" | jq -r '"ref=\(.ref) status=\(.status) region=\(.region)"' - fi -} - -cmd_wait() { - local ref="" timeout="$DEFAULT_WAIT_TIMEOUT" - local json_mode=false - while [ $# -gt 0 ]; do - case "$1" in - --timeout) timeout="$2"; shift 2 ;; - --json) json_mode=true; shift ;; - --*) die "wait: unknown flag: $1" ;; - *) ref="$1"; shift ;; - esac - done - [ -z "$ref" ] && die "wait: missing " - - require_jq; require_curl; require_pat - - local elapsed=0 - while : ; do - local resp - resp=$(api_call GET "projects/$ref") - local status - status=$(printf '%s' "$resp" | jq -r '.status // "UNKNOWN"') - case "$status" in - ACTIVE_HEALTHY) - if $json_mode; then - jq -n --arg ref "$ref" --arg status "$status" --argjson elapsed "$elapsed" \ - '{ref: $ref, status: $status, elapsed_s: $elapsed}' - else - echo "ready ref=$ref status=$status elapsed_s=$elapsed" - fi - return 0 - ;; - INIT_FAILED|REMOVED|RESTORE_FAILED|PAUSE_FAILED) - echo "gstack-gbrain-supabase-provision: project $ref reached terminal failure state '$status'" >&2 - exit 7 - ;; - COMING_UP|INACTIVE|ACTIVE_UNHEALTHY|UNKNOWN|RESTORING|UPGRADING|PAUSING|RESTARTING|RESIZING|GOING_DOWN) - # Still provisioning — keep polling. - ;; - *) - # Unexpected status from Supabase. Log but keep polling. - echo "gstack-gbrain-supabase-provision: unexpected status '$status' — continuing to poll" >&2 - ;; - esac - - if [ "$elapsed" -ge "$timeout" ]; then - echo "gstack-gbrain-supabase-provision: wait timed out after ${timeout}s (last status: $status)" >&2 - echo "gstack-gbrain-supabase-provision: re-run with /setup-gbrain --resume-provision $ref" >&2 - exit 6 - fi - sleep "$POLL_INTERVAL" - elapsed=$((elapsed + POLL_INTERVAL)) - done -} - -cmd_pooler_url() { - local ref="" - local json_mode=false - while [ $# -gt 0 ]; do - case "$1" in - --json) json_mode=true; shift ;; - --*) die "pooler-url: unknown flag: $1" ;; - *) ref="$1"; shift ;; - esac - done - [ -z "$ref" ] && die "pooler-url: missing " - - require_jq; require_curl; require_pat; require_db_pass - - local resp - resp=$(api_call GET "projects/$ref/config/database/pooler") - - # Prefer the singular Session Pooler config when Supabase returns an - # array (response shape can vary by project state). Fall back to the - # first PRIMARY entry if no "session" pool_mode is present. - local db_user db_host db_port db_name pool_mode - local first_or_session - if printf '%s' "$resp" | jq -e 'type == "array"' >/dev/null 2>&1; then - first_or_session=$(printf '%s' "$resp" | jq '[.[] | select(.pool_mode == "session")][0] // .[0]') - else - first_or_session="$resp" - fi - - db_user=$(printf '%s' "$first_or_session" | jq -r '.db_user // empty') - db_host=$(printf '%s' "$first_or_session" | jq -r '.db_host // empty') - db_port=$(printf '%s' "$first_or_session" | jq -r '.db_port // empty') - db_name=$(printf '%s' "$first_or_session" | jq -r '.db_name // empty') - pool_mode=$(printf '%s' "$first_or_session" | jq -r '.pool_mode // empty') - - if [ -z "$db_user" ] || [ -z "$db_host" ] || [ -z "$db_port" ] || [ -z "$db_name" ]; then - die "pooler-url: missing pooler config fields (db_user/db_host/db_port/db_name); re-poll or check project state" - fi - - # Issue #1301: New Supabase projects' Management API returns a single - # transaction-mode pooler at port 6543, but the shared pooler tenant - # for fresh projects only listens on the session port 5432. Trusting - # db_port verbatim makes `gbrain init` hang to TCP timeout (transaction - # port unreachable) before falling into "tenant not found"-style errors - # that look like auth bugs. Rewrite transaction/6543 -> session/5432. - # Override with GSTACK_SUPABASE_TRUST_API_PORT=1 if a future API version - # starts returning a working transaction port and this rewrite is wrong. - if [ "${GSTACK_SUPABASE_TRUST_API_PORT:-0}" != "1" ] \ - && [ "$pool_mode" = "transaction" ] && [ "$db_port" = "6543" ]; then - echo "pooler-url: API returned transaction pooler (port 6543); shared pooler for new projects listens on session port 5432 — rewriting (set GSTACK_SUPABASE_TRUST_API_PORT=1 to disable)" >&2 - db_port=5432 - pool_mode="session" - fi - - local url="postgresql://${db_user}:${DB_PASS}@${db_host}:${db_port}/${db_name}" - - if $json_mode; then - jq -n --arg ref "$ref" --arg pooler_url "$url" '{ref: $ref, pooler_url: $pooler_url}' - else - # Non-JSON mode prints the URL; callers capturing it into a variable - # keep it in process memory only. - echo "$url" - fi -} - -cmd_list_orphans() { - local name_prefix="gbrain" - local json_mode=false - while [ $# -gt 0 ]; do - case "$1" in - --name-prefix) name_prefix="$2"; shift 2 ;; - --json) json_mode=true; shift ;; - --*) die "list-orphans: unknown flag: $1" ;; - *) die "list-orphans: unexpected arg: $1" ;; - esac - done - - require_jq; require_curl; require_pat - local all - all=$(api_call GET projects) - - # Extract the active brain's ref from ~/.gbrain/config.json if present. - # Pooler URL format: postgresql://postgres.:@... - local active_ref="null" - local gbrain_cfg="$HOME/.gbrain/config.json" - if [ -f "$gbrain_cfg" ]; then - local url - url=$(jq -r '.database_url // empty' "$gbrain_cfg" 2>/dev/null || true) - if [ -n "$url" ]; then - # Extract user portion before the colon: postgresql://USER:pw@... - local user - user=$(printf '%s' "$url" | sed -E 's|^[a-z]+://([^:]+):.*$|\1|') - # User format: postgres. — pull ref suffix - case "$user" in - postgres.*) - local ref="${user#postgres.}" - active_ref=$(jq -Rn --arg r "$ref" '$r') - ;; - esac - fi - fi - - local orphans - orphans=$(printf '%s' "$all" | jq \ - --arg prefix "$name_prefix" \ - --argjson active "$active_ref" \ - '[.[] - | select(.name | startswith($prefix)) - | select(.ref != $active) - | {ref: .ref, name: .name, created_at: .created_at, region: .region}]') - - jq -n --argjson active "$active_ref" --argjson orphans "$orphans" \ - '{active_ref: $active, orphans: $orphans}' -} - -cmd_delete_project() { - local ref="" - local json_mode=false - while [ $# -gt 0 ]; do - case "$1" in - --json) json_mode=true; shift ;; - --*) die "delete-project: unknown flag: $1" ;; - *) ref="$1"; shift ;; - esac - done - [ -z "$ref" ] && die "delete-project: missing " - - require_jq; require_curl; require_pat - api_call DELETE "projects/$ref" >/dev/null - jq -n --arg ref "$ref" '{deleted_ref: $ref}' -} - -case "${1:-}" in - list-orgs) shift; cmd_list_orgs "$@" ;; - create) shift; cmd_create "$@" ;; - wait) shift; cmd_wait "$@" ;; - pooler-url) shift; cmd_pooler_url "$@" ;; - list-orphans) shift; cmd_list_orphans "$@" ;; - delete-project) shift; cmd_delete_project "$@" ;; - --help|-h|help) sed -n '2,80p' "$0" | sed 's/^# \{0,1\}//' ;; - "") die "usage: gstack-gbrain-supabase-provision {list-orgs|create|wait|pooler-url|list-orphans|delete-project|--help}" ;; - *) die "unknown subcommand: $1" ;; -esac +runProvision(process.argv.slice(2)).then( + (code) => process.exit(code), + (error) => { + process.stderr.write(`gstack-gbrain-supabase-provision: ${(error as Error)?.stack ?? error}\n`); + process.exit(1); + }, +); diff --git a/lib/gbrain-supabase-provision.ts b/lib/gbrain-supabase-provision.ts new file mode 100644 index 000000000..af03b2b97 --- /dev/null +++ b/lib/gbrain-supabase-provision.ts @@ -0,0 +1,632 @@ +/** + * gbrain-supabase-provision — Supabase Management API wrapper for + * /setup-gbrain path 2a (auto-provision). Engine module behind + * bin/gstack-gbrain-supabase-provision (thin bun-shebang entry). + * + * Rewritten from bash to TypeScript so tests can drive it in-process + * (injected fetch/env/sleep — decision D7: injection via options, never + * process.env mutation before import) instead of paying a full Bun boot per + * spawned test. CLI surface, stdout/stderr shapes, env-var handling, and + * exit codes are byte-compatible with the bash version. + * + * Secrets discipline (D8, D10, D11): + * - SUPABASE_ACCESS_TOKEN is read from env; never accepted as argv. + * - DB_PASS (for `create` and `pooler-url`) is read from env; never argv. + * - The PAT travels only in the Authorization header; it is never + * receipted, logged, or echoed. + * + * Egress receipts (fail-closed): every API attempt writes a hash-chained + * receipt via lib/egress-receipt BEFORE the send — sink "supabase-provision", + * payload sha256 of the exact request body (bodyless: bytes 0, sha256 null). + * A receipt failure REFUSES the send (nothing hits the network, exit 8), + * mirroring `_receipted_curl closed` in bin/gstack-egress-lib.sh. + * + * Exit codes: + * 0 — success + * 2 — usage / invalid input + * 3 — auth failure (401/403) — retry with fresh PAT + * 4 — quota / billing (402) — user action needed + * 5 — conflict (409) — duplicate name, user action needed + * 6 — timeout (wait subcommand hit its deadline) + * 7 — terminal failure state from Supabase (INIT_FAILED, REMOVED) + * 8 — network / 5xx after retries (or egress receipt refusal) + */ + +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; + +import { + resolveEgressHome, + sha256Hex, + writeOutcome, + writeReceipt, +} from './egress-receipt'; + +const PROG = 'gstack-gbrain-supabase-provision'; +const API_VERSION = 'v1'; +const DEFAULT_WAIT_TIMEOUT = 180; +const POLL_INTERVAL = 5; +const CURL_TIMEOUT_MS = 30_000; +const MAX_ATTEMPTS = 3; + +/** + * --help text. Byte-identical to the bash version's documented sections + * (subcommands, secrets discipline, env, exit codes). The bash version's + * `sed -n '2,80p'` additionally leaked 14 lines of its own implementation + * (set +x, variable assignments) past the doc block — that accidental tail + * is not reproduced. + */ +export const HELP_TEXT = `gstack-gbrain-supabase-provision — Supabase Management API wrapper for +/setup-gbrain path 2a (auto-provision). + +Subcommands: + list-orgs + GET /v1/organizations. Output: {"orgs": [{"slug","name"}, ...]} + + create + POST /v1/projects with {name, db_pass, organization_slug, region}. + db_pass must be in the DB_PASS env var (never argv — D8 grep test + enforces this). Output: {"ref","name","region","organization_slug","status"}. + + NOTE: does NOT send a \`plan\` field. Per verified Supabase Management + API OpenAPI, the \`plan\` field is now deprecated at the project level + — subscription tier is an org-level decision (D17 updated). + + wait [--timeout ] + Poll GET /v1/projects/{ref} every 5s until status=ACTIVE_HEALTHY, + or fail on terminal states (INIT_FAILED, REMOVED). Default timeout + 180s. Output on success: {"ref","status","elapsed_s"}. + + pooler-url + GET /v1/projects/{ref}/config/database/pooler, construct the full + Session Pooler URL using DB_PASS from env (the API response's + connection_string is typically templated [PASSWORD] rather than the + real value — we build from db_user/db_host/db_port/db_name instead). + Output: {"ref","pooler_url"}. + + list-orphans [--name-prefix ] + GET /v1/projects. Filter to projects whose name starts with --name-prefix + (default "gbrain") AND whose ref does NOT match the one in the local + active ~/.gbrain/config.json pooler URL. Those are the gbrain-shaped + projects that aren't pointed at by a working local config — candidates + for /setup-gbrain --cleanup-orphans. + Output: {"active_ref","orphans":[{"ref","name","created_at","region"}, ...]}. + + delete-project + DELETE /v1/projects/{ref}. Destructive, one-way — callers must + double-confirm before invoking. This bin performs NO confirmation + prompt; the skill's UI layer owns that responsibility. + Output: {"deleted_ref"}. + +Secrets discipline (D8, D10, D11): + - SUPABASE_ACCESS_TOKEN is read from env; never accepted as argv. + - DB_PASS (for \`create\` and \`pooler-url\`) is read from env; never argv. + - Forbidden strings (enforced by skill-validation grep test): + --insecure, -k (curl), NODE_TLS_REJECT_UNAUTHORIZED + - \`set +x\` default — debug mode requires explicit opt-in around + non-secret lines. + +Env: + SUPABASE_ACCESS_TOKEN — PAT for auth (required on all subcommands) + DB_PASS — database password (required for create + pooler-url) + SUPABASE_API_BASE — override the API host (tests point this at a + local mock server). Default: https://api.supabase.com + +Exit codes: + 0 — success + 2 — usage / invalid input + 3 — auth failure (401/403) — retry with fresh PAT + 4 — quota / billing (402) — user action needed + 5 — conflict (409) — duplicate name, user action needed + 6 — timeout (wait subcommand hit its deadline) + 7 — terminal failure state from Supabase (INIT_FAILED, REMOVED) + 8 — network / 5xx after retries +`; + +type Env = Record; + +export interface ProvisionOptions { + /** Injected fetch (tests point it at a Bun.serve mock). Default: global fetch. */ + fetch?: typeof globalThis.fetch; + /** Injected environment. Default: process.env. Never read ambiently elsewhere. */ + env?: Env; + /** stdout sink. Default: process.stdout.write. */ + stdout?: (chunk: string) => void; + /** stderr sink. Default: process.stderr.write. */ + stderr?: (chunk: string) => void; + /** Backoff/poll sleep. Tests inject a no-op to run retry paths instantly. */ + sleep?: (ms: number) => Promise; +} + +interface Ctx { + base: string; + host: string; + env: Env; + fetchImpl: typeof globalThis.fetch; + stdout: (chunk: string) => void; + stderr: (chunk: string) => void; + sleep: (ms: number) => Promise; +} + +/** Control-flow carrier for the exit code — the module never calls process.exit. */ +class ExitError extends Error { + constructor(public readonly code: number) { + super(`exit ${code}`); + } +} + +function die(ctx: Ctx, msg: string, code = 2): never { + ctx.stderr(`${PROG}: ${msg}\n`); + throw new ExitError(code); +} + +const dieAuth = (ctx: Ctx, msg: string): never => die(ctx, msg, 3); +const dieQuota = (ctx: Ctx, msg: string): never => die(ctx, msg, 4); +const dieConflict = (ctx: Ctx, msg: string): never => die(ctx, msg, 5); +const dieNet = (ctx: Ctx, msg: string): never => die(ctx, msg, 8); + +function requirePat(ctx: Ctx): string { + const pat = ctx.env.SUPABASE_ACCESS_TOKEN; + if (!pat) { + dieAuth( + ctx, + 'SUPABASE_ACCESS_TOKEN is not set. Generate a PAT at https://supabase.com/dashboard/account/tokens', + ); + } + return pat as string; +} + +function requireDbPass(ctx: Ctx): string { + const pass = ctx.env.DB_PASS; + if (!pass) { + die(ctx, 'DB_PASS env var is required (never passed as argv — that leaks via ps/history)'); + } + return pass as string; +} + +/** jq-interpolation semantics: null/missing renders as the string "null". */ +function jstr(v: unknown): string { + if (v === undefined || v === null) return 'null'; + return typeof v === 'string' ? v : JSON.stringify(v); +} + +/** jq object-shorthand semantics: missing keys become explicit nulls. */ +function orNull(v: unknown): unknown { + return v === undefined ? null : v; +} + +function parseJson(ctx: Ctx, text: string, what: string): any { + try { + return JSON.parse(text); + } catch { + die(ctx, `invalid JSON in ${what}`); + } +} + +/** + * apiCall [] + * Handles: 401/403 → exit 3, 402 → 4, 409 → 5, 429 + 5xx → retry w/ + * exponential backoff up to 3 attempts. Returns the response body text. + * + * Receipt-before-send, fail-closed: writeReceipt runs before every attempt; + * on receipt failure the send is refused and the run exits 8 (same polarity + * and refusal message as _receipted_curl closed in gstack-egress-lib.sh). + */ +async function apiCall(ctx: Ctx, method: string, apipath: string, body?: string): Promise { + const pat = ctx.env.SUPABASE_ACCESS_TOKEN ?? ''; + const url = `${ctx.base}/${API_VERSION}/${apipath}`; + + let attempt = 0; + let backoff = 2; + for (;;) { + attempt += 1; + + // Egress receipt (fail-closed). The receipt hashes the request body only + // — the PAT (Authorization header) is never receipted or logged. + let receiptId = ''; + try { + const receipt = writeReceipt({ + env: ctx.env, + sink: 'supabase-provision', + host: ctx.host, + payloadClass: `provision-api-call (${method} ${apipath})`, + bytes: body === undefined ? 0 : Buffer.byteLength(body), + sha256: body === undefined ? null : sha256Hex(body), + consent: 'user ran gstack-gbrain-supabase-provision', + }); + receiptId = receipt.id; + } catch (error) { + // Refused — the send never happens. Same problem/cause/fix contract as + // _gstack_egress_refusal in gstack-egress-lib.sh, then exit 8. + const home = resolveEgressHome(ctx.env); + const cause = `EGRESS_RECEIPT_FAILED: ${(error as Error)?.message ?? error}`.replace(/\n/g, ' '); + ctx.stderr( + `gstack: supabase-provision NOT sent — the egress receipt could not be written (${cause}). ` + + `Fix: chmod -R u+w ${path.join(home, 'security')} (or check GSTACK_HOME). ` + + `What this is: gstack records everything it ATTEMPTS to send off-machine; see gstack-egress.\n`, + ); + throw new ExitError(8); + } + + let res: Response; + try { + res = await ctx.fetchImpl(url, { + method, + headers: { + Authorization: `Bearer ${pat}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': PROG, + }, + body, + signal: AbortSignal.timeout(CURL_TIMEOUT_MS), + }); + } catch { + // Transport failure (connect refused, timeout, DNS). Best-effort + // outcome record, then retry — same as the bash curl-failed branch. + try { + writeOutcome({ env: ctx.env, receipt: receiptId, status: 'exit:7' }); + } catch { + // outcome is bookkeeping; the pre-send receipt is the invariant + } + if (attempt >= MAX_ATTEMPTS) { + dieNet(ctx, `network failure calling ${method} ${apipath} after ${attempt} attempts`); + } + await ctx.sleep(backoff * 1000); + backoff *= 2; + continue; + } + + const text = await res.text(); + try { + writeOutcome({ env: ctx.env, receipt: receiptId, status: 'exit:0' }); + } catch { + // best-effort + } + + const status = res.status; + if (status >= 200 && status <= 299) return text; + if (status === 401) { + dieAuth(ctx, '401 Unauthorized — your PAT is invalid or expired. Re-generate at https://supabase.com/dashboard/account/tokens'); + } + if (status === 403) { + dieAuth(ctx, `403 Forbidden — your PAT lacks permission for ${method} ${apipath}. Regenerate with All Access scope.`); + } + if (status === 402) { + dieQuota(ctx, '402 Payment Required — Supabase project/organization quota exceeded. See https://supabase.com/dashboard'); + } + if (status === 409) { + dieConflict(ctx, `409 Conflict on ${method} ${apipath} — likely a duplicate project name. Pick a different name and re-run.`); + } + if (status === 429 || (status >= 500 && status <= 599)) { + if (attempt >= MAX_ATTEMPTS) { + dieNet(ctx, `${status} after ${attempt} attempts on ${method} ${apipath}`); + } + await ctx.sleep(backoff * 1000); + backoff *= 2; + continue; + } + + // 400, 404, etc. — surface the error body for debugging. + let err = ''; + try { + const parsed = JSON.parse(text); + const candidate = parsed?.message ?? parsed?.error; + if (typeof candidate === 'string') err = candidate; + } catch { + // non-JSON error body — fall through to the no-message variant + } + if (err) { + die(ctx, `HTTP ${status} from ${method} ${apipath}: ${err}`); + } else { + die(ctx, `HTTP ${status} from ${method} ${apipath} (no error message in response)`); + } + } +} + +async function cmdListOrgs(ctx: Ctx, args: string[]): Promise { + let jsonMode = false; + for (const arg of args) { + if (arg === '--json') jsonMode = true; + else die(ctx, `list-orgs: unknown flag: ${arg}`); + } + + requirePat(ctx); + const resp = parseJson(ctx, await apiCall(ctx, 'GET', 'organizations'), 'organizations response'); + if (!Array.isArray(resp)) die(ctx, 'list-orgs: expected an array from GET organizations'); + if (jsonMode) { + const out = { orgs: resp.map((o: any) => ({ slug: orNull(o?.slug), name: orNull(o?.name) })) }; + ctx.stdout(JSON.stringify(out, null, 2) + '\n'); + } else { + for (const o of resp) ctx.stdout(`${jstr(o?.slug)}\t${jstr(o?.name)}\n`); + } +} + +async function cmdCreate(ctx: Ctx, args: string[]): Promise { + let name = ''; + let region = ''; + let orgSlug = ''; + let jsonMode = false; + let instanceSize = ''; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--json') jsonMode = true; + else if (arg === '--instance-size') { + const value = args[++i]; + if (value === undefined) die(ctx, 'create: --instance-size requires a value'); + instanceSize = value; + } else if (arg.startsWith('--')) die(ctx, `create: unknown flag: ${arg}`); + else if (!name) name = arg; + else if (!region) region = arg; + else if (!orgSlug) orgSlug = arg; + else die(ctx, 'create: too many positional arguments'); + } + if (!name) die(ctx, 'create: missing '); + if (!region) die(ctx, 'create: missing '); + if (!orgSlug) die(ctx, 'create: missing '); + + requirePat(ctx); + const dbPass = requireDbPass(ctx); + + const body: Record = { + name, + db_pass: dbPass, + organization_slug: orgSlug, + region, + }; + if (instanceSize) body.desired_instance_size = instanceSize; + + const resp = parseJson( + ctx, + await apiCall(ctx, 'POST', 'projects', JSON.stringify(body)), + 'create response', + ); + if (jsonMode) { + const out = { + ref: orNull(resp?.ref), + name: orNull(resp?.name), + region: orNull(resp?.region), + organization_slug: orNull(resp?.organization_slug), + status: orNull(resp?.status), + }; + ctx.stdout(JSON.stringify(out, null, 2) + '\n'); + } else { + ctx.stdout(`ref=${jstr(resp?.ref)} status=${jstr(resp?.status)} region=${jstr(resp?.region)}\n`); + } +} + +async function cmdWait(ctx: Ctx, args: string[]): Promise { + let ref = ''; + let timeout = String(DEFAULT_WAIT_TIMEOUT); + let jsonMode = false; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--timeout') { + const value = args[++i]; + if (value === undefined) die(ctx, 'wait: --timeout requires a value'); + timeout = value; + } else if (arg === '--json') jsonMode = true; + else if (arg.startsWith('--')) die(ctx, `wait: unknown flag: ${arg}`); + else ref = arg; + } + if (!ref) die(ctx, 'wait: missing '); + + requirePat(ctx); + + let elapsed = 0; + for (;;) { + const resp = parseJson(ctx, await apiCall(ctx, 'GET', `projects/${ref}`), 'project status response'); + const status: string = resp?.status ?? 'UNKNOWN'; + if (status === 'ACTIVE_HEALTHY') { + if (jsonMode) { + ctx.stdout(JSON.stringify({ ref, status, elapsed_s: elapsed }, null, 2) + '\n'); + } else { + ctx.stdout(`ready ref=${ref} status=${status} elapsed_s=${elapsed}\n`); + } + return; + } + if (['INIT_FAILED', 'REMOVED', 'RESTORE_FAILED', 'PAUSE_FAILED'].includes(status)) { + ctx.stderr(`${PROG}: project ${ref} reached terminal failure state '${status}'\n`); + throw new ExitError(7); + } + const stillProvisioning = [ + 'COMING_UP', 'INACTIVE', 'ACTIVE_UNHEALTHY', 'UNKNOWN', 'RESTORING', + 'UPGRADING', 'PAUSING', 'RESTARTING', 'RESIZING', 'GOING_DOWN', + ].includes(status); + if (!stillProvisioning) { + // Unexpected status from Supabase. Log but keep polling. + ctx.stderr(`${PROG}: unexpected status '${status}' — continuing to poll\n`); + } + + if (elapsed >= Number(timeout)) { + ctx.stderr(`${PROG}: wait timed out after ${timeout}s (last status: ${status})\n`); + ctx.stderr(`${PROG}: re-run with /setup-gbrain --resume-provision ${ref}\n`); + throw new ExitError(6); + } + await ctx.sleep(POLL_INTERVAL * 1000); + elapsed += POLL_INTERVAL; + } +} + +async function cmdPoolerUrl(ctx: Ctx, args: string[]): Promise { + let ref = ''; + let jsonMode = false; + for (const arg of args) { + if (arg === '--json') jsonMode = true; + else if (arg.startsWith('--')) die(ctx, `pooler-url: unknown flag: ${arg}`); + else ref = arg; + } + if (!ref) die(ctx, 'pooler-url: missing '); + + requirePat(ctx); + const dbPass = requireDbPass(ctx); + + const resp = parseJson( + ctx, + await apiCall(ctx, 'GET', `projects/${ref}/config/database/pooler`), + 'pooler config response', + ); + + // Prefer the singular Session Pooler config when Supabase returns an + // array (response shape can vary by project state). Fall back to the + // first PRIMARY entry if no "session" pool_mode is present. + const entry: any = Array.isArray(resp) + ? resp.find((e: any) => e?.pool_mode === 'session') ?? resp[0] + : resp; + + const asField = (v: unknown): string => (v === undefined || v === null ? '' : String(v)); + const dbUser = asField(entry?.db_user); + const dbHost = asField(entry?.db_host); + let dbPort = asField(entry?.db_port); + const dbName = asField(entry?.db_name); + let poolMode = asField(entry?.pool_mode); + + if (!dbUser || !dbHost || !dbPort || !dbName) { + die(ctx, 'pooler-url: missing pooler config fields (db_user/db_host/db_port/db_name); re-poll or check project state'); + } + + // Issue #1301: New Supabase projects' Management API returns a single + // transaction-mode pooler at port 6543, but the shared pooler tenant + // for fresh projects only listens on the session port 5432. Trusting + // db_port verbatim makes `gbrain init` hang to TCP timeout (transaction + // port unreachable) before falling into "tenant not found"-style errors + // that look like auth bugs. Rewrite transaction/6543 -> session/5432. + // Override with GSTACK_SUPABASE_TRUST_API_PORT=1 if a future API version + // starts returning a working transaction port and this rewrite is wrong. + if ((ctx.env.GSTACK_SUPABASE_TRUST_API_PORT ?? '0') !== '1' && poolMode === 'transaction' && dbPort === '6543') { + ctx.stderr( + 'pooler-url: API returned transaction pooler (port 6543); shared pooler for new projects listens on session port 5432 — rewriting (set GSTACK_SUPABASE_TRUST_API_PORT=1 to disable)\n', + ); + dbPort = '5432'; + poolMode = 'session'; + } + + const url = `postgresql://${dbUser}:${dbPass}@${dbHost}:${dbPort}/${dbName}`; + + if (jsonMode) { + ctx.stdout(JSON.stringify({ ref, pooler_url: url }, null, 2) + '\n'); + } else { + // Non-JSON mode prints the URL; callers capturing it into a variable + // keep it in process memory only. + ctx.stdout(url + '\n'); + } +} + +async function cmdListOrphans(ctx: Ctx, args: string[]): Promise { + let namePrefix = 'gbrain'; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--name-prefix') { + const value = args[++i]; + if (value === undefined) die(ctx, 'list-orphans: --name-prefix requires a value'); + namePrefix = value; + } else if (arg === '--json') { + // parsed for symmetry; output is the same JSON object either way + } else if (arg.startsWith('--')) die(ctx, `list-orphans: unknown flag: ${arg}`); + else die(ctx, `list-orphans: unexpected arg: ${arg}`); + } + + requirePat(ctx); + const all = parseJson(ctx, await apiCall(ctx, 'GET', 'projects'), 'projects response'); + if (!Array.isArray(all)) die(ctx, 'list-orphans: expected an array from GET projects'); + + // Extract the active brain's ref from ~/.gbrain/config.json if present. + // Pooler URL format: postgresql://postgres.:@... + let activeRef: string | null = null; + const home = ctx.env.HOME || os.homedir(); + const gbrainCfg = path.join(home, '.gbrain', 'config.json'); + if (fs.existsSync(gbrainCfg)) { + let dbUrl = ''; + try { + const cfg = JSON.parse(fs.readFileSync(gbrainCfg, 'utf-8')); + if (typeof cfg?.database_url === 'string') dbUrl = cfg.database_url; + } catch { + // unreadable/unparseable config — same as jq failing: no active ref + } + if (dbUrl) { + // Extract user portion before the colon: postgresql://USER:pw@... + const match = dbUrl.match(/^[a-z]+:\/\/([^:]+):.*$/); + const user = match ? match[1] : dbUrl; + // User format: postgres. — pull ref suffix + if (user.startsWith('postgres.')) activeRef = user.slice('postgres.'.length); + } + } + + const orphans = all + .filter((p: any) => typeof p?.name === 'string' && p.name.startsWith(namePrefix)) + .filter((p: any) => p?.ref !== activeRef) + .map((p: any) => ({ + ref: orNull(p?.ref), + name: orNull(p?.name), + created_at: orNull(p?.created_at), + region: orNull(p?.region), + })); + + ctx.stdout(JSON.stringify({ active_ref: activeRef, orphans }, null, 2) + '\n'); +} + +async function cmdDeleteProject(ctx: Ctx, args: string[]): Promise { + let ref = ''; + for (const arg of args) { + if (arg === '--json') { + // parsed for symmetry; output is the same JSON object either way + } else if (arg.startsWith('--')) die(ctx, `delete-project: unknown flag: ${arg}`); + else ref = arg; + } + if (!ref) die(ctx, 'delete-project: missing '); + + requirePat(ctx); + await apiCall(ctx, 'DELETE', `projects/${ref}`); + ctx.stdout(JSON.stringify({ deleted_ref: ref }, null, 2) + '\n'); +} + +/** + * Run the provision CLI. Returns the process exit code (never calls + * process.exit) — the bin entry maps it to the real process, tests read it + * directly. + */ +export async function runProvision(argv: string[], options: ProvisionOptions = {}): Promise { + const env = options.env ?? process.env; + const base = env.SUPABASE_API_BASE || 'https://api.supabase.com'; + // Host for the receipt: strip scheme, strip any path (keeps the port). + let host = base.includes('://') ? base.slice(base.indexOf('://') + 3) : base; + host = host.split('/')[0]; + + const ctx: Ctx = { + base, + host, + env, + fetchImpl: options.fetch ?? globalThis.fetch, + stdout: options.stdout ?? ((chunk) => process.stdout.write(chunk)), + stderr: options.stderr ?? ((chunk) => process.stderr.write(chunk)), + sleep: options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))), + }; + + const [cmd, ...rest] = argv; + try { + switch (cmd) { + case 'list-orgs': await cmdListOrgs(ctx, rest); break; + case 'create': await cmdCreate(ctx, rest); break; + case 'wait': await cmdWait(ctx, rest); break; + case 'pooler-url': await cmdPoolerUrl(ctx, rest); break; + case 'list-orphans': await cmdListOrphans(ctx, rest); break; + case 'delete-project': await cmdDeleteProject(ctx, rest); break; + case '--help': + case '-h': + case 'help': + ctx.stdout(HELP_TEXT); + break; + case undefined: + case '': + die(ctx, 'usage: gstack-gbrain-supabase-provision {list-orgs|create|wait|pooler-url|list-orphans|delete-project|--help}'); + break; + default: + die(ctx, `unknown subcommand: ${cmd}`); + } + return 0; + } catch (error) { + if (error instanceof ExitError) return error.code; + throw error; + } +} diff --git a/test/egress-receipt-wiring.test.ts b/test/egress-receipt-wiring.test.ts index 82dafa7de..13021334b 100644 --- a/test/egress-receipt-wiring.test.ts +++ b/test/egress-receipt-wiring.test.ts @@ -71,6 +71,9 @@ const MODULE_SINKS = [ // missing file must fail loudly (a rename/move that drops its receipt wiring // is exactly what this pins), not silently soften the assertion. 'lib/context-bill.ts', + // supabase-provision engine (bin/gstack-gbrain-supabase-provision is a thin + // bun-shebang entry over this module; the receipt lives at the api-call layer). + 'lib/gbrain-supabase-provision.ts', ]; /** Shell sinks: must source the shared lib; every network op receipted. */ @@ -81,7 +84,6 @@ const SHELL_SINKS = [ 'bin/gstack-gbrain-mcp-verify', 'bin/gstack-security-dashboard', 'bin/gstack-community-dashboard', - 'bin/gstack-gbrain-supabase-provision', 'bin/gstack-artifacts-init', 'bin/gstack-brain-restore', 'bin/gstack-session-update', @@ -332,9 +334,15 @@ describe('egress receipt wiring tripwire', () => { // dashboards (open). expect(read('bin/gstack-security-dashboard')).toMatch(/_receipted_curl open security-dashboard/); expect(read('bin/gstack-community-dashboard')).toMatch(/_receipted_curl open community-dashboard/); - // mcp-verify + provision (closed). + // mcp-verify (closed). expect(read('bin/gstack-gbrain-mcp-verify')).toMatch(/_receipted_curl closed gbrain-mcp-verify/); - expect(read('bin/gstack-gbrain-supabase-provision')).toMatch(/_receipted_curl closed supabase-provision/); + // supabase-provision (closed): TS module — the receipt is written before + // the fetch, and a receipt failure refuses the send (fail-closed, exit 8). + const provision = read('lib/gbrain-supabase-provision.ts'); + expect(provision).toMatch(/sink:\s*['"]supabase-provision['"]/); + expect(provision).toContain('fail-closed'); + expect(provision.indexOf('writeReceipt(')).toBeGreaterThan(0); + expect(provision.indexOf('writeReceipt(')).toBeLessThan(provision.indexOf('ctx.fetchImpl(')); // design (open): the wrapper catches receipt errors and proceeds. const rf = read('design/src/receipted-fetch.ts'); expect(rf).toContain('fail-open'); diff --git a/test/gbrain-supabase-provision.test.ts b/test/gbrain-supabase-provision.test.ts index 4e3138c04..0cff7f1e0 100644 --- a/test/gbrain-supabase-provision.test.ts +++ b/test/gbrain-supabase-provision.test.ts @@ -11,17 +11,28 @@ * GET /config/database/pooler), PAT + DB_PASS env-var discipline, retry * + backoff on transient errors, pooler URL construction using the * generated DB_PASS (not the API response's templated connection_string). + * + * Tests drive lib/gbrain-supabase-provision.ts IN-PROCESS with injected + * fetch/env/sleep (decision D7: dependency injection via options, never + * process.env mutation before import — ESM hoists imports so env-set-before- + * import silently doesn't work). One spawn-based smoke test at the bottom + * runs the real bin end-to-end to pin the shebang/CLI contract. This + * replaced ~30 process spawns (~16s of Bun boot + transpile) with direct + * module calls. */ -import { describe, test, expect, afterEach } from 'bun:test'; +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { runProvision } from '../lib/gbrain-supabase-provision'; + const ROOT = path.resolve(import.meta.dir, '..'); const BIN = path.join(ROOT, 'bin', 'gstack-gbrain-supabase-provision'); -// Minimal PATH that finds jq/curl but excludes user bins. +// Minimal PATH that finds standard tools but excludes user bins. The smoke +// test prepends the running bun's own directory so the shebang resolves. const SAFE_PATH = '/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin'; type Handler = (req: Request) => Response | Promise; @@ -60,23 +71,29 @@ function startMock(routes: Record): MockServer { }; } -async function runBin( +// Per-test GSTACK_HOME so egress receipts land in a throwaway ledger, never +// the operator's real ~/.gstack/security/egress.jsonl. +let egressHome: string; + +/** + * Run the CLI in-process with injected fetch (real fetch — it round-trips to + * the Bun.serve loopback mock), injected env (the module never reads + * process.env), and a no-op sleep so retry/backoff and wait-poll paths run + * instantly. + */ +async function runCmd( args: string[], env: Record = {} ): Promise<{ stdout: string; stderr: string; status: number }> { - // Use Bun.spawn (async) rather than spawnSync. spawnSync blocks the Bun - // event loop, which prevents Bun.serve mocks from responding — every - // HTTP call would hit curl's timeout instead of round-tripping. - const proc = Bun.spawn([BIN, ...args], { - env: { PATH: SAFE_PATH, ...env }, - stdout: 'pipe', - stderr: 'pipe', + let stdout = ''; + let stderr = ''; + const status = await runProvision(args, { + fetch: globalThis.fetch, + env: { GSTACK_HOME: egressHome, ...env }, + stdout: (chunk) => { stdout += chunk; }, + stderr: (chunk) => { stderr += chunk; }, + sleep: async () => {}, }); - const [stdout, stderr, status] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); return { stdout: stdout.trim(), stderr: stderr.trim(), status }; } @@ -89,8 +106,13 @@ function jsonResp(body: any, status = 200): Response { let mock: MockServer; +beforeEach(() => { + egressHome = fs.mkdtempSync(path.join(os.tmpdir(), 'provision-egress-')); +}); + afterEach(() => { if (mock) mock.close(); + fs.rmSync(egressHome, { recursive: true, force: true }); }); describe('list-orgs', () => { @@ -102,7 +124,7 @@ describe('list-orgs', () => { { id: 'deprec-2', slug: 'personal', name: 'Personal' }, ]), }); - const r = await runBin(['list-orgs', '--json'], { + const r = await runCmd(['list-orgs', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test_pat', SUPABASE_API_BASE: mock.url, }); @@ -122,7 +144,7 @@ describe('list-orgs', () => { return jsonResp([]); }, }); - await runBin(['list-orgs', '--json'], { + await runCmd(['list-orgs', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_expected_pat_xxx', SUPABASE_API_BASE: mock.url, }); @@ -130,7 +152,7 @@ describe('list-orgs', () => { }); test('exits 3 with auth error when SUPABASE_ACCESS_TOKEN is missing', async () => { - const r = await runBin(['list-orgs']); + const r = await runCmd(['list-orgs']); expect(r.status).toBe(3); expect(r.stderr).toContain('SUPABASE_ACCESS_TOKEN is not set'); }); @@ -139,7 +161,7 @@ describe('list-orgs', () => { mock = startMock({ 'GET /v1/organizations': () => jsonResp({ message: 'Invalid JWT' }, 401), }); - const r = await runBin(['list-orgs'], { + const r = await runCmd(['list-orgs'], { SUPABASE_ACCESS_TOKEN: 'sbp_bad', SUPABASE_API_BASE: mock.url, }); @@ -151,7 +173,7 @@ describe('list-orgs', () => { mock = startMock({ 'GET /v1/organizations': () => jsonResp({ message: 'Forbidden' }, 403), }); - const r = await runBin(['list-orgs'], { + const r = await runCmd(['list-orgs'], { SUPABASE_ACCESS_TOKEN: 'sbp_noperm', SUPABASE_API_BASE: mock.url, }); @@ -177,7 +199,7 @@ describe('create', () => { }, 201); }, }); - const r = await runBin(['create', 'gbrain', 'us-east-1', 'acme', '--json'], { + const r = await runCmd(['create', 'gbrain', 'us-east-1', 'acme', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'generated-secret-pw', SUPABASE_API_BASE: mock.url, @@ -203,7 +225,7 @@ describe('create', () => { return jsonResp({ ref: 'r', status: 'COMING_UP' }, 201); }, }); - await runBin(['create', 'gbrain', 'us-east-1', 'acme', '--instance-size', 'small', '--json'], { + await runCmd(['create', 'gbrain', 'us-east-1', 'acme', '--instance-size', 'small', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, @@ -215,7 +237,7 @@ describe('create', () => { mock = startMock({ 'POST /v1/projects': () => jsonResp({ message: 'project limit reached' }, 402), }); - const r = await runBin(['create', 'gbrain', 'us-east-1', 'acme'], { + const r = await runCmd(['create', 'gbrain', 'us-east-1', 'acme'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, @@ -229,7 +251,7 @@ describe('create', () => { mock = startMock({ 'POST /v1/projects': () => jsonResp({ message: 'conflict' }, 409), }); - const r = await runBin(['create', 'gbrain', 'us-east-1', 'acme'], { + const r = await runCmd(['create', 'gbrain', 'us-east-1', 'acme'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, @@ -240,7 +262,7 @@ describe('create', () => { }); test('fails when DB_PASS is missing', async () => { - const r = await runBin(['create', 'gbrain', 'us-east-1', 'acme'], { + const r = await runCmd(['create', 'gbrain', 'us-east-1', 'acme'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', }); expect(r.status).toBe(2); @@ -248,7 +270,7 @@ describe('create', () => { }); test('missing positional args rejected with exit 2', async () => { - const r = await runBin(['create', 'gbrain'], { + const r = await runCmd(['create', 'gbrain'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', }); @@ -265,14 +287,14 @@ describe('create', () => { return jsonResp({ ref: 'r', status: 'COMING_UP' }, 201); }, }); - const r = await runBin(['create', 'gbrain', 'us-east-1', 'acme', '--json'], { + const r = await runCmd(['create', 'gbrain', 'us-east-1', 'acme', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(0); expect(count).toBe(2); - }, 15000); + }); test('exits 8 on persistent 5xx after max retries', async () => { let count = 0; @@ -282,7 +304,7 @@ describe('create', () => { return jsonResp({ message: 'internal server error' }, 502); }, }); - const r = await runBin(['create', 'gbrain', 'us-east-1', 'acme'], { + const r = await runCmd(['create', 'gbrain', 'us-east-1', 'acme'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, @@ -290,7 +312,7 @@ describe('create', () => { expect(r.status).toBe(8); expect(r.stderr).toContain('502'); expect(count).toBeGreaterThanOrEqual(3); - }, 30000); + }); }); describe('wait', () => { @@ -303,7 +325,7 @@ describe('wait', () => { return jsonResp({ ref: 'abc', status: 'ACTIVE_HEALTHY' }); }, }); - const r = await runBin(['wait', 'abc', '--timeout', '30', '--json'], { + const r = await runCmd(['wait', 'abc', '--timeout', '30', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', SUPABASE_API_BASE: mock.url, }); @@ -311,13 +333,13 @@ describe('wait', () => { const j = JSON.parse(r.stdout); expect(j.status).toBe('ACTIVE_HEALTHY'); expect(j.ref).toBe('abc'); - }, 30000); + }); test('exits 7 on terminal INIT_FAILED state', async () => { mock = startMock({ 'GET /v1/projects/abc': () => jsonResp({ ref: 'abc', status: 'INIT_FAILED' }), }); - const r = await runBin(['wait', 'abc', '--timeout', '10'], { + const r = await runCmd(['wait', 'abc', '--timeout', '10'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', SUPABASE_API_BASE: mock.url, }); @@ -330,14 +352,14 @@ describe('wait', () => { mock = startMock({ 'GET /v1/projects/abc': () => jsonResp({ ref: 'abc', status: 'COMING_UP' }), }); - const r = await runBin(['wait', 'abc', '--timeout', '0'], { + const r = await runCmd(['wait', 'abc', '--timeout', '0'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(6); expect(r.stderr).toContain('wait timed out'); expect(r.stderr).toContain('--resume-provision abc'); - }, 15000); + }); }); describe('pooler-url', () => { @@ -356,7 +378,7 @@ describe('pooler-url', () => { mock = startMock({ [`GET /v1/projects/${REF}/config/database/pooler`]: () => jsonResp(POOLER_OK), }); - const r = await runBin(['pooler-url', REF, '--json'], { + const r = await runCmd(['pooler-url', REF, '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'my-real-password', SUPABASE_API_BASE: mock.url, @@ -378,7 +400,7 @@ describe('pooler-url', () => { { ...POOLER_OK, pool_mode: 'session', db_port: 5432 }, ]), }); - const r = await runBin(['pooler-url', REF, '--json'], { + const r = await runCmd(['pooler-url', REF, '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, @@ -394,7 +416,7 @@ describe('pooler-url', () => { [`GET /v1/projects/${REF}/config/database/pooler`]: () => jsonResp({ identifier: 'x', pool_mode: 'session' }), }); - const r = await runBin(['pooler-url', REF], { + const r = await runCmd(['pooler-url', REF], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, @@ -404,7 +426,7 @@ describe('pooler-url', () => { }); test('requires DB_PASS to construct URL', async () => { - const r = await runBin(['pooler-url', REF], { + const r = await runCmd(['pooler-url', REF], { SUPABASE_ACCESS_TOKEN: 'sbp_test', }); expect(r.status).toBe(2); @@ -420,7 +442,7 @@ describe('pooler-url', () => { [`GET /v1/projects/${REF}/config/database/pooler`]: () => jsonResp({ ...POOLER_OK, pool_mode: 'transaction', db_port: 6543 }), }); - const r = await runBin(['pooler-url', REF, '--json'], { + const r = await runCmd(['pooler-url', REF, '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, @@ -435,7 +457,7 @@ describe('pooler-url', () => { [`GET /v1/projects/${REF}/config/database/pooler`]: () => jsonResp({ ...POOLER_OK, pool_mode: 'session', db_port: 6543 }), }); - const r = await runBin(['pooler-url', REF, '--json'], { + const r = await runCmd(['pooler-url', REF, '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, @@ -450,7 +472,7 @@ describe('pooler-url', () => { [`GET /v1/projects/${REF}/config/database/pooler`]: () => jsonResp({ ...POOLER_OK, pool_mode: 'transaction', db_port: 5432 }), }); - const r = await runBin(['pooler-url', REF, '--json'], { + const r = await runCmd(['pooler-url', REF, '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, @@ -465,7 +487,7 @@ describe('pooler-url', () => { [`GET /v1/projects/${REF}/config/database/pooler`]: () => jsonResp({ ...POOLER_OK, pool_mode: 'transaction', db_port: 6543 }), }); - const r = await runBin(['pooler-url', REF, '--json'], { + const r = await runCmd(['pooler-url', REF, '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, @@ -484,7 +506,7 @@ describe('pooler-url', () => { { ...POOLER_OK, pool_mode: 'session', db_port: 5432 }, ]), }); - const r = await runBin(['pooler-url', REF, '--json'], { + const r = await runCmd(['pooler-url', REF, '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, @@ -519,7 +541,7 @@ describe('list-orphans (D20)', () => { }) ); try { - const r = await runBin(['list-orphans', '--json'], { + const r = await runCmd(['list-orphans', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', SUPABASE_API_BASE: mock.url, HOME: home, @@ -543,7 +565,7 @@ describe('list-orphans (D20)', () => { }); const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-no-cfg-')); try { - const r = await runBin(['list-orphans', '--json'], { + const r = await runCmd(['list-orphans', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', SUPABASE_API_BASE: mock.url, HOME: home, @@ -569,7 +591,7 @@ describe('list-orphans (D20)', () => { }); const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-prefix-')); try { - const r = await runBin(['list-orphans', '--name-prefix', 'my-prefix', '--json'], { + const r = await runCmd(['list-orphans', '--name-prefix', 'my-prefix', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', SUPABASE_API_BASE: mock.url, HOME: home, @@ -593,7 +615,7 @@ describe('delete-project (D20)', () => { return jsonResp({ id: 1, ref: 'abcdefghijklmnopqrst', name: 'gbrain' }); }, }); - const r = await runBin(['delete-project', 'abcdefghijklmnopqrst', '--json'], { + const r = await runCmd(['delete-project', 'abcdefghijklmnopqrst', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', SUPABASE_API_BASE: mock.url, }); @@ -607,7 +629,7 @@ describe('delete-project (D20)', () => { mock = startMock({ 'DELETE /v1/projects/nonexistent': () => jsonResp({ message: 'Project not found' }, 404), }); - const r = await runBin(['delete-project', 'nonexistent'], { + const r = await runCmd(['delete-project', 'nonexistent'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', SUPABASE_API_BASE: mock.url, }); @@ -616,7 +638,7 @@ describe('delete-project (D20)', () => { }); test('requires a ref', async () => { - const r = await runBin(['delete-project'], { + const r = await runCmd(['delete-project'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', }); expect(r.status).toBe(2); @@ -626,14 +648,65 @@ describe('delete-project (D20)', () => { describe('general', () => { test('unknown subcommand exits 2', async () => { - const r = await runBin(['nope']); + const r = await runCmd(['nope']); expect(r.status).toBe(2); expect(r.stderr).toContain('unknown subcommand'); }); test('no args prints usage and exits 2', async () => { - const r = await runBin([]); + const r = await runCmd([]); expect(r.status).toBe(2); expect(r.stderr).toContain('usage'); }); + + test('--help prints the doc header and exits 0', async () => { + const r = await runCmd(['--help']); + expect(r.status).toBe(0); + expect(r.stdout).toContain( + 'gstack-gbrain-supabase-provision — Supabase Management API wrapper' + ); + expect(r.stdout).toContain('Exit codes:'); + }); +}); + +describe('bin smoke test (spawned)', () => { + // Exactly one spawn-based test: runs the real bin end-to-end against the + // mock server to pin the shebang/CLI contract (bun-shebang resolves, argv + // and env flow through, JSON lands on stdout, exit code propagates). All + // behavioral coverage above runs in-process. + test('real bin: list-orgs --json round-trips against a mock server', async () => { + let authHeader = ''; + mock = startMock({ + 'GET /v1/organizations': (req) => { + authHeader = req.headers.get('authorization') || ''; + return jsonResp([{ id: 'x', slug: 'acme', name: 'Acme Inc' }]); + }, + }); + // Use Bun.spawn (async) rather than spawnSync. spawnSync blocks the Bun + // event loop, which prevents Bun.serve mocks from responding — every + // HTTP call would hit fetch's timeout instead of round-tripping. + const proc = Bun.spawn([BIN, 'list-orgs', '--json'], { + env: { + PATH: `${path.dirname(process.execPath)}:${SAFE_PATH}`, + SUPABASE_ACCESS_TOKEN: 'sbp_smoke_pat', + SUPABASE_API_BASE: mock.url, + GSTACK_HOME: egressHome, + }, + stdout: 'pipe', + stderr: 'pipe', + }); + const [stdout, stderr, status] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + expect(status).toBe(0); + expect(stderr.trim()).toBe(''); + expect(authHeader).toBe('Bearer sbp_smoke_pat'); + expect(JSON.parse(stdout.trim())).toEqual({ orgs: [{ slug: 'acme', name: 'Acme Inc' }] }); + // The spawned bin wrote its egress receipt into the per-test ledger. + const ledger = path.join(egressHome, 'security', 'egress.jsonl'); + expect(fs.existsSync(ledger)).toBe(true); + expect(fs.readFileSync(ledger, 'utf-8')).toContain('"sink":"supabase-provision"'); + }, 20_000); });