#!/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 SUPABASE_API_BASE="${SUPABASE_API_BASE:-https://api.supabase.com}" API_VERSION="v1" 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" ) if [ -n "$body_file" ]; then curl_args+=(--data-binary "@$body_file") fi local status if ! status=$(curl "${curl_args[@]}" "$url" 2>/dev/null); 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 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') 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 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