scripts/supabase-worktree.ts
Rama main · réplica de lectura del repositorio original.
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<typeof getSupabaseWorktreeConfig>): string {
const { projectId, ports } = cfg
const lines = raw.split('\n')
let section = ''
const out: string[] = []
const portBySection: Record<string, number> = {
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<typeof getSupabaseWorktreeConfig>): 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<typeof getSupabaseWorktreeConfig> } {
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<string, string> {
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<string, string> = {}
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<string, string>, rest: string[] } {
// Allow leading KEY=VALUE tokens so callers don't need `cross-env` (works cross-platform).
const env: Record<string, string> = {}
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<string>()
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<number> {
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 <command...>')
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 <supabase-subcommand...>`
* - `bun scripts/supabase-worktree.ts with-env <command...>`
*/
async function main(): Promise<number> {
try {
const repoRoot = process.cwd()
const args = process.argv.slice(2)
if (args.length === 0) {
console.error('Usage: bun scripts/supabase-worktree.ts <supabase-subcommand...>\n bun scripts/supabase-worktree.ts with-env <command...>')
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 <command...>')
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()