import { spawn, spawnSync } from 'node:child_process' import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { getSupabaseWorktreeConfig } from './supabase-worktree-config' type SupabaseCmd = { cmd: string, argsPrefix: string[] } /** * Used to decide whether to call the globally installed `supabase` CLI or fall back to `bunx supabase`. */ function hasSupabaseCli(): boolean { const res = spawnSync('supabase', ['--version'], { stdio: 'ignore' }) return res.status === 0 } /** * Resolve the repo-local Supabase CLI binary installed by the npm package's postinstall. */ function getLocalSupabaseCli(repoRoot: string): string | null { const binName = process.platform === 'win32' ? 'supabase.exe' : 'supabase' // Prefer the package shim Bun/npm expose on PATH (.bin), then legacy bin/ layouts. // Do NOT use dist/supabase.js — that is the installer stub and throws // "No matching Supabase CLI binary package" when the platform binary is not // extracted yet (common while `bun install` still runs in parallel with start). const candidates = [ resolve(repoRoot, 'node_modules', '.bin', binName), resolve(repoRoot, 'node_modules', 'supabase', 'bin', binName), ] return candidates.find(candidate => existsSync(candidate)) ?? null } /** * Resolve the Supabase CLI invocation. * * Prefer the repo-local binary to avoid shelling out through `bunx` on every command. * Fall back to a globally installed `supabase` binary when present. * `SUPABASE_CLI_BIN` forces a specific binary (CI sets it while `bun install` runs in parallel, * so a half-extracted node_modules binary is never picked up). */ function getSupabaseCmd(repoRoot: string): SupabaseCmd { if (process.env.SUPABASE_CLI_BIN) return { cmd: process.env.SUPABASE_CLI_BIN, argsPrefix: [] } const localBin = getLocalSupabaseCli(repoRoot) if (localBin) return { cmd: localBin, argsPrefix: [] } if (hasSupabaseCli()) return { cmd: 'supabase', argsPrefix: [] } if (existsSync(resolve(repoRoot, 'node_modules', 'supabase', 'package.json'))) { throw new Error('Supabase CLI is installed without its binary. Run `bun pm trust supabase && bun install` so Bun can execute the package postinstall.') } throw new Error('Supabase CLI not found. Run `bun install` or install the standalone `supabase` binary.') } /** * Ensure `linkPath` is a symlink pointing at `targetPath`. * * This is used to build a lightweight per-worktree Supabase workdir that reuses the repo's * functions/migrations/seed without copying. */ function ensureSymlink(linkPath: string, targetPath: string): void { try { if (existsSync(linkPath)) { // If it already exists and points to the right place, keep it; otherwise replace. const resolved = realpathSync(linkPath) const want = realpathSync(targetPath) if (resolved === want) return rmSync(linkPath, { force: true, recursive: true }) } } catch { // Best effort: replace anything we can't validate. rmSync(linkPath, { force: true, recursive: true }) } mkdirSync(dirname(linkPath), { recursive: true }) symlinkSync(targetPath, linkPath) } /** * Rewrite `supabase/config.toml` to use a worktree-specific `project_id` and port set. * * The `project_id` affects Docker resource names (containers/volumes). Ports are shifted to * avoid collisions when multiple worktrees run Supabase concurrently. */ function rewriteConfigToml(raw: string, cfg: ReturnType): string { const { projectId, ports } = cfg const lines = raw.split('\n') let section = '' const out: string[] = [] const portBySection: Record = { api: ports.api, db: ports.db, 'db.pooler': ports.dbPooler, studio: ports.studio, local_smtp: ports.inbucket, analytics: ports.analytics, } for (const line of lines) { const secMatch = line.match(/^\s*\[([^\]]+)\]\s*$/) if (secMatch) section = secMatch[1] if (line.match(/^\s*project_id\s*=/)) out.push(`project_id = "${projectId}"`) else if (section === 'db' && line.match(/^\s*shadow_port\s*=\s*\d+\s*$/)) out.push(`shadow_port = ${ports.dbShadow}`) else if (section === 'edge_runtime' && line.match(/^\s*inspector_port\s*=\s*\d+\s*$/)) out.push(`inspector_port = ${ports.edgeInspector}`) else if (section in portBySection && line.match(/^\s*port\s*=\s*\d+\s*$/)) out.push(`port = ${portBySection[section]}`) else if (line.includes('./supabase/templates/')) out.push(line.replace('./supabase/templates/', './templates/')) else out.push(line) } return out.join('\n') } function upsertEnvValue(content: string, key: string, value: string): string { const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const matcher = new RegExp(`^${escapedKey}=.*$`, 'm') const line = `${key}=${value}` if (matcher.test(content)) return content.replace(matcher, line) return content.endsWith('\n') || content.length === 0 ? `${content}${line}\n` : `${content}\n${line}\n` } export function buildFunctionsEnvFile(source: string, apiPort: number, cloudflareFunctionUrl?: string): string { const s3Endpoint = `127.0.0.1:${apiPort}/storage/v1/s3` let generated = upsertEnvValue(source, 'S3_ENDPOINT', s3Endpoint) generated = upsertEnvValue(generated, 'FILES_PUBLIC_URL', `http://127.0.0.1:${apiPort}/functions/v1`) if (cloudflareFunctionUrl) generated = upsertEnvValue(generated, 'CLOUDFLARE_FUNCTION_URL', cloudflareFunctionUrl) return generated } /** * The checked-in functions env uses Supabase's default API port. Generate a * worktree-specific copy so functions use the same isolated storage endpoint * and return the externally reachable files URL rather than Docker's internal * Kong hostname. */ function ensureFunctionsEnvFile(repoRoot: string, workdir: string, cfg: ReturnType): string { const sourcePath = resolve(repoRoot, 'supabase', 'functions', '.env') const targetPath = resolve(workdir, 'functions.local.env') const source = existsSync(sourcePath) ? readFileSync(sourcePath, 'utf8') : '' const generated = buildFunctionsEnvFile(source, cfg.ports.api, process.env.CLOUDFLARE_FUNCTION_URL) writeFileSync(targetPath, generated) return targetPath } /** * Create (or update) the per-worktree Supabase workdir under `.context/`. * * The resulting directory is suitable to pass to `supabase --workdir`, and contains a rewritten * `supabase/config.toml` plus symlinks to the project's Supabase assets. */ function ensureWorktreeSupabaseDir(repoRoot: string): { workdir: string, cfg: ReturnType } { const cfg = getSupabaseWorktreeConfig(repoRoot) const workdir = resolve(cfg.repoRoot, '.context', 'supabase-worktrees', cfg.worktreeHash) const supaDir = resolve(workdir, 'supabase') mkdirSync(supaDir, { recursive: true }) // Symlink everything except config.toml so we can safely rewrite ports + project_id per worktree. const repoSupaDir = resolve(cfg.repoRoot, 'supabase') const repoTemplatesDir = resolve(repoSupaDir, 'templates') for (const entry of ['functions', 'migrations', 'schemas', 'tests', 'seed.sql', 'migration_guide.md', '.gitignore']) { const src = resolve(repoSupaDir, entry) if (!existsSync(src)) continue const dst = resolve(supaDir, entry) ensureSymlink(dst, src) } if (existsSync(repoTemplatesDir)) { ensureSymlink(resolve(workdir, 'templates'), repoTemplatesDir) ensureSymlink(resolve(supaDir, 'templates'), repoTemplatesDir) } const baseConfig = readFileSync(resolve(repoSupaDir, 'config.toml'), 'utf8') const rewritten = rewriteConfigToml(baseConfig, cfg) writeFileSync(resolve(supaDir, 'config.toml'), rewritten) return { workdir, cfg } } /** * Parse `supabase status -o json` output, which may include non-JSON informational lines. */ function parseStatusJson(mixed: string): any { // `supabase status -o json` can print non-JSON lines like: // "Stopped services: [...]" const idx = mixed.indexOf('{') if (idx < 0) throw new Error('Failed to parse supabase status output (no JSON object found).') return JSON.parse(mixed.slice(idx)) } /** * Get Supabase status as JSON, optionally for a specific `--workdir`. */ function getStatusJson(supa: SupabaseCmd, workdir?: string): { ok: true, json: any } | { ok: false, status: number } { const args = [...supa.argsPrefix, 'status', '-o', 'json'] if (workdir) args.push('--workdir', workdir) const res = spawnSync(supa.cmd, args, { encoding: 'utf8', env: process.env, }) if ((res.status ?? 1) !== 0) return { ok: false, status: res.status ?? 1 } try { return { ok: true, json: parseStatusJson(res.stdout || '') } } catch { return { ok: false, status: 1 } } } /** * Map Supabase CLI status variables to the env vars used by the codebase/tests. */ function statusToEnv(status: any): Record { const apiUrl = status.API_URL as string | undefined const dbUrl = status.DB_URL as string | undefined const anon = (status.ANON_KEY || status.PUBLISHABLE_KEY) as string | undefined const service = (status.SERVICE_ROLE_KEY || status.SECRET_KEY) as string | undefined const env: Record = {} if (apiUrl) env.SUPABASE_URL = apiUrl if (dbUrl) env.SUPABASE_DB_URL = dbUrl if (anon) env.SUPABASE_ANON_KEY = anon if (service) { // Keep both names for backwards compatibility across scripts/tests. env.SUPABASE_SERVICE_ROLE_KEY = service env.SUPABASE_SERVICE_KEY = service } // Also forward the raw keys so existing scripts that expect them keep working. for (const [k, v] of Object.entries(status)) { if (typeof v === 'string') env[k] = v } return env } /** * Parse leading `KEY=VALUE` tokens into an env map. * * This allows `bun run supabase:with-env -- FOO=bar bunx vitest ...` without requiring `cross-env`. */ function parseInlineEnvAssignments(args: string[]): { env: Record, rest: string[] } { // Allow leading KEY=VALUE tokens so callers don't need `cross-env` (works cross-platform). const env: Record = {} let idx = 0 for (; idx < args.length; idx++) { const token = args[idx] if (!token) break const eq = token.indexOf('=') if (eq <= 0) break const key = token.slice(0, eq) if (!/^[A-Z0-9_]+$/.test(key)) break env[key] = token.slice(eq + 1) } return { env, rest: args.slice(idx) } } /** * Run a Supabase CLI command against the current worktree's generated `--workdir`. */ function buildSupabaseInvocation(args: string[], repoRoot: string): { cmd: string, args: string[], env: NodeJS.ProcessEnv } { const { workdir, cfg } = ensureWorktreeSupabaseDir(repoRoot) const supa = getSupabaseCmd(repoRoot) const commandArgs = [...args] const isFunctionsServe = commandArgs[0] === 'functions' && commandArgs[1] === 'serve' const hasEnvFile = commandArgs.some(arg => arg === '--env-file' || arg.startsWith('--env-file=')) if (isFunctionsServe && !hasEnvFile) commandArgs.push('--env-file', ensureFunctionsEnvFile(repoRoot, workdir, cfg)) // `functions serve` writes its main script under $TMPDIR and bind-mounts it to // /root/index.ts. On macOS $TMPDIR is /var/folders/..., which Docker Desktop // does not share, so the container sees an empty directory and edge-runtime // crashes with "Is a directory (os error 21)". Keep it inside the workdir. const env = { ...process.env } if (isFunctionsServe) { const tmpDir = resolve(workdir, 'tmp') mkdirSync(tmpDir, { recursive: true }) env.TMPDIR = tmpDir } // Supabase CLI 2.109+ plpgsql_check warns on intentional STABLE helpers that // call auth.uid()/request headers. Keep emitting warnings, but do not fail CI // on that newly noisy class unless callers ask for --fail-on error. if ( commandArgs[0] === 'db' && commandArgs[1] === 'lint' && commandArgs.includes('--fail-on') ) { const failOnIdx = commandArgs.indexOf('--fail-on') if (failOnIdx >= 0 && commandArgs[failOnIdx + 1] === 'warning') { commandArgs[failOnIdx + 1] = 'error' } } return { cmd: supa.cmd, args: [...supa.argsPrefix, ...commandArgs, '--workdir', workdir], env } } function runSupabase(args: string[], repoRoot: string): number { const invocation = buildSupabaseInvocation(args, repoRoot) const res = spawnSync(invocation.cmd, invocation.args, { stdio: 'inherit', env: invocation.env, }) return res.status ?? 1 } /** * Run a Supabase command while streaming its output live and keeping a copy for * retry classification. Buffering until exit hid which step a slow `supabase start` * was stuck on (image pull, health check, seed) when CI timed out waiting for it. */ function runSupabaseStreaming(args: string[], repoRoot: string): Promise<{ status: number, output: string }> { const invocation = buildSupabaseInvocation(args, repoRoot) return new Promise((resolvePromise) => { const child = spawn(invocation.cmd, invocation.args, { stdio: ['inherit', 'pipe', 'pipe'], env: invocation.env, }) let output = '' child.stdout.on('data', (chunk: Buffer) => { output += chunk.toString() process.stdout.write(chunk) }) child.stderr.on('data', (chunk: Buffer) => { output += chunk.toString() process.stderr.write(chunk) }) child.on('error', (error) => { console.error(error.message) resolvePromise({ status: 1, output }) }) child.on('close', code => resolvePromise({ status: code ?? 1, output })) }) } export type TransientSupabaseStartFailure = 'docker_image_pull' | 'docker_port_bind' | 'edge_runtime_health_check' /** * Classify only infrastructure failures that are safe to retry before tests run. * Keep generic HTTP 5xx responses out of this classifier so application failures * and deterministic Supabase configuration errors still fail immediately. */ export function getTransientSupabaseStartFailure(output: string): TransientSupabaseStartFailure | null { if (/address already in use/i.test(output) || /failed to bind host port/i.test(output)) return 'docker_port_bind' const lines = output.split(/\r?\n/) const dockerPullContext = /failed to pull docker image|pulling image|docker\.io|(?:container|image) registry|failed to resolve reference|failed to fetch anonymous token|error response from daemon[^\n]*(?:pull|image|manifest|blob|registry)/i const transientPullSignal = /toomanyrequests|too many requests|data limit exceeded|unexpected eof|tls handshake timeout|connection reset by peer|context deadline exceeded|i\/o timeout|(?:status|response|request)[^\n]{0,80}\b(?:429|500|502|503|504)\b|\b(?:429|500|502|503|504)\b[^\n]{0,80}(?:status|response)/i for (let index = 0; index < lines.length; index++) { const nearbyOutput = lines.slice(Math.max(0, index - 3), index + 4).join('\n') if (dockerPullContext.test(nearbyOutput) && transientPullSignal.test(nearbyOutput)) return 'docker_image_pull' } // The edge runtime bundles every function before it answers its health // check; on a loaded runner that can outlast the CLI's wait, which then // dumps the container logs and exits with "Error status 503". The same // commit starts on a rerun, so retry it like the Docker failures. if (/Waiting for health checks/.test(output) && /supabase_edge_runtime_\S+ container logs:/.test(output) && /^Error status 503:/m.test(output)) { return 'edge_runtime_health_check' } return null } function getCloudflareWorkerPorts(): number[] { const raw = process.env.CLOUDFLARE_WORKER_PORT_OFFSET if (!raw || !/^\d+$/.test(raw)) return [] const offset = Number(raw) // Match scripts/start-cloudflare-workers.sh / cloudflare-test-config.ts so we never // freeHostPorts() derived ports from an invalid offset before the launcher rejects it. if (!Number.isSafeInteger(offset) || offset < 0 || offset > 50_000) return [] // wrangler worker ports + inspector ports from scripts/start-cloudflare-workers.sh return [ 8787 + offset, 8788 + offset, 8789 + offset, 9230 + offset, 9231 + offset, 9232 + offset, ] } /** * Keep worktree host ports out of the kernel ephemeral pool. * * CI offsets land in 32k–60k, which overlaps Linux's default local port range. * Without reserving them, outbound sockets can steal a Supabase port and make * `docker` fail with "address already in use" on an otherwise idle runner. */ function reserveWorktreePortsFromEphemeralPool(repoRoot: string): void { if (process.platform !== 'linux') return if (!process.env.SUPABASE_WORKTREE_PORT_OFFSET && !process.env.CI) return const { cfg } = ensureWorktreeSupabaseDir(repoRoot) const ports = [ ...Object.values(cfg.ports).filter(port => Number.isFinite(port)), ...getCloudflareWorkerPorts(), ].sort((a, b) => a - b) if (ports.length === 0) return const reserved = [...new Set(ports)].join(',') const result = spawnSync('sudo', ['sysctl', '-w', `net.ipv4.ip_local_reserved_ports=${reserved}`], { encoding: 'utf8', }) if ((result.status ?? 1) !== 0) { const fallback = spawnSync('sysctl', ['-w', `net.ipv4.ip_local_reserved_ports=${reserved}`], { encoding: 'utf8', }) if ((fallback.status ?? 1) !== 0) { console.warn(`Could not reserve Supabase ports from the ephemeral pool: ${reserved}`) return } } console.error(`Reserved Supabase worktree ports from ephemeral pool: ${reserved}`) } /** * Drop whatever still holds worktree host ports after a partial Docker start. * * `fuser` alone is not enough on GitHub runners: docker-proxy / leftover * containers from a failed bind can keep the port until removed explicitly. */ function freeHostPorts(ports: number[]): void { if (process.platform === 'win32' || ports.length === 0) return const uniquePorts = [...new Set(ports.filter(port => Number.isFinite(port)))] const holders = new Set() for (const port of uniquePorts) { const byPublish = spawnSync('docker', ['ps', '-aq', '--filter', `publish=${port}`], { encoding: 'utf8', }) for (const id of (byPublish.stdout ?? '').split(/\s+/).filter(Boolean)) holders.add(id) } // Match host-port publish strings docker prints (0.0.0.0:58722->5432/tcp). const listed = spawnSync('docker', ['ps', '-a', '--format', '{{.ID}} {{.Ports}}'], { encoding: 'utf8', }) if ((listed.status ?? 1) === 0) { for (const line of (listed.stdout ?? '').split('\n')) { const trimmed = line.trim() if (!trimmed) continue const spaceIdx = trimmed.indexOf(' ') const id = spaceIdx >= 0 ? trimmed.slice(0, spaceIdx) : trimmed const published = spaceIdx >= 0 ? trimmed.slice(spaceIdx + 1) : '' if (uniquePorts.some(port => published.includes(`:${port}->`) || published.includes(`:${port}/`))) holders.add(id) } } if (holders.size > 0) { console.error(`Removing Docker containers still publishing worktree ports: ${[...holders].join(', ')}`) spawnSync('docker', ['rm', '-f', ...holders], { stdio: 'inherit' }) } for (const port of uniquePorts) { spawnSync('fuser', ['-k', `${port}/tcp`], { stdio: 'ignore' }) // Close lingering sockets that still occupy the port after docker-proxy dies. spawnSync('ss', ['-K', 'sport', '=', `:${port}`], { stdio: 'ignore' }) } } /** * Cancelled CI jobs can leave named Supabase containers that still hold host * ports after `supabase stop`. Force-remove anything matching this worktree. */ function removeLeftoverWorktreeContainers(projectId: string): void { if (process.platform === 'win32') return const listed = spawnSync('docker', ['ps', '-aq', '--filter', `name=${projectId}`], { encoding: 'utf8', }) if ((listed.status ?? 1) !== 0) return const ids = (listed.stdout ?? '').split(/\s+/).filter(Boolean) if (ids.length === 0) return console.error(`Removing leftover Docker containers for ${projectId}: ${ids.join(', ')}`) spawnSync('docker', ['rm', '-f', ...ids], { stdio: 'inherit' }) } /** * `supabase start` can fail on GitHub runners with transient Docker port binds * or image-pull failures before tests start. Retry only classified infrastructure * failures so permanent start and application errors fail fast. */ async function runSupabaseStartWithRetry(args: string[], repoRoot: string): Promise { const { cfg } = ensureWorktreeSupabaseDir(repoRoot) const ports = [ ...Object.values(cfg.ports).filter(port => Number.isFinite(port)), ...getCloudflareWorkerPorts(), ] reserveWorktreePortsFromEphemeralPool(repoRoot) removeLeftoverWorktreeContainers(cfg.projectId) freeHostPorts(ports) const maxAttempts = 5 for (let attempt = 1; attempt <= maxAttempts; attempt++) { const { status, output } = await runSupabaseStreaming(args, repoRoot) if (status === 0) return 0 const transientFailure = getTransientSupabaseStartFailure(output) if (attempt >= maxAttempts || transientFailure === null) { console.error(`SUPABASE_START_FINAL_FAILURE=${transientFailure ?? 'non_transient'}`) return status } const reason = { docker_image_pull: 'Docker image pull', docker_port_bind: 'Docker port bind', edge_runtime_health_check: 'edge runtime health check', }[transientFailure] console.error(`Supabase start hit a transient ${reason} failure (attempt ${attempt}/${maxAttempts}); stopping and retrying...`) // The edge runtime fails after the database is up and may be reusing a // developer's volumes: keep them. Docker failures happen before any data. runSupabase(transientFailure === 'edge_runtime_health_check' ? ['stop'] : ['stop', '--no-backup'], repoRoot) removeLeftoverWorktreeContainers(cfg.projectId) freeHostPorts(ports) // Back off so docker-proxy / TIME_WAIT can release before the next bind. await new Promise(resolveSleep => setTimeout(resolveSleep, Math.min(2 ** attempt, 8) * 1000)) } return 1 } /** * Run an arbitrary command with Supabase env (URL/keys) injected for the current worktree. * * This is used by the test scripts so parallel worktrees do not accidentally target the same * local Supabase stack. */ function runWithEnv(cmdArgs: string[], repoRoot: string): number { const { workdir } = ensureWorktreeSupabaseDir(repoRoot) const supa = getSupabaseCmd(repoRoot) const { env: inlineEnv, rest: commandArgs } = parseInlineEnvAssignments(cmdArgs) if (commandArgs.length === 0) { console.error('Usage: bun scripts/supabase-worktree.ts with-env ') return 2 } if (commandArgs[0] !== 'bun' && commandArgs[0] !== 'bunx') { console.error('Unsupported command for supabase:with-env. Use `bun` or `bunx` as the executable (e.g. `bunx vitest run ...`).') return 2 } // An explicitly scoped job must never fall back to a default stack: that can // silently make two CI jobs share the same database. const requiresIsolatedStack = Boolean(process.env.SUPABASE_WORKTREE_INSTANCE || process.env.SUPABASE_WORKTREE_PORT_OFFSET) const worktreeStatus = getStatusJson(supa, workdir) if (!worktreeStatus.ok && requiresIsolatedStack) { console.error('The isolated Supabase stack is not running. Start it with `bun run supabase:start`.') return 1 } // Preserve legacy compatibility for unscoped developer commands only. const legacyStatus = worktreeStatus.ok ? null : getStatusJson(supa, undefined) if (!worktreeStatus.ok && !legacyStatus?.ok) { console.error('Supabase is not running. Start it with `bun run supabase:start` (preferred) or `supabase start` (legacy).') return 1 } if (!worktreeStatus.ok && legacyStatus?.ok) console.warn('Using legacy Supabase stack (no worktree isolation detected).') const status = (worktreeStatus.ok ? worktreeStatus.json : (legacyStatus as any).json) as any const childEnv = { ...process.env, ...statusToEnv(status), ...inlineEnv } const res = spawnSync(commandArgs[0], commandArgs.slice(1), { stdio: 'inherit', env: childEnv, // On Windows, many package binaries are `.cmd` shims and require `shell: true`. shell: process.platform === 'win32', }) return res.status ?? 1 } /** * CLI entrypoint. Supports: * - `bun scripts/supabase-worktree.ts ` * - `bun scripts/supabase-worktree.ts with-env ` */ async function main(): Promise { try { const repoRoot = process.cwd() const args = process.argv.slice(2) if (args.length === 0) { console.error('Usage: bun scripts/supabase-worktree.ts \n bun scripts/supabase-worktree.ts with-env ') return 2 } if (args[0] === 'with-env') { const cmdArgs = args.slice(1) if (cmdArgs.length === 0) { console.error('Usage: bun scripts/supabase-worktree.ts with-env ') return 2 } return runWithEnv(cmdArgs, repoRoot) } if (args[0] === 'start') return runSupabaseStartWithRetry(args, repoRoot) return runSupabase(args, repoRoot) } catch (error) { console.error(error instanceof Error ? error.message : String(error)) return 1 } } if (import.meta.main) process.exitCode = await main()