From a8cc0d24651a5719c1ec2270b386473a6acf2098 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 24 Apr 2026 00:01:18 -0700 Subject: [PATCH] feat(setup-gbrain): add gstack-gbrain-supabase-provision Management API wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four subcommands: list-orgs, create, wait, pooler-url. Built against the verified Supabase Management API shape (Pre-Impl Gate 1): - POST /v1/projects with {name, db_pass, organization_slug, region} — not the original plan's /v1/organizations/{ref}/projects - No `plan` field; subscription tier is org-level per the OpenAPI description ("Subscription Plan is now set on organization level and is ignored in this request") - GET /v1/projects/{ref}/config/database/pooler for pooler config — not /config/database Secrets discipline: SUPABASE_ACCESS_TOKEN (PAT) and DB_PASS read from env only, never from argv (D8 grep test enforces this). `set +x` at the top as a defensive default so debug tracing never leaks secrets. Management API hostname hardcoded to SUPABASE_API_BASE env override — no user-controlled URL portion (SSRF guard). HTTP error paths: 401/403 → exit 3 (auth), 402 → 4 (quota), 409 → 5 (conflict), 429 + 5xx → exponential-backoff retry up to 3 attempts, then exit 8. Wait subcommand polls every 5s until ACTIVE_HEALTHY with a configurable timeout; terminal states (INIT_FAILED, REMOVED, etc.) exit 7 immediately with a clear message. Timeout emits the --resume-provision hint so the skill can recover. Pooler-url constructs the URL locally from db_user/host/port/name + DB_PASS rather than trusting the API response's connection_string field, which is templated with [PASSWORD] rather than the real value. Handles both object and array response shapes, preferring session pool_mode when Supabase returns multiple pooler configs. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/gstack-gbrain-supabase-provision | 364 +++++++++++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100755 bin/gstack-gbrain-supabase-provision diff --git a/bin/gstack-gbrain-supabase-provision b/bin/gstack-gbrain-supabase-provision new file mode 100755 index 00000000..7bc6c9a0 --- /dev/null +++ b/bin/gstack-gbrain-supabase-provision @@ -0,0 +1,364 @@ +#!/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"}. +# +# 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 +} + +case "${1:-}" in + list-orgs) shift; cmd_list_orgs "$@" ;; + create) shift; cmd_create "$@" ;; + wait) shift; cmd_wait "$@" ;; + pooler-url) shift; cmd_pooler_url "$@" ;; + --help|-h|help) sed -n '2,60p' "$0" | sed 's/^# \{0,1\}//' ;; + "") die "usage: gstack-gbrain-supabase-provision {list-orgs|create|wait|pooler-url|--help}" ;; + *) die "unknown subcommand: $1" ;; +esac