Ctrl K
ComunidadGuías…

scripts/supabase-worktree.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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()