mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-11 07:29:00 +02:00
Merge remote-tracking branch 'origin/main' into garrytan/binding-polarity-wave
This commit is contained in:
+6
-2
@@ -12,7 +12,7 @@
|
||||
* per-kind default — last resort
|
||||
*
|
||||
* Kinds and their defaults:
|
||||
* capture — AskUserQuestion SDK capture runs (quality matters): opus
|
||||
* capture — AskUserQuestion SDK capture runs: sonnet (D1a)
|
||||
* warmup — PTY warm-up ping (cheapest thing that answers): haiku
|
||||
* distill — free-text distillation (cheap, structured): haiku (pinned)
|
||||
*/
|
||||
@@ -21,7 +21,11 @@
|
||||
// 'capture' | 'warmup' | 'distill' — a `Record<string, string>` annotation
|
||||
// would widen it to string and let any typo through the type gate.
|
||||
const DEFAULTS = {
|
||||
capture: "claude-opus-4-7",
|
||||
// D1a (2026-08 test-infra review): capture runs default to Sonnet, matching
|
||||
// session-runner — the old Opus default was an inconsistency between
|
||||
// runners, not a choice; tests needing Opus pass it explicitly or set
|
||||
// GSTACK_EVAL_MODEL_CAPTURE.
|
||||
capture: "claude-sonnet-4-6",
|
||||
warmup: "claude-haiku-4-5",
|
||||
distill: "claude-haiku-4-5-20251001",
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
/**
|
||||
* 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;
|
||||
let text: string;
|
||||
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),
|
||||
});
|
||||
// Body read stays INSIDE the transport try: a server that sends
|
||||
// headers then resets or stalls the stream is a transport failure
|
||||
// (retry, then exit 8) — not an uncaught exception at exit 1.
|
||||
text = await res.text();
|
||||
} 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;
|
||||
}
|
||||
|
||||
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>');
|
||||
// Validate up front: NaN would make the deadline comparison below always
|
||||
// false and the poll loop run forever (the bash predecessor errored here).
|
||||
const timeoutSeconds = Number(timeout);
|
||||
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 0) {
|
||||
die(ctx, 'wait: --timeout must be a non-negative integer (seconds)');
|
||||
}
|
||||
|
||||
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 >= timeoutSeconds) {
|
||||
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';
|
||||
}
|
||||
|
||||
// Percent-encode the password segment: DB_PASS is caller-controlled and a
|
||||
// reserved character (/ # ? % @) changes URI structure — the project
|
||||
// provisions fine and then every consumer fails to parse the DSN, leaving
|
||||
// an unusable billable orphan.
|
||||
const url = `postgresql://${dbUser}:${encodeURIComponent(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>:<PASSWORD>@...
|
||||
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:PASSWORD@...
|
||||
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;
|
||||
}
|
||||
}
|
||||
+33
-11
@@ -189,6 +189,7 @@ const PLACEHOLDER_STRUCTURAL = [
|
||||
// keys like AKIAIOSFODNN7EXAMPLE are bare tokens, so the guard still catches them.
|
||||
const PLACEHOLDER_SUBSTRING = [
|
||||
/example/i, // AKIAIOSFODNN7EXAMPLE etc — AWS docs convention
|
||||
/^pass(word)?$/i, // literal PASSWORD/pass in URL-format doc comments
|
||||
/^changeme$/i,
|
||||
/^redacted/i,
|
||||
/^placeholder/i,
|
||||
@@ -253,6 +254,34 @@ export function insideUuid(match: RegExpExecArray): boolean {
|
||||
|
||||
// ── The taxonomy ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* URL-embedded passwords that are interpolation forms, not credentials:
|
||||
* `${identifier}` (bash or JS template, any case) or bare `$UPPER_SNAKE`
|
||||
* (shell convention). Bare lowercase `$word` stays BLOCKED — a real password
|
||||
* that merely starts with `$` (e.g. `$` + a dictionary word) must not slip
|
||||
* through the HIGH gate just because it looks vaguely variable-shaped.
|
||||
* Shared by db.url_with_password and creds.basic_auth_url so the two
|
||||
* validators cannot drift.
|
||||
*/
|
||||
// Fully-braced `${...}` spanning the whole password segment is template code
|
||||
// regardless of content — `${dbPass}` and `${encodeURIComponent(dbPass)}`
|
||||
// alike (the identifier-only form flagged the DSN-encoding call site as a
|
||||
// pushed secret). Bare `$word` stays uppercase-only: `$hunter2` must block.
|
||||
const INTERPOLATED_PASSWORD_RE = /^(\$\{.+\}|\$[A-Z_][A-Z0-9_]*)$/;
|
||||
function urlPasswordIsPlaceholder(span: string): boolean {
|
||||
const m = span.match(/:\/\/[^:]+:([^@]+)@/);
|
||||
const pw = m?.[1] ?? "";
|
||||
if (pw === "") return true;
|
||||
if (INTERPOLATED_PASSWORD_RE.test(pw)) return true;
|
||||
// URL-password position is STRICTER than generic placeholder detection.
|
||||
// Doc-comment convention writes placeholders in ALL CAPS
|
||||
// (postgres://USER:PASSWORD@host); a lowercase `password` or `pass` at
|
||||
// this position is a real (terrible) credential and must block — the
|
||||
// case-insensitive isPlaceholderSpan words would wave it through.
|
||||
if (/^[A-Z][A-Z0-9_]*$/.test(pw)) return true;
|
||||
return PLACEHOLDER_STRUCTURAL.some((re) => re.test(pw));
|
||||
}
|
||||
|
||||
export const PATTERNS: RedactPattern[] = [
|
||||
// ===== HIGH — genuinely-secret credentials (block) =====
|
||||
{
|
||||
@@ -436,12 +465,8 @@ export const PATTERNS: RedactPattern[] = [
|
||||
category: "secret",
|
||||
description: "Database URL with embedded password",
|
||||
regex: /\b((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:\s/@]+:[^@\s/]+@[^\s/]+)/,
|
||||
// Skip when the password segment is itself a placeholder.
|
||||
validate: (span) => {
|
||||
const m = span.match(/:\/\/[^:]+:([^@]+)@/);
|
||||
const pw = m?.[1] ?? "";
|
||||
return !isPlaceholderSpan(pw) && pw !== "" && !/^\$\{?[A-Z_]+\}?$/.test(pw);
|
||||
},
|
||||
// Skip when the password segment is itself a placeholder/interpolation.
|
||||
validate: (span) => !urlPasswordIsPlaceholder(span),
|
||||
},
|
||||
{
|
||||
id: "creds.basic_auth_url",
|
||||
@@ -449,11 +474,8 @@ export const PATTERNS: RedactPattern[] = [
|
||||
category: "secret",
|
||||
description: "HTTP(S) URL with embedded basic-auth credentials",
|
||||
regex: /(https?:\/\/[^:\s/@]+:[^@\s/]+@[^\s/]+)/,
|
||||
validate: (span) => {
|
||||
const m = span.match(/:\/\/[^:]+:([^@]+)@/);
|
||||
const pw = m?.[1] ?? "";
|
||||
return !isPlaceholderSpan(pw) && pw !== "" && !/^\$\{?[A-Z_]+\}?$/.test(pw);
|
||||
},
|
||||
// Skip when the password segment is itself a placeholder/interpolation.
|
||||
validate: (span) => !urlPasswordIsPlaceholder(span),
|
||||
},
|
||||
|
||||
// ===== MEDIUM — demoted credential-shaped (high-FP / context-variable) =====
|
||||
|
||||
Reference in New Issue
Block a user