mirror of https://github.com/garrytan/gstack.git
test: supabase-provision runs in-process — 16.5s -> 0.45s
bin/gstack-gbrain-supabase-provision (482-line bash) becomes a 26-line bun-shebang entry over a new importable lib/gbrain-supabase-provision.ts with an injected-deps seam (fetch/env/stdout/sleep — D7: args, never env-mutation-before-import). The 33 spawn-per-test cases run in-process against the same Bun.serve mocks; exactly one spawn smoke keeps the shebang/CLI/receipt contract covered. Byte-compat proven by a 25-case differential harness (old bash bin from git vs new, same mock): stdout, stderr, exit codes identical across all subcommands, JSON/plain modes, and error paths. Egress receipts stay per-attempt, receipt-before-send, fail-closed (scanner updated: SHELL_SINKS -> MODULE_SINKS). No-op sleep injection makes retry/backoff paths instant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
faade9e592
commit
9b8f8d7767
|
|
@ -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 <name> <region> <org-slug>
|
||||
# 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 <ref> [--timeout <seconds>]
|
||||
# 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 <ref>
|
||||
# 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 <str>]
|
||||
# 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 <ref>
|
||||
# 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 <method> <path> [<json-body-file>]
|
||||
# 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 <name>"
|
||||
[ -z "$region" ] && die "create: missing <region>"
|
||||
[ -z "$org_slug" ] && die "create: missing <org-slug>"
|
||||
|
||||
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 <ref>"
|
||||
|
||||
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 <ref>"
|
||||
|
||||
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.<ref>:<pw>@...
|
||||
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.<ref> — 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 <ref>"
|
||||
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 <name> <region> <org-slug>
|
||||
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 <ref> [--timeout <seconds>]
|
||||
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 <ref>
|
||||
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 <str>]
|
||||
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 <ref>
|
||||
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<string, string | undefined>;
|
||||
|
||||
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<void>;
|
||||
}
|
||||
|
||||
interface Ctx {
|
||||
base: string;
|
||||
host: string;
|
||||
env: Env;
|
||||
fetchImpl: typeof globalThis.fetch;
|
||||
stdout: (chunk: string) => void;
|
||||
stderr: (chunk: string) => void;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
/** 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 <method> <path> [<json-body>]
|
||||
* 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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 <name>');
|
||||
if (!region) die(ctx, 'create: missing <region>');
|
||||
if (!orgSlug) die(ctx, 'create: missing <org-slug>');
|
||||
|
||||
requirePat(ctx);
|
||||
const dbPass = requireDbPass(ctx);
|
||||
|
||||
const body: Record<string, string> = {
|
||||
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<void> {
|
||||
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 <ref>');
|
||||
|
||||
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<void> {
|
||||
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 <ref>');
|
||||
|
||||
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<void> {
|
||||
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.<ref>:<pw>@...
|
||||
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.<ref> — 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<void> {
|
||||
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 <ref>');
|
||||
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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<Response>;
|
||||
|
|
@ -60,23 +71,29 @@ function startMock(routes: Record<string, Handler>): 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<string, string> = {}
|
||||
): 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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue