From 8b3a1c6bf041ce1dc2d4afcea54b2393f70aedd8 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Mon, 18 May 2026 20:01:32 +0200 Subject: [PATCH 1/7] fix(cli): prevent fork-bomb when supabase-go binary is not found (#5282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes CLI-1488: the Go proxy layer was silently falling back to `"supabase"` on PATH when the `supabase-go` binary could not be resolved. When the shim itself is on PATH and `supabase-go` is not co-located, this fallback resolves to the shim and causes a fork-bomb (silent multi-minute hang in CI followed by SIGTERM). ## Changes - **Binary resolution**: `resolveBinary()` now returns a `BinaryResolution` discriminated union (`{ found: string } | { notFound: string[] }`) instead of a fallback string. The `notFound` variant tracks all attempted locations for user-facing diagnostics. - **Error handling**: When binary resolution fails, `makeGoProxyLayer` now prints a specific diagnostic message to stderr and exits with code 1 via `ProcessControl.exit()`, rather than attempting to spawn a non-existent binary. - **User-facing diagnostics**: New `formatGoBinaryNotFoundError()` function generates a helpful error message that: - Lists all locations the resolver checked - Provides platform-specific `curl | tar` install snippets (when CLI_VERSION is not the dev sentinel and the host architecture is supported) - Suggests npm installation and SUPABASE_GO_BINARY environment variable as alternatives - **Test coverage**: Added comprehensive unit tests for `formatGoBinaryNotFoundError()` covering: - Basic error message formatting with all attempted locations - Dev build behavior (no curl snippet when CLI_VERSION is "0.0.0-dev") - Platform-specific snippet generation (linux x64, darwin arm64, win32 → windows mapping) - Unsupported architecture handling (no snippet when release asset doesn't exist) - Integration test verifying the layer refuses to spawn and exits 1 when binary is not found The fix ensures the CLI fails fast with actionable guidance instead of silently fork-bombing itself. https://claude.ai/code/session_01Lb1v3o9Dabe6qjH565ki4k --------- Co-authored-by: Claude --- apps/cli/src/shared/legacy/go-proxy.layer.ts | 104 +++++++++++-- .../shared/legacy/go-proxy.layer.unit.test.ts | 140 +++++++++++++++++- 2 files changed, 230 insertions(+), 14 deletions(-) diff --git a/apps/cli/src/shared/legacy/go-proxy.layer.ts b/apps/cli/src/shared/legacy/go-proxy.layer.ts index 1e7cd5f0e..eb12c5125 100644 --- a/apps/cli/src/shared/legacy/go-proxy.layer.ts +++ b/apps/cli/src/shared/legacy/go-proxy.layer.ts @@ -6,6 +6,7 @@ import process from "node:process"; import { Effect, Layer } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { CLI_VERSION } from "../cli/version.ts"; import { ProcessControl } from "../runtime/process-control.service.ts"; import { LegacyGoProxy } from "./go-proxy.service.ts"; @@ -25,16 +26,30 @@ const PLATFORM_CANDIDATES: Partial }; + +function resolveBinary(): BinaryResolution { + const tried: string[] = []; + const envBin = process.env["SUPABASE_GO_BINARY"]; - if (envBin) return envBin; + if (envBin) return { found: envBin }; + tried.push("$SUPABASE_GO_BINARY (unset)"); const ext = process.platform === "win32" ? ".exe" : ""; // When running as a compiled standalone SFE (exec'd by the base shim via execFileSync), // process.execPath is the SFE binary path. Look for supabase-go co-located next to it. const colocated = path.join(path.dirname(process.execPath), `supabase-go${ext}`); - if (existsSync(colocated)) return colocated; + if (existsSync(colocated)) return { found: colocated }; + tried.push(`${colocated} (not found alongside the shim)`); // When running from source, resolve via installed npm packages. // Guard with existsSync — in dev the workspace stub packages exist but their bin/ is empty. @@ -43,14 +58,58 @@ function resolveBinary(): string { try { const pkgPath = path.dirname(require.resolve(`@supabase/cli-${suffix}/package.json`)); const bin = path.join(pkgPath, "bin", `supabase-go${ext}`); - if (existsSync(bin)) return bin; + if (existsSync(bin)) return { found: bin }; + tried.push(`${bin} (npm package present, binary missing)`); } catch { - // Package not installed — try next candidate. + tried.push(`@supabase/cli-${suffix} (npm package not installed)`); } } - // Fall back to `supabase` on PATH (useful in CI and development). - return "supabase"; + return { notFound: tried }; +} + +/** + * Build a concrete `curl | tar` install snippet for the host platform, using + * the version baked into this shim at build time (`CLI_VERSION`). The release + * pipeline ships a `.tar.gz` for every (platform, arch) pair we support — + * including Windows — so the snippet is uniform across hosts. Returns null + * only when CLI_VERSION is the dev sentinel (we have no concrete URL) or the + * host arch isn't one the release pipeline targets. + */ +function reinstallTarballSnippet(): ReadonlyArray | null { + if (CLI_VERSION === "0.0.0-dev") return null; + const archSuffix = process.arch === "x64" ? "amd64" : process.arch === "arm64" ? "arm64" : null; + if (archSuffix === null) return null; + // Map Node's `process.platform` to the release asset's OS slug. `win32` is + // historical (Win16 vs Win32); GitHub assets use the modern `windows` slug. + const osSlug = process.platform === "win32" ? "windows" : process.platform; + const asset = `supabase_${CLI_VERSION}_${osSlug}_${archSuffix}.tar.gz`; + return [ + ` mkdir -p "$HOME/.local/share/supabase"`, + ` curl -sL https://github.com/supabase/cli/releases/download/v${CLI_VERSION}/${asset} \\`, + ` | tar -xzf - -C "$HOME/.local/share/supabase"`, + ` export PATH="$HOME/.local/share/supabase:$PATH"`, + ]; +} + +export function formatGoBinaryNotFoundError(tried: ReadonlyArray): string { + const snippet = reinstallTarballSnippet(); + return [ + "Could not find the `supabase-go` binary.", + "", + "The Supabase CLI ships as two co-located binaries: `supabase` (this shim)", + "and `supabase-go` (the Go CLI that the shim forwards to). The shim looked", + "for `supabase-go` in:", + "", + ...tried.map((line) => ` • ${line}`), + "", + "To fix, do one of:", + " • Extract the release tarball into a directory and add the directory to", + " PATH (do not move `supabase` somewhere `supabase-go` doesn't follow).", + ...(snippet === null ? [] : [" For example, on this host:", "", ...snippet, ""]), + " • Install via npm: `npm i -g supabase`.", + " • Set SUPABASE_GO_BINARY to the absolute path of `supabase-go`.", + ].join("\n"); } // --------------------------------------------------------------------------- @@ -77,24 +136,45 @@ export function makeGoProxyLayer(opts?: { env?: Record; globalArgs?: ReadonlyArray; /** - * Override the binary path. Primarily a test seam so specs don't have to - * mutate `process.env.SUPABASE_GO_BINARY`. In production, leave unset and - * let `resolveBinary()` pick the right artifact for the host platform. + * Override binary resolution. Primarily a test seam so specs don't have to + * mutate `process.env.SUPABASE_GO_BINARY` or stub the filesystem: + * - `string` — treat as the resolved Go binary path. + * - `{ notFound: [...] }` — simulate the not-found path; `.exec` will + * print the diagnostic and exit non-zero. + * + * In production, leave unset and let `resolveBinary()` pick the right + * artifact for the host platform. */ - binary?: string; + binary?: string | BinaryResolution; }): Layer.Layer { return Layer.effect( LegacyGoProxy, Effect.gen(function* () { const processControl = yield* ProcessControl; const spawner = yield* ChildProcessSpawner; - const binary = opts?.binary ?? resolveBinary(); + const resolved: BinaryResolution = + typeof opts?.binary === "string" + ? { found: opts.binary } + : (opts?.binary ?? resolveBinary()); const globalArgs = opts?.globalArgs ?? []; return LegacyGoProxy.of({ exec: (args) => Effect.scoped( Effect.gen(function* () { + if (!("found" in resolved)) { + // CLI-1488: never silently fall back to `supabase` on PATH — + // when the shim is on PATH and `supabase-go` is not co-located, + // that fallback resolves to the shim itself and fork-bombs. + // Print a specific diagnostic and exit non-zero instead. + yield* Effect.sync(() => { + process.stderr.write(`${formatGoBinaryNotFoundError(resolved.notFound)}\n`); + }); + yield* processControl.exit(1); + return; + } + const binary = resolved.found; + // Hold the terminal-signals on the parent for the duration of // the child's lifetime. Rationale: // diff --git a/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts b/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts index 88bda5fee..8fc4713d7 100644 --- a/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts +++ b/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, it } from "@effect/vitest"; +import { describe, expect, it, vi } from "@effect/vitest"; import { Deferred, Effect, Fiber, Layer, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { type CliProcessSignal, ProcessControl } from "../runtime/process-control.service.ts"; import { LegacyGoProxy } from "./go-proxy.service.ts"; -import { makeGoProxyLayer } from "./go-proxy.layer.ts"; +import { formatGoBinaryNotFoundError, makeGoProxyLayer } from "./go-proxy.layer.ts"; /** * Regression tests for the SIGINT propagation fix in go-proxy.layer.ts. @@ -157,6 +157,107 @@ function mockSpawner(exit: ExitBehavior, spawnedBeforeExit?: Deferred.Deferred { + const TRIED = [ + "$SUPABASE_GO_BINARY (unset)", + "/usr/local/bin/supabase-go (not found alongside the shim)", + "@supabase/cli-linux-x64 (npm package not installed)", + ]; + + it("renders each tried location as a bullet and includes remediation hints", () => { + const message = formatGoBinaryNotFoundError(TRIED); + expect(message).toContain("Could not find the `supabase-go` binary"); + expect(message).toContain(" • $SUPABASE_GO_BINARY (unset)"); + expect(message).toContain(" • /usr/local/bin/supabase-go (not found alongside the shim)"); + expect(message).toContain(" • @supabase/cli-linux-x64 (npm package not installed)"); + expect(message).toContain("npm i -g supabase"); + expect(message).toContain("SUPABASE_GO_BINARY"); + }); + + it("omits the curl|tar snippet on dev builds (no CLI_VERSION baked in)", () => { + // The vitest run does not go through the production bundler, so + // CLI_VERSION resolves to the "0.0.0-dev" sentinel from version.ts and + // the snippet is suppressed — we have nothing concrete to point at. + const message = formatGoBinaryNotFoundError(TRIED); + expect(message).not.toContain("curl -sL"); + // The prose remediation steps still appear so users have actionable hints. + expect(message).toContain("Extract the release tarball"); + }); +}); + +// The version- and platform-pinned curl|tar snippet exercised below +// instantiates a fresh module instance with a stubbed CLI_VERSION so we can +// assert against a known release version + asset filename. The fixture lives +// in a child `describe` so it doesn't bleed module mocks into other suites. +describe("formatGoBinaryNotFoundError - pinned snippet", () => { + const TRIED = ["$SUPABASE_GO_BINARY (unset)"]; + const PINNED_VERSION = "2.100.0"; + + async function withMockedHost( + opts: { platform: NodeJS.Platform; arch: NodeJS.Architecture }, + fn: (mod: typeof import("./go-proxy.layer.ts")) => void | Promise, + ): Promise { + vi.resetModules(); + vi.doMock("../cli/version.ts", () => ({ CLI_VERSION: PINNED_VERSION })); + const originalPlatform = process.platform; + const originalArch = process.arch; + Object.defineProperty(process, "platform", { value: opts.platform, configurable: true }); + Object.defineProperty(process, "arch", { value: opts.arch, configurable: true }); + try { + const mod = await import("./go-proxy.layer.ts"); + await fn(mod); + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + Object.defineProperty(process, "arch", { value: originalArch, configurable: true }); + vi.doUnmock("../cli/version.ts"); + vi.resetModules(); + } + } + + it("renders a copy-pasteable install snippet for linux x64", async () => { + await withMockedHost({ platform: "linux", arch: "x64" }, (mod) => { + const message = mod.formatGoBinaryNotFoundError(TRIED); + expect(message).toContain( + `https://github.com/supabase/cli/releases/download/v${PINNED_VERSION}/supabase_${PINNED_VERSION}_linux_amd64.tar.gz`, + ); + expect(message).toContain(`mkdir -p "$HOME/.local/share/supabase"`); + expect(message).toContain(`export PATH="$HOME/.local/share/supabase:$PATH"`); + }); + }); + + it("maps Node's win32 platform to the release asset's `windows` slug", async () => { + // Release pipeline publishes `.tar.gz` for every (platform, arch) pair, + // Windows included, so the snippet renders on win32 too — just with the + // modern `windows` slug instead of Node's historical `win32`. + await withMockedHost({ platform: "win32", arch: "x64" }, (mod) => { + const message = mod.formatGoBinaryNotFoundError(TRIED); + expect(message).toContain( + `https://github.com/supabase/cli/releases/download/v${PINNED_VERSION}/supabase_${PINNED_VERSION}_windows_amd64.tar.gz`, + ); + // Never emit Node's internal `win32` token in the user-facing URL. + expect(message).not.toContain("win32"); + }); + }); + + it("maps darwin arm64 to the matching release asset", async () => { + await withMockedHost({ platform: "darwin", arch: "arm64" }, (mod) => { + expect(mod.formatGoBinaryNotFoundError(TRIED)).toContain( + `supabase_${PINNED_VERSION}_darwin_arm64.tar.gz`, + ); + }); + }); + + it("omits the snippet on unsupported architectures (no release asset)", async () => { + // ia32 has never been a release target — the snippet should not invent a URL. + await withMockedHost({ platform: "linux", arch: "ia32" }, (mod) => { + expect(mod.formatGoBinaryNotFoundError(TRIED)).not.toContain("curl -sL"); + }); + }); +}); + describe("makeGoProxyLayer", () => { it.effect("passes detached:false and inherited stdio to the spawner", () => { const spawner = mockSpawner({ kind: "success", code: 0 }); @@ -296,6 +397,41 @@ describe("makeGoProxyLayer", () => { }).pipe(Effect.provide(layer)); }); + // Regression guard for CLI-1488 — the previous `resolveBinary()` returned the + // literal string "supabase" when no Go binary was found, which when run from + // a PATH that contained the shim would fork-bomb the shim against itself + // (silent multi-minute hang in CI followed by SIGTERM). The layer must now + // refuse to spawn anything and surface a specific diagnostic + non-zero exit. + it.effect("prints a diagnostic and exits 1 when supabase-go cannot be resolved", () => { + const spawner = mockSpawner({ kind: "success", code: 0 }); + const pc = mockProcessControl({ exitBehavior: "terminateDie" }); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const tried = [ + "$SUPABASE_GO_BINARY (unset)", + "/usr/local/bin/supabase-go (not found alongside the shim)", + ]; + const layer = makeGoProxyLayer({ binary: { notFound: tried } }).pipe( + Layer.provide(Layer.mergeAll(spawner.layer, pc.layer)), + ); + return Effect.gen(function* () { + const proxy = yield* LegacyGoProxy; + yield* proxy.exec(["db", "start"]).pipe(Effect.exit); + + // Did NOT spawn anything — the whole point is to refuse the fork-bomb. + expect(spawner.spawned).toHaveLength(0); + // Exited with code 1 via ProcessControl.exit. + expect(pc.exitCalls).toEqual([1]); + // Wrote the diagnostic to stderr, including each tried location. + expect(stderr).toHaveBeenCalledTimes(1); + const written = String(stderr.mock.calls[0]![0]); + expect(written).toContain("Could not find the `supabase-go` binary"); + expect(written).toContain("$SUPABASE_GO_BINARY (unset)"); + expect(written).toContain("/usr/local/bin/supabase-go"); + expect(written).toContain("SUPABASE_GO_BINARY"); + stderr.mockRestore(); + }).pipe(Effect.provide(layer)); + }); + it.effect("opens and closes a fresh hold scope per sequential exec call", () => { const spawner = mockSpawner({ kind: "success", code: 0 }); const pc = mockProcessControl(); From a0feddbc5405d3ae48fac2a17b39a3a198bf7b66 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Mon, 18 May 2026 20:30:14 +0200 Subject: [PATCH 2/7] fix(cli): remove rawProjectConfig from ProjectContext (#5281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the unused `rawProjectConfig` field from `ProjectContext` and stop loading the raw project config in the project context layer. ## Changes - Removed `rawProjectConfig: Option.Option` from `ProjectContextShape` interface - Removed `loadProjectConfig` call from `projectContextLayer` — only `loadProjectEnvironment` is now used - Updated `mockProjectContext` test helper to remove the `rawProjectConfig` option parameter - Added unit test for `projectContextLayer` covering the scenario where `config.toml` uses `env()` on numeric fields (CLI-1489) and the case where no Supabase project is found ## Implementation Details The `rawProjectConfig` field was not being used anywhere in the codebase. By removing it, we simplify the `ProjectContext` service and reduce unnecessary I/O during initialization. The project context now only loads and exposes the project environment configuration, which is what consumers actually need. https://claude.ai/code/session_01C7reiRogHr6WXQoKiEDQeW --------- Co-authored-by: Claude --- .../interactions.json | 1 + .../cli-e2e/src/tests/config-toml.e2e.test.ts | 63 +++++++++++++ apps/cli-go/.gitignore | 1 + .../src/next/config/project-context.layer.ts | 10 +- .../config/project-context.layer.unit.test.ts | 94 +++++++++++++++++++ .../next/config/project-context.service.ts | 3 +- apps/cli/tests/helpers/mocks.ts | 4 +- 7 files changed, 162 insertions(+), 14 deletions(-) create mode 100644 apps/cli-e2e/fixtures/scenarios/env-in-config-toml-does-not-crash-on-numeric-fields/interactions.json create mode 100644 apps/cli-e2e/src/tests/config-toml.e2e.test.ts create mode 100644 apps/cli/src/next/config/project-context.layer.unit.test.ts diff --git a/apps/cli-e2e/fixtures/scenarios/env-in-config-toml-does-not-crash-on-numeric-fields/interactions.json b/apps/cli-e2e/fixtures/scenarios/env-in-config-toml-does-not-crash-on-numeric-fields/interactions.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/apps/cli-e2e/fixtures/scenarios/env-in-config-toml-does-not-crash-on-numeric-fields/interactions.json @@ -0,0 +1 @@ +[] diff --git a/apps/cli-e2e/src/tests/config-toml.e2e.test.ts b/apps/cli-e2e/src/tests/config-toml.e2e.test.ts new file mode 100644 index 000000000..d96a2e254 --- /dev/null +++ b/apps/cli-e2e/src/tests/config-toml.e2e.test.ts @@ -0,0 +1,63 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect } from "vitest"; +import { PROJECT_REF } from "./env.ts"; +import { testBehaviour } from "./test-context.ts"; + +// CLI-1489: v2.99.0 introduced a TypeScript config loader in the Bun shell +// that strictly decoded supabase/config.toml through an Effect schema. Any +// non-string field written as env(VAR) — e.g. a port — was rejected before +// env-resolution could run, crashing the CLI at boot with +// ProjectConfigParseError. This test runs against every CLI_HARNESS_TARGET +// (go, ts-legacy, ts-next) so the regression cannot return on any shell. +// +// A 401 is injected so the test does not need a real API fixture: pre-fix the +// TS shells crashed before any API call, post-fix they reach the (faked) API +// and get the injected error. Either way we only assert that the CLI got +// past config decode. + +function writeConfigWithEnvPorts(dir: string): void { + mkdirSync(join(dir, "supabase"), { recursive: true }); + writeFileSync( + join(dir, "supabase", "config.toml"), + [ + 'project_id = "with-env-ports"', + "", + "[api]", + 'port = "env(SUPABASE_API_PORT)"', + "", + "[db]", + 'port = "env(SUPABASE_DB_PORT)"', + "", + "[analytics]", + 'port = "env(SUPABASE_ANALYTICS_PORT)"', + "", + ].join("\n"), + ); +} + +const ENV_PORTS = { + SUPABASE_API_PORT: "54321", + SUPABASE_DB_PORT: "54322", + SUPABASE_ANALYTICS_PORT: "54327", +}; + +describe("env-in-config-toml", () => { + testBehaviour("does not crash on numeric fields", async ({ run, workspace, apiUrl }) => { + writeConfigWithEnvPorts(workspace.path); + + await fetch(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), + }); + + const result = await run(["secrets", "list", "--project-ref", PROJECT_REF], { + env: ENV_PORTS, + }); + + const output = `${result.stdout}\n${result.stderr}`; + expect(output).not.toContain("ProjectConfigParseError"); + expect(output).not.toMatch(/Expected number.*env\(SUPABASE_/); + }); +}); diff --git a/apps/cli-go/.gitignore b/apps/cli-go/.gitignore index e119d9725..085699240 100644 --- a/apps/cli-go/.gitignore +++ b/apps/cli-go/.gitignore @@ -9,6 +9,7 @@ *.so *.dylib /cli +/supabase-go # Test binary, built with `go test -c` *.test diff --git a/apps/cli/src/next/config/project-context.layer.ts b/apps/cli/src/next/config/project-context.layer.ts index 775156c26..402c77fe6 100644 --- a/apps/cli/src/next/config/project-context.layer.ts +++ b/apps/cli/src/next/config/project-context.layer.ts @@ -1,4 +1,4 @@ -import { loadProjectConfig, loadProjectEnvironment } from "@supabase/config"; +import { loadProjectEnvironment } from "@supabase/config"; import { Effect, Layer, Option } from "effect"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { ProjectContext } from "./project-context.service.ts"; @@ -6,7 +6,6 @@ import { ProjectContext } from "./project-context.service.ts"; const emptyProjectContext = ProjectContext.of({ paths: Option.none(), projectEnv: Option.none(), - rawProjectConfig: Option.none(), }); const makeProjectContext = Effect.gen(function* () { @@ -20,16 +19,9 @@ const makeProjectContext = Effect.gen(function* () { return emptyProjectContext; } - const loadedConfig = yield* loadProjectConfig(runtimeInfo.cwd); - - if (loadedConfig === null) { - return emptyProjectContext; - } - return ProjectContext.of({ paths: Option.some(projectEnv.paths), projectEnv: Option.some(projectEnv), - rawProjectConfig: Option.some(loadedConfig.config), }); }); diff --git a/apps/cli/src/next/config/project-context.layer.unit.test.ts b/apps/cli/src/next/config/project-context.layer.unit.test.ts new file mode 100644 index 000000000..b3eb8a324 --- /dev/null +++ b/apps/cli/src/next/config/project-context.layer.unit.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { mkdtempSync } from "node:fs"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Effect, Layer, Option } from "effect"; +import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; +import { projectContextLayer } from "./project-context.layer.ts"; +import { ProjectContext } from "./project-context.service.ts"; + +function makeTempDir(): string { + return mkdtempSync(join(tmpdir(), "supabase-project-context-")); +} + +function buildLayer(opts: { cwd: string; env?: Record }) { + const runtimeInfoLayer = mockRuntimeInfo({ + cwd: opts.cwd, + homeDir: join(opts.cwd, ".home"), + }); + const envLayer = processEnvLayer(opts.env ?? {}); + return projectContextLayer.pipe( + Layer.provide(BunServices.layer), + Layer.provide(runtimeInfoLayer), + Layer.provide(envLayer), + ); +} + +describe("projectContextLayer", () => { + it.live("loads when supabase/config.toml uses env() on numeric fields (CLI-1489)", () => { + const tempDir = makeTempDir(); + const projectRoot = join(tempDir, "repo"); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); + yield* Effect.tryPromise(() => + writeFile( + join(projectRoot, "supabase", "config.toml"), + [ + 'project_id = "with-env-ports"', + "", + "[api]", + 'port = "env(SUPABASE_API_PORT)"', + "", + "[db]", + 'port = "env(SUPABASE_DB_PORT)"', + "", + "[analytics]", + 'port = "env(SUPABASE_ANALYTICS_PORT)"', + "", + ].join("\n"), + ), + ); + + const projectContext = yield* Effect.gen(function* () { + return yield* ProjectContext; + }).pipe( + Effect.provide( + buildLayer({ + cwd: projectRoot, + env: { + SUPABASE_API_PORT: "54321", + SUPABASE_DB_PORT: "54322", + SUPABASE_ANALYTICS_PORT: "54327", + }, + }), + ), + ); + + expect(Option.isSome(projectContext.paths)).toBe(true); + if (Option.isSome(projectContext.paths)) { + expect(projectContext.paths.value.projectRoot).toBe(projectRoot); + } + expect(Option.isSome(projectContext.projectEnv)).toBe(true); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("returns empty context when no supabase project is found", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + const projectContext = yield* Effect.gen(function* () { + return yield* ProjectContext; + }).pipe(Effect.provide(buildLayer({ cwd: tempDir }))); + + expect(Option.isNone(projectContext.paths)).toBe(true); + expect(Option.isNone(projectContext.projectEnv)).toBe(true); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); +}); diff --git a/apps/cli/src/next/config/project-context.service.ts b/apps/cli/src/next/config/project-context.service.ts index b59336529..4c02dae19 100644 --- a/apps/cli/src/next/config/project-context.service.ts +++ b/apps/cli/src/next/config/project-context.service.ts @@ -1,11 +1,10 @@ -import type { ProjectConfig, ProjectEnvironment, ProjectPaths } from "@supabase/config"; +import type { ProjectEnvironment, ProjectPaths } from "@supabase/config"; import type { Option } from "effect"; import { Context } from "effect"; interface ProjectContextShape { readonly paths: Option.Option; readonly projectEnv: Option.Option; - readonly rawProjectConfig: Option.Option; } export class ProjectContext extends Context.Service()( diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index cc6024b0a..d5ab3253d 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -2,7 +2,7 @@ import process from "node:process"; import { BunServices } from "@effect/platform-bun"; import { Deferred, Effect, Layer, Option, PubSub, Redacted, Stream } from "effect"; import type { ReactElement } from "react"; -import type { ProjectConfig, ProjectEnvironment, ProjectPaths } from "@supabase/config"; +import type { ProjectEnvironment, ProjectPaths } from "@supabase/config"; import { NoRunningStackError, StateNotFoundError, @@ -769,7 +769,6 @@ export function mockProjectContext( opts: { paths?: Option.Option; projectEnv?: Option.Option; - rawProjectConfig?: Option.Option; } = {}, ): Layer.Layer { return Layer.succeed( @@ -777,7 +776,6 @@ export function mockProjectContext( ProjectContext.of({ paths: opts.paths ?? Option.none(), projectEnv: opts.projectEnv ?? Option.none(), - rawProjectConfig: opts.rawProjectConfig ?? Option.none(), }), ); } From 170f20db178f4ddf154dc74b0cbb976c6fc619cf Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 19 May 2026 02:59:25 +0200 Subject: [PATCH 3/7] docs: use relative markdown links (#5285) Fixes https://github.com/supabase/cli/issues/5284 --- apps/cli/README.md | 14 +++++++------- apps/cli/docs/supabase-home.md | 6 +++--- packages/stack/docs/detach-mode.md | 8 ++++---- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/cli/README.md b/apps/cli/README.md index bce8884cc..9b5223a18 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -15,13 +15,13 @@ This workspace currently contains the next/V3 CLI shell and the scaffolding for For current migration/parity status, see: -- [`docs/go-cli-porting-status.md`](/Users/jgoux/Code/supabase/dx-labs/apps/cli/docs/go-cli-porting-status.md) +- [`docs/go-cli-porting-status.md`](./docs/go-cli-porting-status.md) For the generated command/reference docs, see: -- [`docs/go-cli-reference.md`](/Users/jgoux/Code/supabase/dx-labs/apps/cli/docs/go-cli-reference.md) -- [`docs/supabase-home.md`](/Users/jgoux/Code/supabase/dx-labs/apps/cli/docs/supabase-home.md) -- [`../../packages/stack/docs/service-versioning.md`](/Users/jgoux/Code/supabase/dx-labs/packages/stack/docs/service-versioning.md) +- [`docs/go-cli-reference.md`](./docs/go-cli-reference.md) +- [`docs/supabase-home.md`](./docs/supabase-home.md) +- [`../../packages/stack/docs/service-versioning.md`](../../packages/stack/docs/service-versioning.md) The README is intentionally brief. Command details should live in the generated docs and the parity tracker above. @@ -127,9 +127,9 @@ can surface `Downloading` before normal runtime states. Useful companion docs: -- [`../../packages/stack/docs/architecture.md`](/Users/jgoux/Code/supabase/dx-labs/packages/stack/docs/architecture.md) -- [`../../packages/stack/docs/detach-mode.md`](/Users/jgoux/Code/supabase/dx-labs/packages/stack/docs/detach-mode.md) -- [`docs/ui.md`](/Users/jgoux/Code/supabase/dx-labs/apps/cli/docs/ui.md) +- [`../../packages/stack/docs/architecture.md`](../../packages/stack/docs/architecture.md) +- [`../../packages/stack/docs/detach-mode.md`](../../packages/stack/docs/detach-mode.md) +- [`docs/ui.md`](./docs/ui.md) ## Development diff --git a/apps/cli/docs/supabase-home.md b/apps/cli/docs/supabase-home.md index 7c9b9ecba..e96210caa 100644 --- a/apps/cli/docs/supabase-home.md +++ b/apps/cli/docs/supabase-home.md @@ -297,6 +297,6 @@ When deciding where something belongs, use this rule of thumb: ## Related Docs -- [CLI Code Structure](/Users/jgoux/Code/supabase/dx-labs/apps/cli/docs/code-structure.md) -- [Service Versioning](/Users/jgoux/Code/supabase/dx-labs/packages/stack/docs/service-versioning.md) -- [Project Config Loading](/Users/jgoux/Code/supabase/dx-labs/packages/config/docs/project-config-loading.md) +- [CLI Code Structure](./code-structure.md) +- [Service Versioning](../../../packages/stack/docs/service-versioning.md) +- [Project Config Loading](../../../packages/config/docs/project-config-loading.md) diff --git a/packages/stack/docs/detach-mode.md b/packages/stack/docs/detach-mode.md index 6b921d492..5da902a2e 100644 --- a/packages/stack/docs/detach-mode.md +++ b/packages/stack/docs/detach-mode.md @@ -73,7 +73,7 @@ The live daemon socket is runtime state and lives under the OS temp directory, n Project-scoped service version state such as `.supabase/project.json` and `.supabase/local-versions.json` is documented separately in -[`service-versioning.md`](/Users/jgoux/Code/supabase/dx-labs/packages/stack/docs/service-versioning.md). +[`service-versioning.md`](./service-versioning.md). ### State File Formats @@ -110,7 +110,7 @@ Project-scoped service version state such as `.supabase/project.json` and { "pid": 12345, "name": "default", - "projectDir": "/Users/jgoux/Code/myapp", + "projectDir": "", "apiPort": 54321, "dbPort": 54322, "socketPath": "/tmp/supabase/s-123456789abc/daemon.sock", @@ -347,8 +347,8 @@ within that project. This works from any nested directory inside the project. **Examples:** -- cwd = `/Users/jgoux/Code/myapp/src/components/` -- discovered project root = `/Users/jgoux/Code/myapp` +- cwd = `/src/components/` +- discovered project root = `` - resolved stack = `.supabase/stacks/default` **Edge cases:** From eb0f0ec7ed9aa916c2c6ae3b7c03c2c016c6d0ac Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Tue, 19 May 2026 07:31:22 +0530 Subject: [PATCH 4/7] fix: hide init flags (#5287) ## TL;DR hides legacy init compatibility flags from help output to match the existing go behavior ## ref: - closes https://github.com/supabase/cli/issues/5286 --------- Co-authored-by: Andrew Valleteau --- .../src/legacy/commands/init/init.command.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/legacy/commands/init/init.command.ts b/apps/cli/src/legacy/commands/init/init.command.ts index 1d76eb43c..347462fff 100644 --- a/apps/cli/src/legacy/commands/init/init.command.ts +++ b/apps/cli/src/legacy/commands/init/init.command.ts @@ -1,5 +1,6 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withHidden } from "../../../shared/cli/hidden-flag.ts"; import { legacyInit } from "./init.handler.ts"; const config = { @@ -13,14 +14,18 @@ const config = { force: Flag.boolean("force").pipe( Flag.withDescription("Overwrite existing supabase/config.toml."), ), - withVscodeWorkspace: Flag.boolean("with-vscode-workspace").pipe( - Flag.withDescription("Generate VS Code workspace."), + withVscodeWorkspace: withHidden( + Flag.boolean("with-vscode-workspace").pipe(Flag.withDescription("Generate VS Code workspace.")), ), - withVscodeSettings: Flag.boolean("with-vscode-settings").pipe( - Flag.withDescription("Generate VS Code settings for Deno."), + withVscodeSettings: withHidden( + Flag.boolean("with-vscode-settings").pipe( + Flag.withDescription("Generate VS Code settings for Deno."), + ), ), - withIntellijSettings: Flag.boolean("with-intellij-settings").pipe( - Flag.withDescription("Generate IntelliJ IDEA settings for Deno."), + withIntellijSettings: withHidden( + Flag.boolean("with-intellij-settings").pipe( + Flag.withDescription("Generate IntelliJ IDEA settings for Deno."), + ), ), } as const; From 068496ac7cb431ceacbcb59e1d2dac57e3064834 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Tue, 19 May 2026 09:31:52 +0200 Subject: [PATCH 5/7] fix(cli): scope hidden flags per-command instead of globally (#5289) Refactor the hidden flag system to scope flag visibility per-command rather than maintaining a global registry. This prevents flag name collisions when the same flag name appears in different commands with different visibility requirements. ## Changes - **Replaced global hidden flag registry with per-command annotations**: Changed from a module-level `Set` to a `Context.Reference` (`LegacyHiddenFlags`) that attaches hidden flag metadata directly to each `Command` via `Command.annotate`. - **Introduced `withHiddenFromConfig` helper**: New pipe step that walks a command's flag config, collects all flags wrapped with `withHidden`, and attaches the resulting set to the command. This materializes the per-command list at command construction time, scoping visibility to that specific command. - **Updated `withHidden` to use WeakMap tracking**: Changed from adding to a global set to recording flag references in a module-local `WeakMap`, allowing `withHiddenFromConfig` to look up which flags were marked hidden without polluting global state. - **Updated `stripHiddenFlagsFromHelpDoc` to read from annotations**: Now reads the hidden flag set from the help doc's `LegacyHiddenFlags` annotation instead of checking a global registry, enabling per-command filtering. - **Applied `withHiddenFromConfig` to all legacy commands**: Updated `stop`, `start`, `init`, `functions deploy`, `functions download`, and `functions serve` commands to wire their configs through `withHiddenFromConfig`. - **Added `--backup` hidden flag to stop command**: Restored the historical `--backup` flag (hidden by default) for Go CLI parity, with handler logic to forward `--backup=false` when explicitly disabled. - **Added integration test for stop command**: New test file validates that the `--backup` flag is correctly forwarded to the Go proxy. ## Implementation Details The refactor maintains backward compatibility with the existing `withHidden` API while fixing the architectural issue where flags with the same name in different commands would interfere with each other. The `Context.Reference` pattern ensures each command carries its own isolated set of hidden flag names, read during help doc rendering. Related: CLI-1483 https://claude.ai/code/session_01EB93upyjdFDB3czXheQknG Co-authored-by: Claude --- .../functions/deploy/deploy.command.ts | 3 +- .../functions/download/download.command.ts | 3 +- .../commands/functions/serve/serve.command.ts | 3 +- .../src/legacy/commands/init/init.command.ts | 3 +- .../projects/create/create.command.ts | 3 +- .../legacy/commands/start/start.command.ts | 3 +- .../src/legacy/commands/stop/stop.command.ts | 10 ++ .../src/legacy/commands/stop/stop.handler.ts | 3 + .../commands/stop/stop.integration.test.ts | 54 ++++++++ apps/cli/src/shared/cli/hidden-flag.ts | 55 ++++++-- .../src/shared/cli/hidden-flag.unit.test.ts | 117 +++++++++++++++--- 11 files changed, 227 insertions(+), 30 deletions(-) create mode 100644 apps/cli/src/legacy/commands/stop/stop.integration.test.ts diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts index 502a45ab1..68edbdc05 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts @@ -1,5 +1,5 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; -import { withHidden } from "../../../../shared/cli/hidden-flag.ts"; +import { withHidden, withHiddenFromConfig } from "../../../../shared/cli/hidden-flag.ts"; import { legacyFunctionsDeploy } from "./deploy.handler.ts"; const config = { @@ -42,6 +42,7 @@ const config = { export const legacyFunctionsDeployCommand = Command.make("deploy", config).pipe( Command.withDescription("Deploy a Function to the linked Supabase project."), Command.withShortDescription("Deploy a Function to Supabase"), + withHiddenFromConfig(config), Command.withHandler((flags) => legacyFunctionsDeploy({ functionNames: flags.functionNames.map(String), diff --git a/apps/cli/src/legacy/commands/functions/download/download.command.ts b/apps/cli/src/legacy/commands/functions/download/download.command.ts index c58288647..276ac8216 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.command.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.command.ts @@ -1,6 +1,6 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; -import { withHidden } from "../../../../shared/cli/hidden-flag.ts"; +import { withHidden, withHiddenFromConfig } from "../../../../shared/cli/hidden-flag.ts"; import { legacyFunctionsDownload } from "./download.handler.ts"; const config = { @@ -32,5 +32,6 @@ export const legacyFunctionsDownloadCommand = Command.make("download", config).p "Download the source code for a Function from the linked Supabase project. If no function name is provided, downloads all functions.", ), Command.withShortDescription("Download a Function from Supabase"), + withHiddenFromConfig(config), Command.withHandler((flags) => legacyFunctionsDownload(flags)), ); diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.command.ts b/apps/cli/src/legacy/commands/functions/serve/serve.command.ts index d19ed641d..3fe425b32 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.command.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.command.ts @@ -1,6 +1,6 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; -import { withHidden } from "../../../../shared/cli/hidden-flag.ts"; +import { withHidden, withHiddenFromConfig } from "../../../../shared/cli/hidden-flag.ts"; import { legacyFunctionsServe } from "./serve.handler.ts"; const INSPECT_MODES = ["run", "brk", "wait"] as const; @@ -35,5 +35,6 @@ export type LegacyFunctionsServeFlags = CliCommand.Command.Config.Infer legacyFunctionsServe(flags)), ); diff --git a/apps/cli/src/legacy/commands/init/init.command.ts b/apps/cli/src/legacy/commands/init/init.command.ts index 347462fff..84e84a7e6 100644 --- a/apps/cli/src/legacy/commands/init/init.command.ts +++ b/apps/cli/src/legacy/commands/init/init.command.ts @@ -1,6 +1,6 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; -import { withHidden } from "../../../shared/cli/hidden-flag.ts"; +import { withHidden, withHiddenFromConfig } from "../../../shared/cli/hidden-flag.ts"; import { legacyInit } from "./init.handler.ts"; const config = { @@ -34,5 +34,6 @@ export type LegacyInitFlags = CliCommand.Command.Config.Infer; export const legacyInitCommand = Command.make("init", config).pipe( Command.withDescription("Initialize a local project."), Command.withShortDescription("Initialize a local project"), + withHiddenFromConfig(config), Command.withHandler((flags) => legacyInit(flags)), ); diff --git a/apps/cli/src/legacy/commands/projects/create/create.command.ts b/apps/cli/src/legacy/commands/projects/create/create.command.ts index b880639bb..3f75a95df 100644 --- a/apps/cli/src/legacy/commands/projects/create/create.command.ts +++ b/apps/cli/src/legacy/commands/projects/create/create.command.ts @@ -1,6 +1,6 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; -import { withHidden } from "../../../../shared/cli/hidden-flag.ts"; +import { withHidden, withHiddenFromConfig } from "../../../../shared/cli/hidden-flag.ts"; import { legacyProjectsCreate } from "./create.handler.ts"; const AWS_REGIONS = [ @@ -93,5 +93,6 @@ export const legacyProjectsCreateCommand = Command.make("create", config).pipe( description: "Create a new project", }, ]), + withHiddenFromConfig(config), Command.withHandler((flags) => legacyProjectsCreate(flags)), ); diff --git a/apps/cli/src/legacy/commands/start/start.command.ts b/apps/cli/src/legacy/commands/start/start.command.ts index 7de8cae1b..a381fd254 100644 --- a/apps/cli/src/legacy/commands/start/start.command.ts +++ b/apps/cli/src/legacy/commands/start/start.command.ts @@ -1,6 +1,6 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; -import { withHidden } from "../../../shared/cli/hidden-flag.ts"; +import { withHidden, withHiddenFromConfig } from "../../../shared/cli/hidden-flag.ts"; import { legacyStart } from "./start.handler.ts"; const config = { @@ -25,5 +25,6 @@ export type LegacyStartFlags = CliCommand.Command.Config.Infer; export const legacyStartCommand = Command.make("start", config).pipe( Command.withDescription("Start containers for Supabase local development."), Command.withShortDescription("Start local Supabase stack"), + withHiddenFromConfig(config), Command.withHandler((flags) => legacyStart(flags)), ); diff --git a/apps/cli/src/legacy/commands/stop/stop.command.ts b/apps/cli/src/legacy/commands/stop/stop.command.ts index 37b7f4ef8..128ddbf4e 100644 --- a/apps/cli/src/legacy/commands/stop/stop.command.ts +++ b/apps/cli/src/legacy/commands/stop/stop.command.ts @@ -1,5 +1,6 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withHidden, withHiddenFromConfig } from "../../../shared/cli/hidden-flag.ts"; import { legacyStop } from "./stop.handler.ts"; const config = { @@ -7,6 +8,14 @@ const config = { Flag.withDescription("Local project ID to stop."), Flag.optional, ), + // Hidden boolean kept for Go CLI parity: `--backup=false` is the historical + // way to skip the backup and is functionally identical to `--no-backup`. + backup: withHidden( + Flag.boolean("backup").pipe( + Flag.withDescription("Backs up the current database before stopping."), + Flag.withDefault(true), + ), + ), noBackup: Flag.boolean("no-backup").pipe( Flag.withDescription("Deletes all data volumes after stopping."), ), @@ -20,5 +29,6 @@ export type LegacyStopFlags = CliCommand.Command.Config.Infer; export const legacyStopCommand = Command.make("stop", config).pipe( Command.withDescription("Stop all local Supabase containers."), Command.withShortDescription("Stop all local Supabase containers"), + withHiddenFromConfig(config), Command.withHandler((flags) => legacyStop(flags)), ); diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index 2934cd5b3..ab7f5f9ad 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -6,6 +6,9 @@ export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopF const proxy = yield* LegacyGoProxy; const args: string[] = ["stop"]; if (Option.isSome(flags.projectId)) args.push("--project-id", flags.projectId.value); + // `--backup` defaults to true; only forward when explicitly disabled, which + // matches the Go CLI semantics (`!noBackup` && `--backup=false`). + if (!flags.backup) args.push("--backup=false"); if (flags.noBackup) args.push("--no-backup"); if (flags.all) args.push("--all"); yield* proxy.exec(args); diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts new file mode 100644 index 000000000..83bf74a5f --- /dev/null +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; +import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { legacyStop } from "./stop.handler.ts"; +import type { LegacyStopFlags } from "./stop.command.ts"; + +function setupLegacyStop() { + const calls: Array> = []; + const layer = Layer.succeed(LegacyGoProxy, { + exec: (args) => + Effect.sync(() => { + calls.push(args); + }), + }); + return { layer, calls }; +} + +const baseFlags: LegacyStopFlags = { + projectId: Option.none(), + backup: true, + noBackup: false, + all: false, +}; + +describe("legacy stop", () => { + it.live("forwards no extra flags when defaults are used", () => { + const { layer, calls } = setupLegacyStop(); + return Effect.gen(function* () { + yield* legacyStop(baseFlags); + expect(calls).toEqual([["stop"]]); + }).pipe(Effect.provide(layer)); + }); + + it.live("forwards --backup=false when the hidden --backup flag is disabled", () => { + const { layer, calls } = setupLegacyStop(); + return Effect.gen(function* () { + yield* legacyStop({ ...baseFlags, backup: false }); + expect(calls).toEqual([["stop", "--backup=false"]]); + }).pipe(Effect.provide(layer)); + }); + + it.live("forwards --no-backup, --project-id and --all", () => { + const { layer, calls } = setupLegacyStop(); + return Effect.gen(function* () { + yield* legacyStop({ + projectId: Option.some("abc"), + backup: true, + noBackup: true, + all: true, + }); + expect(calls).toEqual([["stop", "--project-id", "abc", "--no-backup", "--all"]]); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/shared/cli/hidden-flag.ts b/apps/cli/src/shared/cli/hidden-flag.ts index 8cc4c0a0c..f83c877c6 100644 --- a/apps/cli/src/shared/cli/hidden-flag.ts +++ b/apps/cli/src/shared/cli/hidden-flag.ts @@ -1,7 +1,19 @@ -import type { Flag, HelpDoc } from "effect/unstable/cli"; -import type * as Param from "effect/unstable/cli/Param"; +import { Context } from "effect"; +import { Command, type Flag, type HelpDoc } from "effect/unstable/cli"; +import * as Param from "effect/unstable/cli/Param"; -const hiddenFlagNames = new Set(); +/** + * Per-command set of hidden flag names. Attached to a `Command` via + * `Command.annotate` so each command carries its own list, then read off + * `HelpDoc.annotations` by `stripHiddenFlagsFromHelpDoc`. + */ +export const LegacyHiddenFlags: Context.Reference> = Context.Reference< + ReadonlySet +>("supabase/legacy/LegacyHiddenFlags", { + defaultValue: () => new Set(), +}); + +const hiddenFlagNames = new WeakMap>(); const collectSingleNames = (param: Param.Param): Array => { const node = param as @@ -25,17 +37,44 @@ const collectSingleNames = (param: Param.Param): Array * Marks a flag as hidden so that it is parsed normally but omitted from * `--help` output. This mirrors Cobra's `MarkHidden` from the Go CLI, which * the upstream Effect CLI does not yet expose natively. + * + * The flag reference is recorded in a module-local `WeakMap`; the + * per-command list is materialised by `withHiddenFromConfig` so flags with + * the same name in unrelated commands do not collide. */ export const withHidden = (flag: Flag.Flag): Flag.Flag => { - for (const name of collectSingleNames(flag)) { - hiddenFlagNames.add(name); - } + hiddenFlagNames.set(flag, collectSingleNames(flag)); return flag; }; +/** + * Pipe step for a `Command` that walks the command's flag config, finds + * every flag previously wrapped with `withHidden`, and attaches the + * resulting set of hidden flag names to the command via `Command.annotate`. + * Apply directly after `Command.make(name, config)` so the same `config` + * object is in scope. + */ +export const withHiddenFromConfig = + (config: Record) => + ( + cmd: Command.Command, + ): Command.Command => { + const hidden = new Set(); + for (const value of Object.values(config)) { + if (value === null || typeof value !== "object") continue; + const names = hiddenFlagNames.get(value); + if (names === undefined) continue; + for (const name of names) hidden.add(name); + } + if (hidden.size === 0) return cmd; + return Command.annotate(cmd, LegacyHiddenFlags, hidden); + }; + export const stripHiddenFlagsFromHelpDoc = (doc: HelpDoc.HelpDoc): HelpDoc.HelpDoc => { - const filteredFlags = doc.flags.filter((flag) => !hiddenFlagNames.has(flag.name)); - const filteredGlobalFlags = doc.globalFlags?.filter((flag) => !hiddenFlagNames.has(flag.name)); + const hidden = Context.get(doc.annotations, LegacyHiddenFlags); + if (hidden.size === 0) return doc; + const filteredFlags = doc.flags.filter((flag) => !hidden.has(flag.name)); + const filteredGlobalFlags = doc.globalFlags?.filter((flag) => !hidden.has(flag.name)); return { ...doc, flags: filteredFlags, diff --git a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts index 43215e5e1..474293a2c 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -1,7 +1,12 @@ import { Context, Option } from "effect"; -import { Flag, type HelpDoc } from "effect/unstable/cli"; +import { Command, Flag, type HelpDoc } from "effect/unstable/cli"; import { describe, expect, it } from "vitest"; -import { stripHiddenFlagsFromHelpDoc, withHidden } from "./hidden-flag.ts"; +import { + LegacyHiddenFlags, + stripHiddenFlagsFromHelpDoc, + withHidden, + withHiddenFromConfig, +} from "./hidden-flag.ts"; const flagDoc = (name: string): HelpDoc.FlagDoc => ({ name, @@ -19,34 +24,114 @@ const helpDoc = (overrides: Partial): HelpDoc.HelpDoc => ({ ...overrides, }); +const helpDocWithHidden = ( + hidden: ReadonlyArray, + overrides: Partial, +): HelpDoc.HelpDoc => + helpDoc({ + ...overrides, + annotations: Context.make(LegacyHiddenFlags, new Set(hidden)), + }); + +// Reach into the internal command shape to obtain the help doc the formatter +// would render. Effect builds this from `Command.annotations`, which is the +// contract `withHiddenFromConfig` relies on. +interface CommandImpl { + readonly buildHelpDoc: (path: ReadonlyArray) => HelpDoc.HelpDoc; +} +const buildHelpDoc = ( + cmd: Command.Command, +): HelpDoc.HelpDoc => (cmd as unknown as CommandImpl).buildHelpDoc([]); + describe("withHidden", () => { it("returns the same flag instance", () => { const flag = Flag.boolean("legacy-bundle"); expect(withHidden(flag)).toBe(flag); }); - it("registers the underlying single name even when wrapped with combinators", () => { - const flag = Flag.string("plan").pipe(Flag.optional); - withHidden(flag); + it("does not register flag names globally — only commands wired via withHiddenFromConfig hide them", () => { + withHidden(Flag.boolean("stray")); - const stripped = stripHiddenFlagsFromHelpDoc( - helpDoc({ flags: [flagDoc("plan"), flagDoc("visible")] }), - ); - expect(stripped.flags.map((f) => f.name)).toEqual(["visible"]); + const stripped = stripHiddenFlagsFromHelpDoc(helpDoc({ flags: [flagDoc("stray")] })); + expect(stripped.flags.map((f) => f.name)).toEqual(["stray"]); }); +}); + +describe("withHiddenFromConfig", () => { + it("strips wrapped flags from the command's help doc", () => { + const config = { + plan: withHidden(Flag.string("plan").pipe(Flag.optional)), + visible: Flag.boolean("visible"), + } as const; - it("filters hidden flags from globalFlags as well", () => { - withHidden(Flag.boolean("preview")); + const cmd = Command.make("demo", config).pipe(withHiddenFromConfig(config)); + const doc = stripHiddenFlagsFromHelpDoc(buildHelpDoc(cmd)); - const stripped = stripHiddenFlagsFromHelpDoc( - helpDoc({ globalFlags: [flagDoc("preview"), flagDoc("verbose")] }), + expect(doc.flags.map((f) => f.name)).toEqual(["visible"]); + }); + + it("scopes hidden-ness to the wrapping command — same flag name in another command stays visible", () => { + const hiddenConfig = { + interactive: withHidden(Flag.boolean("interactive")), + } as const; + const visibleConfig = { + interactive: Flag.boolean("interactive"), + } as const; + + const hiddenCmd = Command.make("create", hiddenConfig).pipe(withHiddenFromConfig(hiddenConfig)); + const visibleCmd = Command.make("init", visibleConfig).pipe( + withHiddenFromConfig(visibleConfig), ); + + expect(stripHiddenFlagsFromHelpDoc(buildHelpDoc(hiddenCmd)).flags.map((f) => f.name)).toEqual( + [], + ); + expect(stripHiddenFlagsFromHelpDoc(buildHelpDoc(visibleCmd)).flags.map((f) => f.name)).toEqual([ + "interactive", + ]); + }); + + it("is a no-op when the config contains no hidden flags", () => { + const config = { visible: Flag.boolean("visible") } as const; + const cmd = Command.make("demo", config); + const piped = cmd.pipe(withHiddenFromConfig(config)); + + expect(piped).toBe(cmd); + }); + + it("collects names through Flag combinators like optional", () => { + const config = { + plan: withHidden(Flag.string("plan").pipe(Flag.optional)), + } as const; + const cmd = Command.make("demo", config).pipe(withHiddenFromConfig(config)); + const annotated = Context.get(buildHelpDoc(cmd).annotations, LegacyHiddenFlags); + + expect([...annotated]).toEqual(["plan"]); + }); +}); + +describe("stripHiddenFlagsFromHelpDoc", () => { + it("returns the doc unchanged when annotations are empty", () => { + const doc = helpDoc({ flags: [flagDoc("foo")] }); + expect(stripHiddenFlagsFromHelpDoc(doc)).toBe(doc); + }); + + it("filters both flags and globalFlags by the doc's annotation", () => { + const doc = helpDocWithHidden(["preview", "plan"], { + flags: [flagDoc("plan"), flagDoc("visible")], + globalFlags: [flagDoc("preview"), flagDoc("verbose")], + }); + + const stripped = stripHiddenFlagsFromHelpDoc(doc); + expect(stripped.flags.map((f) => f.name)).toEqual(["visible"]); expect(stripped.globalFlags?.map((f) => f.name)).toEqual(["verbose"]); }); - it("leaves docs without globalFlags untouched", () => { - const stripped = stripHiddenFlagsFromHelpDoc(helpDoc({ flags: [flagDoc("foo")] })); + it("leaves docs without globalFlags untouched in that field", () => { + const doc = helpDocWithHidden(["foo"], { flags: [flagDoc("foo"), flagDoc("bar")] }); + const stripped = stripHiddenFlagsFromHelpDoc(doc); + expect(stripped.globalFlags).toBeUndefined(); - expect(stripped.flags.map((f) => f.name)).toEqual(["foo"]); + expect(stripped.flags.map((f) => f.name)).toEqual(["bar"]); }); }); From d2ddf9f2dcfc21a71701c0343e0fb5b13600f2af Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Tue, 19 May 2026 09:46:43 +0200 Subject: [PATCH 6/7] chore: action hardening (#5291) ## What kind of change does this PR introduce? CI hardening ## Additional context Removes interpolation and uses env instead. General hardening and best practice. Not reachable by untrusted code (forks/PRs) Co-authored-by: Andrew Valleteau --- .github/workflows/cli-go-pg-prove.yml | 8 +++-- .github/workflows/cli-go-publish-migra.yml | 8 +++-- .github/workflows/release-shared.yml | 40 +++++++++++++++------- 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/.github/workflows/cli-go-pg-prove.yml b/.github/workflows/cli-go-pg-prove.yml index 792485e70..4d732d3b7 100644 --- a/.github/workflows/cli-go-pg-prove.yml +++ b/.github/workflows/cli-go-pg-prove.yml @@ -74,10 +74,12 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Merge multi-arch manifests + env: + IMAGE_TAG: ${{ needs.settings.outputs.image_tag }} run: | - docker buildx imagetools create -t ${{ needs.settings.outputs.image_tag }} \ - ${{ needs.settings.outputs.image_tag }}_amd64 \ - ${{ needs.settings.outputs.image_tag }}_arm64 + docker buildx imagetools create -t "${IMAGE_TAG}" \ + "${IMAGE_TAG}_amd64" \ + "${IMAGE_TAG}_arm64" publish: needs: diff --git a/.github/workflows/cli-go-publish-migra.yml b/.github/workflows/cli-go-publish-migra.yml index 13caa2894..cacfdbff6 100644 --- a/.github/workflows/cli-go-publish-migra.yml +++ b/.github/workflows/cli-go-publish-migra.yml @@ -74,10 +74,12 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Merge multi-arch manifests + env: + IMAGE_TAG: ${{ needs.settings.outputs.image_tag }} run: | - docker buildx imagetools create -t "${{ needs.settings.outputs.image_tag }}" \ - "${{ needs.settings.outputs.image_tag }}_amd64" \ - "${{ needs.settings.outputs.image_tag }}_arm64" + docker buildx imagetools create -t "${IMAGE_TAG}" \ + "${IMAGE_TAG}_amd64" \ + "${IMAGE_TAG}_arm64" publish: needs: diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 948c09eba..df0990c4d 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -46,6 +46,9 @@ on: jobs: build: runs-on: large-linux-x86 + env: + BUN_SHELL: ${{ inputs.shell }} + VERSION: ${{ inputs.version }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -71,10 +74,10 @@ jobs: sudo apt-get install -y nfpm - name: Sync versions - run: pnpm exec bun apps/cli/scripts/sync-versions.ts --version ${{ inputs.version }} + run: pnpm exec bun apps/cli/scripts/sync-versions.ts --version "${VERSION}" - name: Build selected shell - run: pnpm exec bun apps/cli/scripts/build.ts --version ${{ inputs.version }} --shell ${{ inputs.shell }} + run: pnpm exec bun apps/cli/scripts/build.ts --version "${VERSION}" --shell "${BUN_SHELL}" - name: Verify build artifacts run: | @@ -100,6 +103,9 @@ jobs: matrix: runner: [ubuntu-latest, macos-latest, macos-15-intel, windows-latest] runs-on: ${{ matrix.runner }} + env: + NPM_TAG: ${{ inputs.npm_tag }} + VERSION: ${{ inputs.version }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -128,13 +134,17 @@ jobs: run: chmod +x packages/cli-*/bin/supabase || true - name: Run smoke tests - run: pnpm run test:smoke -- --version ${{ inputs.version }} --tag ${{ inputs.npm_tag }} + run: pnpm run test:smoke -- --version "${VERSION}" --tag "${NPM_TAG}" working-directory: apps/cli publish: needs: smoke-test if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest + env: + CHANNEL: ${{ inputs.channel }} + NPM_TAG: ${{ inputs.npm_tag }} + VERSION: ${{ inputs.version }} permissions: contents: write id-token: write @@ -151,10 +161,10 @@ jobs: name: cli-build-${{ inputs.shell }}-${{ inputs.version }} - name: Sync versions - run: pnpm exec bun apps/cli/scripts/sync-versions.ts --version ${{ inputs.version }} + run: pnpm exec bun apps/cli/scripts/sync-versions.ts --version "${VERSION}" - name: Publish to npm - run: pnpm exec bun apps/cli/scripts/publish.ts --tag ${{ inputs.npm_tag }} + run: pnpm exec bun apps/cli/scripts/publish.ts --tag ""${NPM_TAG}"" # Push the version tag to origin as soon as npm has the bytes, before any # downstream step that can fail. Without this, a failure in the GH-release @@ -166,7 +176,7 @@ jobs: - name: Push version tag run: | set -euo pipefail - tag="v${{ inputs.version }}" + tag="v${VERSION}" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" if git ls-remote --tags origin "refs/tags/${tag}" | grep -q .; then @@ -193,8 +203,8 @@ jobs: if: ${{ inputs.prerelease }} run: | set -euo pipefail - tag="v${{ inputs.version }}" - channel="${{ inputs.channel }}" + tag="v${VERSION}" + channel="${CHANNEL}" note_ref="refs/notes/semantic-release-${tag}" if git ls-remote origin "${note_ref}" | grep -q .; then echo "Channel note ${note_ref} already on origin; skipping push." @@ -211,7 +221,7 @@ jobs: run: | set -euo pipefail for triple in darwin_arm64 darwin_amd64 linux_arm64 linux_amd64 windows_arm64 windows_amd64; do - cp "dist/supabase_${{ inputs.version }}_${triple}.tar.gz" "dist/supabase_${triple}.tar.gz" + cp "dist/supabase_${VERSION}_${triple}.tar.gz" "dist/supabase_${triple}.tar.gz" done - name: Create draft GitHub Release @@ -247,12 +257,15 @@ jobs: - name: Publish GitHub Release (immutable) env: GH_TOKEN: ${{ github.token }} - run: gh release edit v${{ inputs.version }} --draft=false + run: gh release edit "v${VERSION}" --draft=false publish-homebrew: needs: publish if: ${{ !inputs.dry_run && inputs.publish_brew_scoop }} runs-on: ubuntu-latest + env: + BREW_NAME: ${{ inputs.brew_name }} + VERSION: ${{ inputs.version }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -284,7 +297,7 @@ jobs: git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Update Homebrew formula - run: pnpm exec bun apps/cli/scripts/update-homebrew.ts --version ${{ inputs.version }} --name ${{ inputs.brew_name }} + run: pnpm exec bun apps/cli/scripts/update-homebrew.ts --version "${VERSION}" --name "${BREW_NAME}" env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} @@ -292,6 +305,9 @@ jobs: needs: publish if: ${{ !inputs.dry_run && inputs.publish_brew_scoop }} runs-on: ubuntu-latest + env: + SCOOP_NAME: ${{ inputs.scoop_name }} + VERSION: ${{ inputs.version }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -323,6 +339,6 @@ jobs: git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Update Scoop manifest - run: pnpm exec bun apps/cli/scripts/update-scoop.ts --version ${{ inputs.version }} --name ${{ inputs.scoop_name }} + run: pnpm exec bun apps/cli/scripts/update-scoop.ts --version "${VERSION}" --name "${SCOOP_NAME}" env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} From 403972439439c8fdf9be3a47a786cf96f778eed0 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 10:11:24 +0200 Subject: [PATCH 7/7] chore: sync API types from infrastructure (#5292) This PR was automatically created to sync API types from the infrastructure repository. Changes were detected in the generated API code after syncing with the latest spec from infrastructure. Co-authored-by: supabase-cli-releaser[bot] <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Co-authored-by: Andrew Valleteau --- apps/cli-go/pkg/api/types.gen.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/cli-go/pkg/api/types.gen.go b/apps/cli-go/pkg/api/types.gen.go index 23fe108c3..c597c94f0 100644 --- a/apps/cli-go/pkg/api/types.gen.go +++ b/apps/cli-go/pkg/api/types.gen.go @@ -1395,6 +1395,7 @@ const ( // Defines values for V1ListEntitlementsResponseEntitlementsFeatureKey. const ( V1ListEntitlementsResponseEntitlementsFeatureKeyAssistantAdvanceModel V1ListEntitlementsResponseEntitlementsFeatureKey = "assistant.advance_model" + V1ListEntitlementsResponseEntitlementsFeatureKeyAuditLogDrains V1ListEntitlementsResponseEntitlementsFeatureKey = "audit_log_drains" V1ListEntitlementsResponseEntitlementsFeatureKeyAuthAdvancedAuthSettings V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.advanced_auth_settings" V1ListEntitlementsResponseEntitlementsFeatureKeyAuthCustomJwtTemplate V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.custom_jwt_template" V1ListEntitlementsResponseEntitlementsFeatureKeyAuthCustomOauthMaxProviders V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.custom_oauth.max_providers" @@ -2203,15 +2204,18 @@ type BranchResponse struct { PreviewProjectStatus *BranchResponsePreviewProjectStatus `json:"preview_project_status,omitempty"` ProjectRef string `json:"project_ref"` ReviewRequestedAt *time.Time `json:"review_requested_at,omitempty"` - Status BranchResponseStatus `json:"status"` - UpdatedAt time.Time `json:"updated_at"` - WithData bool `json:"with_data"` + + // Status This field is deprecated. List action runs to get branch status instead. + // Deprecated: + Status BranchResponseStatus `json:"status"` + UpdatedAt time.Time `json:"updated_at"` + WithData bool `json:"with_data"` } // BranchResponsePreviewProjectStatus defines model for BranchResponse.PreviewProjectStatus. type BranchResponsePreviewProjectStatus string -// BranchResponseStatus defines model for BranchResponse.Status. +// BranchResponseStatus This field is deprecated. List action runs to get branch status instead. type BranchResponseStatus string // BranchRestoreResponse defines model for BranchRestoreResponse.