From 187f2cc2c5f95a0a1cba56e91fcb9d452dddbb23 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 7 Jul 2026 11:13:40 +0100 Subject: [PATCH 01/49] fix(cli): read piped stdin for gen signing-key's overwrite prompt (Go parity) (#5794) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Current Behavior Go's overwrite-confirmation prompt for `gen signing-key` reads piped stdin even in non-TTY mode (`internal/utils/console.go`'s `PromptYesNo`/`ReadLine`, racing a 100ms timeout) and honors an explicit y/n answer. The TS port's `signing-key.handler.ts` had its own local `confirmOverwrite` that returned `true` unconditionally in non-TTY mode without reading stdin at all, so `echo n | supabase gen signing-key` silently overwrote the existing key file instead of canceling — a data-loss risk for scripted/CI usage. ## Expected Behavior - Deletes the local `confirmOverwrite` and switches the call site to the shared `legacyPromptYesNo` helper (already used by `seed buckets`, `config push`, `logout`, `storage rm`, `db pull`), which already correctly implements Go's non-TTY read-with-timeout-and-parse behavior. - Swaps `LegacyYesFlag` for `legacyResolveYes` (matching those same five callers), so `gen signing-key` now also honors `SUPABASE_YES` and an explicit `--yes=false`, matching Go's `viper.GetBool("YES")`. - Fails the overwrite closed (rather than silently defaulting to yes) when a real interactive TTY requests a non-text `--output-format` — this command has no structured json/stream-json payload (SIDE_EFFECTS.md), and the shared helper's own default-on-non-text short-circuit would otherwise silently overwrite irrecoverable key material with no prompt at all. A non-TTY caller (piped or not) is unaffected by this guard. Fixes CLI-1865 --- .../commands/gen/signing-key/SIDE_EFFECTS.md | 11 +- .../gen/signing-key/signing-key.command.ts | 5 + .../gen/signing-key/signing-key.e2e.test.ts | 63 +++++ .../gen/signing-key/signing-key.handler.ts | 50 ++-- .../signing-key.integration.test.ts | 229 +++++++++++++++++- 5 files changed, 323 insertions(+), 35 deletions(-) create mode 100644 apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts diff --git a/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md index 8cf887643d..2ea04a7fab 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md @@ -22,9 +22,9 @@ ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| - | - | - | +| Variable | Purpose | Required? | +| -------------- | ---------------------------------------------------------------------------------- | --------- | +| `SUPABASE_YES` | Auto-confirms the overwrite prompt, same as `--yes` (Go's `viper.GetBool("YES")`). | No | ## Exit Codes @@ -47,16 +47,17 @@ ### `--output-format json` -Not applicable; the command uses raw stdout and stderr text like the Go CLI. +Not applicable to output rendering; the command uses raw stdout and stderr text like the Go CLI. It does, however, affect the overwrite-confirmation prompt: since this command has no structured json/stream-json payload, requesting a non-text format from a real interactive terminal (no `--yes`, no piped stdin) fails the overwrite closed (`context canceled`) rather than silently defaulting to yes on a destructive, irreversible action. A non-TTY caller (piped or not) is unaffected — piped `y`/`n` answers are honored regardless of `--output-format`. ### `--output-format stream-json` -Not applicable; the command uses raw stdout and stderr text like the Go CLI. +Same as `--output-format json` above. ## Notes - `--algorithm` accepts `ES256` (default, recommended) or `RS256`. - `--append` appends the new key to an existing keys file instead of overwriting. +- The overwrite prompt honors `SUPABASE_YES` and an explicit `--yes=false` override, matching Go's `viper.GetBool("YES")` precedence (flag wins over env; an omitted flag falls back to the env var). On non-TTY stdin, a piped `y`/`n` line is read within a 100ms timeout and honored before falling back to the default (`y`), matching Go's `Console.ReadLine`/`PromptYesNo` — a piped answer other than an exact `y`/`yes`/`n`/`no` (case-insensitive) also falls back to the default. - `auth.signing_keys_path` is resolved relative to the active `supabase/config.toml` or `supabase/config.json`. - Generated keys are JWKs, not PEM files. - No network or Management API calls are involved. diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts index 35a026f682..3cbedfbc01 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts @@ -3,6 +3,7 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; @@ -29,6 +30,10 @@ const legacyGenSigningKeyRuntimeLayer = Layer.mergeAll( cliConfig, legacyTelemetryStateLayer, commandRuntimeLayer(["gen", "signing-key"]), + // The overwrite-confirmation prompt reads piped stdin via `legacyPromptYesNo` + // (`stdin.readLine`), same as `config push`, `seed buckets`, `storage rm`, `db pull`, + // and `logout` — all of which merge `stdinLayer` alongside their runtime layer. + stdinLayer, ); export const legacyGenSigningKeyCommand = Command.make("signing-key", config).pipe( diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts new file mode 100644 index 0000000000..3a2b377ee4 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts @@ -0,0 +1,63 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { runSupabase } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +/** + * Golden-path e2e for CLI-1865: exercises the real compiled-binary boundary — + * `signing-key.command.ts`'s actual production runtime layer, not the mocked + * `Stdin` the integration suite provides via `Layer.succeed`. A missing + * `stdinLayer` in that composition only surfaces as a "Service not found" defect + * at this boundary (see the legacy CLAUDE.md Go Parity Checklist item 5). Per-branch + * prompt/format coverage lives in the integration suite. + */ +describe("supabase gen signing-key (legacy)", () => { + let projectDir: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), "supabase-gen-signing-key-e2e-")); + mkdirSync(join(projectDir, "supabase"), { recursive: true }); + writeFileSync( + join(projectDir, "supabase", "config.toml"), + '[auth]\nsigning_keys_path = "./signing_keys.json"\n', + ); + writeFileSync(join(projectDir, "supabase", "signing_keys.json"), "[]\n"); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + test( + "declines the overwrite on a piped 'n' without crashing or writing the file", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stderr } = await runSupabase(["gen", "signing-key"], { + entrypoint: "legacy", + cwd: projectDir, + stdin: "n\n", + }); + expect(exitCode).toBe(1); + expect(stderr).toContain("context canceled"); + expect(stderr).not.toContain("Service not found"); + const saved = readFileSync(join(projectDir, "supabase", "signing_keys.json"), "utf8"); + expect(JSON.parse(saved)).toEqual([]); + }, + ); + + test("overwrites on a piped 'y'", { timeout: E2E_TIMEOUT_MS }, async () => { + const { exitCode, stderr } = await runSupabase(["gen", "signing-key"], { + entrypoint: "legacy", + cwd: projectDir, + stdin: "y\n", + }); + expect(exitCode).toBe(0); + expect(stderr).toContain("JWT signing key appended to:"); + const saved = readFileSync(join(projectDir, "supabase", "signing_keys.json"), "utf8"); + expect(JSON.parse(saved)).toHaveLength(1); + }); +}); diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts index 6396c658c6..775a39140e 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts @@ -7,8 +7,9 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { findGitRootPath } from "../../../../shared/git/git-root.ts"; import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { Tty } from "../../../../shared/runtime/tty.service.ts"; import type { LegacyGenSigningKeyFlags } from "./signing-key.command.ts"; @@ -246,25 +247,6 @@ const isGitIgnored = Effect.fnUntraced(function* (filePath: string, searchFrom: .pipe(Effect.map((exitCode) => Option.some(Number(exitCode) === 0))); }); -const confirmOverwrite = Effect.fnUntraced(function* (title: string) { - const output = yield* Output; - const tty = yield* Tty; - const yes = yield* LegacyYesFlag; - if (yes) { - yield* output.raw(`${title} [Y/n] y\n`, "stderr"); - return true; - } - if (!tty.stdinIsTty) { - yield* output.raw(`${title} [Y/n] \n`, "stderr"); - return true; - } - // In json / stream-json mode `promptConfirm` fails with NonInteractiveError; treat that as a - // declined overwrite so the command cancels cleanly instead of corrupting the machine payload. - return yield* output - .promptConfirm(title, { defaultValue: true }) - .pipe(Effect.orElseSucceed(() => false)); -}); - export const legacyGenSigningKey = Effect.fn("legacy.gen.signing-key")(function* ( flags: LegacyGenSigningKeyFlags, ) { @@ -273,6 +255,7 @@ export const legacyGenSigningKey = Effect.fn("legacy.gen.signing-key")(function* const telemetryState = yield* LegacyTelemetryState; const output = yield* Output; const tty = yield* Tty; + const yes = yield* legacyResolveYes; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const emphasize = (text: string) => styleIfTty(tty.stdoutIsTty, "bold", text); @@ -298,9 +281,30 @@ export const legacyGenSigningKey = Effect.fn("legacy.gen.signing-key")(function* const nextKeys = flags.append ? [...configured.value.existingKeys, key] : yield* Effect.gen(function* () { - const confirmed = yield* confirmOverwrite( - `Do you want to overwrite the existing ${emphasize(configured.value.displayPath)} file?`, - ); + // `legacyPromptYesNo` silently returns the default (true) for any non-text + // `--output-format`, but this command has no structured json/stream-json output + // (SIDE_EFFECTS.md) — that combination only arises from a real interactive TTY + // explicitly requesting machine output. Fail closed rather than silently + // overwriting irrecoverable key material. + const confirmed = + !yes && tty.stdinIsTty && output.format !== "text" + ? false + : yield* legacyPromptYesNo( + // `legacyPromptYesNo` checks `output.format !== "text"` BEFORE it checks + // TTY, so a non-TTY (piped or empty) invocation under `json`/`stream-json` + // would otherwise hit that check first and return the default without + // ever reading stdin. Go's `console.PromptYesNo` + // (apps/cli-go/internal/utils/console.go:64-82) has no concept of output + // format at all — it always reads piped stdin — so a piped `y`/`n` answer + // must be honored here the same as in text mode. Present a text-shaped + // view of `output` to reach that read; `raw`/`promptConfirm` write the + // prompt to stderr under every `Output` layer, so this never touches the + // machine-readable stdout payload. + output.format === "text" ? output : { ...output, format: "text" }, + yes, + `Do you want to overwrite the existing ${emphasize(configured.value.displayPath)} file?`, + true, + ); if (!confirmed) { return yield* Effect.fail( new LegacyGenSigningKeyCancelledError({ message: "context canceled" }), diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts index 93ac85949d..549b3151f5 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts @@ -10,6 +10,7 @@ import { mockAnalytics, mockOutput, mockRuntimeInfo, + mockStdin, mockTty, processEnvLayer, } from "../../../../../tests/helpers/mocks.ts"; @@ -20,6 +21,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; import { LEGACY_GLOBAL_FLAGS, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; @@ -32,6 +34,7 @@ import { legacyGenSigningKey } from "./signing-key.handler.ts"; const tempRoot = useLegacyTempWorkdir("supabase-gen-signing-key-int-"); interface SetupOptions { + readonly format?: "text" | "json" | "stream-json"; readonly stdinIsTty?: boolean; readonly yes?: boolean; readonly promptConfirmResponses?: ReadonlyArray; @@ -39,6 +42,10 @@ interface SetupOptions { // Exit code returned by the mocked `git check-ignore` subprocess. `0` means the path is // ignored, any non-zero code means it is not. Only consumed by the gitignore-warning branch. readonly gitCheckIgnoreExitCode?: number; + // Piped (non-TTY) stdin answer for the overwrite prompt (CLI-1865). + readonly pipedAnswer?: string; + // Raw argv for `legacyResolveYes`'s explicit `--yes=false` detection. + readonly cliArgs?: ReadonlyArray; } // `git check-ignore` is invoked via ChildProcessSpawner. Mock it with a controlled exit code so @@ -68,7 +75,7 @@ function mockGitCheckIgnore(exitCode: number) { function setup(options: SetupOptions = {}) { const out = mockOutput({ - format: "text", + format: options.format ?? "text", interactive: options.stdinIsTty ?? false, promptConfirmResponses: options.promptConfirmResponses, }); @@ -82,6 +89,8 @@ function setup(options: SetupOptions = {}) { const layer = Layer.mergeAll( buildLegacyTestRuntime({ out, api, cliConfig, tty, telemetry: telemetry?.layer }), Layer.succeed(LegacyYesFlag, options.yes ?? false), + Layer.succeed(CliArgs, { args: options.cliArgs ?? [] }), + mockStdin(options.stdinIsTty ?? false, options.pipedAnswer), Layer.succeed(LegacyDebugLogger, { debug: () => Effect.void, http: () => Effect.void, @@ -156,6 +165,8 @@ describe("legacy gen signing-key integration", () => { processEnvLayer({ SUPABASE_HOME: tempRoot.current }), mockRuntimeInfo({ cwd: tempRoot.current, homeDir: tempRoot.current }), mockTty({ stdinIsTty: false, stdoutIsTty: false }), + Layer.succeed(CliArgs, { args: [] }), + mockStdin(false), Layer.succeed( TelemetryRuntime, TelemetryRuntime.of({ @@ -203,8 +214,38 @@ describe("legacy gen signing-key integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("overwrites the configured signing keys file and defaults to yes on non-tty", () => { - const { layer, out } = setup({ stdinIsTty: false }); + it.live( + "overwrites the configured signing keys file and defaults to yes on non-tty when stdin has no piped answer", + () => { + const { layer, out } = setup({ stdinIsTty: false }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + yield* legacyGenSigningKey({ algorithm: "RS256", append: false }); + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + const parsed = JSON.parse(saved) as ReadonlyArray>; + expect(parsed).toHaveLength(1); + expect(parsed[0]?.alg).toBe("RS256"); + expect(out.stderrText).toContain("Do you want to overwrite the existing"); + expect(out.stderrText).toContain("JWT signing key appended to: "); + expect(out.stderrText).toContain(join("supabase", "signing_keys.json")); + }).pipe(Effect.provide(layer)); + }, + ); + + // CLI-1865: Go's overwrite prompt reads piped stdin even in non-TTY mode and honors an + // explicit "n" — before this fix, TS returned `true` unconditionally without reading stdin at + // all, so `echo n | supabase gen signing-key` silently overwrote instead of canceling. + it.live("cancels the overwrite when a piped non-tty answer of 'n' is read", () => { + const { layer, out } = setup({ stdinIsTty: false, pipedAnswer: "n" }); return Effect.gen(function* () { yield* Effect.tryPromise(() => writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), @@ -213,17 +254,41 @@ describe("legacy gen signing-key integration", () => { writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), ); - yield* legacyGenSigningKey({ algorithm: "RS256", append: false }); + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenSigningKeyCancelledError"); + expect(json).toContain("context canceled"); + } + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved)).toEqual([]); + // Go's non-TTY prompt echoes the piped answer back to stderr after the label. + expect(out.stderrText).toContain("[Y/n] n\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("overwrites when a piped non-tty answer of 'y' is read", () => { + const { layer, out } = setup({ stdinIsTty: false, pipedAnswer: "y" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); const saved = yield* Effect.tryPromise(() => readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), ); const parsed = JSON.parse(saved) as ReadonlyArray>; expect(parsed).toHaveLength(1); - expect(parsed[0]?.alg).toBe("RS256"); expect(out.stderrText).toContain("Do you want to overwrite the existing"); - expect(out.stderrText).toContain("JWT signing key appended to: "); - expect(out.stderrText).toContain(join("supabase", "signing_keys.json")); }).pipe(Effect.provide(layer)); }); @@ -440,6 +505,156 @@ describe("legacy gen signing-key integration", () => { }).pipe(Effect.provide(layer)); }); + // This command has no structured json/stream-json output (SIDE_EFFECTS.md), so a real TTY + // requesting machine output is an unsupported combination — fail closed on this destructive, + // irreversible overwrite rather than silently defaulting to yes with no prompt at all. + it.live( + "declines the overwrite without prompting on a tty when --output-format is not text", + () => { + const { layer, out } = setup({ format: "json", stdinIsTty: true }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenSigningKeyCancelledError"); + } + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved)).toEqual([]); + expect(out.promptConfirmCalls).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + + // CLI-1865 follow-up: `legacyPromptYesNo` checks `output.format !== "text"` BEFORE it + // checks TTY, so a non-TTY invocation under `json`/`stream-json` must not fall into that + // early return — this command has no structured json/stream-json payload, so a piped + // answer must be honored the same as text mode. Before this fix, a piped "n" here was + // silently ignored and the file was overwritten with the default (true). + it.live("honors a piped non-tty 'n' even when --output-format is json", () => { + const { layer, out } = setup({ format: "json", stdinIsTty: false, pipedAnswer: "n" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyGenSigningKeyCancelledError"); + } + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved)).toEqual([]); + expect(out.promptConfirmCalls).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors a piped non-tty 'y' when --output-format is stream-json", () => { + const { layer } = setup({ format: "stream-json", stdinIsTty: false, pipedAnswer: "y" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved) as ReadonlyArray).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors SUPABASE_YES and overwrites even when a piped 'n' is present", () => { + // Go reads `viper.GetBool("YES")` (incl. the SUPABASE_YES env var) BEFORE scanning + // stdin (`console.go:71`), so `SUPABASE_YES=1 printf 'n\n' | supabase gen signing-key` + // auto-confirms and overwrites rather than consuming the piped `n`. The handler + // resolves `yes` via `legacyResolveYes`, not the raw --yes flag. + const prev = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "1"; + const { layer } = setup({ stdinIsTty: false, pipedAnswer: "n" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + const parsed = JSON.parse(saved) as ReadonlyArray>; + expect(parsed).toHaveLength(1); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ); + }); + + it.live("an explicit --yes=false overrides SUPABASE_YES and honors a piped 'n'", () => { + const prev = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "1"; + const { layer } = setup({ + stdinIsTty: false, + pipedAnswer: "n", + cliArgs: ["gen", "signing-key", "--yes=false"], + }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyGenSigningKeyCancelledError"); + } + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved)).toEqual([]); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ); + }); + it.live("flushes telemetry state after the command finishes", () => { const { layer, telemetry } = setup({ trackTelemetry: true }); return Effect.gen(function* () { From 4ade688c62fc5344674763cd42e9f815791a9adf Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 7 Jul 2026 11:37:38 +0100 Subject: [PATCH 02/49] docs(cli): correct inaccurate SIDE_EFFECTS.md and AGENTS.md claims (#5813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Docs fix — Legacy port SIDE_EFFECTS.md / AGENTS.md accuracy (F3-F10, F12 from the parity audit). ## What is the current behavior? 11 `SIDE_EFFECTS.md` files and `AGENTS.md` contained factual errors where the code was already correct but the documentation was wrong — wrong file paths, a fabricated stdout message, an inverted claim about whether a command writes the linked-project cache, a missing json/stream-json output envelope, a fictitious subprocess name, a backwards project-ref claim, and a fabricated telemetry-identity call. Each claim was independently re-verified against `apps/cli-go/` (and, for the `completion` exit-code claim, against the actual compiled Go binary and the actual TS CLI) before editing. ## What is the new behavior? - **F3**: `db/branch/{create,delete,list,switch}` — corrected the current-branch file path to `supabase/.branches/_current_branch`, matching `internal/utils/misc.go:99`. - **F4**: `db/remote/commit` — removed the false claim it prints `Finished supabase db pull.` (that belongs only to `db pull`). - **F5**: `db/lint` — corrected: it DOES write the linked-project cache on `--linked`, matching Go's `ensureProjectGroupsCached`. - **F6**: `db/schema/declarative/generate` — corrected: it has a real json/stream-json success envelope. - **F7**: `db/schema/declarative/sync` — replaced a fictitious `migration up --local` subprocess with the real `db reset --local` recovery-path subprocess. - **F8**: `completion` — the doc's exit-code claim was backwards. Go exits `0` on both bare `completion` and an unrecognized shell subcommand (verified against the compiled binary); the legacy TS shell currently exits `1` for both — a genuine, systemic exit-code bug in the shared CLI harness, not `completion`-specific (it reproduces on any bare/unrecognized group-command invocation, e.g. `branches`). Rather than fixing the code here or describing the bug as intended, the doc now states Go's true behavior and flags the current TS divergence, filed separately as CLI-1906. - **F9**: `config/push` + `AGENTS.md` — corrected the linked-project-cache path (was a fabricated `~/.supabase//...`, real path is `/supabase/.temp/linked-project.json`) and fixed an adjacent conflation where the project-ref-fallback file was wrongly attributed to the same cache file, when it's actually a separate file (`/supabase/.temp/project-ref`). - **F10**: `branches/update` — corrected: the upgrade-suggest call uses the branch's own resolved ref, not the parent `--project-ref`. - **F12**: `AGENTS.md` telemetry table — removed a fabricated `analytics.identify(gotrueId)` claim from the `login` row; Go's `StitchLogin` only aliases. A second, wider pre-existing doc bug was found during F9's verification (the same fabricated linked-project-cache path is copy-pasted across 29 other `SIDE_EFFECTS.md` files not touched here) — filed separately as CLI-1907 rather than expanding this diff. Docs-only change; no `.ts`/`.go` files touched. `pnpm check:all` passes. --- apps/cli/AGENTS.md | 18 ++++++------ .../commands/branches/update/SIDE_EFFECTS.md | 10 +++---- .../commands/completion/SIDE_EFFECTS.md | 24 +++++++++++----- .../commands/config/push/SIDE_EFFECTS.md | 23 +++++++-------- .../commands/db/branch/create/SIDE_EFFECTS.md | 6 ++-- .../commands/db/branch/delete/SIDE_EFFECTS.md | 6 ++-- .../commands/db/branch/list/SIDE_EFFECTS.md | 2 +- .../commands/db/branch/switch/SIDE_EFFECTS.md | 6 ++-- .../legacy/commands/db/lint/SIDE_EFFECTS.md | 13 +++++---- .../commands/db/remote/commit/SIDE_EFFECTS.md | 5 +++- .../declarative/generate/SIDE_EFFECTS.md | 12 ++++++-- .../schema/declarative/sync/SIDE_EFFECTS.md | 28 +++++++++---------- 12 files changed, 88 insertions(+), 65 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index c30465cd85..2aee95eb10 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -90,14 +90,14 @@ Always check `src/shared/` before writing new infrastructure. Do not duplicate w Also check the following `legacy/` infrastructure before writing equivalent helpers from scratch: -| Path | What it provides | -| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `legacy/config/legacy-cli-config.layer.ts` | `LegacyCliConfig` — resolves `SUPABASE_PROFILE` (built-in name **or** YAML file path), `--workdir`, `--experimental`, project-id from `supabase/config.toml` | -| `legacy/config/legacy-project-ref.layer.ts` | `LegacyProjectRefResolver` — `--project-ref` flag → env → linked-project.json → config fallback chain; matches Go's resolver order | -| `legacy/telemetry/legacy-telemetry-state.layer.ts` | `LegacyTelemetryState.flush` — writes `~/.supabase/telemetry.json`, runs in every command's `Effect.ensuring` | -| `legacy/telemetry/legacy-linked-project-cache.layer.ts` | `LegacyLinkedProjectCache.cache(ref)` — writes `~/.supabase//linked-project.json` after `--project-ref` resolves; bypasses generated schema validation (uses raw HTTP client) | -| `legacy/auth/legacy-http-debug.layer.ts` | `legacyHttpClientLayer` — wraps the HTTP transport with a `--debug` stderr logger in Go's `log.LstdFlags` format | -| `legacy/output/legacy-glamour-table.ts` | `renderGlamourTable(headers, rows)` — byte-exact ASCII match for Go's `glamour.RenderTable(..., AsciiStyle)` | +| Path | What it provides | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `legacy/config/legacy-cli-config.layer.ts` | `LegacyCliConfig` — resolves `SUPABASE_PROFILE` (built-in name **or** YAML file path), `--workdir`, `--experimental`, project-id from `supabase/config.toml` | +| `legacy/config/legacy-project-ref.layer.ts` | `LegacyProjectRefResolver` — `--project-ref` flag → env → `supabase/.temp/project-ref` file → prompt; matches Go's resolver order | +| `legacy/telemetry/legacy-telemetry-state.layer.ts` | `LegacyTelemetryState.flush` — writes `~/.supabase/telemetry.json`, runs in every command's `Effect.ensuring` | +| `legacy/telemetry/legacy-linked-project-cache.layer.ts` | `LegacyLinkedProjectCache.cache(ref)` — writes `/supabase/.temp/linked-project.json` after `--project-ref` resolves; bypasses generated schema validation (uses raw HTTP client) | +| `legacy/auth/legacy-http-debug.layer.ts` | `legacyHttpClientLayer` — wraps the HTTP transport with a `--debug` stderr logger in Go's `log.LstdFlags` format | +| `legacy/output/legacy-glamour-table.ts` | `renderGlamourTable(headers, rows)` — byte-exact ASCII match for Go's `glamour.RenderTable(..., AsciiStyle)` | --- @@ -286,7 +286,7 @@ The legacy shell sends the same PostHog events to the same product analytics pip | Command | Event | Identity / groups | Go source | | ------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------- | - | `login` | `cli_login_completed` | `analytics.alias(gotrueId, deviceId)` + `analytics.identify(gotrueId)` after token persists | `internal/login/login.go:283-296` | + | `login` | `cli_login_completed` | `analytics.alias(gotrueId, deviceId)` after token persists | `internal/login/login.go:283-296` | | `link` | `cli_project_linked` | `analytics.groupIdentify("organization", slug, …)` + `analytics.groupIdentify("project", ref, …)` after link write | `internal/link/link.go:60` | | `start` | `cli_stack_started` | none — fired after stack health check passes | `internal/start/start.go:1245` | | `sso/{list,create,update,remove}`, `branches/{create,update}` | `cli_upgrade_suggested` | none — payload is `{feature_key, org_slug}`, fired inside billing-gate error branch | 7 call-sites under `internal/{sso,branches}/` | diff --git a/apps/cli/src/legacy/commands/branches/update/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/branches/update/SIDE_EFFECTS.md index 09bab3fd8f..4983406350 100644 --- a/apps/cli/src/legacy/commands/branches/update/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/branches/update/SIDE_EFFECTS.md @@ -6,10 +6,10 @@ Same auth and project-ref resolution chain as every Management-API legacy comman ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves — Go parity | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command — Go parity | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------ | +| `/supabase/.temp/linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves — Go parity | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command — Go parity | ## API Routes @@ -51,4 +51,4 @@ In Go encoder modes, the header goes to stderr followed by the encoded payload o ## Notes -The upgrade-suggest call uses the parent project ref (resolved from `--project-ref`) rather than the branch's project ref. Both refs belong to the same organization, so the entitlement check returns the same `org_slug` either way; this also sidesteps a known API schema constraint where `getProject` strictly requires a `^[a-z]{20}$` ref. +The upgrade-suggest call uses the branch's own resolved project ref (`legacyResolveBranchProjectRef`), matching Go's `update.go:26` (`pause.GetBranchProjectRef`) — not the parent `--project-ref` value — so the entitlements check is scoped to the branch's org. diff --git a/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md index f25912c9c5..ed1b76d1c2 100644 --- a/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md @@ -26,10 +26,10 @@ ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------ | -| `0` | success — completion script for the chosen shell printed to stdout | -| `1` | invocation error (missing or unknown shell subcommand) | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------- | +| `0` | success — completion script for the chosen shell printed to stdout | +| `1` | unknown shell subcommand, or bare `completion` with no shell subcommand — **known divergence, see Notes (CLI-1906)** | ## Output @@ -58,9 +58,19 @@ straight to the Go binary. - Effect CLI's `--completions` global flag remains exposed at the root for `next/` users; it does not satisfy the legacy parity contract and is not what this subcommand routes through. -- The Go CLI exits non-zero when called without a shell subcommand (e.g. - `supabase completion`). Effect CLI surfaces the same condition through its usual - "missing subcommand" help-with-exit-1 behavior. +- **Known divergence (CLI-1906):** Go's cobra CLI exits `0` on both bare + `completion` (no shell subcommand) AND `completion ` — cobra + treats an unrecognized subcommand name the same as a missing one: a + non-`Runnable()` command with no `RunE` returns `flag.ErrHelp`, which cobra + maps to printing help and returning a nil error (verified against the + compiled Go binary for both cases: `completion` and `completion +bogus-shell` both exit `0`). The legacy TS shell currently exits `1` for + both invocations; this is a real, systemic exit-code bug in the shared CLI + harness (`shared/cli/run.ts`), not `completion`-specific — it reproduces on + any bare or unrecognized-subcommand invocation of a group command with + subcommands (e.g. `branches`, `branches bogus-subcommand`). See CLI-1906 for + the fix; this doc describes current (buggy) behavior, not the intended + target. - Each of `bash`/`zsh`/`fish`/`powershell` declares `--no-descriptions` (cobra's auto-registered flag, `completions.go` in `spf13/cobra`) and forwards it to the Go binary, so the emitted script omits completion descriptions exactly as it diff --git a/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md index 8a15ea687b..660cddd1fe 100644 --- a/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md @@ -7,20 +7,21 @@ local → if changed, print the unified diff and confirm → PATCH/PUT/POST. ## Files Read -| Path | Format | When | -| ------------------------------------------------ | ------------------------- | --------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, before any network call (parse error aborts, exit 1) | -| `/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` | -| Auth email template HTML (`content_path`) | HTML | when `auth.enabled`; paths resolved per Go rules (see below) | -| `~/.supabase//linked-project.json` | JSON | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file) | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, before any network call (parse error aborts, exit 1) | +| `/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` | +| Auth email template HTML (`content_path`) | HTML | when `auth.enabled`; paths resolved per Go rules (see below) | +| `/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file) | +| `/supabase/.temp/linked-project.json` | JSON | existence check only, to decide whether the cache write below is skipped (mirrors Go's `ensureProjectGroupsCached` telemetry cache — see `db/lint`'s Notes for the full mechanism) | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ---------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | `Effect.ensuring` after run (success **and** failure), if ref resolved | -| `~/.supabase/telemetry.json` | JSON | `Effect.ensuring` after run (success **and** failure) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | `Effect.ensuring` after run (success **and** failure), if ref resolved | +| `~/.supabase/telemetry.json` | JSON | `Effect.ensuring` after run (success **and** failure) | No writes to `config.toml`. diff --git a/apps/cli/src/legacy/commands/db/branch/create/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/branch/create/SIDE_EFFECTS.md index 5987e44efc..bd930c4242 100644 --- a/apps/cli/src/legacy/commands/db/branch/create/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/branch/create/SIDE_EFFECTS.md @@ -8,9 +8,9 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | --------- | ------ | -| `/.branches//` (directory) | directory | always | +| Path | Format | When | +| --------------------------------------------------------- | --------- | ------ | +| `/supabase/.branches//` (directory) | directory | always | ## API Routes diff --git a/apps/cli/src/legacy/commands/db/branch/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/branch/delete/SIDE_EFFECTS.md index 6b392e7110..97c31f4eb1 100644 --- a/apps/cli/src/legacy/commands/db/branch/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/branch/delete/SIDE_EFFECTS.md @@ -8,9 +8,9 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | --------- | ------- | -| `/.branches//` (directory) | directory | removed | +| Path | Format | When | +| --------------------------------------------------------- | --------- | ------- | +| `/supabase/.branches//` (directory) | directory | removed | ## API Routes diff --git a/apps/cli/src/legacy/commands/db/branch/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/branch/list/SIDE_EFFECTS.md index 14e1803271..bbbe030daf 100644 --- a/apps/cli/src/legacy/commands/db/branch/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/branch/list/SIDE_EFFECTS.md @@ -48,4 +48,4 @@ Not applicable. ## Notes - Deprecated in the Go CLI: use `branches list` instead. -- This is a local-only operation listing branches in `/.branches/`. +- This is a local-only operation listing branches in `/supabase/.branches/`. diff --git a/apps/cli/src/legacy/commands/db/branch/switch/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/branch/switch/SIDE_EFFECTS.md index 5cce0f2423..aa1cd0cbf5 100644 --- a/apps/cli/src/legacy/commands/db/branch/switch/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/branch/switch/SIDE_EFFECTS.md @@ -8,9 +8,9 @@ ## Files Written -| Path | Format | When | -| ------------------------------------ | ---------- | ------ | -| `/.supabase/current-branch` | plain text | always | +| Path | Format | When | +| ---------------------------------------------- | ---------- | ------ | +| `/supabase/.branches/_current_branch` | plain text | always | ## API Routes diff --git a/apps/cli/src/legacy/commands/db/lint/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/lint/SIDE_EFFECTS.md index f76f966c3c..49f2aac8ed 100644 --- a/apps/cli/src/legacy/commands/db/lint/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/lint/SIDE_EFFECTS.md @@ -12,15 +12,18 @@ Native TypeScript port of Go's `internal/db/lint`. ## Files Written -| Path | Format | When | -| ---------------------------- | ------ | ---------------------------------------------------- | -| `~/.supabase/telemetry.json` | JSON | always (PostHog state flush, Go `PersistentPostRun`) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | `--linked` only, via `LegacyLinkedProjectCache.cache` (Go's `ensureProjectGroupsCached`, `cmd/root.go:174,212-234`) | +| `~/.supabase/telemetry.json` | JSON | always (PostHog state flush, Go `PersistentPostRun`) | No user data is written: the lint runs inside a transaction that is **always rolled back** (`BEGIN` … `ROLLBACK`), matching Go — including `CREATE EXTENSION plpgsql_check`, which is issued on the same connection inside -the open transaction and so is rolled back too. `db lint` does not write the -linked-project cache (it has no `LegacyLinkedProjectCache` dependency). +the open transaction and so is rolled back too. `db lint` DOES write the +linked-project cache when run with `--linked` (matching Go's +`ensureProjectGroupsCached`); the default `--local` and `--db-url` paths never +populate a project ref, so no cache write occurs there. ## API Routes diff --git a/apps/cli/src/legacy/commands/db/remote/commit/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/remote/commit/SIDE_EFFECTS.md index 2b2e6ad3d2..88785a5530 100644 --- a/apps/cli/src/legacy/commands/db/remote/commit/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/remote/commit/SIDE_EFFECTS.md @@ -37,7 +37,10 @@ ### `--output-format text` (Go CLI compatible) -Prints `Finished supabase db pull.` on success. +Prints `Schema written to ` to stderr on success (from the shared +`pull.Run`, `internal/db/pull/pull.go:72`); no stdout confirmation message is +printed. The `Finished supabase db pull.` PostRun message belongs only to +`db pull` (`cmd/db.go:198-200`), not `db remote commit`. ### `--output-format json` diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md index 5df90ac1a9..de3e3b967f 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md @@ -54,9 +54,15 @@ pg-delta catalog (source) against the target database's catalog (target). ## Output -Text mode only (no machine envelope). Diagnostics + the final -`Declarative schema written to ` go to stderr; the PostRun prints -`Finished supabase db schema declarative generate.` to stdout on success. +Diagnostics (target resolution, prompts, `Declarative schema written to `) +always go to stderr, in every `--output-format`. On success: + +- `text` mode prints `Finished supabase db schema declarative generate.` to + stdout (matches Go's PostRun `fmt.Println`, `cmd/db_schema_declarative.go:116-118`). +- `json`/`stream-json` mode instead emits a structured success envelope + (`output.success("Finished supabase db schema declarative generate.")`) so + the machine stdout payload isn't corrupted by a bare human line + (`generate.command.ts:74-90`, CLI-1546 invariant). ## Notes diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 53d1a64fad..83250c0487 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -23,12 +23,12 @@ as a new timestamped migration. ## Subprocesses / Containers -| What | When | -| -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | -| `supabase-go db schema declarative __catalog --mode migrations --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply migrations → catalog | always | -| `supabase-go db schema declarative __catalog --mode declarative --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply declarative → catalog | always | -| Edge-runtime container running the pg-delta diff Deno script | always | -| `supabase-go migration up --local` | when the migration is applied (`--apply` / prompt / `--yes`) | +| What | When | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `supabase-go db schema declarative __catalog --mode migrations --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply migrations → catalog | always | +| `supabase-go db schema declarative __catalog --mode declarative --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply declarative → catalog | always | +| Edge-runtime container running the pg-delta diff Deno script | always | +| `supabase-go db reset --local [--network-id ]` (seam) — only on the failed-apply recovery path; `db reset` is still Go-proxied (`wrapped`), so the reset itself shells out to the bundled binary | TTY only, apply failed, and the user confirms "reset and reapply" | ## Environment Variables @@ -42,14 +42,14 @@ as a new timestamped migration. ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------ | -| `0` | success (migration created, applied, or "No schema changes found") | -| `1` | conflicting `--apply`/`--no-apply` (mutually exclusive) | -| `1` | pg-delta not enabled | -| `1` | no declarative schema files found | -| `1` | shadow-database / edge-runtime / diff failure | -| `1` | apply failure (when applied) — propagated from `migration up` | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------------------- | +| `0` | success (migration created, applied, or "No schema changes found") | +| `1` | conflicting `--apply`/`--no-apply` (mutually exclusive) | +| `1` | pg-delta not enabled | +| `1` | no declarative schema files found | +| `1` | shadow-database / edge-runtime / diff failure | +| `1` | apply failure (when applied) — propagated from the native migration apply (`applyMigrationToLocal`) | ## Output From 47e9a2c61b1e83b4d96e3407d973cb8b66c43575 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 7 Jul 2026 11:37:42 +0100 Subject: [PATCH 03/49] docs(cli): fix fabricated SUPABASE_API_URL and bare PROJECT_ID env-var references (#5811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Docs fix — Legacy port SIDE_EFFECTS.md accuracy. ## What is the current behavior? Two systemic env-var documentation errors across `src/legacy/**/SIDE_EFFECTS.md`, found in the parity audit: - **~36 files** document `SUPABASE_API_URL` as a real override for the Management API base URL. No such env var exists in Go: `SetEnvPrefix("SUPABASE")` + `AutomaticEnv()` never binds `API_URL`, there's no `os.Getenv("SUPABASE_API_URL")` anywhere, and the `Profile.api_url` field loads on a separate, unbound `viper.New()` instance (`internal/utils/profile.go:99`). The real override is `SUPABASE_PROFILE` (built-in name or a path to a YAML profile file). - **~18 files** document bare `PROJECT_ID` as if it were itself a settable env var. It's Go's internal viper key name — the real, user-facing env var is `SUPABASE_PROJECT_ID` (`viper.GetString("PROJECT_ID")` under the global `SetEnvPrefix("SUPABASE")` singleton, `internal/utils/flags/project_ref.go:62`). Both claims were independently verified against the real Go source and, for the viper-specific mechanics, a compiled test program against the exact pinned `github.com/spf13/viper v1.21.0`. ## What is the new behavior? - Removed the fabricated `SUPABASE_API_URL` rows; where a file didn't already document `SUPABASE_PROFILE`, added a real row for it (including the persisted `~/.supabase/profile` fallback step in the resolution chain, matching `legacy-cli-config.layer.ts`). - Corrected every bare `PROJECT_ID` mention (Environment Variables tables, Files Read "When" columns, and prose) to `SUPABASE_PROJECT_ID`, converging on the dominant convention already used correctly across ~15 sibling files. - Left `db/advisors/SIDE_EFFECTS.md` untouched — it already correctly documents that `SUPABASE_API_URL` is not honored. - Filed follow-ups for two related-but-out-of-scope findings surfaced during review: CLI-1904 (global choice-flag telemetry gap, unrelated) and CLI-1905 (bare `DB_PASSWORD` has the same bug class as this PR's F2, in a set of files this PR doesn't touch). Docs-only change; no `.ts` files touched. `pnpm check:all` passes. --- apps/cli/src/legacy/SIDE_EFFECTS_TEMPLATE.md | 2 +- .../commands/backups/list/SIDE_EFFECTS.md | 1 - .../commands/backups/restore/SIDE_EFFECTS.md | 1 - .../legacy/commands/bootstrap/SIDE_EFFECTS.md | 14 +++++------ .../commands/config/push/SIDE_EFFECTS.md | 1 - .../legacy/commands/domains/SIDE_EFFECTS.md | 10 ++++---- .../commands/encryption/SIDE_EFFECTS.md | 23 +++++++++---------- .../commands/functions/delete/SIDE_EFFECTS.md | 2 +- .../functions/download/SIDE_EFFECTS.md | 2 +- .../commands/functions/list/SIDE_EFFECTS.md | 1 - .../legacy/commands/gen/keys/SIDE_EFFECTS.md | 8 +++---- .../legacy/commands/gen/types/SIDE_EFFECTS.md | 2 +- .../commands/inspect/db/SIDE_EFFECTS.md | 2 +- .../commands/inspect/report/SIDE_EFFECTS.md | 12 +++++----- .../commands/network-bans/get/SIDE_EFFECTS.md | 12 +++++----- .../network-bans/remove/SIDE_EFFECTS.md | 12 +++++----- .../network-restrictions/get/SIDE_EFFECTS.md | 12 +++++----- .../update/SIDE_EFFECTS.md | 12 +++++----- .../postgres-config/delete/SIDE_EFFECTS.md | 12 +++++----- .../postgres-config/get/SIDE_EFFECTS.md | 12 +++++----- .../postgres-config/update/SIDE_EFFECTS.md | 12 +++++----- .../legacy/commands/projects/SIDE_EFFECTS.md | 2 +- .../projects/api-keys/SIDE_EFFECTS.md | 2 +- .../commands/projects/create/SIDE_EFFECTS.md | 2 +- .../commands/projects/delete/SIDE_EFFECTS.md | 2 +- .../commands/projects/list/SIDE_EFFECTS.md | 2 +- .../commands/secrets/list/SIDE_EFFECTS.md | 1 - .../commands/secrets/set/SIDE_EFFECTS.md | 1 - .../commands/secrets/unset/SIDE_EFFECTS.md | 1 - .../legacy/commands/services/SIDE_EFFECTS.md | 2 +- .../snippets/download/SIDE_EFFECTS.md | 5 ++-- .../commands/snippets/list/SIDE_EFFECTS.md | 5 ++-- .../ssl-enforcement/get/SIDE_EFFECTS.md | 12 +++++----- .../ssl-enforcement/update/SIDE_EFFECTS.md | 12 +++++----- .../activate/SIDE_EFFECTS.md | 12 +++++----- .../check-availability/SIDE_EFFECTS.md | 12 +++++----- .../vanity-subdomains/delete/SIDE_EFFECTS.md | 12 +++++----- .../vanity-subdomains/get/SIDE_EFFECTS.md | 12 +++++----- 38 files changed, 126 insertions(+), 136 deletions(-) diff --git a/apps/cli/src/legacy/SIDE_EFFECTS_TEMPLATE.md b/apps/cli/src/legacy/SIDE_EFFECTS_TEMPLATE.md index a27b7f3ab6..9ab7ef9ade 100644 --- a/apps/cli/src/legacy/SIDE_EFFECTS_TEMPLATE.md +++ b/apps/cli/src/legacy/SIDE_EFFECTS_TEMPLATE.md @@ -61,7 +61,7 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md index f69b0219d6..cff8a366c1 100644 --- a/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md @@ -31,7 +31,6 @@ | `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (Go parity — used by the cli-e2e test harness). | no (defaults to `supabase`) | | `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | | `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | -| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md index 8b08e2727b..768bb9b728 100644 --- a/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md @@ -31,7 +31,6 @@ | `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (Go parity — used by the cli-e2e test harness). | no (defaults to `supabase`) | | `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | | `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | -| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md index a6428d8f47..6c5c7cefbe 100644 --- a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md @@ -46,13 +46,13 @@ leaking the change to the surrounding process. ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------------- | -------------------------------------------------- | --------- | -| `SUPABASE_WORKDIR` | target dir (`--workdir` flag → env → prompt → cwd) | no | -| `SUPABASE_DB_PASSWORD` | DB password (`-p` flag → env → prompt/generate) | no | -| `GITHUB_TOKEN` | raise the GitHub API rate limit for template fetch | no | -| `SUPABASE_ACCESS_TOKEN` | auth bypass for ensure-login | no | -| `SUPABASE_API_URL`, `SUPABASE_PROFILE` | API host / profile | no | +| Variable | Purpose | Required? | +| ----------------------- | ------------------------------------------------------------ | --------- | +| `SUPABASE_WORKDIR` | target dir (`--workdir` flag → env → prompt → cwd) | no | +| `SUPABASE_DB_PASSWORD` | DB password (`-p` flag → env → prompt/generate) | no | +| `GITHUB_TOKEN` | raise the GitHub API rate limit for template fetch | no | +| `SUPABASE_ACCESS_TOKEN` | auth bypass for ensure-login | no | +| `SUPABASE_PROFILE` | profile name/path (env → `~/.supabase/profile` → `supabase`) | no | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md index 660cddd1fe..374a785a22 100644 --- a/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md @@ -56,7 +56,6 @@ when its local gate is off. | `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | | `SUPABASE_YES` | auto-confirm prompts (`--yes`) | no | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | | `SUPABASE_PROFILE` | API profile selection | no | | `env(VAR)` references | interpolated into `config.toml` values at load | no | diff --git a/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md index 90660186db..5dada1097d 100644 --- a/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md @@ -6,10 +6,10 @@ Cloudflare DNS-over-HTTPS CNAME pre-check. ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `PROJECT_ID` env are unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ----------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | ## Files Written @@ -92,7 +92,7 @@ suppressed on stderr. `delete` ignores `-o`. - `--custom-hostname` is required for `create`. - `create` validates the CNAME via Cloudflare DNS-over-HTTPS (`https://1.1.1.1`, 10s timeout) before initializing; on failure it short-circuits before any POST. -- All subcommands resolve the ref via `--project-ref` → `PROJECT_ID` env → linked-project file, matching Go. +- All subcommands resolve the ref via `--project-ref` → `SUPABASE_PROJECT_ID` env → linked-project file, matching Go. - The project-ref fallback env var is `SUPABASE_PROJECT_ID`, matching Go (Go calls `viper.GetString("PROJECT_ID")` under `viper.SetEnvPrefix("SUPABASE")`, which resolves to the `SUPABASE_PROJECT_ID` environment variable). - **Documented divergences from Go (intentional):** - `--include-raw-output` is declared as a normal boolean **on each subcommand** (Go declares it as a persistent flag on the `domains` group). Two consequences: (a) it must appear after the subcommand name (`domains get --include-raw-output`) rather than before it (`domains --include-raw-output get`), matching how `--project-ref` is already handled shell-wide; (b) it cannot reproduce Cobra's help-hiding or the `Flag --include-raw-output has been deprecated` stderr warning, which Effect CLI has no hook for. It still reproduces the behavioral effect (forces `-o json` when `-o` is unset/pretty); on `delete` it is inert, matching Go. diff --git a/apps/cli/src/legacy/commands/encryption/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/encryption/SIDE_EFFECTS.md index 458b7aeb86..770358b237 100644 --- a/apps/cli/src/legacy/commands/encryption/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/encryption/SIDE_EFFECTS.md @@ -6,11 +6,11 @@ additionally reads the new key from stdin. ## Files Read -| Path | Format | When | -| ------------------------------------------------ | ------------------------- | ------------------------------------------------------------------ | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `~/.supabase//linked-project.json` | JSON | when `--project-ref` / `PROJECT_ID` unset, to resolve linked ref | -| stdin | raw bytes / masked TTY | `update-root-key` only — masked TTY input or piped bytes (the key) | +| Path | Format | When | +| ------------------------------------------------ | ------------------------- | ------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `~/.supabase//linked-project.json` | JSON | when `--project-ref` / `SUPABASE_PROJECT_ID` unset, to resolve linked ref | +| stdin | raw bytes / masked TTY | `update-root-key` only — masked TTY input or piped bytes (the key) | ## Files Written @@ -30,12 +30,11 @@ additionally reads the new key from stdin. ## Environment Variables -| Variable | Purpose | Required? | -| ------------------------------------ | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` / `PROJECT_ID` | project ref (fallback when `--project-ref` unset) | no (falls back to linked-project file → prompt) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (defaults to `supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | project ref (fallback when `--project-ref` unset) | no (falls back to linked-project file → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes @@ -76,7 +75,7 @@ One `result` event carrying `{root_key}` (both subcommands). ## Notes -- Requires `--project-ref`, `SUPABASE_PROJECT_ID`/`PROJECT_ID`, or a linked project. +- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, or a linked project. - `update-root-key` reads the key from stdin: a real TTY is read with a masked prompt; piped stdin is decoded as UTF-8 and whitespace-trimmed. An empty or whitespace-only key sends an empty `root_key`, matching Go's `io.Copy` + diff --git a/apps/cli/src/legacy/commands/functions/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/delete/SIDE_EFFECTS.md index f3914b1b22..e3c3e92dd6 100644 --- a/apps/cli/src/legacy/commands/functions/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/delete/SIDE_EFFECTS.md @@ -23,7 +23,7 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md index 7a70bcb517..ff2a034c35 100644 --- a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md @@ -31,7 +31,7 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md index b6764b7dfc..f1613f83fe 100644 --- a/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md @@ -34,7 +34,6 @@ | `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | | `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset | no (falls back to `/supabase/.temp/project-ref`) | | `SUPABASE_WORKDIR` | sets `` for local Supabase temp files | no (falls back to `--workdir` -> current working dir) | -| ~~`SUPABASE_API_URL`~~ | **not honored** - Go parity. Use `SUPABASE_PROFILE` instead. | - | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/gen/keys/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/keys/SIDE_EFFECTS.md index 325e8d58ed..00f28f8924 100644 --- a/apps/cli/src/legacy/commands/gen/keys/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/keys/SIDE_EFFECTS.md @@ -20,10 +20,10 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md index 1bc6151b77..c7accf16f1 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -59,7 +59,7 @@ default 10s pg-delta probe timeout. | Variable | Purpose | Required? | | ---------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token for linked/project-id mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | | `SUPABASE_DB_PASSWORD` | database password for `--local` and the `--linked` workdir project | no (defaults to `postgres`; **ignored** for ad-hoc `--project-id`, which always mints a temporary login role) | | `SUPABASE_SERVICES_HOSTNAME` | host used for the local TLS probe | no (defaults to `127.0.0.1`) | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | pg-meta image registry override (`docker.io` → Docker Hub; any other value → that registry) | no (defaults to the ECR registry) | diff --git a/apps/cli/src/legacy/commands/inspect/db/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/SIDE_EFFECTS.md index f5728c86be..7bfc41e7a1 100644 --- a/apps/cli/src/legacy/commands/inspect/db/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/inspect/db/SIDE_EFFECTS.md @@ -40,7 +40,7 @@ no new config reads. | ---------------------------------------------------- | --------------------------------- | --------------------------------------- | | `SUPABASE_DB_PASSWORD` / `DB_PASSWORD` | database password (linked/local) | no (prompts / config fallback) | | `SUPABASE_ACCESS_TOKEN` | Management API auth (linked only) | no (falls back to keyring / token file) | -| `PROJECT_ID` | project ref fallback (linked) | no (config resolution fallback) | +| `SUPABASE_PROJECT_ID` | project ref fallback (linked) | no (config resolution fallback) | | libpq vars (`PGSSLROOTCERT`, `PGCONNECT_TIMEOUT`, …) | honored when `--db-url` is used | no | ## Database Queries diff --git a/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md index 3da2b98fdb..9307ddcd2a 100644 --- a/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md @@ -54,12 +54,12 @@ resolve the connection (via `LegacyDbConfigResolver`). ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | --------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no | -| `SUPABASE_API_URL` | override Management API base URL | no | -| `SUPABASE_DB_*` | override `[db]` port / shadow_port / password | no | -| `SUPABASE_ENV` | selects which project `.env` files load | no | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------ | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_DB_*` | override `[db]` port / shadow_port / password | no | +| `SUPABASE_ENV` | selects which project `.env` files load | no | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md index f9042c22a7..edfa550b89 100644 --- a/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -27,8 +27,8 @@ The Management API exposes this read operation as `POST .../network-bans/retriev | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md index 333bf103a1..937970c60d 100644 --- a/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -27,8 +27,8 @@ | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md index 7c746dbd01..f39c04d7c9 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md index fb31633592..f30b5ed87e 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -31,8 +31,8 @@ when no `--db-allow-cidr` was supplied), matching Go's `&[]string{}` initializat | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/postgres-config/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/postgres-config/delete/SIDE_EFFECTS.md index 69bd23294e..97168c0975 100644 --- a/apps/cli/src/legacy/commands/postgres-config/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/postgres-config/delete/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -28,8 +28,8 @@ This command does not call a delete endpoint. It mirrors Go: fetch current confi | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/postgres-config/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/postgres-config/get/SIDE_EFFECTS.md index 6582161bd1..d969c35982 100644 --- a/apps/cli/src/legacy/commands/postgres-config/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/postgres-config/get/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/postgres-config/update/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/postgres-config/update/SIDE_EFFECTS.md index edd0de4c4a..8cfba86318 100644 --- a/apps/cli/src/legacy/commands/postgres-config/update/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/postgres-config/update/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -28,8 +28,8 @@ The initial `GET` is skipped when `--replace-existing-overrides` is set. Otherwi | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/projects/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/SIDE_EFFECTS.md index 23e0b9711e..fa2e82029d 100644 --- a/apps/cli/src/legacy/commands/projects/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/SIDE_EFFECTS.md @@ -41,7 +41,7 @@ subcommand's own `SIDE_EFFECTS.md`. | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | | `SUPABASE_PROJECT_REF` | linked project ref (via the config layer) | no (used by `list` marker / `api-keys` ref / `delete` unlink) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | > `DB_PASSWORD` is **not** consumed. In Go it only mirrors `--db-password` via a > viper binding for downstream local-stack use; `projects create` never reads it. diff --git a/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md index 79a7642c2c..b0207e720a 100644 --- a/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md @@ -28,7 +28,7 @@ Management API to return the full secret keys (`sb_secret_...`) in `api_key` ins | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Flags diff --git a/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md index 0726aaa348..73d151ebd5 100644 --- a/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md @@ -24,7 +24,7 @@ | Variable | Purpose | Required? | | ----------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | | `DB_PASSWORD` | **not consumed** — Go only mirrors `--db-password` into viper for local-stack reuse; `projects create` never reads it | n/a | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/projects/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/delete/SIDE_EFFECTS.md index f4e46eab50..3d94401b92 100644 --- a/apps/cli/src/legacy/commands/projects/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/delete/SIDE_EFFECTS.md @@ -30,7 +30,7 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/projects/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/list/SIDE_EFFECTS.md index f4785c0f14..8ddce1209a 100644 --- a/apps/cli/src/legacy/commands/projects/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/list/SIDE_EFFECTS.md @@ -24,7 +24,7 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md index a7a248607e..9a1ce5d299 100644 --- a/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md @@ -32,7 +32,6 @@ | `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (Go parity — used by the cli-e2e test harness). | no (defaults to `supabase`) | | `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | | `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | -| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/secrets/set/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/secrets/set/SIDE_EFFECTS.md index c99884485e..e9b725e37b 100644 --- a/apps/cli/src/legacy/commands/secrets/set/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/secrets/set/SIDE_EFFECTS.md @@ -37,7 +37,6 @@ | `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | | `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | | `env(VAR)` references | values matching `env(NAME)` in `[edge_runtime.secrets]` are resolved against the loaded env. Missing variables preserve the literal verbatim (Go parity). | — | -| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/secrets/unset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/secrets/unset/SIDE_EFFECTS.md index 504a4d99f1..873a57cf6d 100644 --- a/apps/cli/src/legacy/commands/secrets/unset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/secrets/unset/SIDE_EFFECTS.md @@ -33,7 +33,6 @@ | `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (Go parity — used by the cli-e2e test harness). | no (defaults to `supabase`) | | `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | | `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | -| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md index 07092ca913..dfef6aa082 100644 --- a/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md @@ -36,7 +36,7 @@ matching `apps/cli-go/pkg/fetcher/gateway.go`. | Variable | Purpose | Required? | | ----------------------- | --------------------------------------------------- | ----------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token for Management API linked-version checks | no (falls back to keyring, then `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md index 19f3323f9e..ec7b0f9f11 100644 --- a/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md @@ -7,7 +7,7 @@ | keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` | | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `PROJECT_ID` env are unset | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | | `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | ## Files Written @@ -30,8 +30,7 @@ Only `content.sql` is rendered in text mode. The full payload is exposed via `-- | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | | `SUPABASE_PROFILE` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md index 867b2331ac..85e3717180 100644 --- a/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md @@ -7,7 +7,7 @@ | keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` | | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `PROJECT_ID` env are unset | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | | `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | ## Files Written @@ -28,8 +28,7 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | | `SUPABASE_PROFILE` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md index ccf0e03d43..d56ed25db2 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md index 6f067ad6b5..2a5657772a 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md index 23fbe98051..aace0d1a0b 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md index dc361d9fbc..9fc14225dc 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md index 2596dc4cb6..5e67410233 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md index 15c79abde7..73b367ec63 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | ## Exit Codes From 973ed75950553761d69d2f57342afe8c75b4c576 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Tue, 7 Jul 2026 12:49:16 +0200 Subject: [PATCH 04/49] feat(cli): port db push, db reset, and db start to native TypeScript (#5715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the `db push`, `db reset`, and `db start` commands of the legacy CLI shell from Go-binary proxies to native TypeScript (CLI-1325), and introduces a hidden Go seam for the container-bootstrap primitives that aren't ported. ## What changed **`db push`** — fully native: pending-migration reconciliation, seed-file ops with `seed_files` hash tracking, `[db.vault]` upsert, `--include-roles`/`--include-seed`/`--dry-run`, against local / linked / `--db-url`. **`db reset`** — native on both legs: - Remote (`--linked` / remote `--db-url`): drop user schemas → vault upsert → migrate + seed, `--version`/`--last`, and the Go-parity `--sql-paths` seed override (merged from develop, mutually exclusive with `--no-seed`). - Local (`--local` / local `--db-url`): running check, `Resetting local database…`, container recreate + migrate + seed via the seam, storage-gated bucket seeding (reuses the ported `seed buckets` core), and the `Finished … on branch .` line. - Only the niche `--experimental` remote schema-files path still delegates to the Go binary. **`db start`** — native: config validation, the `AssertSupabaseDbIsRunning` check (prints Go's "already running" line), else container bootstrap via the seam. No status table and no `cli_stack_started` — those belong to the top-level `supabase start`, not `db start`. **Hidden Go seam (`db __db-bootstrap`)** — mirrors the existing `db __shadow` seam. Exposes the un-ported container primitives (`StartDatabase` + `DockerRemoveAll` cleanup, the PG14/PG15 reset recreate, the storage health gate) behind `--mode {start|recreate|await-storage}`. The TS side orchestrates everything else (messages, version resolution, bucket seeding, the git-branch line, telemetry, `--output-format` shaping); the seam runs with telemetry disabled and stderr inherited, like `__shadow`. **Config loading (review follow-ups)** — `db push` / `db reset` / `db start` load `config.toml` through the legacy Go-parity reader (`legacyCheckDbToml`, Go's `flags.LoadConfig` → `config.Load` + `Validate`) rather than `@supabase/config`, so their config semantics match the Go CLI exactly: - Go-style env-reference booleans (e.g. `[db.seed] enabled = "env(SEED_ENABLED)"`) load like Go (env-expand → `strconv.ParseBool`) instead of failing with a parse error. - a matched `[remotes.]` block's `db.migrations.enabled` / `db.seed.enabled` beats the `SUPABASE_DB_*_ENABLED` env var (Go's `v.Set` override tier sits above AutomaticEnv), closing the last remote-vs-env precedence gap. - `encrypted:` `[db.vault]` secrets are decrypted at config-load time with the shell **and** project-`.env` `DOTENV_PRIVATE_KEY*` keys and fail fast (before any connect / schema drop) on an undecryptable value, matching Go's `DecryptSecretHookFunc`. - seed `sql_paths` (config and `--sql-paths`) resolve once to Go's config-load form; `db start` validates config before the already-running check. Malformed config now surfaces the reader's Go message (`failed to load config`), consistent with `db diff` / `dump` / `pull` / `migration`. ## Why `db start` / `db reset --local` need container lifecycle (create/recreate, image pull, health checks, init schema, service restarts) that is impractical to reimplement in TS. The seam keeps that lifecycle in Go while moving orchestration, output parity, telemetry, and `--output-format` handling to native TS — matching the approach already used for `db diff` / `db pull`. ## Reviewer notes - The seam is the only `db reset`/`db start` boundary the in-process integration suites mock; it's covered end-to-end by the cli-e2e live suite (`db-reset-start.live.e2e.test.ts`, run via `pnpm --filter @supabase/cli-e2e test:e2e:live`), which exercises the local leg against a real Docker socket and the remote `db reset` leg against the staging session pooler. Both `describe`s skip the `ts-next` target (the next shell has no `db` group). - `db reset --local` recreate forwards `--no-seed` / `--sql-paths` to the seam, which applies them via the same `applyDbResetSeedFlags` helper `db reset` uses, so seed handling stays identical across local and remote. - The bundled `supabase-go` binary must be rebuilt (`pnpm build:go-sidecar` copies it into `dist/`) since the seam adds the `db __db-bootstrap` command. - `docs/go-cli-porting-status.md` flips `db push` / `db reset` / `db start` to `ported`; each command's `SIDE_EFFECTS.md` documents the files, subprocesses, DB mutations, env vars, and exit codes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude --- .../live/db-reset-start.live.e2e.test.ts | 81 ++ apps/cli-go/cmd/db.go | 74 ++ apps/cli-go/internal/db/reset/reset.go | 32 + apps/cli/docs/go-cli-porting-status.md | 300 ++--- .../legacy-platform-api.layer.unit.test.ts | 1 + .../legacy/commands/db/push/SIDE_EFFECTS.md | 109 +- .../legacy/commands/db/push/push.command.ts | 25 +- .../legacy/commands/db/push/push.errors.ts | 56 + .../legacy/commands/db/push/push.handler.ts | 349 ++++- .../commands/db/push/push.integration.test.ts | 826 ++++++++++++ .../legacy/commands/db/push/push.layers.ts | 78 ++ .../legacy/commands/db/reset/SIDE_EFFECTS.md | 145 ++- .../legacy/commands/db/reset/reset.command.ts | 25 +- .../legacy/commands/db/reset/reset.errors.ts | 88 ++ .../legacy/commands/db/reset/reset.handler.ts | 430 +++++- .../db/reset/reset.integration.test.ts | 1153 +++++++++++++++-- .../legacy/commands/db/reset/reset.layers.ts | 80 ++ .../db/shared/legacy-db-bootstrap.errors.ts | 20 + .../shared/legacy-db-bootstrap.seam.layer.ts | 241 ++++ .../legacy-db-bootstrap.seam.service.ts | 68 + .../commands/db/shared/legacy-drop-schemas.ts | 169 +++ .../db/shared/legacy-migration-pending.ts | 123 ++ .../legacy-migration-pending.unit.test.ts | 83 ++ .../db/shared/legacy-pgdelta.seam.layer.ts | 8 +- .../commands/db/shared/legacy-seed-ops.ts | 372 ++++++ .../db/shared/legacy-seed-ops.unit.test.ts | 126 ++ .../legacy/commands/db/start/SIDE_EFFECTS.md | 67 +- .../legacy/commands/db/start/start.command.ts | 16 +- .../legacy/commands/db/start/start.handler.ts | 87 +- .../db/start/start.integration.test.ts | 240 ++++ .../legacy/commands/db/start/start.layers.ts | 27 + .../commands/seed/buckets/buckets.handler.ts | 560 +------- .../seed/buckets/buckets.integration.test.ts | 65 + .../services/services.integration.test.ts | 1 + .../legacy/config/legacy-cli-config.layer.ts | 19 +- .../legacy-cli-config.layer.unit.test.ts | 9 +- .../config/legacy-cli-config.service.ts | 8 + .../legacy-project-ref.layer.unit.test.ts | 1 + .../legacy/shared/legacy-connect-errors.ts | 91 ++ .../shared/legacy-connect-errors.unit.test.ts | 74 ++ .../legacy-db-config.integration.test.ts | 27 + .../legacy/shared/legacy-db-config.layer.ts | 33 +- .../shared/legacy-db-config.toml-read.ts | 268 +++- .../legacy-db-config.toml-read.unit.test.ts | 160 +++ .../shared/legacy-db-connection.service.ts | 8 + .../legacy-db-connection.sql-pg.layer.ts | 20 +- .../legacy/shared/legacy-db-target-flags.ts | 4 +- .../legacy/shared/legacy-docker-run.layer.ts | 11 +- .../legacy/shared/legacy-docker-suggest.ts | 21 + .../shared/legacy-docker-suggest.unit.test.ts | 31 + .../legacy/shared/legacy-migration-apply.ts | 150 ++- .../legacy-migration-apply.unit.test.ts | 26 + .../src/legacy/shared/legacy-prompt-yes-no.ts | 11 +- .../src/legacy/shared/legacy-seed-buckets.ts | 584 +++++++++ apps/cli/src/shared/cli/run.ts | 4 + apps/cli/src/shared/cli/run.unit.test.ts | 3 + apps/cli/src/shared/legacy/global-flags.ts | 19 + apps/cli/tests/helpers/legacy-mocks.ts | 2 + packages/cli-test-helpers/src/normalize.ts | 13 + .../src/normalize.unit.test.ts | 21 + 60 files changed, 6716 insertions(+), 1027 deletions(-) create mode 100644 apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts create mode 100644 apps/cli/src/legacy/commands/db/push/push.errors.ts create mode 100644 apps/cli/src/legacy/commands/db/push/push.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/db/push/push.layers.ts create mode 100644 apps/cli/src/legacy/commands/db/reset/reset.errors.ts create mode 100644 apps/cli/src/legacy/commands/db/reset/reset.layers.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/db/start/start.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/db/start/start.layers.ts create mode 100644 apps/cli/src/legacy/shared/legacy-docker-suggest.ts create mode 100644 apps/cli/src/legacy/shared/legacy-docker-suggest.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-seed-buckets.ts diff --git a/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts new file mode 100644 index 0000000000..9c2ec6f8e7 --- /dev/null +++ b/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts @@ -0,0 +1,81 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect } from "vitest"; +import { TARGET } from "../env.ts"; +import { testLive } from "./live-context.ts"; + +// Real-backend live coverage for the native `db start` / `db reset` ports. +// +// `db start` / `db reset` live only in the `go` reference and the `ts-legacy` +// port (the `next` shell has no `db` group), so skip the `ts-next` target. +// +// The live suite runs serially (`fileParallelism: false`, `maxWorkers: 1`), so the +// destructive remote reset below is safe against the throwaway per-run project. + +// --- Local leg: db start + db reset --local against the real Docker socket ----- +// Exercises the hidden `db __db-bootstrap` Go seam end-to-end — the boundary the +// in-process integration suites mock. The start → already-running → reset cycle +// runs in one test so it shares a single booted stack, and `finally` stops it +// (legacy proxies `stop` to Go) so the run never leaves containers behind. +describe.skipIf(TARGET === "ts-next")("db start / db reset --local (live, local Docker)", () => { + testLive( + "db start boots, is idempotent, and db reset --local recreates", + { timeout: 600_000 }, + async ({ run }) => { + try { + const start = await run(["db", "start"]); + expect(start.exitCode, start.stderr).toBe(0); + // Go tees bootstrap progress to stderr (mode-independent). + expect(`${start.stdout}${start.stderr}`).toMatch(/Starting database|Initialising schema/i); + + // Second start is a no-op: the db is already running, exit 0. + const again = await run(["db", "start"]); + expect(again.exitCode, again.stderr).toBe(0); + expect(`${again.stdout}${again.stderr}`).toMatch(/already[\s-]running/i); + + // Local reset recreates the container and prints the git-branch line. + const reset = await run(["db", "reset", "--local"]); + expect(reset.exitCode, reset.stderr).toBe(0); + expect(reset.stderr).toContain("on branch "); + } finally { + await run(["stop", "--no-backup"]).catch(() => undefined); + } + }, + ); +}); + +// --- Remote leg: db reset against the staging project over the session pooler --- +// Exercises the native remote reset path (drop user schemas → apply local +// migrations → seed) against a real Postgres, no Docker. `--yes` auto-accepts the +// confirmation prompt (the non-interactive default is decline). Mutates the +// throwaway project's schema — deleted on teardown. The IPv4 session pooler +// `dbUrl` is used because the direct host is IPv6-only and unreachable from +// IPv4-only CI runners. +describe.skipIf(TARGET === "ts-next")("db reset (live, remote session pooler)", () => { + testLive( + "resets the remote schema and re-applies a local migration", + { timeout: 600_000 }, + async ({ run, dbUrl, workspace }) => { + const migrations = join(workspace.path, "supabase", "migrations"); + mkdirSync(migrations, { recursive: true }); + writeFileSync( + join(migrations, "20240101000000_e2e_reset.sql"), + "create table if not exists e2e_reset (id int);\n", + ); + + const reset = await run(["db", "reset", "--db-url", dbUrl, "--yes"]); + expect(reset.exitCode, reset.stderr).toBe(0); + expect(reset.stderr).toContain("Resetting remote database"); + // A real connection failure must never be mistaken for a benign outcome. + expect(`${reset.stdout}${reset.stderr}`, "db reset hit a connection error").not.toMatch( + /dial|no route|connection refused|could not connect|server closed the connection|i\/o timeout/i, + ); + + // The migration history shows the re-applied version → proves the drop + + // migrate ran against the remote database. + const listed = await run(["migration", "list", "--db-url", dbUrl]); + expect(listed.exitCode, listed.stderr).toBe(0); + expect(listed.stdout).toContain("20240101000000"); + }, + ); +}); diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index df04364e44..66df4311cc 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "errors" "fmt" "os" @@ -270,6 +271,71 @@ var ( }, } + bootstrapMode string + bootstrapSqlPaths []string + bootstrapFromBackup string + bootstrapVersion string + bootstrapNoSeed bool + + // dbBootstrapCmd is a hidden seam used by the native-TypeScript `db start` and + // `db reset --local` commands to drive the container-bootstrap primitives that + // are not yet ported to TypeScript: creating/recreating the local Postgres + // container, applying the initial schema, and the storage health gate. The TS + // caller orchestrates everything else (the "already running?" check and its + // message, version/last resolution, bucket seeding, the git-branch "Finished…" + // line, telemetry, and --output-format shaping); the seam stays in Go only for + // the Docker lifecycle. It mirrors the existing db __shadow seam: it carries no + // db-url/local/linked target flags, so it loads supabase/config.toml explicitly + // (the root PersistentPreRunE only loads it when a target flag is set). Progress + // goes to stderr; the only stdout output is a single machine-parseable marker + // for --mode await-storage ("ready" or "absent"). + dbBootstrapCmd = &cobra.Command{ + Use: "__db-bootstrap", + Hidden: true, + Short: "Internal: container bootstrap for the native db start / db reset commands", + RunE: func(cmd *cobra.Command, args []string) error { + fsys := afero.NewOsFs() + if err := flags.LoadConfig(fsys); err != nil { + return err + } + switch bootstrapMode { + case "start": + // Mirror start.Run minus the "already running?" check, which the TS + // caller performs (and prints "Postgres database is already running."). + if err := start.StartDatabase(cmd.Context(), bootstrapFromBackup, fsys, os.Stderr); err != nil { + if rmErr := utils.DockerRemoveAll(context.Background(), os.Stderr, utils.Config.ProjectId); rmErr != nil { + fmt.Fprintln(os.Stderr, rmErr) + } + return err + } + return nil + case "recreate": + // The PG14/PG15 container-recreate half of local db reset. The TS + // caller has already printed "Resetting local database…" and validated + // the flags. Apply the same seed handling as `db reset` (dbResetCmd): + // `--no-seed` disables the seed, `--sql-paths` overrides the seed paths, + // before MigrateAndSeed runs inside the recreate. + if err := applyDbResetSeedFlags(bootstrapNoSeed, bootstrapSqlPaths); err != nil { + return err + } + return reset.RecreateLocalDatabase(cmd.Context(), bootstrapVersion, fsys) + case "await-storage": + ready, err := reset.AwaitStorageReady(cmd.Context()) + if err != nil { + return err + } + if ready { + fmt.Println("ready") + } else { + fmt.Println("absent") + } + return nil + default: + return fmt.Errorf("unknown bootstrap mode: %s", bootstrapMode) + } + }, + } + dbRemoteCmd = &cobra.Command{ Hidden: true, Use: "remote", @@ -620,6 +686,14 @@ func init() { shadowFlags.StringSliceVarP(&shadowSchema, "schema", "s", []string{}, "Comma separated list of schema to include.") shadowFlags.StringVar(&shadowProjectRef, "project-ref", "", "Linked project ref, so the shadow merges the matching [remotes.] config override.") dbCmd.AddCommand(dbShadowCmd) + // Build hidden container-bootstrap seam command (native db start / db reset) + bootstrapFlags := dbBootstrapCmd.Flags() + bootstrapFlags.StringVar(&bootstrapMode, "mode", "start", "Bootstrap mode: start, recreate, or await-storage.") + bootstrapFlags.StringVar(&bootstrapFromBackup, "from-backup", "", "Path to a logical backup file (start mode).") + bootstrapFlags.StringVar(&bootstrapVersion, "version", "", "Reset up to the specified version (recreate mode).") + bootstrapFlags.BoolVar(&bootstrapNoSeed, "no-seed", false, "Skip the seed script after recreate (recreate mode).") + bootstrapFlags.StringArrayVar(&bootstrapSqlPaths, "sql-paths", nil, "Override [db.seed].sql_paths for the recreate (recreate mode).") + dbCmd.AddCommand(dbBootstrapCmd) // Build remote command remoteFlags := dbRemoteCmd.PersistentFlags() remoteFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.") diff --git a/apps/cli-go/internal/db/reset/reset.go b/apps/cli-go/internal/db/reset/reset.go index 7d841f4ba3..765153d6d7 100644 --- a/apps/cli-go/internal/db/reset/reset.go +++ b/apps/cli-go/internal/db/reset/reset.go @@ -92,6 +92,38 @@ func toLogMessage(version string) string { return "..." } +// RecreateLocalDatabase is the container-lifecycle half of a local `db reset`, +// exposed for the native-TypeScript `db reset --local` seam (cmd db __db-bootstrap). +// It performs the PG14/PG15 branch — recreate the db container/volume, init schema, +// migrate + seed, and restart the satellite containers — WITHOUT the leading +// "Resetting local database…" line, which the TS caller prints itself. Mirrors +// resetDatabase (above) minus that message. +func RecreateLocalDatabase(ctx context.Context, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { + if utils.Config.Db.MajorVersion <= 14 { + return resetDatabase14(ctx, version, fsys, options...) + } + return resetDatabase15(ctx, version, fsys, options...) +} + +// AwaitStorageReady mirrors the storage-health gate that local `db reset` runs +// before seeding buckets (Run, above): if the storage container exists but is not +// healthy, wait up to 30s for it. It reports whether the storage container exists +// so the native-TypeScript caller knows whether to run the (already-ported) bucket +// seeding. Any inspect error is treated as "storage not running" → false, matching +// Go's `err == nil` gate, which silently skips buckets on any inspect failure. +func AwaitStorageReady(ctx context.Context) (bool, error) { + resp, err := utils.Docker.ContainerInspect(ctx, utils.StorageId) + if err != nil { + return false, nil + } + if resp.State.Health == nil || resp.State.Health.Status != types.Healthy { + if err := start.WaitForHealthyService(ctx, 30*time.Second, utils.StorageId); err != nil { + return false, err + } + } + return true, nil +} + func resetDatabase14(ctx context.Context, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { if err := recreateDatabase(ctx, options...); err != nil { return err diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 42d783057c..e8334c52cf 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -80,51 +80,51 @@ These commands exist in the TS CLI today but have no direct top-level equivalent ## Database -| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | -| --------------------------------- | --------- | -------------------------------------------------- | -------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra diff via edge-runtime against a Go-seam-provisioned live shadow (`db __shadow`); `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. | -| `db dump` | `ported` | `legacy/commands/db/dump/` | `n/a` | `n/a` | Native TS port. Streams `pg_dump`/`pg_dumpall` via a Docker container (`LegacyDockerRun`); schema/data/role modes, `--dry-run` script print, IPv4 transaction-pooler fallback. | -| `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | -| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. The initial-migra pull dumps the remote schema natively (`pg_dump`) then appends the migra diff. Only `--experimental` (structured dump) still delegates to Go, pending a TS PostgreSQL DDL parser for `format.WriteStructuredSchemas`. | -| `db push` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db reset` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db start` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | -| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | -| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `migration down` | `ported` | `legacy/commands/migration/down/` | `n/a` | `n/a` | Native TS port. Revert prompt → drop user schemas → vault upsert → migrate&seed to the target version; defaults to `--local`. Skips Go's pgcache catalog write. | -| `migration fetch` | `ported` | `legacy/commands/migration/fetch/` | `n/a` | `n/a` | Native TS port. Reads `schema_migrations` and writes `supabase/migrations/_.sql`; overwrite prompt for a non-empty dir. | -| `migration list` | `ported` | `legacy/commands/migration/list/` | `n/a` | `n/a` | Native TS port. Merges remote `schema_migrations` with local files into a Glamour ASCII table (Local / Remote / Time-UTC columns); defaults to `--linked`. | -| `migration new` | `ported` | `legacy/commands/migration/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/migrations/_.sql` (mode 0644) from piped stdin; no DB/API. | -| `migration repair` | `ported` | `legacy/commands/migration/repair/` | `n/a` | `n/a` | Native TS port. Transactional create-table + TRUNCATE/UPSERT/DELETE; applied mode reads local files; repair-all prompt; defaults to `--linked`. | -| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration up` | `ported` | `legacy/commands/migration/up/` | `n/a` | `n/a` | Native TS port. Computes pending migrations, upserts `[db.vault]`, applies each transactionally; `--include-all` for out-of-order; defaults to `--local`. Does not seed (matches Go). | -| `seed buckets` | `ported` | `legacy/commands/seed/buckets/` | `n/a` | `n/a` | Native TS port. Local-only (Go's `seed` defines no `--project-ref`, so the ref is always empty): seeds `[storage.buckets]` + `[storage.vector]` against the local Storage service gateway; remote/analytics paths are unreachable and omitted. `--linked`/`--local` accepted for surface parity (both seed local). Vector graceful-skip WARNINGs ported. | -| `test db` | `ported` | `legacy/commands/test/db/` | `n/a` | `n/a` | Native TS port. `--db-url`/`--local`/`--linked` + variadic paths; runs `supabase/pg_prove:3.36` via `docker run`; pgTAP enable/disable via `@effect/sql-pg`. `--network-id` override and `[images]` config override not modeled (documented divergences). | -| `test new` | `ported` | `legacy/commands/test/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/tests/_test.sql` from the embedded pgtap template; `--template` (pgtap). | +| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | +| --------------------------------- | --------- | -------------------------------------------------- | -------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra diff via edge-runtime against a Go-seam-provisioned live shadow (`db __shadow`); `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. | +| `db dump` | `ported` | `legacy/commands/db/dump/` | `n/a` | `n/a` | Native TS port. Streams `pg_dump`/`pg_dumpall` via a Docker container (`LegacyDockerRun`); schema/data/role modes, `--dry-run` script print, IPv4 transaction-pooler fallback. | +| `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | +| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. The initial-migra pull dumps the remote schema natively (`pg_dump`) then appends the migra diff. Only `--experimental` (structured dump) still delegates to Go, pending a TS PostgreSQL DDL parser for `format.WriteStructuredSchemas`. | +| `db push` | `ported` | `legacy/commands/db/push/` | `n/a` | `n/a` | Native TS port. Connects local/linked/`--db-url`; pushes pending migrations, `--include-seed` seeds (`seed_files` hash tracking), `--include-roles`, `[db.vault]` secrets; `--dry-run`. `encrypted:` vault secrets + best-effort pg-delta catalog cache not ported (no output impact). | +| `db reset` | `ported` | `legacy/commands/db/reset/` | `n/a` | `n/a` | Remote path native (drop user schemas, vault upsert, MigrateAndSeed, `--version`/`--last`, `--sql-paths` seed override). Local path native: running check, recreate + migrate + seed via the hidden Go `db __db-bootstrap` seam, storage-gated bucket seeding (reuses `seed buckets`), git-branch `Finished…` line. Only the niche `--experimental` remote schema-files path still delegates to the Go binary (telemetry-disabled). | +| `db start` | `ported` | `legacy/commands/db/start/` | `n/a` | `n/a` | Native TS port. Validates config, checks "already running" (prints Go's line), else delegates the container bootstrap (create + health + initial schema/roles/migrations/seed + `_current_branch`) to the hidden Go `db __db-bootstrap --mode start` seam. No status table / `cli_stack_started` (those are `supabase start`). `--from-backup` supported. | +| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | +| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | +| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `migration down` | `ported` | `legacy/commands/migration/down/` | `n/a` | `n/a` | Native TS port. Revert prompt → drop user schemas → vault upsert → migrate&seed to the target version; defaults to `--local`. Skips Go's pgcache catalog write. | +| `migration fetch` | `ported` | `legacy/commands/migration/fetch/` | `n/a` | `n/a` | Native TS port. Reads `schema_migrations` and writes `supabase/migrations/_.sql`; overwrite prompt for a non-empty dir. | +| `migration list` | `ported` | `legacy/commands/migration/list/` | `n/a` | `n/a` | Native TS port. Merges remote `schema_migrations` with local files into a Glamour ASCII table (Local / Remote / Time-UTC columns); defaults to `--linked`. | +| `migration new` | `ported` | `legacy/commands/migration/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/migrations/_.sql` (mode 0644) from piped stdin; no DB/API. | +| `migration repair` | `ported` | `legacy/commands/migration/repair/` | `n/a` | `n/a` | Native TS port. Transactional create-table + TRUNCATE/UPSERT/DELETE; applied mode reads local files; repair-all prompt; defaults to `--linked`. | +| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration up` | `ported` | `legacy/commands/migration/up/` | `n/a` | `n/a` | Native TS port. Computes pending migrations, upserts `[db.vault]`, applies each transactionally; `--include-all` for out-of-order; defaults to `--local`. Does not seed (matches Go). | +| `seed buckets` | `ported` | `legacy/commands/seed/buckets/` | `n/a` | `n/a` | Native TS port. Local-only (Go's `seed` defines no `--project-ref`, so the ref is always empty): seeds `[storage.buckets]` + `[storage.vector]` against the local Storage service gateway; remote/analytics paths are unreachable and omitted. `--linked`/`--local` accepted for surface parity (both seed local). Vector graceful-skip WARNINGs ported. | +| `test db` | `ported` | `legacy/commands/test/db/` | `n/a` | `n/a` | Native TS port. `--db-url`/`--local`/`--linked` + variadic paths; runs `supabase/pg_prove:3.36` via `docker run`; pgTAP enable/disable via `@effect/sql-pg`. `--network-id` override and `[images]` config override not modeled (documented divergences). | +| `test new` | `ported` | `legacy/commands/test/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/tests/_test.sql` from the embedded pgtap template; `--template` (pgtap). | ## Code Generation @@ -211,111 +211,111 @@ Legend: - `wrapped`: Phase 0 proxy wrapper exists in the legacy shell - `missing`: no legacy shell command yet -| Command | Legacy status | Legacy command path | -| -------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `orgs list` | `ported` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | -| `orgs create` | `ported` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | -| `projects list` | `ported` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | -| `projects create` | `ported` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | -| `projects delete` | `ported` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | -| `projects api-keys` | `ported` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | -| `branches list` | `ported` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | -| `branches create` | `ported` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | -| `branches get` | `ported` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | -| `branches update` | `ported` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | -| `branches pause` | `ported` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | -| `branches unpause` | `ported` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | -| `branches delete` | `ported` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | -| `branches disable` | `ported` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | -| `secrets list` | `ported` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | -| `secrets set` | `ported` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | -| `secrets unset` | `ported` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | -| `config push` | `ported` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | -| `backups list` | `ported` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | -| `backups restore` | `ported` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | -| `snippets list` | `ported` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | -| `snippets download` | `ported` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | -| `sso list` | `ported` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | -| `sso add` | `ported` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | -| `sso remove` | `ported` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | -| `sso update` | `ported` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | -| `sso show` | `ported` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | -| `sso info` | `ported` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | -| `domains create` | `ported` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | -| `domains get` | `ported` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | -| `domains reverify` | `ported` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | -| `domains activate` | `ported` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | -| `domains delete` | `ported` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | -| `vanity-subdomains get` | `ported` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | -| `vanity-subdomains check-availability` | `ported` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | -| `vanity-subdomains activate` | `ported` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | -| `vanity-subdomains delete` | `ported` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | -| `network-bans get` | `ported` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | -| `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | -| `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | -| `network-restrictions update` | `ported` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | -| `encryption get-root-key` | `ported` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | -| `encryption update-root-key` | `ported` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | -| `ssl-enforcement get` | `ported` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | -| `ssl-enforcement update` | `ported` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | -| `postgres-config get` | `ported` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | -| `postgres-config update` | `ported` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | -| `postgres-config delete` | `ported` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | -| `login` | `ported` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | -| `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | -| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | -| `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | -| `bootstrap` | `ported` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) (native; `db push` step delegated to the Go binary — interim) | -| `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | -| `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | -| `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | -| `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | -| `status` | `wrapped` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) | -| `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | -| `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | -| `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | -| `migration list` | `ported` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) — native; merged Local/Remote/Time-UTC Glamour table | -| `migration new` | `ported` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) — native; writes `supabase/migrations/_.sql` from piped stdin | -| `migration repair` | `ported` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) — native; transactional TRUNCATE/UPSERT/DELETE, repair-all prompt | -| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | -| `migration up` | `ported` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) — native; pending compute + vault upsert + per-file apply | -| `migration down` | `ported` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) — native; drop + vault + migrate&seed to target version | -| `migration fetch` | `ported` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) — native; writes history rows to `supabase/migrations/` | -| `gen types` | `ported` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) - native; non-TypeScript project refs use pg-meta with IPv4 pooler retry instead of Go's "Try using --db-url" failure; `--swift-access-control` requires `--lang swift`; explicit source flags with `--query-timeout` on the remote TypeScript path error, while the implicit linked TypeScript path warns and continues | -| `gen signing-key` | `ported` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | -| `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | -| `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | -| `functions list` | `wrapped` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | -| `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | -| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | -| `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | -| `functions new` | `ported` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | -| `functions serve` | `ported` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | -| `storage ls` | `ported` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | -| `storage cp` | `ported` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | -| `storage mv` | `ported` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | -| `storage rm` | `ported` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | -| `test db` | `ported` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | -| `test new` | `ported` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | -| `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | -| `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — native pg-delta / migra; `--use-pgadmin` / `--use-pg-schema` delegate to Go | -| `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | -| `db push` | `wrapped` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | -| `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); initial-migra pull dumps the schema natively (`pg_dump`) + appends the diff; only `--experimental` structured dump still delegates to Go (needs a TS DDL parser for `WriteStructuredSchemas`) | -| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — includes Go-parity `--sql-paths` override for `[db.seed].sql_paths` | -| `db lint` | `ported` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | -| `db start` | `wrapped` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | -| `db query` | `ported` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | -| `db advisors` | `ported` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | -| `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | -| `db branch create` | `wrapped` | [`../src/legacy/commands/db/branch/create/create.command.ts`](../src/legacy/commands/db/branch/create/create.command.ts) | -| `db branch delete` | `wrapped` | [`../src/legacy/commands/db/branch/delete/delete.command.ts`](../src/legacy/commands/db/branch/delete/delete.command.ts) | -| `db branch list` | `wrapped` | [`../src/legacy/commands/db/branch/list/list.command.ts`](../src/legacy/commands/db/branch/list/list.command.ts) | -| `db branch switch` | `wrapped` | [`../src/legacy/commands/db/branch/switch/switch.command.ts`](../src/legacy/commands/db/branch/switch/switch.command.ts) | -| `db remote changes` | `wrapped` | [`../src/legacy/commands/db/remote/changes/changes.command.ts`](../src/legacy/commands/db/remote/changes/changes.command.ts) | -| `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | -| `db schema declarative sync` | `ported` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | -| `db schema declarative generate` | `ported` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | +| Command | Legacy status | Legacy command path | +| -------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `orgs list` | `ported` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | +| `orgs create` | `ported` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | +| `projects list` | `ported` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | +| `projects create` | `ported` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | +| `projects delete` | `ported` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | +| `projects api-keys` | `ported` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | +| `branches list` | `ported` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | +| `branches create` | `ported` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | +| `branches get` | `ported` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | +| `branches update` | `ported` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | +| `branches pause` | `ported` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | +| `branches unpause` | `ported` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | +| `branches delete` | `ported` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | +| `branches disable` | `ported` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | +| `secrets list` | `ported` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | +| `secrets set` | `ported` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | +| `secrets unset` | `ported` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | +| `config push` | `ported` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | +| `backups list` | `ported` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | +| `backups restore` | `ported` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | +| `snippets list` | `ported` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | +| `snippets download` | `ported` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | +| `sso list` | `ported` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | +| `sso add` | `ported` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | +| `sso remove` | `ported` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | +| `sso update` | `ported` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | +| `sso show` | `ported` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | +| `sso info` | `ported` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | +| `domains create` | `ported` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | +| `domains get` | `ported` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | +| `domains reverify` | `ported` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | +| `domains activate` | `ported` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | +| `domains delete` | `ported` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | +| `vanity-subdomains get` | `ported` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | +| `vanity-subdomains check-availability` | `ported` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | +| `vanity-subdomains activate` | `ported` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | +| `vanity-subdomains delete` | `ported` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | +| `network-bans get` | `ported` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | +| `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | +| `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | +| `network-restrictions update` | `ported` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | +| `encryption get-root-key` | `ported` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | +| `encryption update-root-key` | `ported` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | +| `ssl-enforcement get` | `ported` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | +| `ssl-enforcement update` | `ported` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | +| `postgres-config get` | `ported` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | +| `postgres-config update` | `ported` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | +| `postgres-config delete` | `ported` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | +| `login` | `ported` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | +| `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | +| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | +| `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | +| `bootstrap` | `ported` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) (native; `db push` step delegated to the Go binary — interim) | +| `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | +| `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | +| `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | +| `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | +| `status` | `wrapped` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) | +| `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | +| `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | +| `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | +| `migration list` | `ported` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) — native; merged Local/Remote/Time-UTC Glamour table | +| `migration new` | `ported` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) — native; writes `supabase/migrations/_.sql` from piped stdin | +| `migration repair` | `ported` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) — native; transactional TRUNCATE/UPSERT/DELETE, repair-all prompt | +| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | +| `migration up` | `ported` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) — native; pending compute + vault upsert + per-file apply | +| `migration down` | `ported` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) — native; drop + vault + migrate&seed to target version | +| `migration fetch` | `ported` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) — native; writes history rows to `supabase/migrations/` | +| `gen types` | `ported` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) | +| `gen signing-key` | `ported` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | +| `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | +| `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | +| `functions list` | `wrapped` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | +| `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | +| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | +| `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | +| `functions new` | `ported` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | +| `functions serve` | `ported` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | +| `storage ls` | `ported` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | +| `storage cp` | `ported` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | +| `storage mv` | `ported` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | +| `storage rm` | `ported` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | +| `test db` | `ported` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | +| `test new` | `ported` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | +| `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | +| `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — native pg-delta / migra; `--use-pgadmin` / `--use-pg-schema` delegate to Go | +| `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | +| `db push` | `ported` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | +| `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); initial-migra pull dumps the schema natively (`pg_dump`) + appends the diff; only `--experimental` structured dump still delegates to Go (needs a TS DDL parser for `WriteStructuredSchemas`) | +| `db reset` | `ported` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — includes Go-parity `--sql-paths` override for `[db.seed].sql_paths` | +| `db lint` | `ported` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | +| `db start` | `ported` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | +| `db query` | `ported` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | +| `db advisors` | `ported` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | +| `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | +| `db branch create` | `wrapped` | [`../src/legacy/commands/db/branch/create/create.command.ts`](../src/legacy/commands/db/branch/create/create.command.ts) | +| `db branch delete` | `wrapped` | [`../src/legacy/commands/db/branch/delete/delete.command.ts`](../src/legacy/commands/db/branch/delete/delete.command.ts) | +| `db branch list` | `wrapped` | [`../src/legacy/commands/db/branch/list/list.command.ts`](../src/legacy/commands/db/branch/list/list.command.ts) | +| `db branch switch` | `wrapped` | [`../src/legacy/commands/db/branch/switch/switch.command.ts`](../src/legacy/commands/db/branch/switch/switch.command.ts) | +| `db remote changes` | `wrapped` | [`../src/legacy/commands/db/remote/changes/changes.command.ts`](../src/legacy/commands/db/remote/changes/changes.command.ts) | +| `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | +| `db schema declarative sync` | `ported` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | +| `db schema declarative generate` | `ported` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | Flag divergences from the Go reference: diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts index df39fcba27..8e69052093 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts @@ -37,6 +37,7 @@ function mockCliConfig(opts: { apiUrl: opts.apiUrl ?? "https://api.supabase.com", projectHost: opts.projectHost ?? "supabase.co", poolerHost: "supabase.com", + dashboardUrl: "https://supabase.com/dashboard", accessToken: opts.accessToken === undefined ? Option.none() : Option.some(Redacted.make(opts.accessToken)), projectId: Option.none(), diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index 11337a8955..5216459f76 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -1,59 +1,104 @@ # `supabase db push` +Native TypeScript port of `apps/cli-go/internal/db/push/push.go`. Applies pending +local migrations (and optionally seed data and custom roles) to the local or +linked/remote Postgres database. + ## Files Read -| Path | Format | When | -| -------------------------------- | ---------- | ------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | -| `/supabase/migrations/` | directory | always, to list migration files to push | -| `/supabase/roles.sql` | SQL | when `--include-roles` is set | -| seed files from config | SQL | when `--include-seed` is set | +| Path | Format | When | +| ------------------------------------- | ---------- | ----------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (embedded defaults used when absent) | +| `~/.supabase//project-ref` | plain text | on the `--linked` path (and the default target), to resolve the ref | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and a linked temp-role is minted | +| `/supabase/migrations/` | directory | when `[db.migrations].enabled` (default true), to list local files | +| `/supabase/migrations/*.sql` | SQL | for each pending migration, when applied (and not `--dry-run`) | +| seed files from `[db.seed].sql_paths` | SQL | when `--include-seed` and `[db.seed].enabled` (paths under `supabase/`) | +| `/supabase/roles.sql` | SQL | when `--include-roles` (existence check + apply) | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | ------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | on the `--linked` path (post-run cache, Go's `ensureProjectGroupsCached`) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | + +No project files are written. All other effects are database mutations (below). + +## Database Mutations + +| Statement | When | +| ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `RESET ALL` + `BEGIN` … migration statements … `INSERT INTO supabase_migrations.schema_migrations(version, name, statements)` … `COMMIT` | per pending migration (after confirmation) | +| `CREATE SCHEMA/TABLE … supabase_migrations.schema_migrations`, `ALTER TABLE … ADD COLUMN …` | once before applying migrations (idempotent) | +| `RESET ALL` + `BEGIN` … roles.sql statements … `COMMIT` (no history row) | per `--include-roles` globals file (after confirmation) | +| `SELECT id, name FROM vault.secrets …`, `SELECT vault.update_secret(...)`, `SELECT vault.create_secret(...)` | when `[db.vault]` has syncable secrets and migrations are applied | +| `CREATE TABLE … supabase_migrations.seed_files`, seed statements, `INSERT … seed_files(path, hash) … ON CONFLICT …` | per pending seed file with `--include-seed` (after confirmation); a dirty seed only refreshes the hash | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| — | — | — | — | The native handler connects to Postgres directly. On the `--linked` path the db-config resolver may call the Management API to mint a temporary login role (inherited from the shared resolver). | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `DB_PASSWORD` | password for direct database connection | no | +| Variable | Purpose | Required? | +| ----------------------- | ------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no (`--password`/`-p` takes precedence) | +| `SUPABASE_YES` | auto-confirm prompts (Go's `viper YES`) | no (also `--yes`) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | migration apply error | +| Code | Condition | +| ---- | ------------------------------------------------------------------------- | +| `0` | success (including "up to date") | +| `1` | mutually exclusive target flags (`[db-url linked local]`) | +| `1` | `ErrMissingLocal` — remote versions absent locally (suggests repair/pull) | +| `1` | `ErrMissingRemote` without `--include-all` (suggests `--include-all`) | +| `1` | user declined a confirmation prompt (`context canceled`) | +| `1` | `config.toml` parse failure | +| `1` | database connection / migration / seed / roles / vault apply failure | ## Output -### `--output-format text` (Go CLI compatible) +Diagnostics ("Connecting to…", "Applying migration…", "Seeding…", "Updating vault +secrets…", skip/up-to-date notices, dry-run plan, prompts) go to **stderr**. The +two summary lines Go prints to **stdout** — ` is up to date.` and +`Finished supabase db push.` (the command name in Aqua) — go to stdout in text +mode; in machine modes they are suppressed and a structured result is emitted. -Prints applied migration versions to stderr. With `--dry-run`, prints the migrations that would be applied. +### `--output-format text` (Go CLI compatible) -### `--output-format json` +Byte-matches Go: connection status, per-item progress, prompts, and the stdout +summary line, including ANSI color (Aqua command name, Bold file paths). -Not applicable. +### `--output-format json` / `stream-json` -### `--output-format stream-json` +stdout is payload-only. A single `result` object is emitted: -Not applicable. +```json +{ + "upToDate": false, + "dryRun": false, + "migrations": [".sql"], + "seeds": ["supabase/seed.sql"], + "roles": ["supabase/roles.sql"] +} +``` ## Notes -- `--dry-run` prints the migrations that would be applied without applying them. -- `--include-all` includes all migrations not found in remote history table. -- `--include-roles` includes custom roles from the roles file. -- `--include-seed` includes seed data from config. -- `--db-url`, `--linked` (default true), and `--local` are mutually exclusive. +- **Targets**: `--db-url`, `--linked` (default), and `--local` are mutually + exclusive; with no flag the target defaults to linked, matching Go. +- **Prompt order**: custom roles → migrations → seeds; each defaults to "yes" and + declining returns `context canceled`. +- **`--dry-run`** prints the plan (roles / migrations / seeds) and applies nothing. +- **`[db.migrations].enabled = false`** / **`[db.seed].enabled = false`** print a + skip notice naming the project ref (empty for local/db-url). +- **Vault**: only non-empty, non-`env()` `[db.vault]` literals are synced (Go syncs + secrets with a non-empty SHA256). **Known gap vs Go**: `encrypted:`-prefixed + vault secrets are currently skipped — dotenvx/ECIES decryption is not yet ported. +- **Migrations catalog cache** (Go's best-effort `pgcache.TryCacheMigrationsCatalog`, + warning-only) is not ported; it produces no output, so parity is preserved. diff --git a/apps/cli/src/legacy/commands/db/push/push.command.ts b/apps/cli/src/legacy/commands/db/push/push.command.ts index 2d6d8d17e9..8c936315ca 100644 --- a/apps/cli/src/legacy/commands/db/push/push.command.ts +++ b/apps/cli/src/legacy/commands/db/push/push.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyDbPush } from "./push.handler.ts"; +import { legacyDbPushRuntimeLayer } from "./push.layers.ts"; const config = { includeAll: Flag.boolean("include-all").pipe( @@ -37,5 +41,24 @@ export type LegacyDbPushFlags = CliCommand.Command.Config.Infer; export const legacyDbPushCommand = Command.make("push", config).pipe( Command.withDescription("Push new migrations to the remote database."), Command.withShortDescription("Push new migrations to the remote database"), - Command.withHandler((flags) => legacyDbPush(flags)), + Command.withHandler((flags) => + legacyDbPush(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + "include-all": flags.includeAll, + "include-roles": flags.includeRoles, + "include-seed": flags.includeSeed, + "dry-run": flags.dryRun, + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + // `password` is a credential — always reaches telemetry as ``. + password: flags.password, + }, + aliases: { p: "password" }, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyDbPushRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/db/push/push.errors.ts b/apps/cli/src/legacy/commands/db/push/push.errors.ts new file mode 100644 index 0000000000..3849d55add --- /dev/null +++ b/apps/cli/src/legacy/commands/db/push/push.errors.ts @@ -0,0 +1,56 @@ +import { Data } from "effect"; + +/** + * Conflicting database-target flags. Reproduces cobra's + * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` error byte-for-byte + * (`apps/cli-go/cmd/db.go:526`). + */ +export class LegacyDbPushTargetFlagsError extends Data.TaggedError("LegacyDbPushTargetFlagsError")<{ + readonly message: string; +}> {} + +/** + * Remote migration versions are missing from the local directory. Byte-matches + * Go's `migration.ErrMissingLocal` (`pkg/migration/apply.go:16`); the + * `migration repair` / `db pull` suggestion is attached (Go's `CmdSuggestion`). + */ +export class LegacyDbPushMissingLocalError extends Data.TaggedError( + "LegacyDbPushMissingLocalError", +)<{ + readonly message: string; + readonly suggestion: string; +}> {} + +/** + * Local migration files are ordered before the remote head and `--include-all` + * was not passed. Byte-matches Go's `migration.ErrMissingRemote` + * (`pkg/migration/apply.go:15`); the `--include-all` suggestion is attached. + */ +export class LegacyDbPushMissingRemoteError extends Data.TaggedError( + "LegacyDbPushMissingRemoteError", +)<{ + readonly message: string; + readonly suggestion: string; +}> {} + +/** + * The user declined a confirmation prompt. Go returns `errors.New(context.Canceled)` + * (`internal/db/push/push.go:80,91,110`), rendered as `context canceled`. + */ +export class LegacyDbPushCancelledError extends Data.TaggedError("LegacyDbPushCancelledError")<{ + readonly message: string; +}> {} + +/** Locating `supabase/roles.sql` failed (Go's `failed to find custom roles: %w`). */ +export class LegacyDbPushRolesError extends Data.TaggedError("LegacyDbPushRolesError")<{ + readonly message: string; +}> {} + +/** + * A migration / seed / globals / vault statement failed while applying. Carries + * the underlying Postgres error (with Go's `At statement: ` context for + * migrations) so stderr matches Go's propagated error. + */ +export class LegacyDbPushApplyError extends Data.TaggedError("LegacyDbPushApplyError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/push/push.handler.ts b/apps/cli/src/legacy/commands/db/push/push.handler.ts index fddb270174..8bb4fdcd76 100644 --- a/apps/cli/src/legacy/commands/db/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/db/push/push.handler.ts @@ -1,17 +1,340 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Effect, FileSystem, Option, Path } from "effect"; + +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; +import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { legacyAqua, legacyBold } from "../../../shared/legacy-colors.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { + legacyCheckDbToml, + legacyLoadProjectEnv, +} from "../../../shared/legacy-db-config.toml-read.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { + legacyApplyMigrations, + legacySeedGlobals, +} from "../../../shared/legacy-migration-apply.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-flags.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyListLocalMigrations } from "../shared/legacy-pgdelta.cache.ts"; +import { + LEGACY_ERR_MISSING_LOCAL, + LEGACY_ERR_MISSING_REMOTE, + legacyFindPendingMigrations, + legacyIncludeAllPending, + legacySuggestIgnoreFlag, + legacySuggestRevertHistory, +} from "../shared/legacy-migration-pending.ts"; +import { + type LegacySeedFile, + legacyGetPendingSeeds, + legacySeedData, +} from "../shared/legacy-seed-ops.ts"; +import { legacyUpsertVaultSecrets } from "../../../shared/legacy-vault.ts"; +// Listing the remote `schema_migrations` history (with the 42P01 → empty rule) +// lives in the shared migration-history module (Go's `migration.ListRemoteMigrations`). +import { legacyListRemoteMigrations } from "../../../shared/legacy-migration-history.ts"; import type { LegacyDbPushFlags } from "./push.command.ts"; +import { + LegacyDbPushApplyError, + LegacyDbPushCancelledError, + LegacyDbPushMissingLocalError, + LegacyDbPushMissingRemoteError, + LegacyDbPushRolesError, + LegacyDbPushTargetFlagsError, +} from "./push.errors.ts"; + +const CUSTOM_ROLES_PATH = "supabase/roles.sql"; + +const toSlash = (p: string): string => p.replaceAll("\\", "/"); + +/** Go's `confirmPushAll` (`internal/db/push/push.go:123-129`) — bold filenames. */ +const confirmPushAll = (filenames: ReadonlyArray): string => + filenames.map((name) => ` • ${legacyBold(name)}\n`).join(""); + +/** Go's `confirmSeedAll` (`internal/db/push/push.go:131-140`) — bold paths, hash notice. */ +const confirmSeedAll = (seeds: ReadonlyArray): string => + seeds + .map((seed) => ` • ${legacyBold(seed.dirty ? `${seed.path} (hash update)` : seed.path)}\n`) + .join(""); + +const applyError = (message: string) => new LegacyDbPushApplyError({ message }); +/** + * `supabase db push` — apply pending local migrations (and optionally seed data + * and custom roles) to the local or linked/remote database. + * + * Strict 1:1 port of `apps/cli-go/internal/db/push/push.go`. + */ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: LegacyDbPushFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["db", "push"]; - if (flags.includeAll) args.push("--include-all"); - if (flags.includeRoles) args.push("--include-roles"); - if (flags.includeSeed) args.push("--include-seed"); - if (flags.dryRun) args.push("--dry-run"); - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - if (Option.isSome(flags.password)) args.push("--password", flags.password.value); - yield* proxy.exec(args); + const output = yield* Output; + const resolver = yield* LegacyDbConfigResolver; + const dbConn = yield* LegacyDbConnection; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cliArgs = yield* CliArgs; + const dnsResolver = yield* LegacyDnsResolverFlag; + + const workdir = cliConfig.workdir; + // Go's `ParseDatabaseConfig` runs `loadNestedEnv` (which `os.Setenv`s each project-`.env` + // key) before `PromptYesNo` reads `viper.GetBool("YES")`, so a `SUPABASE_YES` set only in + // `supabase/.env` auto-confirms. Resolve `yes` with that project env, as `db pull` does. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + let linkedRefForCache: string | undefined; + + const body = Effect.gen(function* () { + const target = resolveLegacyDbTargetFlags(cliArgs.args); + // cobra MarkFlagsMutuallyExclusive("db-url", "linked", "local"), keyed off the + // explicitly-set flags (cobra's `Changed`), not the `--linked` default value. + if (target.setFlags.length > 1) { + return yield* Effect.fail( + new LegacyDbPushTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }), + ); + } + // Go's push defaults `--linked` to true, so no target flag → linked. + const connType = target.connType ?? "linked"; + + // The linked path resolves the project ref before loading config so a matching + // `[remotes.]` block merges (Go's ParseDatabaseConfig → LoadConfig). For + // `--local` / `--db-url`, Go leaves `flags.ProjectRef` empty. + let projectRef = ""; + if (connType === "linked") { + const refResolver = yield* LegacyProjectRefResolver; + projectRef = yield* refResolver.loadProjectRef(Option.none()); + linkedRefForCache = projectRef; + } + + // Single Go-parity config load (`flags.LoadConfig` → `config.Load` + `Validate`): + // decodes the whole config with Go's env-expansion + `strconv.ParseBool` weak typing + // (so `enabled = "env(SEED_ENABLED)"` etc. load like Go), applies `SUPABASE_*` + // AutomaticEnv overrides, merges a matching `[remotes.]` block, and decrypts every + // `encrypted:` secret with the shell AND project-`.env` `DOTENV_PRIVATE_KEY*` keys — + // aborting here (before connecting or writing) on any undecryptable/invalid config. + const toml = yield* legacyCheckDbToml( + fs, + path, + workdir, + projectRef !== "" ? projectRef : undefined, + ); + if (toml.appliedRemote !== undefined) { + yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); + } + const vaultSecrets = toml.vault; + + if (flags.dryRun) { + yield* output.raw("DRY RUN: migrations will *not* be pushed to the database.\n", "stderr"); + } + + const cfg = yield* resolver.resolve({ + dbUrl: flags.dbUrl, + connType, + dnsResolver, + password: flags.password, + }); + const databaseName = cfg.isLocal ? "local database" : "remote database"; + const statusTarget = cfg.isLocal ? "Local database" : "Remote database"; + + yield* Effect.scoped( + Effect.gen(function* () { + yield* output.raw( + `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, + "stderr", + ); + const session = yield* dbConn.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver }); + + // --- Collect pending migrations --- + let pending: ReadonlyArray = []; + if (!toml.migrationsEnabled) { + yield* output.raw( + `Skipping migrations because it is disabled in config.toml for project: ${projectRef}\n`, + "stderr", + ); + } else { + const migrationsDir = path.join(workdir, "supabase", "migrations"); + const remote = yield* legacyListRemoteMigrations(session); + const local = yield* legacyListLocalMigrations(fs, path, migrationsDir); + const result = legacyFindPendingMigrations(local, remote); + if (result.kind === "missing-local") { + return yield* Effect.fail( + new LegacyDbPushMissingLocalError({ + message: LEGACY_ERR_MISSING_LOCAL, + suggestion: legacySuggestRevertHistory(result.versions), + }), + ); + } + if (result.kind === "missing-remote") { + if (!flags.includeAll) { + // Go's suggestIgnoreFlag lists the workdir-relative paths. + const relPaths = result.paths.map((p) => toSlash(path.relative(workdir, p))); + return yield* Effect.fail( + new LegacyDbPushMissingRemoteError({ + message: LEGACY_ERR_MISSING_REMOTE, + suggestion: legacySuggestIgnoreFlag(relPaths), + }), + ); + } + pending = legacyIncludeAllPending(local, remote.length, result.paths); + } else { + pending = result.pending; + } + } + + // --- Collect pending seeds --- + let seeds: ReadonlyArray = []; + if (flags.includeSeed) { + if (!toml.seed.enabled) { + yield* output.raw( + `Skipping seed because it is disabled in config.toml for project: ${projectRef}\n`, + "stderr", + ); + } else { + seeds = yield* legacyGetPendingSeeds(session, fs, path, toml.seed.sqlPaths, workdir); + } + } + + // --- Collect custom roles --- + const globals: Array = []; + if (flags.includeRoles) { + const exists = yield* fs.exists(path.join(workdir, CUSTOM_ROLES_PATH)).pipe( + Effect.mapError( + (cause) => + new LegacyDbPushRolesError({ + message: `failed to find custom roles: ${cause.message}`, + }), + ), + ); + if (exists) globals.push(CUSTOM_ROLES_PATH); + } + + // --- Nothing to push --- + if (pending.length === 0 && seeds.length === 0 && globals.length === 0) { + if (output.format === "text") { + yield* output.raw(`${statusTarget} is up to date.\n`); + } else { + yield* output.success(`${statusTarget} is up to date.`, { + upToDate: true, + dryRun: flags.dryRun, + migrations: [], + seeds: [], + roles: [], + }); + } + return; + } + + if (flags.dryRun) { + if (globals.length > 0) { + yield* output.raw( + `Would create custom roles ${legacyBold(globals[0]!)}...\n`, + "stderr", + ); + } + if (pending.length > 0) { + yield* output.raw("Would push these migrations:\n", "stderr"); + yield* output.raw(confirmPushAll(pending.map((p) => path.basename(p))), "stderr"); + } + if (seeds.length > 0) { + yield* output.raw("Would seed these files:\n", "stderr"); + yield* output.raw(confirmSeedAll(seeds), "stderr"); + } + } else { + // --- Custom roles --- + if (globals.length > 0) { + const ok = yield* legacyPromptYesNo( + output, + yes, + "Do you want to create custom roles in the database cluster?", + true, + ); + if (!ok) { + return yield* Effect.fail( + new LegacyDbPushCancelledError({ message: "context canceled" }), + ); + } + yield* legacySeedGlobals( + session, + fs, + path, + globals.map((g) => path.join(workdir, g)), + applyError, + ); + } + + // --- Migrations --- + if (pending.length > 0) { + const ok = yield* legacyPromptYesNo( + output, + yes, + `Do you want to push these migrations to the ${databaseName}?\n${confirmPushAll(pending.map((p) => path.basename(p)))}`, + true, + ); + if (!ok) { + return yield* Effect.fail( + new LegacyDbPushCancelledError({ message: "context canceled" }), + ); + } + yield* legacyUpsertVaultSecrets(session, vaultSecrets); + yield* legacyApplyMigrations(session, fs, path, pending, applyError); + // Go best-effort caches the migrations catalog for pg-delta; a failure + // only warns (`push.go:99-101`). The catalog cache is not yet ported, so + // there is nothing to warn about — parity is preserved (no extra output). + } else { + yield* output.raw("Schema migrations are up to date.\n", "stderr"); + } + + // --- Seeds --- + if (seeds.length > 0) { + const ok = yield* legacyPromptYesNo( + output, + yes, + `Do you want to seed the ${databaseName} with these files?\n${confirmSeedAll(seeds)}`, + true, + ); + if (!ok) { + return yield* Effect.fail( + new LegacyDbPushCancelledError({ message: "context canceled" }), + ); + } + yield* legacySeedData(session, fs, workdir, path, seeds, applyError); + } else if (flags.includeSeed) { + yield* output.raw("Seed files are up to date.\n", "stderr"); + } + } + + if (output.format === "text") { + yield* output.raw(`Finished ${legacyAqua("supabase db push")}.\n`); + } else { + yield* output.success("Finished supabase db push.", { + upToDate: false, + dryRun: flags.dryRun, + migrations: pending.map((p) => path.basename(p)), + seeds: seeds.map((s) => s.path), + roles: globals, + }); + } + }), + ); + }); + + yield* body.pipe( + Effect.ensuring( + Effect.suspend(() => + linkedRefForCache !== undefined && linkedRefForCache !== "" + ? linkedProjectCache.cache(linkedRefForCache) + : Effect.void, + ), + ), + Effect.ensuring(telemetryState.flush), + ); }); diff --git a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts new file mode 100644 index 0000000000..9a4b21db68 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts @@ -0,0 +1,826 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; + +import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { LegacyDnsResolverFlag, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import type { + LegacyDbConfigFlags, + LegacyResolvedDbConfig, +} from "../../../shared/legacy-db-config.types.ts"; +import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { legacyDbPush } from "./push.handler.ts"; +import type { LegacyDbPushFlags } from "./push.command.ts"; + +const LIST_MIGRATIONS = + "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; +const SELECT_SEEDS = "SELECT path, hash FROM supabase_migrations.seed_files"; +const READ_VAULT = "SELECT id, name FROM vault.secrets WHERE name = ANY($1)"; + +const LOCAL_CONN: LegacyPgConnInput = { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", +}; + +const DEFAULT_FLAGS: LegacyDbPushFlags = { + includeAll: false, + includeRoles: false, + includeSeed: false, + dryRun: false, + dbUrl: Option.none(), + linked: false, + local: true, + password: Option.none(), +}; + +function mockResolver(opts: { isLocal?: boolean } = {}) { + return Layer.succeed(LegacyDbConfigResolver, { + resolve: (_flags: LegacyDbConfigFlags) => + Effect.succeed({ + conn: LOCAL_CONN, + isLocal: opts.isLocal ?? true, + } satisfies LegacyResolvedDbConfig), + resolvePoolerFallback: () => Effect.succeed(Option.none()), + }); +} + +function mockConnection(opts: { + remoteMigrations?: ReadonlyArray; + remoteSeeds?: Readonly>; + vaultRows?: ReadonlyArray<{ id: string; name: string }>; + noSeedTable?: boolean; + failExec?: string; +}) { + const execs: Array = []; + const queries: Array<{ sql: string; params?: ReadonlyArray }> = []; + const layer = Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.succeed({ + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + exec: (sql: string): Effect.Effect => + Effect.suspend((): Effect.Effect => { + execs.push(sql); + if (opts.failExec !== undefined && sql === opts.failExec) { + return Effect.fail( + new LegacyDbExecError({ message: "ERROR: boom (SQLSTATE 42601)" }), + ); + } + return Effect.void; + }), + query: ( + sql: string, + params?: ReadonlyArray, + ): Effect.Effect>, LegacyDbExecError> => + Effect.suspend( + (): Effect.Effect>, LegacyDbExecError> => { + queries.push({ sql, params }); + if (sql === LIST_MIGRATIONS) { + return Effect.succeed( + (opts.remoteMigrations ?? []).map((version) => ({ version })), + ); + } + if (sql === SELECT_SEEDS) { + if (opts.noSeedTable === true) { + return Effect.fail( + new LegacyDbExecError({ + message: 'relation "supabase_migrations.seed_files" does not exist', + code: "42P01", + }), + ); + } + return Effect.succeed( + Object.entries(opts.remoteSeeds ?? {}).map(([path, hash]) => ({ path, hash })), + ); + } + if (sql === READ_VAULT) { + return Effect.succeed(opts.vaultRows ?? []); + } + return Effect.succeed([]); + }, + ), + }), + }); + return { + layer, + get execs() { + return execs; + }, + get queries() { + return queries; + }, + }; +} + +function setup( + workdir: string, + opts: { + toml?: string; + files?: Readonly>; + format?: OutputFormat; + confirm?: ReadonlyArray; + args?: ReadonlyArray; + yes?: boolean; + isLocal?: boolean; + projectRef?: string; + linkedFails?: boolean; + remoteMigrations?: ReadonlyArray; + remoteSeeds?: Readonly>; + vaultRows?: ReadonlyArray<{ id: string; name: string }>; + noSeedTable?: boolean; + failExec?: string; + }, +) { + if (opts.toml !== undefined) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); + } + for (const [rel, content] of Object.entries(opts.files ?? {})) { + const abs = join(workdir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + } + + const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); + const conn = mockConnection(opts); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedCache = mockLegacyLinkedProjectCacheTracked(); + const projectRefLayer = Layer.succeed(LegacyProjectRefResolver, { + resolve: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + resolveForLink: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + resolveOptional: () => Effect.succeed(Option.some(opts.projectRef ?? LEGACY_VALID_REF)), + loadProjectRef: () => + opts.linkedFails === true + ? Effect.fail( + new LegacyProjectNotLinkedError({ + message: "Cannot find project ref. Have you run supabase link?", + }), + ) + : Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + promptProjectRef: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + }); + + const layer = Layer.mergeAll( + out.layer, + conn.layer, + mockResolver({ isLocal: opts.isLocal ?? true }), + mockLegacyCliConfig({ workdir }), + BunServices.layer, + // Prompts (migration/seed confirmation) are answered through mockOutput's + // `promptConfirmResponses` (the TTY/clack path), so mark stdin a TTY. Stdin is + // only referenced by legacyPromptYesNo's non-TTY branch (unreached here). + mockTty({ stdinIsTty: true }), + mockStdin(true), + Layer.succeed(CliArgs, { args: opts.args ?? ["db", "push", "--local"] }), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + Layer.succeed(LegacyDnsResolverFlag, "native"), + projectRefLayer, + telemetry.layer, + linkedCache.layer, + ); + return { layer, out, conn, telemetry, linkedCache }; +} + +const MIGRATION_DIR = "supabase/migrations"; +const migrationFile = (version: string, body = "create table t ();") => ({ + [`${MIGRATION_DIR}/${version}_test.sql`]: body, +}); + +describe("legacy db push", () => { + const tmp = useLegacyTempWorkdir("supabase-db-push-"); + + it.live("reports up to date when nothing is pending (text)", () => { + const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + // No migration was applied. + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("emits a json result for an up-to-date run", () => { + const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', format: "json" }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["upToDate"]).toBe(true); + expect(success?.data?.["migrations"]).toEqual([]); + }); + }); + + it.live("rejects mutually exclusive target flags", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "push", "--local", "--linked"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); + + it.live("applies a pending migration after confirmation", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + // "supabase db push" is wrapped in Aqua (cyan) on stdout, matching Go. + expect(out.stdoutText).toContain("Finished"); + expect(out.stdoutText).toContain("supabase db push"); + // The migration body + history insert ran inside a transaction. + expect(conn.execs).toContain("BEGIN"); + expect(conn.execs).toContain("COMMIT"); + expect(conn.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations"))).toBe( + true, + ); + }); + }); + + it.live("returns context canceled when the migration prompt is declined", () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("context canceled"); + } + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("prints the plan without applying in dry-run mode", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, dryRun: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("DRY RUN: migrations will *not* be pushed to the database."); + expect(out.stderrText).toContain("Would push these migrations:"); + expect(out.stderrText).toContain("20240101000000_test.sql"); + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("fails with a repair suggestion when remote has versions missing locally", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + remoteMigrations: ["20240101000000"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "Remote migration versions not found in local migrations directory.", + ); + expect(JSON.stringify(exit.cause)).toContain("migration repair --status reverted"); + } + expect(out).toBeDefined(); + }); + }); + + it.live("fails with an --include-all suggestion for out-of-order local migrations", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + // 0101 is local-only and ordered before the already-applied remote 0202. + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + remoteMigrations: ["20240202000000"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("--include-all"); + } + }); + }); + + it.live("pushes out-of-order migrations with --include-all", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + remoteMigrations: ["20240202000000"], + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeAll: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }); + }); + + it.live("skips migrations when disabled in config and reports up to date", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nenabled = false\n', + files: migrationFile("20240101000000"), + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain( + "Skipping migrations because it is disabled in config.toml for project:", + ); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + }); + }); + + it.live("seeds a new file with --include-seed", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); + expect( + conn.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations.seed_files")), + ).toBe(true); + }); + }); + + it.live("expands a directory in [db.seed].sql_paths to its sorted .sql children", () => { + // Go's `GetPendingSeeds` (`Glob.SQLFiles`) walks a matched directory and seeds its + // regular `.sql` files recursively; non-.sql files are skipped. Without dir expansion + // the directory path reached `readFileString()` and failed. + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nsql_paths = ["seeds"]\n', + files: { + "supabase/seeds/a.sql": "insert into t values (1);", + "supabase/seeds/nested/b.sql": "insert into t values (2);", + "supabase/seeds/notes.txt": "not a seed", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seeding data from supabase/seeds/a.sql..."); + expect(out.stderrText).toContain("Seeding data from supabase/seeds/nested/b.sql..."); + expect(out.stderrText).not.toContain("notes.txt"); + }); + }); + + it.live("reports seed files up to date when hash matches remote", () => { + // sha256 of the seed body must match the remote hash to be skipped. + const body = "insert into t values (1);"; + const hash = createHash("sha256").update(body).digest("hex"); + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": body }, + remoteSeeds: { "supabase/seed.sql": hash }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + }); + }); + + it.live("hashes a non-UTF-8 seed file by its raw bytes (Go's io.Copy parity)", () => { + // Go's `NewSeedFile` hashes the raw stream; a UTF-8 string decode would replace the + // invalid bytes and change the hash. Write invalid UTF-8 and pre-seed the remote with + // the RAW-byte sha256 — the push must treat it as already-applied (byte hash matches), + // not re-run it. A string-decoded hash here would differ and mark the seed dirty. + const raw = Buffer.from([0x2d, 0x2d, 0x20, 0xff, 0xfe, 0x00, 0x01, 0x0a]); + const rawHash = createHash("sha256").update(raw).digest("hex"); + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + remoteSeeds: { "supabase/seed.sql": rawHash }, + }); + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "seed.sql"), raw); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + }); + }); + + it.live("skips seeding when disabled in config", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain( + "Skipping seed because it is disabled in config.toml for project:", + ); + }); + }); + + it.live("creates custom roles with --include-roles", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seeding globals from roles.sql..."); + }); + }); + + it.live("--include-roles without a roles.sql pushes migrations and skips globals", () => { + // Go's push only globs supabase/roles.sql when it exists; an absent file is + // silently skipped (no error, no "Seeding globals" line) and the rest pushes. + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).not.toContain("Seeding globals"); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }); + }); + + it.live("emits the seeded file paths in the json success payload", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + format: "json", + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["upToDate"]).toBe(false); + expect(success?.data?.["migrations"]).toEqual(["20240101000000_test.sql"]); + expect(success?.data?.["seeds"]).toEqual(["supabase/seed.sql"]); + }); + }); + + it.live("reports schema migrations up to date when only roles are pushed", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Schema migrations are up to date."); + }); + }); + + it.live("returns context canceled when the roles prompt is declined", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); + }); + }); + + it.live("returns context canceled when the seed prompt is declined", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); + }); + }); + + it.live("re-hashes a dirty seed without re-running its statements", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + // Remote hash differs → dirty. + remoteSeeds: { "supabase/seed.sql": "stalehash" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Updating seed hash to supabase/seed.sql..."); + // Dirty seed only upserts the hash; the body statement is not executed. + expect(conn.execs).not.toContain("insert into t values (1);"); + }); + }); + + it.live("treats every seed as pending when the seed_files table is absent", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + noSeedTable: true, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); + }); + }); + + it.live("warns and reports up to date when no seed files match", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nsql_paths = ["missing.sql"]\n', + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("WARN: no files matched pattern: supabase/missing.sql"); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + }); + }); + + it.live("reports seed files up to date when migrations push but no seeds match", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nsql_paths = ["missing.sql"]\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seed files are up to date."); + }); + }); + + it.live("upserts vault secrets (update existing, create new) before migrating", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.vault]\nexisting = "v1"\nfresh = "v2"\n', + files: migrationFile("20240101000000"), + // `existing` already present remotely → update; `fresh` → create. + vaultRows: [{ id: "id-1", name: "existing" }], + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Updating vault secrets..."); + const sqls = conn.queries.map((q) => q.sql); + expect(sqls).toContain("SELECT vault.update_secret($1, $2)"); + expect(sqls).toContain("SELECT vault.create_secret($1, $2)"); + }); + }); + + it.live("decrypts an encrypted vault secret keyed by the project .env (not process.env)", () => { + // Regression: the old point-of-use vault decryption keyed only on `process.env`, so a + // `DOTENV_PRIVATE_KEY` present only in the project `.env` failed to decrypt. Go's config + // load merges the project `.env` into the key set (`legacyCheckDbToml`), so it resolves. + const PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; + const ENCRYPTED = + "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; + const { layer, out, conn } = setup(tmp.current, { + toml: `project_id = "test"\n\n[db.vault]\nmy_secret = "${ENCRYPTED}"\n`, + files: { + ...migrationFile("20240101000000"), + "supabase/.env": `DOTENV_PRIVATE_KEY=${PRIVATE_KEY}\n`, + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Updating vault secrets..."); + // The decrypted plaintext ("value") is written, proving the project-.env key was used. + const create = conn.queries.find((q) => q.sql === "SELECT vault.create_secret($1, $2)"); + expect(create?.params).toEqual(["value", "my_secret"]); + }); + }); + + it.live("defaults to the linked target when no target flag is set", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "push"], + isLocal: false, + projectRef: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Connecting to remote database..."); + expect(out.stdoutText).toBe("Remote database is up to date.\n"); + }); + }); + + it.live("surfaces an apply error with statement context", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000", "BOOM;"), + failExec: "BOOM", + confirm: [true], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("At statement: 0"); + } + }); + }); + + it.live("dry-run lists roles, migrations and seeds without applying", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/roles.sql": "create role app;", + "supabase/seed.sql": "insert into t values (1);", + }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ + ...DEFAULT_FLAGS, + dryRun: true, + includeRoles: true, + includeSeed: true, + }).pipe(Effect.provide(layer)); + // The roles path is wrapped in Bold (ANSI), matching Go's utils.Bold. + expect(out.stderrText).toContain("Would create custom roles"); + expect(out.stderrText).toContain("roles.sql"); + expect(out.stderrText).toContain("Would push these migrations:"); + expect(out.stderrText).toContain("Would seed these files:"); + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("dry-run with only custom roles lists them without a migration section", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, dryRun: true, includeRoles: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Would create custom roles"); + expect(out.stderrText).not.toContain("Would push these migrations:"); + }); + }); + + it.live("uses embedded defaults when no config file is present", () => { + const { layer, out } = setup(tmp.current, { + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + // No config.toml written → loadProjectConfig returns null → default config + // (migrations enabled), and the vault document is absent. + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }); + }); + + it.live("auto-confirms pending migrations via SUPABASE_YES set only in the project .env", () => { + // Go's loadNestedEnv sets project-.env keys before PromptYesNo reads viper YES, so a + // `SUPABASE_YES` in supabase/.env auto-confirms without any interactive answer. + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), "supabase/.env": "SUPABASE_YES=true\n" }, + // Deliberately no `confirm` responses — the prompt must be auto-confirmed. + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }); + }); + + it.live("fails when config.toml cannot be parsed", () => { + const { layer } = setup(tmp.current, { toml: "this is = = not [[[ valid toml" }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + // Config now loads through the Go-parity reader (`legacyCheckDbToml`), so a malformed + // config aborts with Go's `failed to load config` message (the reader path), same as + // the other db commands (diff/dump/pull/migration). + expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + } + }); + }); + + it.live("loads a Go-style env() boolean in config (no ProjectConfigParseError)", () => { + // Regression for the strict @supabase/config loader rejecting `enabled = "env(VAR)"`: + // Go decodes it via env-expansion + strconv.ParseBool, so the config must load and the + // migration proceed. Previously native push aborted before the Go-compatible parse ran. + const previous = process.env["SEED_ENABLED"]; + process.env["SEED_ENABLED"] = "true"; + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SEED_ENABLED"]; + else process.env["SEED_ENABLED"] = previous; + }), + ), + ); + }); + + it.live("a matched remote block's migrations.enabled beats the shell env override", () => { + // Go merges a matched [remotes.] block at viper's override tier (`v.Set`), which + // sits ABOVE AutomaticEnv — so `[remotes.preview.db.migrations] enabled = false` wins + // over `SUPABASE_DB_MIGRATIONS_ENABLED=true` and the push skips migrations. (Before the + // config-reader convergence, push resolved this gate env-first and wrongly applied.) + const previous = process.env["SUPABASE_DB_MIGRATIONS_ENABLED"]; + process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = "true"; + const { layer, out } = setup(tmp.current, { + toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${LEGACY_VALID_REF}"\n\n[remotes.preview.db.migrations]\nenabled = false\n`, + files: migrationFile("20240101000000"), + args: ["db", "push", "--linked"], + isLocal: false, + projectRef: LEGACY_VALID_REF, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Skipping migrations because it is disabled"); + expect(out.stderrText).not.toContain("Applying migration 20240101000000"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_DB_MIGRATIONS_ENABLED"]; + else process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = previous; + }), + ), + ); + }); + + it.live("announces a matching [remotes.*] override on the linked path", () => { + const { layer, out } = setup(tmp.current, { + toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${LEGACY_VALID_REF}"\n`, + args: ["db", "push", "--linked"], + isLocal: false, + projectRef: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Loading config override: [remotes.preview]"); + }); + }); + + it.live("pushes to the linked project and caches the project ref (json)", () => { + const { layer, out, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + args: ["db", "push", "--linked"], + isLocal: false, + projectRef: LEGACY_VALID_REF, + format: "json", + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Connecting to remote database..."); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["migrations"]).toEqual(["20240101000000_test.sql"]); + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/push/push.layers.ts b/apps/cli/src/legacy/commands/db/push/push.layers.ts new file mode 100644 index 0000000000..dbc5bd5250 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/push/push.layers.ts @@ -0,0 +1,78 @@ +import { Layer } from "effect"; + +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyCredentialsLayer } from "../../../auth/legacy-credentials.layer.ts"; +import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; +import { legacyPlatformApiFactoryLayer } from "../../../auth/legacy-platform-api-factory.layer.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; +import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; +import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; +import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitch.ts"; +import { legacyLinkedProjectCacheLayer } from "../../../telemetry/legacy-linked-project-cache.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; + +/** + * Runtime layer for `supabase db push`. Same shape as `db lint`: it spans local + * (`--local` / `--db-url`) and linked DB access, so it composes the Postgres + * connection, the db-config resolver, project-ref resolution, and the + * linked-project cache (Go's PersistentPostRun `ensureProjectGroupsCached`). + * + * Like `db lint`, it deliberately uses the **lazy** `legacyPlatformApiFactoryLayer` + * (not the eager management-API runtime) so the auth-free `--local` path never + * resolves an access token at layer-build time. `legacyCliConfigLayer` is provided + * to each consumer that needs it (legacy CLAUDE.md item 5); the single + * `legacyIdentityStitchLayer` reference is shared so the factory, the cache, and + * the db-config resolver share one `stitchAttempted` guard. + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), +); + +const platformApiFactory = legacyPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +const projectRef = legacyProjectRefLayer.pipe( + Layer.provide(platformApiFactory), + Layer.provide(cliConfig), +); + +const linkedProjectCache = legacyLinkedProjectCacheLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(httpClient), + Layer.provide(legacyIdentityStitchLayer), +); + +const dbConfig = legacyDbConfigLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +export const legacyDbPushRuntimeLayer = Layer.mergeAll( + dbConfig, + legacyDbConnectionLayer, + cliConfig, + httpClient, + credentials, + projectRef, + linkedProjectCache, + legacyIdentityStitchLayer, + legacyTelemetryStateLayer, + // `legacyPromptYesNo`'s non-TTY branch reads the piped answer via `Stdin` (Go's + // `console.ReadLine`); without it a CI/piped `db push` that reaches a confirmation + // prompt fails with a missing-service defect instead of honoring `y`/`n` or the default. + stdinLayer, + commandRuntimeLayer(["db", "push"]), +); diff --git a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md index d1291e27a4..6089303d32 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -1,60 +1,143 @@ # `supabase db reset` +Native TypeScript port of `apps/cli-go/internal/db/reset/reset.go`. Reinitialises a +database from local migrations (plus seed). The **remote** path (`--linked`, or a +remote `--db-url`) is native: drop all user schemas, upsert vault secrets, then +re-apply migrations and seed. The **local** path (`--local`/default, or a `--db-url` +pointing at the local stack) is also native: TS orchestrates the running check, +messages, bucket seeding, and git-branch line, while the container-recreate +primitives run behind the hidden Go `db __db-bootstrap` seam. Only the niche +**`--experimental`** remote schema-files path still delegates to the Go binary. + ## Files Read -| Path | Format | When | -| --------------------------------------- | ---------- | ------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | -| `/supabase/migrations/` | directory | always, to load migration files | -| seed files from config or `--sql-paths` | SQL | unless `--no-seed` is set | +| Path | Format | When | +| ------------------------------------------------------ | ---------- | ------------------------------------------------------------------------- | +| `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations | +| `/supabase/config.toml` | TOML | remote path + local bucket seeding (embedded defaults when absent) | +| `/.git/HEAD` (walked upward) | plain text | local path, for the `Finished … on branch .` line | +| `~/.supabase//project-ref` | plain text | `--linked`, to resolve the ref | +| `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted | +| seed files from `--sql-paths` or `[db.seed].sql_paths` | SQL | when seeding is enabled (not `--no-seed`); `--sql-paths` overrides config | +| `/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | --------------------------------- | +| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | + +On the local path the Go seam additionally recreates the `supabase_db_` +container/volume and applies the initial schema (`SetupLocalDatabase`); the +`--experimental` remote path produces whatever the delegated Go binary writes. + +## Subprocesses + +| Command | When | Purpose | +| --------------------------------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------- | +| `docker container inspect supabase_db_` | local path | `AssertSupabaseDbIsRunning` probe (Podman fallback) | +| `supabase-go db __db-bootstrap --mode recreate [--version ] [--no-seed]` | local path | recreate container + init schema + migrate + seed + restart services | +| `supabase-go db __db-bootstrap --mode await-storage` | local path | storage health gate before bucket seeding (`ready` / `absent`) | +| `supabase-go db reset --linked\|--db-url … [--no-seed]` | `--experimental` remote, no version | the un-ported experimental schema-files apply path (telemetry disabled) | + +The seam subprocesses run with `SUPABASE_TELEMETRY_DISABLED=1`, stderr inherited; +`--network-id` / a flag-selected `--profile` are forwarded. + +## Database Mutations + +### Remote path (native, in TS) + +| Statement | When | +| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `drop.sql` `DO` block (drops user schemas/extensions/public objects, truncates auth/migrations) | always, first | +| `SELECT vault.update_secret(...)` / `vault.create_secret(...)` | when `[db.vault]` has syncable secrets | +| migration statements + `schema_migrations` history insert (per file, transactional) | when `[db.migrations].enabled`, for migrations `≤ --version` | +| seed statements + `seed_files` hash upsert | when `[db.seed].enabled` and not `--no-seed` | + +### Local path (inside the Go seam) + +The recreate seam drops & recreates the `postgres`/`_supabase` databases (PG≤14) or +removes & recreates the db container/volume (PG15), applies the initial schema + +roles, then runs `MigrateAndSeed` (migrations `≤ --version`, seed unless `--no-seed`) +and restarts the storage/auth/realtime/pooler containers. Bucket objects are then +seeded over the Storage gateway (reusing the `seed buckets` local path). ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| — | — | — | — | Connects to Postgres directly. The `--linked` resolver may call the Management API to mint a temporary login role; local bucket seeding calls the Storage gateway. | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `DB_PASSWORD` | password for direct database connection | no | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no | +| `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) | +| `SUPABASE_EXPERIMENTAL` | routes the experimental schema-files path to Go | no (also `--experimental`) | +| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`) | no | ## Exit Codes -| Code | Condition | -| ---- | --------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | migration apply error | +| Code | Condition | +| ---- | ---------------------------------------------------------------- | +| `0` | success | +| `1` | mutually exclusive target flags (`[db-url linked local]`) | +| `1` | `--version` + `--last` together (`[last version]`) | +| `1` | `--version` not an integer (`invalid version number`) | +| `1` | `--version` has no matching migration file | +| `1` | local: database not running (`supabase start is not running.`) | +| `1` | user declined the reset confirmation (`context canceled`) | +| `1` | `config.toml` parse failure | +| `1` | drop / migrate / seed / vault apply failure, or connection error | +| `1` | local: container recreate / storage health-gate failure (seam) | ## Output +The remote path prints `Resetting remote database…` to **stderr**, then the +drop/migrate/seed progress (`Applying migration …`, `Seeding data from …`). Go +connects with `io.Discard`, so there is **no** `Connecting to … database…` line and +**no** `Finished …` line on the remote path. + +The local path prints `Resetting local database…` to **stderr**, then the seam's +`Recreating database...` / `Restarting containers...` progress, and finally +`Finished supabase db reset on branch .` (`supabase db reset` and `` +in Aqua). + ### `--output-format text` (Go CLI compatible) -Prints progress to stderr as migrations are applied. +Byte-matches Go's stderr progress for both the remote and local paths. The +`--experimental` remote path passes the delegated Go binary's output through +unchanged. -### `--output-format json` +### `--output-format json` / `stream-json` -Not applicable. +stdout is payload-only; a `result` object is emitted: -### `--output-format stream-json` +```json +{ "target": "remote" | "local", "version": "" } +``` -Not applicable. +In machine modes the remote confirmation prompt is non-interactive and takes its +default (`false`), so a remote reset is declined unless `--yes` is set. The local +path has no confirmation prompt. ## Notes -- `--no-seed` skips running the seed script after reset. -- `--sql-paths` overrides `[db.seed].sql_paths` for one reset; repeat it to seed multiple files or glob patterns. -- `--sql-paths` force-enables seeding for that reset even when `[db.seed].enabled = false`. -- With `--linked` or `--db-url`, `--sql-paths` seeds the selected remote database after migrations. -- `--version` resets up to the specified migration version. -- `--last` resets up to the last n migration versions; mutually exclusive with `--version`. +- **Target/local split** follows Go's `IsLocalDatabase(resolved config)`, not the + flag name: a `--db-url` pointing at the local stack is treated as a local reset. +- `--no-seed` forces seeding off (Go sets `Config.Db.Seed.Enabled = false`); on the + local path it is forwarded to the recreate seam so `MigrateAndSeed` skips the seed. +- `--sql-paths` overrides `[db.seed].sql_paths` for one reset and force-enables seeding + even when `[db.seed].enabled = false`; repeat it to seed multiple files or glob + patterns (supabase-relative). Mutually exclusive with `--no-seed`. On the local path + it is forwarded to the recreate seam; on the remote path it seeds the selected + database after migrations (Go warns when paired with `--linked` / `--db-url`). +- `--last n` reverts the most recent `n` migrations; if `n ≥ total`, the reset target + version becomes `-` (revert everything). Mutually exclusive with `--version`. - `--db-url`, `--linked`, and `--local` (default true) are mutually exclusive. +- **Known interim**: only `--experimental` remote resets run via the Go binary; the + best-effort pg-delta catalog cache (inside the seam) is not surfaced (no output + impact). `encrypted:` vault secrets are skipped on the remote path. diff --git a/apps/cli/src/legacy/commands/db/reset/reset.command.ts b/apps/cli/src/legacy/commands/db/reset/reset.command.ts index 15a61c8d88..979d088f84 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.command.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyDbReset } from "./reset.handler.ts"; +import { legacyDbResetRuntimeLayer } from "./reset.layers.ts"; const noSqlPaths: ReadonlyArray = []; @@ -42,5 +46,24 @@ export type LegacyDbResetFlags = CliCommand.Command.Config.Infer; export const legacyDbResetCommand = Command.make("reset", config).pipe( Command.withDescription("Resets the local database to current migrations."), Command.withShortDescription("Resets the local database to current migrations"), - Command.withHandler((flags) => legacyDbReset(flags)), + Command.withHandler((flags) => + legacyDbReset(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + "no-seed": flags.noSeed, + "sql-paths": flags.sqlPaths, + version: flags.version, + last: flags.last, + }, + // NO safeFlags: `markFlagTelemetrySafe` is per flag INSTANCE, and Go only + // marks migration squash's `--version` (cmd/migration.go:134). db reset's + // `--version` (cmd/db.go) is unmarked, so Go redacts it — match that. + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyDbResetRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts new file mode 100644 index 0000000000..bfb4c3e539 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts @@ -0,0 +1,88 @@ +import { Data } from "effect"; + +/** + * Conflicting database-target flags. Reproduces cobra's + * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` (`cmd/db.go:573`). + */ +export class LegacyDbResetTargetFlagsError extends Data.TaggedError( + "LegacyDbResetTargetFlagsError", +)<{ + readonly message: string; +}> {} + +/** + * `--version` and `--last` together. Reproduces cobra's + * `MarkFlagsMutuallyExclusive("version", "last")` (`cmd/db.go:576`). + */ +export class LegacyDbResetVersionFlagsError extends Data.TaggedError( + "LegacyDbResetVersionFlagsError", +)<{ + readonly message: string; +}> {} + +/** + * `--version` is not a valid integer. Byte-matches Go's + * `failed to parse : invalid version number` (`repair.go:24-29`). + */ +export class LegacyDbResetInvalidVersionError extends Data.TaggedError( + "LegacyDbResetInvalidVersionError", +)<{ + readonly message: string; +}> {} + +/** + * No migration file matches `--version`. Byte-matches Go's + * `glob supabase/migrations/_*.sql: file does not exist` + * (`repair.GetMigrationFile`). + */ +export class LegacyDbResetMigrationFileError extends Data.TaggedError( + "LegacyDbResetMigrationFileError", +)<{ + readonly message: string; +}> {} + +/** + * The user declined the reset confirmation. Go returns + * `errors.New(context.Canceled)` (`internal/db/reset/reset.go:248`). + */ +export class LegacyDbResetCancelledError extends Data.TaggedError("LegacyDbResetCancelledError")<{ + readonly message: string; +}> {} + +/** A drop / migrate / seed / vault statement failed during the remote reset. */ +export class LegacyDbResetApplyError extends Data.TaggedError("LegacyDbResetApplyError")<{ + readonly message: string; +}> {} + +/** + * The local database container is not running. Byte-matches Go's + * `utils.ErrNotRunning` (`internal/utils/misc.go:116`), `"supabase start + * is not running."`, returned by `AssertSupabaseDbIsRunning` before the local + * reset (`internal/db/reset/reset.go:57`). + */ +export class LegacyDbResetNotRunningError extends Data.TaggedError("LegacyDbResetNotRunningError")<{ + readonly message: string; +}> {} + +/** + * `--last` was given a negative value. Go declares `--last` as an unsigned flag + * (`UintVar`, `cmd/db.go`), so cobra rejects a negative at parse time. Byte-matches + * cobra's parse error for `strconv.ParseUint`. + */ +export class LegacyDbResetLastFlagError extends Data.TaggedError("LegacyDbResetLastFlagError")<{ + readonly message: string; +}> {} + +/** + * Invalid `--sql-paths` usage. Byte-matches Go's `validateDbResetSeedFlags` + * (`cmd/db.go`): `"--no-seed cannot be used with --sql-paths"` and + * `"--sql-paths requires a non-empty path or glob pattern"`. + */ +export class LegacyDbResetSeedFlagsError extends Data.TaggedError("LegacyDbResetSeedFlagsError")<{ + readonly message: string; + /** + * Actionable hint rendered as a `Suggestion:` line, mirroring Go's + * `validateDbResetSeedFlags` `utils.CmdSuggestion` (`cmd/db.go`). + */ + readonly suggestion?: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts index de35923ac2..640392a7c0 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -1,16 +1,426 @@ -import { Effect, Option } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; + +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { detectGitBranch } from "../../../../shared/git/git-branch.ts"; +import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + legacyResolveExperimentalWithProjectEnv, + legacyResolveYesWithProjectEnv, +} from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyAqua, legacyYellow } from "../../../shared/legacy-colors.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { + legacyCheckDbToml, + legacyLoadProjectEnv, + legacyResolveSeedSqlPath, +} from "../../../shared/legacy-db-config.toml-read.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyApplyMigrations } from "../../../shared/legacy-migration-apply.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { + type LegacyDbConnType, + resolveLegacyDbTargetFlags, +} from "../../../shared/legacy-db-target-flags.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyDropUserSchemas } from "../shared/legacy-drop-schemas.ts"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; +import { legacyListLocalMigrations } from "../shared/legacy-pgdelta.cache.ts"; +import { + legacyGetPendingSeeds, + legacyMatchPattern, + legacySeedData, +} from "../shared/legacy-seed-ops.ts"; +import { legacyUpsertVaultSecrets } from "../../../shared/legacy-vault.ts"; +import { legacySeedBucketsRun } from "../../../shared/legacy-seed-buckets.ts"; import type { LegacyDbResetFlags } from "./reset.command.ts"; +import { + LegacyDbResetApplyError, + LegacyDbResetCancelledError, + LegacyDbResetInvalidVersionError, + LegacyDbResetLastFlagError, + LegacyDbResetMigrationFileError, + LegacyDbResetNotRunningError, + LegacyDbResetSeedFlagsError, + LegacyDbResetTargetFlagsError, + LegacyDbResetVersionFlagsError, +} from "./reset.errors.ts"; -export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: LegacyDbResetFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["db", "reset"]; +const INTEGER_PATTERN = /^[+-]?\d+$/u; +const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; + +const applyError = (message: string) => new LegacyDbResetApplyError({ message }); + +/** Go's `toLogMessage` (`internal/db/reset/reset.go:88-91`). */ +const toLogMessage = (version: string): string => + version.length > 0 ? ` to version: ${version}` : "..."; + +/** + * Rebuilds the `db reset` argv for the remaining Go-delegated path: a remote + * `--experimental` reset with no resolved version. Only the flags reachable on + * that path are forwarded — `--local` always takes the native path, and a set + * `--version`/`--last` resolves a non-empty version which disables the experimental + * delegation (a degenerate `--last 0` resolves to "" and is behaviourally identical + * whether or not it is forwarded, so it is omitted). + * + * The target selector is forwarded from the RESOLVED `connType`, not the raw `--linked` + * boolean: the parent's `resolveLegacyDbTargetFlags` follows Cobra's `Changed` semantics, so + * `--linked=false` selects the linked/remote target (this path is remote-only). Forwarding + * only when `flags.linked === true` would drop the selector for `--linked=false` and let the + * Go child fall back to its local default — resetting the wrong database. + */ +const buildResetArgs = ( + flags: LegacyDbResetFlags, + connType: LegacyDbConnType, + yes: boolean, +): Array => { + const args = ["db", "reset"]; if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); + else if (connType === "linked") args.push("--linked"); if (flags.noSeed) args.push("--no-seed"); - for (const path of flags.sqlPaths) args.push("--sql-paths", path); - if (Option.isSome(flags.version)) args.push("--version", flags.version.value); - if (Option.isSome(flags.last)) args.push("--last", String(flags.last.value)); - yield* proxy.exec(args); + for (const p of flags.sqlPaths) args.push("--sql-paths", p); + // Forward the parent's RESOLVED `yes` as a bound flag. Go's `--yes` beats `AutomaticEnv`, + // so `--yes=false` overrides an inherited `SUPABASE_YES=true` (the child no longer + // auto-confirms a reset the user protected with `--yes=false`), while `--yes=true` honors + // an explicit `--yes` / env even in machine mode where the child's stdin is ignored. + // `--yes=false` still prompts on a TTY (Go's PromptYesNo only short-circuits on true), so + // this matches the default behavior when neither flag nor env is set. + args.push(`--yes=${yes}`); + return args; +}; + +/** + * `supabase db reset` — reinitialise a database from local migrations (+ seed). + * + * Strict 1:1 port of `apps/cli-go/internal/db/reset/reset.go`. The remote path + * (`--linked` / a remote `--db-url`) is native. The local path (and the niche + * `--experimental` schema-files path) delegate to the Go binary as a documented + * interim until the container-bootstrap seam is ported (CLI-1325 Stage 3). + */ +export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: LegacyDbResetFlags) { + const output = yield* Output; + const resolver = yield* LegacyDbConfigResolver; + const dbConn = yield* LegacyDbConnection; + const proxy = yield* LegacyGoProxy; + const seam = yield* LegacyDbBootstrapSeam; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cliArgs = yield* CliArgs; + const dnsResolver = yield* LegacyDnsResolverFlag; + + const workdir = cliConfig.workdir; + const migrationsDir = path.join(workdir, "supabase", "migrations"); + // Go's `ParseDatabaseConfig` runs `loadNestedEnv` (which `os.Setenv`s each project-.env key) + // before `reset.Run` reads `viper.GetBool("YES")` / `viper.GetBool("EXPERIMENTAL")`, so a + // `SUPABASE_YES` / `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` is honored. Load the + // project env first and resolve both gates against it, as `db pull` does for `yes`. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnv); + let linkedRefForCache: string | undefined; + + const body = Effect.gen(function* () { + const target = resolveLegacyDbTargetFlags(cliArgs.args); + // cobra MarkFlagsMutuallyExclusive("db-url", "linked", "local"). + if (target.setFlags.length > 1) { + return yield* Effect.fail( + new LegacyDbResetTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }), + ); + } + // Go declares `--last` as `UintVar`, so cobra rejects a negative at parse time + // (`Flag.integer` here accepts it). Reject it the same way rather than silently + // treating it as "no --last" and resetting the full history. + if (Option.isSome(flags.last) && flags.last.value < 0) { + return yield* Effect.fail( + new LegacyDbResetLastFlagError({ + message: `invalid argument "${flags.last.value}" for "--last" flag: strconv.ParseUint: parsing "${flags.last.value}": invalid syntax`, + }), + ); + } + // cobra MarkFlagsMutuallyExclusive("version", "last") — alphabetical group. + if (Option.isSome(flags.version) && Option.isSome(flags.last)) { + return yield* Effect.fail( + new LegacyDbResetVersionFlagsError({ + message: + "if any flags in the group [last version] are set none of the others can be; [last version] were all set", + }), + ); + } + + // Go's validateDbResetSeedFlags (PreRunE): `--no-seed` conflicts with + // `--sql-paths`, and each `--sql-paths` value must be non-empty. + if (flags.noSeed && flags.sqlPaths.length > 0) { + return yield* Effect.fail( + new LegacyDbResetSeedFlagsError({ + message: "--no-seed cannot be used with --sql-paths", + suggestion: `Use either ${legacyAqua("--no-seed")} to skip seeding or ${legacyAqua( + "--sql-paths", + )} to override seed files, not both.`, + }), + ); + } + if (flags.sqlPaths.some((p) => p.length === 0)) { + return yield* Effect.fail( + new LegacyDbResetSeedFlagsError({ + message: "--sql-paths requires a non-empty path or glob pattern", + suggestion: `Pass a non-empty file path or glob pattern to ${legacyAqua("--sql-paths")}.`, + }), + ); + } + // Go's warnRemoteResetSeedOverride (PreRunE): a remote target flag + --sql-paths. + if ( + flags.sqlPaths.length > 0 && + (target.setFlags.includes("linked") || target.setFlags.includes("db-url")) + ) { + yield* output.raw( + `${legacyYellow("WARNING:")} --sql-paths overrides [db.seed].sql_paths and seeds the remote database selected by --linked or --db-url.\n`, + "stderr", + ); + } + + // Version / last resolution (Go's reset.Run lines 34-52), filesystem only. + let resolvedVersion = ""; + if (Option.isSome(flags.version)) { + const v = flags.version.value; + if (!INTEGER_PATTERN.test(v)) { + return yield* Effect.fail( + new LegacyDbResetInvalidVersionError({ + message: `failed to parse ${v}: invalid version number`, + }), + ); + } + // Go validates the version with `repair.GetMigrationFile` (repair.go:90-100), + // which globs `supabase/migrations/_*.sql` DIRECTLY with no filtering — + // so a deprecated first migration (e.g. `20200101000000_init.sql`) that + // `legacyListLocalMigrations` excludes is still accepted. Mirror that with a raw + // directory read + Go-glob match instead of the filtered migration listing. + const entries = yield* fs + .readDirectory(migrationsDir) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + const found = entries.some((name) => legacyMatchPattern(`${v}_*.sql`, path.basename(name))); + if (!found) { + return yield* Effect.fail( + new LegacyDbResetMigrationFileError({ + message: `glob supabase/migrations/${v}_*.sql: file does not exist`, + }), + ); + } + resolvedVersion = v; + } else if (Option.isSome(flags.last) && flags.last.value > 0) { + const locals = yield* legacyListLocalMigrations(fs, path, migrationsDir); + const versions = locals.flatMap((p) => { + const m = MIGRATE_FILE_PATTERN.exec(path.basename(p)); + return m?.[1] !== undefined ? [m[1]] : []; + }); + const total = versions.length; + const last = flags.last.value; + resolvedVersion = last < total ? versions[total - last - 1]! : "-"; + } + + const connType = target.connType ?? "local"; + // Go's ParseDatabaseConfig runs LoadProjectRef BEFORE the fallible linked + // resolution (db_url.go:87-95), and Execute() writes the linked-project cache + // even when a later step errors (root.go:171-181). Pre-load the ref so the + // post-run cache finalizer still fires when resolve fails mid-way (merged + // config, temp-role mint, connection) — mirrors push.handler. + if (connType === "linked") { + const refResolver = yield* LegacyProjectRefResolver; + linkedRefForCache = yield* refResolver.loadProjectRef(Option.none()); + } + const cfg = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType, dnsResolver }); + + // Local target → native local reset. The container-recreate primitives live + // behind the hidden Go `db __db-bootstrap` seam; TS orchestrates the rest + // (running check, messages, bucket seeding, git-branch line, output shaping). + // Mirrors `internal/db/reset/reset.go:57-77`. + if (cfg.isLocal) { + // AssertSupabaseDbIsRunning — error if the local db container is down. + const running = yield* seam.isDbRunning(); + if (!running) { + return yield* Effect.fail( + new LegacyDbResetNotRunningError({ + message: `${legacyAqua("supabase start")} is not running.`, + }), + ); + } + // resetDatabase: "Resetting local database…" then recreate + migrate + seed. + yield* output.raw(`Resetting local database${toLogMessage(resolvedVersion)}\n`, "stderr"); + yield* seam.recreateDatabase({ + version: resolvedVersion, + noSeed: flags.noSeed, + sqlPaths: flags.sqlPaths, + }); + + // Seed objects from supabase/buckets when storage is up (Go gates buckets on + // an existing, healthy storage container). Reuses the ported seed-buckets + // local path; its summary is suppressed (reset emits its own result). + const storageReady = yield* seam.awaitStorageReady(); + if (storageReady) { + // Go's `buckets.Run(ctx, "", false, fsys)` — non-interactive: overwrite/prune + // confirmations take their defaults instead of blocking on input. + // + // Bucket seeding re-loads config.toml through the strict `@supabase/config` + // loader, which (unlike the Go-parity reader used elsewhere in reset) rejects some + // Go-valid configs — e.g. `[db.seed] enabled = "env(SEED_ENABLED)"`. The seam's Go + // `recreate` has already run Go's full `LoadConfig`+`Validate` on this same config, + // so a parse failure HERE is that loader-strictness gap, not a genuinely invalid + // config. Recreate already dropped/rebuilt the DB, so aborting now would leave the + // reset half-done; warn and skip buckets so `db reset` finishes like Go instead. + yield* legacySeedBucketsRun({ + projectRef: "", + emitSummary: false, + interactive: false, + // Go loads nested env before `buckets.Run`, so `SUPABASE_YES` in `supabase/.env` + // auto-confirms bucket/vector/analytics prune prompts. Pass the project-env-resolved + // `yes` (the shared runner's own `legacyResolveYes` only sees the shell env). + yes, + }).pipe( + Effect.catchTag("LegacySeedConfigLoadError", (error) => + output.raw( + `${legacyYellow("WARNING:")} skipped seeding storage buckets: ${error.message}\n`, + "stderr", + ), + ), + ); + } + + // "Finished supabase db reset on branch ." (both Aqua). + const branch = Option.getOrElse(yield* detectGitBranch(workdir), () => "main"); + yield* output.raw( + `Finished ${legacyAqua("supabase db reset")} on branch ${legacyAqua(branch)}.\n`, + "stderr", + ); + if (output.format !== "text") { + yield* output.success("Reset local database.", { + target: "local", + version: resolvedVersion, + }); + } + return; + } + + // Resolve the linked ref before any return so the post-run cache (Go's + // `PersistentPostRun` `ensureProjectGroupsCached`) is written even on the + // delegated `--experimental` path below — the Go child runs with telemetry + // disabled and skips that cache, so the TS finalizer must own it. + const linkedRef = Option.getOrUndefined(cfg.ref ?? Option.none()); + if (connType === "linked" && linkedRef !== undefined) linkedRefForCache = linkedRef; + + // Remote path. The niche `--experimental` schema-files apply path + // (`apply.MigrateAndSeed`) is not ported; delegate it to the Go child. In text + // mode inherit its stdio. Under a machine-output mode (`--output-format + // json|stream-json`) the Go child emits no TS envelope, so suppress its stdout + // (capture + discard) and emit the same structured success the native local and + // remote paths do, keeping the JSON contract consistent across all reset paths. + if (experimental && resolvedVersion === "") { + const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; + if (output.format === "text") { + yield* proxy.exec(buildResetArgs(flags, connType, yes), { env }); + } else { + // Machine-output mode is non-interactive: give the Go child a non-TTY stdin + // (`stdin: "ignore"`) so it can't block on (or be answered at) Go's + // destructive reset prompt — it takes the default `false`, matching the + // native reset path which suppresses prompts under json/stream-json. + yield* proxy.execCapture(buildResetArgs(flags, connType, yes), { env, stdin: "ignore" }); + yield* output.success("Reset remote database.", { + target: "remote", + version: resolvedVersion, + }); + } + return; + } + + // Single Go-parity config load (`flags.LoadConfig` → `config.Load` + `Validate`): + // decodes the whole config with Go's env-expansion + `strconv.ParseBool` weak typing + // (so `enabled = "env(SEED_ENABLED)"` etc. load like Go), applies `SUPABASE_*` + // AutomaticEnv overrides, merges a matching `[remotes.]` block, and decrypts every + // `encrypted:` secret with the shell AND project-`.env` `DOTENV_PRIVATE_KEY*` keys — + // aborting here (before the destructive prompt / `legacyDropUserSchemas`) on any + // undecryptable/invalid config, exactly like Go's `LoadConfig` before ResetAll. + const configRef = connType === "linked" && linkedRef !== undefined ? linkedRef : undefined; + const toml = yield* legacyCheckDbToml(fs, path, workdir, configRef); + if (toml.appliedRemote !== undefined) { + yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); + } + const vaultSecrets = toml.vault; + + // Go's resetRemote: prompt (default false) → cancel, then ResetAll. + const shouldReset = yield* legacyPromptYesNo( + output, + yes, + "Do you want to reset the remote database?", + false, + ); + if (!shouldReset) { + return yield* Effect.fail(new LegacyDbResetCancelledError({ message: "context canceled" })); + } + yield* output.raw(`Resetting remote database${toLogMessage(resolvedVersion)}\n`, "stderr"); + + // Go connects with io.Discard, so NO "Connecting to ... database..." line. + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* dbConn.connect(cfg.conn, { isLocal: false, dnsResolver }); + // ResetAll: drop user schemas → upsert vault → migrate + seed. + yield* legacyDropUserSchemas(session, applyError); + yield* legacyUpsertVaultSecrets(session, vaultSecrets); + + if (toml.migrationsEnabled) { + const locals = yield* legacyListLocalMigrations(fs, path, migrationsDir); + // LoadPartialMigrations filter: version === "" || v <= version. + const pending = locals.filter((p) => { + if (resolvedVersion === "") return true; + const m = MIGRATE_FILE_PATTERN.exec(path.basename(p)); + return m?.[1] !== undefined && m[1] <= resolvedVersion; + }); + yield* legacyApplyMigrations(session, fs, path, pending, applyError); + } + + // `--no-seed` disables seeding; `--sql-paths` overrides [db.seed].sql_paths + // and force-enables it (Go's applyDbResetSeedFlags). The two are mutually + // exclusive (validated above). + const overrideSeed = flags.sqlPaths.length > 0; + // `--sql-paths` force-enables seeding (Go's applyDbResetSeedFlags); otherwise + // honor `db.seed.enabled` (already `SUPABASE_DB_SEED_ENABLED`-resolved by the reader). + const seedEnabled = overrideSeed || (toml.seed.enabled && !flags.noSeed); + if (seedEnabled) { + // `[db.seed].sql_paths` is already Go-config-resolved (supabase/-joined) by the + // reader; the `--sql-paths` override is resolved here the same way Go's + // `resolveSeedSqlPaths` does, so both feed the glob identical paths. + const seedPaths = overrideSeed + ? flags.sqlPaths.map((p) => legacyResolveSeedSqlPath(path, p)) + : toml.seed.sqlPaths; + const seeds = yield* legacyGetPendingSeeds(session, fs, path, seedPaths, workdir); + yield* legacySeedData(session, fs, workdir, path, seeds, applyError); + } + // Go's best-effort pgcache catalog warning is not ported (no output impact). + }), + ); + + if (output.format !== "text") { + yield* output.success("Reset remote database.", { + target: "remote", + version: resolvedVersion, + }); + } + }); + + yield* body.pipe( + Effect.ensuring( + Effect.suspend(() => + linkedRefForCache !== undefined && linkedRefForCache !== "" + ? linkedProjectCache.cache(linkedRefForCache) + : Effect.void, + ), + ), + Effect.ensuring(telemetryState.flush), + ); }); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts index bd533d2adc..1ddec7f3b3 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -1,26 +1,65 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; -import { CliOutput, Command } from "effect/unstable/cli"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { Effect, Exit, Layer, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import { + mockOutput, + mockRuntimeInfo, + mockStdin, + mockTty, +} from "../../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApiService, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { + LegacyDnsResolverFlag, + LegacyExperimentalFlag, + LegacyYesFlag, +} from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; -import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; -import { legacyDbResetCommand } from "./reset.command.ts"; +import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import type { + LegacyDbConfigFlags, + LegacyResolvedDbConfig, +} from "../../../shared/legacy-db-config.types.ts"; +import { LegacyDbConfigConnectTempRoleError } from "../../../shared/legacy-db-config.errors.ts"; +import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; import { legacyDbReset } from "./reset.handler.ts"; import type { LegacyDbResetFlags } from "./reset.command.ts"; -function setupLegacyDbReset() { - const calls: Array> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args) => - Effect.sync(() => { - calls.push(args); - }), - execCapture: () => Effect.succeed(""), - }); - return { layer, calls }; -} +const LIST_MIGRATIONS = + "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; +const SELECT_SEEDS = "SELECT path, hash FROM supabase_migrations.seed_files"; + +const CONN: LegacyPgConnInput = { + host: "db.example.supabase.co", + port: 5432, + user: "postgres", + password: "secret", + database: "postgres", +}; -const baseFlags: LegacyDbResetFlags = { +const DEFAULT_FLAGS: LegacyDbResetFlags = { dbUrl: Option.none(), linked: false, local: false, @@ -30,118 +69,1026 @@ const baseFlags: LegacyDbResetFlags = { last: Option.none(), }; +function mockResolver(opts: { + isLocal: boolean; + ref?: string; + omitRef?: boolean; + resolveFails?: boolean; +}) { + return Layer.succeed(LegacyDbConfigResolver, { + resolve: (_flags: LegacyDbConfigFlags) => + opts.resolveFails === true + ? Effect.fail( + new LegacyDbConfigConnectTempRoleError({ + message: "failed to create login role: network error", + }), + ) + : Effect.succeed( + (opts.omitRef === true + ? { conn: CONN, isLocal: opts.isLocal } + : { + conn: CONN, + isLocal: opts.isLocal, + ref: opts.ref !== undefined ? Option.some(opts.ref) : Option.none(), + }) satisfies LegacyResolvedDbConfig, + ), + resolvePoolerFallback: () => Effect.succeed(Option.none()), + }); +} + +function mockConnection(opts: { remoteSeeds?: Readonly> }) { + const execs: Array = []; + const queries: Array<{ sql: string; params?: ReadonlyArray }> = []; + const layer = Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.succeed({ + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + exec: (sql: string): Effect.Effect => + Effect.sync(() => { + execs.push(sql); + }), + query: ( + sql: string, + params?: ReadonlyArray, + ): Effect.Effect>, LegacyDbExecError> => + Effect.suspend( + (): Effect.Effect>, LegacyDbExecError> => { + queries.push({ sql, params }); + if (sql === SELECT_SEEDS) { + return Effect.succeed( + Object.entries(opts.remoteSeeds ?? {}).map(([path, hash]) => ({ path, hash })), + ); + } + if (sql === LIST_MIGRATIONS) return Effect.succeed([]); + return Effect.succeed([]); + }, + ), + }), + }); + return { + layer, + get execs() { + return execs; + }, + get queries() { + return queries; + }, + }; +} + +/** + * Stateful mock of the container-bootstrap seam. `running` drives + * `AssertSupabaseDbIsRunning`; `storageReady` drives the bucket-seed gate. Records + * the recreate args so tests can assert version / `--no-seed` propagation. + */ +function mockBootstrapSeam(opts: { running?: boolean; storageReady?: boolean }) { + const recreateCalls: Array<{ + version: string; + noSeed: boolean; + sqlPaths: ReadonlyArray; + }> = []; + let storageChecked = false; + const layer = Layer.succeed(LegacyDbBootstrapSeam, { + isDbRunning: () => Effect.succeed(opts.running ?? true), + startDatabase: () => Effect.void, + recreateDatabase: (args: { + version: string; + noSeed: boolean; + sqlPaths: ReadonlyArray; + }) => + Effect.sync(() => { + recreateCalls.push(args); + }), + awaitStorageReady: () => + Effect.sync(() => { + storageChecked = true; + return opts.storageReady ?? false; + }), + }); + return { + layer, + get recreateCalls() { + return recreateCalls; + }, + get storageChecked() { + return storageChecked; + }, + }; +} + +// Dummy HTTP client; the local-reset bucket-seed core only reaches it when storage +// is ready AND buckets are configured (no reset test configures buckets, so the +// gateway is never actually called). Present to satisfy the handler's R. +const mockStorageHttp = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}", { status: 404 }))), + ), +); + +function mockProxy() { + const calls: Array<{ args: ReadonlyArray; env?: Record }> = []; + const layer = Layer.succeed(LegacyGoProxy, { + exec: (args, opts) => + Effect.sync(() => { + calls.push({ args, env: opts?.env }); + }), + execCapture: () => Effect.succeed(""), + }); + return { + layer, + get calls() { + return calls; + }, + }; +} + +function setup( + workdir: string, + opts: { + toml?: string; + files?: Readonly>; + format?: OutputFormat; + confirm?: ReadonlyArray; + args?: ReadonlyArray; + isLocal?: boolean; + ref?: string; + experimental?: boolean; + remoteSeeds?: Readonly>; + yes?: boolean; + omitRef?: boolean; + resolveFails?: boolean; + running?: boolean; + storageReady?: boolean; + }, +) { + if (opts.toml !== undefined) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); + } + for (const [rel, content] of Object.entries(opts.files ?? {})) { + const abs = join(workdir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + } + + const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); + const conn = mockConnection(opts); + const proxy = mockProxy(); + const seam = mockBootstrapSeam({ running: opts.running, storageReady: opts.storageReady }); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedCache = mockLegacyLinkedProjectCacheTracked(); + // The local-reset bucket-seed core statically requires the (lazy) Management-API + // factory; never invoked on `--local` (projectRef === ""). + const platformApi = mockLegacyPlatformApiService({}); + + const layer = Layer.mergeAll( + out.layer, + conn.layer, + proxy.layer, + seam.layer, + mockResolver({ + isLocal: opts.isLocal ?? false, + ref: opts.ref ?? LEGACY_VALID_REF, + omitRef: opts.omitRef, + resolveFails: opts.resolveFails, + }), + mockLegacyCliConfig({ workdir }), + BunServices.layer, + mockRuntimeInfo(), + // The remote-reset confirmation is answered through mockOutput's + // `promptConfirmResponses` (the TTY/clack path), so mark stdin a TTY. Stdin is + // only referenced by legacyPromptYesNo's non-TTY branch (unreached here) but must + // be present to satisfy the effect's requirements. + mockTty({ stdinIsTty: true }), + mockStdin(true), + // The linked ref is pre-loaded (for the post-run cache) before resolve, + // mirroring Go's LoadProjectRef-before-NewDbConfigWithPassword order. + Layer.succeed(LegacyProjectRefResolver, { + resolve: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), + resolveForLink: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), + resolveOptional: () => Effect.succeed(Option.some(opts.ref ?? LEGACY_VALID_REF)), + loadProjectRef: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), + promptProjectRef: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), + }), + mockStorageHttp, + Layer.succeed(LegacyPlatformApiFactory, { + make: LegacyPlatformApi.pipe(Effect.provide(platformApi.layer)), + }), + Layer.succeed(CliArgs, { args: opts.args ?? ["db", "reset", "--linked"] }), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + Layer.succeed(LegacyDnsResolverFlag, "native"), + Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), + telemetry.layer, + linkedCache.layer, + ); + return { layer, out, conn, proxy, seam, telemetry, linkedCache }; +} + +const migrationFile = (version: string, body = "create table t ();") => ({ + [`supabase/migrations/${version}_test.sql`]: body, +}); + describe("legacy db reset", () => { - it.live("forwards the empty-array baseline without seed override flags", () => { - const { layer, calls } = setupLegacyDbReset(); + const tmp = useLegacyTempWorkdir("supabase-db-reset-"); + + it.live("resets the local database via the bootstrap seam", () => { + const { layer, out, seam, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // Native path — no Go delegation. + expect(proxy.calls).toHaveLength(0); + expect(out.stderrText).toContain("Resetting local database..."); + expect(seam.recreateCalls).toEqual([{ version: "", noSeed: false, sqlPaths: [] }]); + // Storage gate checked; with no buckets configured nothing is seeded. + expect(seam.storageChecked).toBe(true); + expect(out.stderrText).toContain("Finished "); + expect(out.stderrText).toContain("on branch "); + }); + }); + + it.live("fails a local reset when the database is not running", () => { + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: false, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("is not running."); + expect(seam.recreateCalls).toHaveLength(0); + }); + }); + + it.live("seeds buckets after a local reset when storage is ready", () => { + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + storageReady: true, + }); + return Effect.gen(function* () { + // No buckets configured → the seed-buckets core short-circuits, but the + // storage gate is still consulted (Go inspects storage before buckets.Run). + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.storageChecked).toBe(true); + expect(seam.recreateCalls).toHaveLength(1); + }); + }); + + it.live("finishes a local reset when bucket seeding hits a strict-loader-rejected config", () => { + // The bucket-seeding core re-loads config via the strict `@supabase/config` loader, + // which rejects some Go-valid configs (e.g. `[db.seed] enabled = "env(SEED_ENABLED)"`). + // The seam's Go recreate already validated + rebuilt the DB, so aborting here would + // leave the reset half-done — warn and skip buckets so reset finishes like Go. + const previous = process.env["SEED_ENABLED"]; + process.env["SEED_ENABLED"] = "1"; + const { layer, out, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + storageReady: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("skipped seeding storage buckets"); + expect(out.stderrText).toContain("Finished "); + expect(seam.recreateCalls).toHaveLength(1); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SEED_ENABLED"]; + else process.env["SEED_ENABLED"] = previous; + }), + ), + ); + }); + + it.live("uses the detected git branch in the Finished line", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + }); + // `detectGitBranch` checks `$GITHUB_HEAD_REF` first (matching Go's + // `GetGitBranchOrDefault`). Set it explicitly so the test is deterministic in + // both a plain checkout and a GitHub Actions PR run (where it is preset to the + // PR branch); restore it afterwards. + const previous = process.env["GITHUB_HEAD_REF"]; + process.env["GITHUB_HEAD_REF"] = "feature-x"; + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // The branch name is wrapped in ANSI (legacyAqua), so assert on the token. + expect(out.stderrText).toContain("on branch "); + expect(out.stderrText).toContain("feature-x"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["GITHUB_HEAD_REF"]; + else process.env["GITHUB_HEAD_REF"] = previous; + }), + ), + ); + }); + + it.live("fails a remote reset on a malformed config.toml", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "unterminated\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + // Config now loads through the Go-parity reader (`legacyCheckDbToml`), so a malformed + // config aborts with Go's `failed to load config` message, same as the other db + // commands (diff/dump/pull/migration). + expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + } + }); + }); + + it.live("loads a Go-style env() boolean in config for a remote reset", () => { + // Regression: `enabled = "env(VAR)"` must load via Go's env-expansion + ParseBool + // (`legacyCheckDbToml`) instead of the strict @supabase/config loader rejecting it. + const previous = process.env["MIGRATIONS_ENABLED"]; + process.env["MIGRATIONS_ENABLED"] = "true"; + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nenabled = "env(MIGRATIONS_ENABLED)"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["MIGRATIONS_ENABLED"]; + else process.env["MIGRATIONS_ENABLED"] = previous; + }), + ), + ); + }); + + it.live("emits a json result for a local reset", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + format: "json", + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["target"]).toBe("local"); + }); + }); + + it.live("rejects mutually exclusive target flags", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--linked", "--local"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); + + it.live("rejects --version together with --last", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + last: Option.some(1), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("[last version]"); + }); + }); + + it.live("rejects a non-integer --version", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("not-a-number"), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(JSON.stringify(exit.cause)).toContain("invalid version number"); + }); + }); + + it.live("fails when --version has no matching migration file", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "glob supabase/migrations/20240101000000_*.sql: file does not exist", + ); + } + }); + }); + + it.live("returns context canceled when the reset prompt is declined", () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); + expect(conn.execs).toHaveLength(0); + }); + }); + + it.live("drops schemas and applies migrations + seed on a confirmed remote reset", () => { + const { layer, out, conn, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database..."); + // No "Connecting to ... database..." line (Go uses io.Discard). + expect(out.stderrText).not.toContain("Connecting to"); + // Drop block ran, then the migration applied. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); + expect(linkedCache.cached).toBe(true); + }); + }); + + it.live("fails a remote reset before dropping schemas on an undecryptable secret", () => { + // Regression: the old point-of-use vault decryption ran AFTER `legacyDropUserSchemas`, + // so an undecryptable `encrypted:` secret dropped the schemas before failing. Go runs + // `flags.LoadConfig` (which decrypts every secret) before ResetAll, so the reset must + // abort before any destructive work — matched here by `legacyCheckDbToml` at load time. + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.vault]\nmy_secret = "encrypted:anything"\n', + confirm: [true], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to parse config: missing private key"); + } + // Config load failed before ResetAll → schemas were never dropped. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); + }); + }); + + it.live("fails a remote reset before dropping schemas on an empty project_id", () => { + // Go's config.Validate rejects an explicit `project_id = ""` before the reset prompt, so + // the native remote reset must abort before `legacyDropUserSchemas`. + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = ""\n', + confirm: [true], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "Missing required field in config: project_id", + ); + } + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); + }); + }); + + it.live("auto-confirms a remote reset via SUPABASE_YES set only in the project .env", () => { + // Go's loadNestedEnv sets project-.env keys before the reset prompt reads viper YES, so + // a `SUPABASE_YES` in supabase/.env auto-confirms the destructive prompt (default false). + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/.env": "SUPABASE_YES=true\n" }, + // Deliberately no `confirm` responses — the prompt must be auto-confirmed. + }); return Effect.gen(function* () { - yield* legacyDbReset(baseFlags); - expect(calls).toEqual([["db", "reset"]]); - }).pipe(Effect.provide(layer)); + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + }); }); - it.live("forwards --no-seed alone", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("still caches the linked ref when DB-config resolution fails", () => { + // Go's Execute() runs ensureProjectGroupsCached after ExecuteC returns even on + // error (root.go:171-181), and ParseDatabaseConfig sets ProjectRef via + // LoadProjectRef BEFORE the fallible temp-role/connection step — so a failed + // linked resolve must not skip the post-run linked-project cache write. + const { layer, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + resolveFails: true, + }); return Effect.gen(function* () { - yield* legacyDbReset({ ...baseFlags, noSeed: true }); - expect(calls).toEqual([["db", "reset", "--no-seed"]]); - }).pipe(Effect.provide(layer)); + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); + }); }); - it.live("forwards a single --sql-paths flag", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("resets to a specific version, applying only migrations up to it", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + ...migrationFile("20240202000000"), + }, + confirm: [true], + }); return Effect.gen(function* () { yield* legacyDbReset({ - ...baseFlags, - sqlPaths: ["./seeds/base.sql"], + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database to version: 20240101000000"); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + expect(out.stderrText).not.toContain("Applying migration 20240202000000_test.sql..."); + expect(conn).toBeDefined(); + }); + }); + + it.live("resolves --last to a version prefix", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + ...migrationFile("20240202000000"), + }, + confirm: [true], + }); + return Effect.gen(function* () { + // last=1 → revert the most recent → reset to version 20240101000000. + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, last: Option.some(1) }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Resetting remote database to version: 20240101000000"); + }); + }); + + it.live("reverts all migrations when --last covers the full history", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + confirm: [true], + }); + return Effect.gen(function* () { + // last=2 with 2 local migrations → revert all → version "-". + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, last: Option.some(2) }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Resetting remote database to version: -"); + }); + }); + + it.live("skips seeding with --no-seed", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, noSeed: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).not.toContain("Seeding data from"); + }); + }); + + it.live("delegates an experimental remote reset to the Go binary", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + expect(proxy.calls[0]!.args).toEqual(["db", "reset", "--linked", "--yes=false"]); + expect(proxy.calls[0]!.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); + }); + }); + + it.live("forwards the linked selector to the delegate even for --linked=false", () => { + // Cobra `Changed` semantics: `--linked=false` still selects the linked/remote target in + // the parent, so the delegated argv must carry `--linked` — otherwise the Go child falls + // back to its local default and resets the wrong database. + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--linked=false"], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: false }).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + expect(proxy.calls[0]!.args).toEqual(["db", "reset", "--linked", "--yes=false"]); + }); + }); + + it.live("forwards --yes=false to the delegate even when SUPABASE_YES is set", () => { + // Explicit `--yes=false` beats `AutomaticEnv` in Go; the delegated child must receive the + // bound false flag so an inherited `SUPABASE_YES=true` doesn't auto-confirm the reset and + // drop the remote schemas the user tried to protect. + const previous = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "true"; + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--linked", "--yes=false"], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toContain("--yes=false"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = previous; + }), + ), + ); + }); + + it.live("forwards --yes=true to the delegate when --yes is set", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--linked", "--yes"], + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toContain("--yes=true"); + }); + }); + + it.live( + "takes the experimental delegate path via SUPABASE_EXPERIMENTAL in the project .env", + () => { + // Go loads nested env before reset.Run reads viper EXPERIMENTAL, so the versionless remote + // reset delegates to the Go binary rather than replaying migrations natively. + const previous = process.env["SUPABASE_EXPERIMENTAL"]; + delete process.env["SUPABASE_EXPERIMENTAL"]; + const { layer, proxy, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/.env": "SUPABASE_EXPERIMENTAL=true\n" }, + // No experimental flag / shell env — only the project .env sets it. }); - expect(calls).toEqual([["db", "reset", "--sql-paths", "./seeds/base.sql"]]); - }).pipe(Effect.provide(layer)); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + // Delegated, so the native remote path never dropped schemas. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; + else process.env["SUPABASE_EXPERIMENTAL"] = previous; + }), + ), + ); + }, + ); + + it.live("attaches the Go seed-flag conflict suggestion to --no-seed + --sql-paths", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + noSeed: true, + sqlPaths: ["seed.sql"], + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("--no-seed cannot be used with --sql-paths"); + // Go's validateDbResetSeedFlags CmdSuggestion, rendered as a Suggestion: line. + expect(JSON.stringify(exit.cause)).toContain("Use either"); + } + }); }); - it.live("forwards repeated --sql-paths flags in order", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("forwards --db-url and --no-seed on an experimental remote db-url reset", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], + }); return Effect.gen(function* () { yield* legacyDbReset({ - ...baseFlags, - sqlPaths: ["./seeds/base.sql", "./seeds/demo/*.sql"], - }); - expect(calls).toEqual([ - ["db", "reset", "--sql-paths", "./seeds/base.sql", "--sql-paths", "./seeds/demo/*.sql"], + ...DEFAULT_FLAGS, + dbUrl: Option.some("postgresql://db.example.com:5432/postgres"), + noSeed: true, + }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toEqual([ + "db", + "reset", + "--db-url", + "postgresql://db.example.com:5432/postgres", + "--no-seed", + "--yes=false", ]); - }).pipe(Effect.provide(layer)); + }); }); - it.live("forwards --no-seed with --sql-paths so Go owns the diagnostic", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("passes --no-seed and the resolved --last version to the recreate seam", () => { + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + args: ["db", "reset", "--local"], + isLocal: true, + running: true, + }); return Effect.gen(function* () { + // last=1 with 2 local migrations → recreate up to version 20240101000000. yield* legacyDbReset({ - ...baseFlags, + ...DEFAULT_FLAGS, + local: true, noSeed: true, - sqlPaths: ["./seeds/base.sql"], - }); - expect(calls).toEqual([["db", "reset", "--no-seed", "--sql-paths", "./seeds/base.sql"]]); - }).pipe(Effect.provide(layer)); + last: Option.some(1), + }).pipe(Effect.provide(layer)); + expect(seam.recreateCalls).toEqual([ + { version: "20240101000000", noSeed: true, sqlPaths: [] }, + ]); + }); }); - it.live("forwards an empty --sql-paths value so Go owns the diagnostic", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("recreates to a specific --version on a local db-url reset", () => { + const { layer, out, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + args: ["db", "reset", "--db-url", "postgresql://localhost:54322/postgres"], + isLocal: true, + running: true, + }); return Effect.gen(function* () { yield* legacyDbReset({ - ...baseFlags, + ...DEFAULT_FLAGS, + dbUrl: Option.some("postgresql://localhost:54322/postgres"), + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting local database to version: 20240101000000"); + expect(seam.recreateCalls).toEqual([ + { version: "20240101000000", noSeed: false, sqlPaths: [] }, + ]); + }); + }); + + it.live("resets a remote --db-url target without loading a remote config override", () => { + const { layer, out, conn } = setup(tmp.current, { + // No config file → embedded defaults (migrations + seed enabled). + files: migrationFile("20240101000000"), + args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], + isLocal: false, + omitRef: true, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + dbUrl: Option.some("postgresql://db.example.com:5432/postgres"), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database..."); + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + }); + }); + + it.live("announces a matching [remotes.*] override", () => { + const { layer, out } = setup(tmp.current, { + toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${LEGACY_VALID_REF}"\n`, + confirm: [true], + ref: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Loading config override: [remotes.preview]"); + }); + }); + + it.live("skips migrations and seed when both are disabled in config", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nenabled = false\n\n[db.seed]\nenabled = false\n', + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + // Schemas are still dropped, but nothing is applied or seeded. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + expect(out.stderrText).not.toContain("Applying migration"); + expect(out.stderrText).not.toContain("Seeding data from"); + }); + }); + + it.live("emits a json result for a confirmed remote reset (--yes)", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + format: "json", + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["target"]).toBe("remote"); + }); + }); + + it.live("emits a json result for a confirmed remote reset", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + format: "json", + }); + return Effect.gen(function* () { + // json mode is non-interactive → prompt takes the default (false) → cancel. + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + // default-false prompt in non-text mode declines → context canceled. + expect(Exit.isFailure(exit)).toBe(true); + expect(out).toBeDefined(); + }); + }); + + it.live("rejects --no-seed together with --sql-paths", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + noSeed: true, + sqlPaths: ["seed.sql"], + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("--no-seed cannot be used with --sql-paths"); + } + }); + }); + + it.live("rejects an empty --sql-paths value", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, sqlPaths: [""], - }); - expect(calls).toEqual([["db", "reset", "--sql-paths", ""]]); - }).pipe(Effect.provide(layer)); - }); - - it("parses repeated --sql-paths flags from the command surface", async () => { - const { layer, calls } = setupLegacyDbReset(); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* Command.runWith(legacyDbResetCommand, { version: "0.0.0-test" })([ - "--sql-paths", - "./seeds/base.sql", - "--sql-paths", - "./seeds/demo/*.sql", - ]); - expect(calls).toEqual([ - ["db", "reset", "--sql-paths", "./seeds/base.sql", "--sql-paths", "./seeds/demo/*.sql"], - ]); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - layer, - mockOutput({ format: "text" }).layer, - CliOutput.layer(textCliOutputFormatter()), - ), - ), - ) as Effect.Effect, - ); + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "--sql-paths requires a non-empty path or glob pattern", + ); + } + }); }); - it("forwards mutually exclusive seed flags from the command surface", async () => { - const { layer, calls } = setupLegacyDbReset(); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* Command.runWith(legacyDbResetCommand, { version: "0.0.0-test" })([ - "--no-seed", - "--sql-paths", - "./seeds/base.sql", - ]); - expect(calls).toEqual([["db", "reset", "--no-seed", "--sql-paths", "./seeds/base.sql"]]); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - layer, - mockOutput({ format: "text" }).layer, - CliOutput.layer(textCliOutputFormatter()), - ), - ), - ) as Effect.Effect, - ); + it.live("rejects a negative --last value", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + last: Option.some(-1), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const cause = JSON.stringify(exit.cause); + expect(cause).toContain("invalid argument"); + expect(cause).toContain("strconv.ParseUint"); + } + }); + }); + + it.live("seeds an absolute --sql-paths file on a remote reset", () => { + const absSeed = join(tmp.current, "external-seed.sql"); + writeFileSync(absSeed, "insert into t values (3);"); + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + sqlPaths: [absSeed], + }).pipe(Effect.provide(layer)); + // Absolute paths are preserved (not prefixed with supabase/) and seeded. + expect(out.stderrText).toContain(`Seeding data from ${absSeed}...`); + }); + }); + + it.live("warns and seeds from --sql-paths overriding config on a remote reset", () => { + const { layer, out } = setup(tmp.current, { + // Seed disabled in config — --sql-paths must force-enable it. + toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', + files: { + ...migrationFile("20240101000000"), + "supabase/custom-seed.sql": "insert into t values (2);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + sqlPaths: ["custom-seed.sql"], + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("--sql-paths overrides [db.seed].sql_paths"); + expect(out.stderrText).toContain("Seeding data from supabase/custom-seed.sql..."); + }); + }); + + it.live("forwards --sql-paths to the recreate seam on a local reset", () => { + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + running: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + local: true, + sqlPaths: ["custom-seed.sql", "demo/*.sql"], + }).pipe(Effect.provide(layer)); + expect(seam.recreateCalls).toEqual([ + { version: "", noSeed: false, sqlPaths: ["custom-seed.sql", "demo/*.sql"] }, + ]); + }); + }); + + it.live("forwards --sql-paths to the Go binary on an experimental remote reset", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + sqlPaths: ["custom-seed.sql"], + }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toEqual([ + "db", + "reset", + "--linked", + "--sql-paths", + "custom-seed.sql", + "--yes=false", + ]); + }); }); }); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.layers.ts b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts new file mode 100644 index 0000000000..7cf648f3fe --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts @@ -0,0 +1,80 @@ +import { Layer } from "effect"; + +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyCredentialsLayer } from "../../../auth/legacy-credentials.layer.ts"; +import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; +import { legacyPlatformApiFactoryLayer } from "../../../auth/legacy-platform-api-factory.layer.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; +import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; +import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; +import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitch.ts"; +import { legacyLinkedProjectCacheLayer } from "../../../telemetry/legacy-linked-project-cache.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; +import { legacyDbBootstrapSeamLayer } from "../shared/legacy-db-bootstrap.seam.layer.ts"; + +/** + * Runtime layer for `supabase db reset`. Same composition as `db push` / `db lint`: + * the Postgres connection, the db-config resolver, project-ref resolution, and the + * linked-project cache, all over the lazy management-API factory so the local / + * `--db-url` paths never resolve an access token at layer-build time. `LegacyGoProxy` + * (used to delegate the local / experimental reset paths) is ambient from the root. + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), +); + +const platformApiFactory = legacyPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +const projectRef = legacyProjectRefLayer.pipe( + Layer.provide(platformApiFactory), + Layer.provide(cliConfig), +); + +const linkedProjectCache = legacyLinkedProjectCacheLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(httpClient), + Layer.provide(legacyIdentityStitchLayer), +); + +const dbConfig = legacyDbConfigLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +export const legacyDbResetRuntimeLayer = Layer.mergeAll( + dbConfig, + legacyDbConnectionLayer, + cliConfig, + httpClient, + credentials, + projectRef, + // Exposed (not just provided to `projectRef`) because the local reset path reuses + // the seed-buckets core, whose `legacyResolveStorageCredentials` requires the + // (lazy) Management-API factory for the linked branch — never hit on `--local`, + // but a static service requirement of the shared core. + platformApiFactory, + linkedProjectCache, + legacyIdentityStitchLayer, + legacyTelemetryStateLayer, + // `legacyPromptYesNo`'s non-TTY branch reads the piped answer via `Stdin` (Go's + // `console.ReadLine`); without it a CI/piped remote `db reset` that reaches the + // confirmation prompt fails with a missing-service defect instead of the default. + stdinLayer, + // Container-recreate / storage-health primitives for the native local reset. + legacyDbBootstrapSeamLayer.pipe(Layer.provide(cliConfig)), + commandRuntimeLayer(["db", "reset"]), +); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts new file mode 100644 index 0000000000..673e78a466 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts @@ -0,0 +1,20 @@ +import { Data } from "effect"; + +/** + * Driving the bundled Go binary's hidden `db __db-bootstrap` seam failed — the + * container-lifecycle primitives that back native `db start` / `db reset --local` + * (create/recreate the local Postgres container, apply the initial schema, the + * storage health gate) are not yet ported to TypeScript. Wraps a failed inspect, + * a missing `supabase-go` binary, or a non-zero seam exit. The seam tees its own + * progress to stderr, so this message is the fallback shown when the subprocess + * dies without surfacing a more specific Go error. + */ +export class LegacyDbBootstrapError extends Data.TaggedError("LegacyDbBootstrapError")<{ + readonly message: string; + /** + * Optional actionable hint rendered as a separate "Suggestion:" line, mirroring + * Go's `utils.CmdSuggestion` — set to the Docker-install hint when the container + * runtime's daemon is unreachable (`AssertServiceIsRunning`, `misc.go:148-154`). + */ + readonly suggestion?: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts new file mode 100644 index 0000000000..bcf5735978 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts @@ -0,0 +1,241 @@ +import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { + LegacyNetworkIdFlag, + LegacyProfileFlag, + legacyResolveExperimental, +} from "../../../../shared/legacy/global-flags.ts"; +import { resolveBinary } from "../../../../shared/legacy/go-proxy.layer.ts"; +import { ProcessControl } from "../../../../shared/runtime/process-control.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; +import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; +import { + legacyResolveLocalProjectId, + localDbContainerId, +} from "../../../shared/legacy-docker-ids.ts"; +import { + LEGACY_SUGGEST_DOCKER_INSTALL, + legacyIsDockerDaemonUnreachable, +} from "../../../shared/legacy-docker-suggest.ts"; +import { LegacyDbBootstrapError } from "./legacy-db-bootstrap.errors.ts"; +import { LegacyDbBootstrapSeam } from "./legacy-db-bootstrap.seam.service.ts"; + +const seamFailure = (message: string) => new LegacyDbBootstrapError({ message }); + +const decodeChunks = (chunks: ReadonlyArray): string => { + const total = chunks.reduce((size, chunk) => size + chunk.length, 0); + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return new TextDecoder().decode(bytes); +}; + +/** + * Real {@link LegacyDbBootstrapSeam}: drives the bundled `supabase-go`'s hidden + * `db __db-bootstrap --mode ` command. The binary is resolved exactly like + * `LegacyGoProxy` (`resolveBinary`); the child's telemetry is disabled and its + * progress teed to stderr, matching the `db __shadow` seam. `--network-id` and a + * flag-selected `--profile` are forwarded so the spawned containers land on the + * same network and the child re-runs Go's identical config resolution. + */ +export const legacyDbBootstrapSeamLayer = Layer.effect( + LegacyDbBootstrapSeam, + Effect.gen(function* () { + const cliConfig = yield* LegacyCliConfig; + const networkId = yield* LegacyNetworkIdFlag; + const profile = yield* LegacyProfileFlag; + const profileArgs = profile !== "supabase" ? ["--profile", profile] : []; + const networkArgs = Option.isSome(networkId) ? ["--network-id", networkId.value] : []; + // Forward `--experimental` (env-aware) so the seam's `SetupLocalDatabase` / + // `apply.MigrateAndSeed` takes Go's experimental schema-file path on a + // versionless reset/start, matching `viper.GetBool("EXPERIMENTAL")`. + const experimental = yield* legacyResolveExperimental; + const experimentalArgs = experimental ? ["--experimental"] : []; + const spawner = yield* ChildProcessSpawner; + const processControl = yield* ProcessControl; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolved = resolveBinary(); + + /** + * Run `db __db-bootstrap` with the given mode args. `captureStdout` pipes + * stdout (for the `await-storage` marker); otherwise stdout is inherited. + * Returns the captured stdout (empty when inherited). + */ + const runBootstrap = (modeArgs: ReadonlyArray, captureStdout: boolean) => + Effect.scoped( + Effect.gen(function* () { + if (!("found" in resolved)) { + return yield* Effect.fail( + seamFailure( + "Could not find the supabase-go binary required to bootstrap the local database.", + ), + ); + } + // `runCli` treats `db start`/`db reset` as self-managed and installs no + // global signal handler, and this direct child spawn (unlike + // `LegacyGoProxy.exec`) inherits the foreground process group. Hold + // SIGINT/SIGTERM/SIGHUP with no-op listeners so an interactive Ctrl-C + // during container startup/restore does not default-terminate the TS + // parent out from under the Go child's docker-cleanup path — the parent + // stays blocked on the child's exit and propagates its real status. + // Scoped, so the listeners are removed on completion/failure/interrupt. + yield* processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]); + const args = [ + "db", + "__db-bootstrap", + ...modeArgs, + ...networkArgs, + ...profileArgs, + ...experimentalArgs, + ]; + const command = ChildProcess.make(resolved.found, args, { + cwd: cliConfig.workdir, + stdin: "inherit", + stdout: captureStdout ? "pipe" : "inherit", + stderr: "inherit", + extendEnv: true, + // Disable the child's telemetry so the hidden seam never records its + // own `cli_command_executed` on top of the user's TS command, matching + // the `db __shadow` seam and the explicit LegacyGoProxy delegates. + env: { SUPABASE_TELEMETRY_DISABLED: "1" }, + detached: false, + }); + if (!captureStdout) { + const exitCode = yield* spawner + .exitCode(command) + .pipe(Effect.mapError(() => seamFailure("failed to run supabase-go."))); + if (exitCode !== 0) { + // Fail (rather than `processControl.exit`) so the handler's finalizers — + // `Effect.ensuring(telemetryState.flush)` + the legacy command + // instrumentation — still run; an immediate `process.exit` here would + // skip them. Go likewise exits non-zero on a bootstrap error only after + // its `PersistentPostRun`. The child's detailed failure is already on the + // inherited stderr. (Preserving the child's *exact* exit code while still + // running finalizers would require a shared `runCli` change — deferred.) + return yield* Effect.fail( + seamFailure(`failed to bootstrap the local database: exit ${exitCode}`), + ); + } + return ""; + } + const handle = yield* spawner + .spawn(command) + .pipe(Effect.mapError(() => seamFailure("failed to run supabase-go."))); + const chunks: Array = []; + yield* Stream.runForEach(handle.stdout, (chunk) => + Effect.sync(() => { + chunks.push(chunk); + }), + ).pipe(Effect.mapError(() => seamFailure("failed to bootstrap the local database."))); + const exitCode = yield* handle.exitCode.pipe( + Effect.mapError(() => seamFailure("failed to bootstrap the local database.")), + ); + if (exitCode !== 0) { + return yield* Effect.fail( + seamFailure(`failed to bootstrap the local database: exit ${exitCode}`), + ); + } + return decodeChunks(chunks); + }), + ); + + return LegacyDbBootstrapSeam.of({ + isDbRunning: () => + Effect.scoped( + Effect.gen(function* () { + // Resolve `utils.DbId` exactly as Go does (env → config.toml → workdir + // basename); the config.toml read is best-effort (`validate: false`) since + // the handler has already run Go's `LoadConfig` validation — an invalid + // config would have failed there, so here we only want the `projectId` and + // tolerate a fallback to the workdir basename rather than re-throwing. + const tomlProjectId = yield* legacyReadDbToml(fs, path, cliConfig.workdir, undefined, { + validate: false, + }).pipe( + Effect.map((toml) => toml.projectId), + // The lenient read still surfaces a genuinely unreadable/malformed project + // `.env`; fall back to the workdir basename in that case rather than failing + // the running-check (the handler has already validated config). + Effect.orElseSucceed(() => Option.none()), + ); + const projectId = legacyResolveLocalProjectId( + Option.getOrUndefined(cliConfig.projectId), + Option.getOrUndefined(tomlProjectId), + cliConfig.workdir, + ); + const containerId = localDbContainerId(projectId); + // Go's AssertSupabaseDbIsRunning = ContainerInspect → NotFound ⇒ not + // running. Discard stdout (the inspect JSON) so the unconsumed pipe can + // never deadlock; only the exit code + stderr matter. + const child = yield* spawnContainerCli(spawner, ["container", "inspect", containerId], { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + extendEnv: true, + }).pipe(Effect.mapError(() => seamFailure("failed to inspect service"))); + const stderrChunks: Array = []; + yield* Stream.runForEach(child.stderr, (chunk) => + Effect.sync(() => { + stderrChunks.push(chunk); + }), + ).pipe(Effect.mapError(() => seamFailure("failed to inspect service"))); + const inspectExit = yield* child.exitCode.pipe( + Effect.map(Number), + Effect.mapError(() => seamFailure("failed to inspect service")), + ); + if (inspectExit === 0) return true; // container exists ⇒ running + + const stderr = decodeChunks(stderrChunks).trim(); + // Only a missing container means "not running". Docker reports this as + // either "No such container" or "No such object" depending on daemon + // version/CLI path (the same pair handled in `shared/functions/serve.ts`). + // Any other inspect failure (e.g. the Docker daemon is down) propagates, + // matching Go's `AssertSupabaseDbIsRunning`. + if (!stderr.includes("No such container") && !stderr.includes("No such object")) { + // Go's `AssertServiceIsRunning` sets `CmdSuggestion = suggestDockerInstall` + // on a daemon-connection failure (`misc.go:148-154`), so a down daemon + // still surfaces the actionable Docker Desktop hint, not just raw stderr. + return yield* Effect.fail( + new LegacyDbBootstrapError({ + message: + stderr.length > 0 + ? `failed to inspect service: ${stderr}` + : "failed to inspect service", + ...(legacyIsDockerDaemonUnreachable(stderr) + ? { suggestion: LEGACY_SUGGEST_DOCKER_INSTALL } + : {}), + }), + ); + } + return false; + }), + ), + startDatabase: ({ fromBackup }) => + runBootstrap( + ["--mode", "start", ...(fromBackup !== undefined ? ["--from-backup", fromBackup] : [])], + false, + ).pipe(Effect.asVoid), + recreateDatabase: ({ version, noSeed, sqlPaths }) => + runBootstrap( + [ + "--mode", + "recreate", + ...(version !== "" ? ["--version", version] : []), + ...(noSeed ? ["--no-seed"] : []), + ...sqlPaths.flatMap((p) => ["--sql-paths", p]), + ], + false, + ).pipe(Effect.asVoid), + awaitStorageReady: () => + runBootstrap(["--mode", "await-storage"], true).pipe( + Effect.map((stdout) => stdout.trim() === "ready"), + ), + }); + }), +); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts new file mode 100644 index 0000000000..4c29ddb67a --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts @@ -0,0 +1,68 @@ +import { Context, type Effect } from "effect"; + +import type { LegacyDbBootstrapError } from "./legacy-db-bootstrap.errors.ts"; + +/** + * Seam over the bundled Go binary's hidden `db __db-bootstrap` command, exposing + * the container-bootstrap primitives that native `db start` / `db reset --local` + * still need but that are not ported to TypeScript: the local-stack "is running?" + * probe, the database container create/recreate flows, and the storage health gate + * before bucket seeding. The TS handlers orchestrate everything else (user-facing + * messages, version resolution, bucket seeding, the git-branch line, telemetry, + * and `--output-format` shaping); only the Docker lifecycle lives behind here. + * + * Mirrors {@link LegacyDeclarativeSeam} (`db __shadow`): each method shells out to + * the same resolved `supabase-go`, with the child's telemetry disabled so the + * hidden seam never double-counts the user's command, and its progress teed to + * stderr. + */ +interface LegacyDbBootstrapSeamShape { + /** + * Go's `utils.AssertSupabaseDbIsRunning` (`internal/utils/misc.go:144`): inspect + * the local Postgres container. `true` when it exists (the stack is up), `false` + * when Docker reports "No such container" (Go's `ErrNotRunning`). Any other + * inspect failure (e.g. the Docker daemon is unreachable) fails with + * {@link LegacyDbBootstrapError}, matching Go, which returns the wrapped inspect + * error rather than treating the database as stopped. + */ + readonly isDbRunning: () => Effect.Effect; + /** + * `db start`'s container bootstrap — `start.StartDatabase(fromBackup)` plus Go's + * `DockerRemoveAll` cleanup on failure (`internal/db/start/start.go:54-60`): + * create the Postgres container, wait for health, apply the initial schema + + * roles + migrations + seed on a fresh volume, and write `_current_branch`. + * Progress (`Starting database...`, `Initialising schema...`) is teed to stderr. + */ + readonly startDatabase: (opts: { + readonly fromBackup?: string; + }) => Effect.Effect; + /** + * The PG14/PG15 container-recreate half of local `db reset` + * (`reset.RecreateLocalDatabase`): recreate the db container/volume, init schema, + * migrate + seed up to `version`, and restart the satellite containers. The + * caller has already printed `Resetting local database…`; the seam tees the + * remaining progress (`Recreating database...`, `Restarting containers...`) to + * stderr. `version` is the resolved migration version ("" for all migrations); + * `noSeed` disables the seed and `sqlPaths` overrides `[db.seed].sql_paths` + * inside the recreate's MigrateAndSeed, mirroring the `db reset` + * `--no-seed` / `--sql-paths` handling (`cmd/db.go` `dbResetCmd`). + */ + readonly recreateDatabase: (opts: { + readonly version: string; + readonly noSeed: boolean; + readonly sqlPaths: ReadonlyArray; + }) => Effect.Effect; + /** + * The storage health gate local `db reset` runs before seeding buckets + * (`reset.AwaitStorageReady`): if the storage container exists but is unhealthy, + * wait up to 30s for it. Resolves `true` when the storage container exists (so + * the caller should run the ported bucket seeding) and `false` when it does not + * — matching Go, which silently skips buckets when storage is absent. + */ + readonly awaitStorageReady: () => Effect.Effect; +} + +export class LegacyDbBootstrapSeam extends Context.Service< + LegacyDbBootstrapSeam, + LegacyDbBootstrapSeamShape +>()("supabase/legacy/DbBootstrapSeam") {} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts b/apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts new file mode 100644 index 0000000000..88d394498c --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts @@ -0,0 +1,169 @@ +import { Effect } from "effect"; + +import type { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; + +/** + * Verbatim port of Go's embedded `pkg/migration/queries/drop.sql` + * (`DropUserSchemas`). A single PL/pgSQL `DO` block that drops user schemas, + * extensions, public-schema objects, and non-managed publications, then + * truncates the auth / supabase_functions / supabase_migrations tables. Run as a + * single simple-query statement, matching Go's one-statement `ExecBatch`. + */ +const DROP_OBJECTS = `do $$ declare + rec record; +begin + -- schemas + for rec in + select pn.* + from pg_namespace pn + left join pg_depend pd on pd.objid = pn.oid + where pd.deptype is null + and not pn.nspname like any(array['information\\_schema', 'pg\\_%', '\\_analytics', '\\_realtime', '\\_supavisor', 'pgbouncer', 'pgmq', 'pgsodium', 'pgtle', 'supabase\\_migrations', 'vault', 'extensions', 'public']) + and pn.nspowner::regrole::text != 'supabase_admin' + loop + -- If an extension uses a schema it doesn't create, dropping the schema will cascade to also + -- drop the extension. But if an extension creates its own schema, dropping the schema will + -- throw an error. Hence, we drop schemas first while excluding those created by extensions. + raise notice 'dropping schema: %', rec.nspname; + execute format('drop schema if exists %I cascade', rec.nspname); + end loop; + + -- extensions + for rec in + select * + from pg_extension p + where p.extname not in ('pg_graphql', 'pg_net', 'pg_stat_statements', 'pgcrypto', 'pgjwt', 'pgsodium', 'plpgsql', 'supabase_vault', 'uuid-ossp') + loop + raise notice 'dropping extension: %', rec.extname; + execute format('drop extension if exists %I cascade', rec.extname); + end loop; + + -- functions + for rec in + select * + from pg_proc p + where p.pronamespace::regnamespace::name = 'public' + loop + -- supports aggregate, function, and procedure + raise notice 'dropping function: %.%', rec.pronamespace::regnamespace::name, rec.proname; + execute format('drop routine if exists %I.%I(%s) cascade', rec.pronamespace::regnamespace::name, rec.proname, pg_catalog.pg_get_function_identity_arguments(rec.oid)); + end loop; + + -- views (necessary for views referencing objects in Supabase-managed schemas) + for rec in + select * + from pg_class c + where + c.relnamespace::regnamespace::name = 'public' + and c.relkind = 'v' + loop + raise notice 'dropping view: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('drop view if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- materialized views (necessary for materialized views referencing objects in Supabase-managed schemas) + for rec in + select * + from pg_class c + where + c.relnamespace::regnamespace::name = 'public' + and c.relkind = 'm' + loop + raise notice 'dropping materialized view: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('drop materialized view if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- tables (cascade to dependent objects) + for rec in + select * + from pg_class c + where + c.relnamespace::regnamespace::name = 'public' + and c.relkind not in ('c', 'S', 'v', 'm') + order by c.relkind desc + loop + -- supports all table like relations, except views, complex types, and sequences + raise notice 'dropping table: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('drop table if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- truncate tables in auth, webhooks, and migrations schema + for rec in + select * + from pg_class c + where + (c.relnamespace::regnamespace::name = 'auth' and c.relname != 'schema_migrations' + or c.relnamespace::regnamespace::name = 'supabase_functions' and c.relname != 'migrations' + or c.relnamespace::regnamespace::name = 'supabase_migrations') + and c.relkind = 'r' + loop + raise notice 'truncating table: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('truncate %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- sequences + for rec in + select * + from pg_class c + where + c.relnamespace::regnamespace::name = 'public' + and c.relkind = 's' + loop + raise notice 'dropping sequence: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('drop sequence if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- types + for rec in + select * + from pg_type t + where + t.typnamespace::regnamespace::name = 'public' + and typtype != 'b' + loop + raise notice 'dropping type: %.%', rec.typnamespace::regnamespace::name, rec.typname; + execute format('drop type if exists %I.%I cascade', rec.typnamespace::regnamespace::name, rec.typname); + end loop; + + -- policies + for rec in + select * + from pg_policies p + loop + raise notice 'dropping policy: %', rec.policyname; + execute format('drop policy if exists %I on %I.%I cascade', rec.policyname, rec.schemaname, rec.tablename); + end loop; + + -- publications + for rec in + select * + from pg_publication p + where + not p.pubname like any(array['supabase\\_realtime%', 'realtime\\_messages%']) + loop + raise notice 'dropping publication: %', rec.pubname; + execute format('drop publication if exists %I', rec.pubname); + end loop; +end $$;`; + +/** + * Drops all user-created database objects, mirroring Go's + * `migration.DropUserSchemas` (`pkg/migration/drop.go:34-38`): the `drop.sql` `DO` + * block runs as a single transactional statement (no migration-history row). + */ +export const legacyDropUserSchemas = ( + session: LegacyDbSession, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + // Go's `DropUserSchemas` runs only `drop.sql` via `ExecBatch` (drop.go:34-38) — + // no `RESET ALL`. Resetting here would clear caller-supplied DB URL runtime + // params (e.g. `options=-c statement_timeout=…`) before the destructive drop, so + // the remote `db reset --db-url` path must NOT reset (matches Go's ExecBatch). + yield* session.exec("BEGIN"); + yield* session + .exec(DROP_OBJECTS) + .pipe(Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore))); + yield* session.exec("COMMIT"); + }).pipe(Effect.mapError((error: LegacyDbExecError) => mapError(error.message))); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.ts new file mode 100644 index 0000000000..579a0b675a --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.ts @@ -0,0 +1,123 @@ +import { legacyBold } from "../../../shared/legacy-colors.ts"; + +/** + * `pkg/migration/file.go` — local migration filenames are `_.sql`. + * `ListLocalMigrations` guarantees every path in `localMigrations` matches, so the + * version capture group is always present. + */ +const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; + +/** Last path segment, mirroring Go's `filepath.Base`. */ +const baseName = (path: string): string => { + const normalized = path.replace(/[/\\]+$/u, ""); + const slash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")); + return slash === -1 ? normalized : normalized.slice(slash + 1); +}; + +/** + * `pkg/migration/apply.go:14-16` — the exact error strings Go raises so the legacy + * handler can byte-match them on stderr. + */ +export const LEGACY_ERR_MISSING_REMOTE = + "Found local migration files to be inserted before the last migration on remote database."; +export const LEGACY_ERR_MISSING_LOCAL = + "Remote migration versions not found in local migrations directory."; + +/** + * The outcome of comparing local migration files against the remote + * `schema_migrations` history. Pure 1:1 port of Go's `FindPendingMigrations` + * (`pkg/migration/apply.go:21-54`). + * + * - `ok` — `pending` are the local migration paths to apply (those + * beyond the remote history, in order). + * - `missing-local` — remote has versions with no local file (`ErrMissingLocal`). + * `versions` are the offending remote versions. + * - `missing-remote`— local has files ordered before the remote head + * (`ErrMissingRemote`). `paths` are the out-of-order local + * migration paths. + */ +export type LegacyPendingMigrations = + | { readonly kind: "ok"; readonly pending: ReadonlyArray } + | { readonly kind: "missing-local"; readonly versions: ReadonlyArray } + | { readonly kind: "missing-remote"; readonly paths: ReadonlyArray }; + +/** + * Two-pointer reconciliation of local migration paths vs remote applied versions. + * Mirrors Go's `FindPendingMigrations` exactly, including its **string** + * comparison of versions (`remote == local` / `remote < local`) — version + * prefixes are fixed-width timestamps, so lexical order equals chronological + * order, matching Go. + */ +export function legacyFindPendingMigrations( + localMigrations: ReadonlyArray, + remoteMigrations: ReadonlyArray, +): LegacyPendingMigrations { + const unapplied: Array = []; + const missing: Array = []; + let i = 0; + let j = 0; + while (i < remoteMigrations.length && j < localMigrations.length) { + const remote = remoteMigrations[i]!; + const filename = baseName(localMigrations[j]!); + // ListLocalMigrations guarantees a match, so the capture group is present. + const local = MIGRATE_FILE_PATTERN.exec(filename)![1]!; + if (remote === local) { + i++; + j++; + } else if (remote < local) { + missing.push(remote); + i++; + } else { + // Include out-of-order local migrations. + unapplied.push(localMigrations[j]!); + j++; + } + } + // Ensure all remote versions exist on local. + if (j === localMigrations.length) { + missing.push(...remoteMigrations.slice(i)); + } + if (missing.length > 0) { + return { kind: "missing-local", versions: missing }; + } + // Enforce migrations are applied in chronological order by default. + if (unapplied.length > 0) { + return { kind: "missing-remote", paths: unapplied }; + } + return { kind: "ok", pending: localMigrations.slice(remoteMigrations.length) }; +} + +/** + * Computes the `--include-all` pending set when reconciliation reports + * `missing-remote`. Mirrors Go's `GetPendingMigrations` includeAll branch + * (`internal/migration/up/up.go:46-48`): the out-of-order paths first, then the + * local migrations beyond `len(remote)+len(diff)`. + */ +export function legacyIncludeAllPending( + localMigrations: ReadonlyArray, + remoteCount: number, + diff: ReadonlyArray, +): ReadonlyArray { + return [...diff, ...localMigrations.slice(remoteCount + diff.length)]; +} + +/** + * Go's `suggestRevertHistory` (`internal/migration/up/up.go:55-61`). `fmt.Sprintln` + * appends a trailing newline to each line, so the suggestion ends with `\n`. + */ +export function legacySuggestRevertHistory(versions: ReadonlyArray): string { + return ( + "\nMake sure your local git repo is up-to-date. If the error persists, try repairing the migration history table:\n" + + `${legacyBold(`supabase migration repair --status reverted ${versions.join(" ")}`)}\n` + + "\nAnd update local migrations to match remote database:\n" + + `${legacyBold("supabase db pull")}\n` + ); +} + +/** Go's `suggestIgnoreFlag` (`internal/migration/up/up.go:63-67`). */ +export function legacySuggestIgnoreFlag(paths: ReadonlyArray): string { + return ( + "\nRerun the command with --include-all flag to apply these migrations:\n" + + `${legacyBold(paths.join("\n"))}\n` + ); +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.unit.test.ts new file mode 100644 index 0000000000..eaf5400d60 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.unit.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { + legacyFindPendingMigrations, + legacyIncludeAllPending, + legacySuggestIgnoreFlag, + legacySuggestRevertHistory, +} from "./legacy-migration-pending.ts"; + +const local = (...versions: ReadonlyArray) => + versions.map((v) => `supabase/migrations/${v}_name.sql`); + +describe("legacyFindPendingMigrations", () => { + it("returns the local migrations beyond the remote history when in sync", () => { + const result = legacyFindPendingMigrations(local("0001", "0002", "0003"), ["0001"]); + expect(result).toEqual({ + kind: "ok", + pending: ["supabase/migrations/0002_name.sql", "supabase/migrations/0003_name.sql"], + }); + }); + + it("is up to date when local and remote match exactly", () => { + const result = legacyFindPendingMigrations(local("0001", "0002"), ["0001", "0002"]); + expect(result).toEqual({ kind: "ok", pending: [] }); + }); + + it("reports missing-local when remote has a version with no local file", () => { + const result = legacyFindPendingMigrations(local("0001", "0003"), ["0001", "0002", "0003"]); + expect(result).toEqual({ kind: "missing-local", versions: ["0002"] }); + }); + + it("reports missing-local for trailing remote versions absent locally", () => { + const result = legacyFindPendingMigrations(local("0001"), ["0001", "0002"]); + expect(result).toEqual({ kind: "missing-local", versions: ["0002"] }); + }); + + it("reports missing-remote for an out-of-order local migration", () => { + const result = legacyFindPendingMigrations(local("0001", "0002"), ["0002"]); + expect(result).toEqual({ + kind: "missing-remote", + paths: ["supabase/migrations/0001_name.sql"], + }); + }); + + it("treats an empty remote history as all-local pending", () => { + const result = legacyFindPendingMigrations(local("0001", "0002"), []); + expect(result).toEqual({ + kind: "ok", + pending: ["supabase/migrations/0001_name.sql", "supabase/migrations/0002_name.sql"], + }); + }); +}); + +describe("legacyIncludeAllPending", () => { + it("prepends the out-of-order diff then the migrations beyond remote+diff", () => { + const locals = local("0001", "0002", "0003"); + const diff = ["supabase/migrations/0001_name.sql"]; + // remoteCount 1, diff length 1 → slice from index 2. + expect(legacyIncludeAllPending(locals, 1, diff)).toEqual([ + "supabase/migrations/0001_name.sql", + "supabase/migrations/0003_name.sql", + ]); + }); +}); + +describe("suggestion strings", () => { + it("builds the revert-history suggestion with a trailing newline per line", () => { + expect(legacySuggestRevertHistory(["0002", "0003"])).toContain( + "supabase migration repair --status reverted 0002 0003", + ); + expect(legacySuggestRevertHistory(["0002"])).toMatch(/\n$/u); + expect(legacySuggestRevertHistory(["0002"])).toContain("supabase db pull"); + }); + + it("builds the include-all suggestion listing each path on its own line", () => { + const suggestion = legacySuggestIgnoreFlag([ + "supabase/migrations/0001_a.sql", + "supabase/migrations/0002_b.sql", + ]); + expect(suggestion).toContain("--include-all"); + expect(suggestion).toContain("supabase/migrations/0001_a.sql\nsupabase/migrations/0002_b.sql"); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index 634c4c7b56..29469069f8 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -209,9 +209,11 @@ export const legacyDeclarativeSeamLayer = Layer.effect( })(), ) .trim(); - // Only a missing container means "not running" → start it. Any other - // inspect failure (e.g. Docker daemon down) propagates, matching Go. - if (!stderr.includes("No such container")) { + // Only a missing container means "not running" → start it. Docker reports + // this as either "No such container" or "No such object" (the same pair + // handled in `shared/functions/serve.ts`). Any other inspect failure (e.g. + // Docker daemon down) propagates, matching Go. + if (!stderr.includes("No such container") && !stderr.includes("No such object")) { return yield* Effect.fail( new LegacyDeclarativeShadowDbError({ message: diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts new file mode 100644 index 0000000000..5caba63a87 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts @@ -0,0 +1,372 @@ +import { createHash } from "node:crypto"; +import { Effect, type FileSystem, Option, type Path } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import type { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyCreateSeedTable } from "../../../shared/legacy-migration-history.ts"; +import { legacySplitAndTrim } from "../../../shared/legacy-sql-split.ts"; + +/** + * Seed-history DML, verbatim from Go's `pkg/migration/history.go`. The schema/table + * DDL (with a transaction-scoped lock timeout) lives in `legacyCreateSeedTable`. + */ +const UPSERT_SEED_FILE = + "INSERT INTO supabase_migrations.seed_files(path, hash) VALUES($1, $2) ON CONFLICT (path) DO UPDATE SET hash = EXCLUDED.hash"; +const SELECT_SEED_TABLE = "SELECT path, hash FROM supabase_migrations.seed_files"; + +/** A local seed file resolved from `[db.seed].sql_paths`, with its content hash. */ +export interface LegacySeedFile { + /** Workdir-relative, forward-slashed path (Go's `filepath.ToSlash`). */ + readonly path: string; + /** Lowercase hex SHA-256 of the file content (Go's `NewSeedFile`). */ + readonly hash: string; + /** True when the remote `seed_files` row has a different hash (re-hash only). */ + readonly dirty: boolean; +} + +const META_CHARS = /[*?[\\]/u; + +/** + * Go's `path.Match` for a single filename (no `/`). Supports `*` (any run of + * non-separator chars), `?` (one char), `[...]` classes with ranges and a + * leading `^`/`!` negation, and `\` escapes. Filenames never contain `/`, so the + * separator subtlety in Go's matcher does not apply here. + */ +export function legacyMatchPattern(pattern: string, name: string): boolean { + const matchClass = (cls: string, ch: string): boolean => { + let negated = false; + let body = cls; + if (body.startsWith("^") || body.startsWith("!")) { + negated = true; + body = body.slice(1); + } + let matched = false; + for (let k = 0; k < body.length; k++) { + if (body[k + 1] === "-" && k + 2 < body.length) { + if (ch >= body[k]! && ch <= body[k + 2]!) matched = true; + k += 2; + } else if (body[k] === ch) { + matched = true; + } + } + return matched !== negated; + }; + + const match = (p: number, n: number): boolean => { + while (p < pattern.length) { + const pc = pattern[p]!; + if (pc === "*") { + // Collapse consecutive stars, then try to match the rest at every offset. + while (pattern[p] === "*") p++; + if (p === pattern.length) return true; + for (let k = n; k <= name.length; k++) { + if (match(p, k)) return true; + } + return false; + } + if (n >= name.length) return false; + if (pc === "?") { + p++; + n++; + continue; + } + if (pc === "[") { + const end = pattern.indexOf("]", p + 1); + if (end === -1) return false; + if (!matchClass(pattern.slice(p + 1, end), name[n]!)) return false; + p = end + 1; + n++; + continue; + } + if (pc === "\\" && p + 1 < pattern.length) { + if (pattern[p + 1] !== name[n]) return false; + p += 2; + n++; + continue; + } + if (pc !== name[n]) return false; + p++; + n++; + } + return n === name.length; + }; + + return match(0, 0); +} + +/** Result of resolving `[db.seed].sql_paths` against the workspace. */ +interface LegacyGlobResult { + /** Workdir-relative, forward-slashed matches, deduplicated in pattern order. */ + readonly files: ReadonlyArray; + /** Per-pattern warnings (`no files matched pattern: …`), joined by Go's `errors.Join`. */ + readonly warning: Option.Option; +} + +/** + * Resolves seed glob patterns to existing files, porting Go's `config.Glob.Files` + * over `fs.Glob` (`pkg/config/config.go:102-124`). Each pattern is first joined + * under the `supabase/` directory (Go resolves `sql_paths` at config load, + * `config.go:884`). Matches per pattern are sorted; the overall result preserves + * first-seen order across patterns. A pattern that matches nothing contributes a + * `no files matched pattern: ` warning but is not fatal. + */ +const legacyGlobSeedFiles = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + patterns: ReadonlyArray, + workdir: string, +) { + const seen = new Set(); + const files: Array = []; + const errors: Array = []; + + for (const rawPattern of patterns) { + // Patterns arrive already resolved to Go's config-load form (relative entries + // supabase/-joined, absolute preserved) via `legacyResolveSeedSqlPath` — the reader + // for `[db.seed].sql_paths`, the caller for `--sql-paths`. Go's `config.Glob.Files` + // globs those resolved paths without re-prefixing (`config.go:102-124`), so only + // normalize separators here; re-joining `supabase/` would double-prefix. + const pattern = toSlash(rawPattern); + const matches = yield* globOne(fs, path, workdir, pattern); + if (matches.length === 0) { + errors.push(`no files matched pattern: ${pattern}`); + continue; + } + for (const match of [...matches].sort()) { + const fp = toSlash(match); + // Go's `GetPendingSeeds` globs via `Glob.SQLFiles`, which `Stat`s each match: a + // directory is expanded to its regular `.sql` files recursively (`walkMatchedDir`, + // sorted) while a file match is kept verbatim (`config.go:157-183`). Without this a + // directory `sql_paths` entry (e.g. `["seeds"]`) would flow into + // `readFileString()` and fail — Go's `db push --include-seed` / remote reset + // seed the directory's SQL children instead. + const matchType = yield* fs.stat(path.isAbsolute(fp) ? fp : path.join(workdir, fp)).pipe( + Effect.map((info) => info.type), + Effect.orElseSucceed(() => "File" as const), + ); + if (matchType === "Directory") { + for (const file of yield* legacyWalkSeedSqlFiles(fs, path, workdir, fp)) { + if (!seen.has(file)) { + seen.add(file); + files.push(file); + } + } + continue; + } + if (!seen.has(fp)) { + seen.add(fp); + files.push(fp); + } + } + } + + return { + files, + warning: errors.length > 0 ? Option.some(errors.join("\n")) : Option.none(), + } satisfies LegacyGlobResult; +}); + +const toSlash = (p: string): string => p.replaceAll("\\", "/"); + +/** Splits a forward-slashed path into its directory prefix and final element. */ +const splitPath = (p: string): { readonly dir: string; readonly file: string } => { + const slash = p.lastIndexOf("/"); + return slash === -1 ? { dir: "", file: p } : { dir: p.slice(0, slash), file: p.slice(slash + 1) }; +}; + +/** Faithful port of Go's `fs.Glob` for one pattern, rooted at `workdir`. */ +const globOne = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + pattern: string, +): Effect.Effect, never> => + Effect.gen(function* () { + // Absolute patterns resolve against the filesystem root (Go preserves absolute + // seed paths); relative ones are rooted at the workdir. + const resolve = (p: string): string => (path.isAbsolute(p) ? p : path.join(workdir, p)); + // No metacharacters: a direct existence check (Go's `fs.Glob` fast path). + if (!META_CHARS.test(pattern)) { + const exists = yield* fs.exists(resolve(pattern)).pipe(Effect.orElseSucceed(() => false)); + return exists ? [pattern] : []; + } + const { dir, file } = splitPath(pattern); + // Resolve the directory level first (recursively if it, too, is a glob). + const dirs = + dir === "" || !META_CHARS.test(dir) ? [dir] : yield* globOne(fs, path, workdir, dir); + const result: Array = []; + for (const d of dirs) { + const absDir = d === "" ? workdir : resolve(d); + const names = yield* fs + .readDirectory(absDir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + for (const name of names) { + if (legacyMatchPattern(file, name)) { + result.push(d === "" ? name : `${d}/${name}`); + } + } + } + return result; + }); + +/** + * Recursively collects the regular `.sql` files under a matched seed directory, porting + * Go's `walkMatchedDir` with the `SQLFiles` include filter (`entry.Type().IsRegular() && + * filepath.Ext(path) == ".sql"`, `config.go:126-131,194-211`). Paths are workdir-relative + * (matching the glob output), forward-slashed, and sorted for deterministic application. + */ +const legacyWalkSeedSqlFiles = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + dir: string, +): Effect.Effect, never> => + Effect.gen(function* () { + const collected: Array = []; + const walk = (rel: string): Effect.Effect => + Effect.gen(function* () { + const absDir = path.isAbsolute(rel) ? rel : path.join(workdir, rel); + const names = yield* fs + .readDirectory(absDir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + for (const name of names) { + const childRel = `${rel}/${name}`; + const childType = yield* fs + .stat(path.isAbsolute(childRel) ? childRel : path.join(workdir, childRel)) + .pipe( + Effect.map((info) => info.type), + Effect.orElseSucceed(() => "Unknown" as const), + ); + if (childType === "Directory") { + yield* walk(childRel); + } else if (childType === "File" && childRel.endsWith(".sql")) { + collected.push(toSlash(childRel)); + } + } + }); + yield* walk(dir); + return collected.sort(); + }); + +/** `SELECT path, hash FROM supabase_migrations.seed_files`, `42P01` → empty map. */ +const readRemoteSeeds = (session: LegacyDbSession) => + session.query(SELECT_SEED_TABLE).pipe( + Effect.map((rows) => { + const applied = new Map(); + for (const row of rows) applied.set(String(row["path"]), String(row["hash"])); + return applied; + }), + Effect.catch((error: LegacyDbExecError) => + isUndefinedTable(error) ? Effect.succeed(new Map()) : Effect.fail(error), + ), + ); + +const isUndefinedTable = (error: LegacyDbExecError): boolean => + error.code !== undefined + ? error.code === "42P01" + : /relation .* does not exist/iu.test(error.message) && + !/column .* does not exist/iu.test(error.message); + +/** + * Resolves the pending seed files for `db push --include-seed`. Mirrors Go's + * `GetPendingSeeds` (`pkg/migration/seed.go:34-63`): glob the configured paths + * (warn, don't fail, on empty patterns), read the remote `seed_files` hashes, + * and emit each local file that is new (`dirty=false`) or hash-changed + * (`dirty=true`); files whose hash already matches are skipped. + */ +export const legacyGetPendingSeeds = Effect.fnUntraced(function* ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + patterns: ReadonlyArray, + workdir: string, +) { + const output = yield* Output; + const { files, warning } = yield* legacyGlobSeedFiles(fs, path, patterns, workdir); + if (Option.isSome(warning)) { + yield* output.raw(`WARN: ${warning.value}\n`, "stderr"); + } + const pending: Array = []; + if (files.length === 0) return pending; + + const applied = yield* readRemoteSeeds(session); + for (const file of files) { + // Go's `NewSeedFile` hashes the raw file stream (`io.Copy`, `pkg/migration/file.go:184`), + // so hash the bytes — not a UTF-8-decoded string, which replaces invalid sequences and + // would drift from the Go-recorded `seed_files` hash for a non-UTF-8 seed (SQL_ASCII dump + // / binary COPY payload), spuriously re-running it across a Go ↔ native switch. + const content = yield* fs.readFile(path.isAbsolute(file) ? file : path.join(workdir, file)); + const hash = createHash("sha256").update(content).digest("hex"); + const appliedHash = applied.get(file); + if (appliedHash !== undefined) { + if (appliedHash === hash) continue; // Already applied, unchanged. + pending.push({ path: file, hash, dirty: true }); + continue; + } + pending.push({ path: file, hash, dirty: false }); + } + return pending; +}); + +/** + * Applies pending seed files. Mirrors Go's `SeedData` + `ExecBatchWithCache` + * (`pkg/migration/seed.go:65-83`, `file.go:198-217`): create the `seed_files` + * table, then per file emit the dirty/clean status line and, in one transaction, + * run the file's statements (skipped when dirty — only the hash is refreshed) + * followed by the `seed_files` hash upsert. + */ +export const legacySeedData = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + workdir: string, + path: Path.Path, + seeds: ReadonlyArray, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + const output = yield* Output; + if (seeds.length === 0) return; + // Go's `CreateSeedTable` (history.go:54-64) runs `SET lock_timeout = '4s'` + + // schema/table DDL in one implicit transaction, so a conflicting schema/table lock + // fails promptly but the timeout reverts on COMMIT and never leaks into the seed + // SQL run below. `legacyCreateSeedTable` reproduces that with BEGIN + SET LOCAL + + // DDL + COMMIT (creating the schema first so a seed-only run doesn't fail). + yield* legacyCreateSeedTable(session); + for (const seed of seeds) { + yield* output.raw( + seed.dirty + ? `Updating seed hash to ${seed.path}...\n` + : `Seeding data from ${seed.path}...\n`, + "stderr", + ); + // Go's `ExecBatchWithCache` parses the file (read + `SplitAndTrim`) + // UNCONDITIONALLY before the dirty check (`file.go:198-211`), so a dirty seed + // that is unreadable or contains malformed SQL still fails and leaves the + // previous hash — only the queueing of statements is gated on `Dirty`. + const lines = legacySplitAndTrim( + yield* fs.readFileString( + path.isAbsolute(seed.path) ? seed.path : path.join(workdir, seed.path), + ), + ); + const statements = seed.dirty ? [] : lines; + yield* session.exec("BEGIN"); + const body = Effect.gen(function* () { + for (const statement of statements) yield* session.exec(statement); + yield* session.query(UPSERT_SEED_FILE, [seed.path, seed.hash]); + yield* session.exec("COMMIT"); + }); + yield* body.pipe(Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore))); + } + }).pipe( + Effect.mapError((error) => + mapError( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" + ? error.message + : String(error), + ), + ), + ); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts new file mode 100644 index 0000000000..3c50f914c8 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts @@ -0,0 +1,126 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect, Exit, FileSystem, Path } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyMatchPattern, legacySeedData } from "./legacy-seed-ops.ts"; + +class TestError extends Data.TaggedError("TestError")<{ readonly message: string }> {} + +function fakeSeedSession() { + const calls: Array<{ kind: "exec" | "query"; sql: string }> = []; + const session: LegacyDbSession = { + exec: (sql) => { + calls.push({ kind: "exec", sql }); + return Effect.void; + }, + query: (sql) => { + calls.push({ kind: "query", sql }); + return Effect.succeed([]); + }, + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return { session, calls }; +} + +describe("legacyMatchPattern", () => { + it("matches a literal filename", () => { + expect(legacyMatchPattern("seed.sql", "seed.sql")).toBe(true); + expect(legacyMatchPattern("seed.sql", "other.sql")).toBe(false); + }); + + it("matches `*` against any run of characters", () => { + expect(legacyMatchPattern("*.sql", "seed.sql")).toBe(true); + expect(legacyMatchPattern("*.sql", "0001_init.sql")).toBe(true); + expect(legacyMatchPattern("*.sql", "seed.txt")).toBe(false); + expect(legacyMatchPattern("seed.*", "seed.sql")).toBe(true); + }); + + it("matches `?` against exactly one character", () => { + expect(legacyMatchPattern("seed?.sql", "seed1.sql")).toBe(true); + expect(legacyMatchPattern("seed?.sql", "seed12.sql")).toBe(false); + expect(legacyMatchPattern("seed?.sql", "seed.sql")).toBe(false); + }); + + it("matches character classes with ranges and negation", () => { + expect(legacyMatchPattern("seed[0-9].sql", "seed5.sql")).toBe(true); + expect(legacyMatchPattern("seed[0-9].sql", "seedx.sql")).toBe(false); + expect(legacyMatchPattern("seed[!0-9].sql", "seedx.sql")).toBe(true); + expect(legacyMatchPattern("seed[!0-9].sql", "seed5.sql")).toBe(false); + }); + + it("honors backslash escapes", () => { + expect(legacyMatchPattern("seed\\*.sql", "seed*.sql")).toBe(true); + expect(legacyMatchPattern("seed\\*.sql", "seedx.sql")).toBe(false); + }); + + it("collapses consecutive stars", () => { + expect(legacyMatchPattern("**.sql", "seed.sql")).toBe(true); + }); +}); + +const runSeed = ( + session: LegacyDbSession, + workdir: string, + seeds: ReadonlyArray<{ readonly path: string; readonly hash: string; readonly dirty: boolean }>, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacySeedData( + session, + fs, + workdir, + path, + seeds, + (message) => new TestError({ message }), + ); + }).pipe(Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(BunServices.layer)); + +describe("legacySeedData (dirty parse)", () => { + it.effect("fails on an unreadable dirty seed instead of refreshing its hash", () => { + // Go's `ExecBatchWithCache` reads + parses the file UNCONDITIONALLY before the + // dirty check, so a dirty seed pointing at a missing file must fail (and leave + // the previous hash) rather than silently upserting the new hash. + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); + const { session, calls } = fakeSeedSession(); + return runSeed(session, dir, [{ path: "missing.sql", hash: "newhash", dirty: true }]).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + // The hash upsert is a `query`; the only execs that ran are the + // schema/table creation (whose DDL also mentions `seed_files`), so assert + // no `query` ran rather than substring-matching the table name. + expect(calls.some((c) => c.kind === "query")).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("refreshes the hash for a dirty seed that parses, without running statements", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); + writeFileSync(join(dir, "data.sql"), "insert into t values (1);"); + const { session, calls } = fakeSeedSession(); + return runSeed(session, dir, [{ path: "data.sql", hash: "newhash", dirty: true }]).pipe( + Effect.tap(() => + Effect.sync(() => { + // Go's CreateSeedTable scopes the lock timeout to the DDL transaction + // (BEGIN + SET LOCAL + COMMIT) so it never leaks into the seed SQL below. + expect(calls.some((c) => c.sql === "SET LOCAL lock_timeout = '4s'")).toBe(true); + // Statements are NOT executed for a dirty seed, but the hash IS upserted. + expect(calls.some((c) => c.sql.includes("insert into t"))).toBe(false); + expect(calls.some((c) => c.kind === "query" && c.sql.includes("seed_files"))).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md index 0c980a749c..dcf8466552 100644 --- a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md @@ -1,17 +1,35 @@ # `supabase db start` +Native TS port of `apps/cli-go/internal/db/start/start.go` `Run`. The handler +validates config, checks whether the local Postgres container is already running, +and otherwise delegates the container bootstrap to the bundled Go binary's hidden +`db __db-bootstrap --mode start` seam (the container-lifecycle primitives are not +ported). This is `db start`, **not** the top-level `supabase start`: no status +table, no `cli_stack_started` event, no `Finished` line. + ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | ---------------------------------- | -| `/supabase/config.toml` | TOML | always, to resolve local DB config | -| `` (from `--from-backup`) | binary | when `--from-backup` flag is set | +| Path | Format | When | +| -------------------------------- | ------ | --------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always — parsed up front; a malformed config aborts before work | +| `` (from `--from-backup`) | binary | when `--from-backup` is set (read by the Go seam on start) | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | --------------------------------------------------------------------------- | +| `/supabase/.branches/_current_branch` | text | by the Go seam (`initCurrentBranch`) when starting; writes `main` if absent | +| local Docker volume `supabase_db_` | — | by the Go seam — the Postgres data volume created on first start | +| `~/.supabase/telemetry.json` | JSON | always (telemetry flush, success and failure) | + +## Subprocesses + +| Command | When | Purpose | +| ---------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `docker container inspect supabase_db_` | always | `AssertSupabaseDbIsRunning` probe (Podman fallback) | +| `supabase-go db __db-bootstrap --mode start [--from-backup

]` | when the database is not running | create container + health check + initial schema/roles/migrations/seed + `_current_branch`; telemetry disabled (`SUPABASE_TELEMETRY_DISABLED=1`), progress teed to stderr | + +`--network-id` and a flag-selected `--profile` are forwarded to the seam. ## API Routes @@ -19,34 +37,45 @@ | ------ | ---- | ---- | ------------ | ---------------------- | | — | — | — | — | — | +(The Go seam may call Auth's JWKS endpoint while applying service migrations on a +fresh PG15 volume; that is internal to the seam, not the TS handler.) + ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| — | — | — | +| Variable | Purpose | Required? | +| ----------------------------- | ---------------------------------------------------- | ---------- | +| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`) | no | +| `SUPABASE_TELEMETRY_DISABLED` | set on the seam subprocess so it never double-counts | (internal) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------ | -| `0` | success | -| `1` | Docker not running | -| `1` | database container start error | +| Code | Condition | +| ---- | --------------------------------------------------------------------- | +| `0` | success — database started, or already running | +| `1` | malformed `supabase/config.toml` | +| `1` | Docker daemon unreachable / inspect failure | +| `1` | container bootstrap failed (the seam cleans up via `DockerRemoveAll`) | ## Output ### `--output-format text` (Go CLI compatible) -Prints progress to stderr as the local Postgres container starts. +- Already running → `Postgres database is already running.` on **stderr**, exit 0. +- Starting → the Go seam tees `Starting database...` / `Initialising schema...` to + **stderr**. No stdout output, no `Finished` line. ### `--output-format json` -Not applicable. +Emits a single result object to stdout: `{ status: "already-running" }` or +`{ status: "started" }`. Progress stays on stderr. ### `--output-format stream-json` -Not applicable. +Same result object as the terminal `result` event; progress on stderr. ## Notes -- `--from-backup` restores the database from a logical backup file on start. +- `--from-backup` restores the database from a logical backup file on start; the + health check is skipped for backups (a large restore can exceed the timeout). +- No `cli_stack_started` telemetry — that event belongs to `supabase start`, not + `db start`. The only event is the standard `cli_command_executed`. diff --git a/apps/cli/src/legacy/commands/db/start/start.command.ts b/apps/cli/src/legacy/commands/db/start/start.command.ts index cc4081ae84..c9457733ae 100644 --- a/apps/cli/src/legacy/commands/db/start/start.command.ts +++ b/apps/cli/src/legacy/commands/db/start/start.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyDbStart } from "./start.handler.ts"; +import { legacyDbStartRuntimeLayer } from "./start.layers.ts"; const config = { fromBackup: Flag.string("from-backup").pipe( @@ -14,5 +18,15 @@ export type LegacyDbStartFlags = CliCommand.Command.Config.Infer; export const legacyDbStartCommand = Command.make("start", config).pipe( Command.withDescription("Starts local Postgres database."), Command.withShortDescription("Starts local Postgres database"), - Command.withHandler((flags) => legacyDbStart(flags)), + Command.withHandler((flags) => + legacyDbStart(flags).pipe( + withLegacyCommandInstrumentation({ + // `--from-backup` is not telemetry-safe in Go (no markFlagTelemetrySafe), + // so a set value reaches telemetry as ``. + flags: { "from-backup": flags.fromBackup }, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyDbStartRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/db/start/start.handler.ts b/apps/cli/src/legacy/commands/db/start/start.handler.ts index a1aa3e586a..7428173f1a 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -1,10 +1,85 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Effect, FileSystem, Option, Path } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyCheckDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; import type { LegacyDbStartFlags } from "./start.command.ts"; +/** + * `supabase db start` — start the local Postgres database. + * + * Strict 1:1 port of `apps/cli-go/internal/db/start/start.go` `Run`. Native TS + * orchestrates: it validates config, checks whether the database is already + * running (printing Go's "already running" line), and otherwise delegates the + * container bootstrap to the hidden Go `__db-bootstrap` seam (create container + + * health + initial schema + `_current_branch`), whose progress is teed to stderr. + * + * Parity notes: this is `db start`, NOT the top-level `supabase start`. It does + * NOT print a status table and does NOT fire `cli_stack_started` — those belong to + * `internal/start/start.go`. There is no `Finished` line. + */ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: LegacyDbStartFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["db", "start"]; - if (Option.isSome(flags.fromBackup)) args.push("--from-backup", flags.fromBackup.value); - yield* proxy.exec(args); + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const seam = yield* LegacyDbBootstrapSeam; + const telemetryState = yield* LegacyTelemetryState; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runtimeInfo = yield* RuntimeInfo; + + const body = Effect.gen(function* () { + // Go's `flags.LoadConfig(fsys)` runs first thing in `start.Run` + // (`internal/db/start/start.go:45`): a missing config is tolerated (defaults), but + // a present config that is malformed, references an undecryptable `encrypted:` + // secret, or fails Validate aborts before any container work. `legacyCheckDbToml` + // is that exact load+validate — call it here (not via the seam's best-effort read, + // which swallows config errors) so `db start` fails fast on a broken config. + yield* legacyCheckDbToml(fs, path, cliConfig.workdir); + + // Go's AssertSupabaseDbIsRunning: if the db container is already up, print to + // stderr and return nil (exit 0). + const running = yield* seam.isDbRunning(); + if (running) { + if (output.format === "text") { + yield* output.raw("Postgres database is already running.\n", "stderr"); + } else { + yield* output.success("Postgres database is already running.", { + status: "already-running", + }); + } + return; + } + + // Not running → bootstrap the container (StartDatabase + DockerRemoveAll on + // failure). The seam tees "Starting database...", "Initialising schema...", + // etc. to stderr. + // + // Resolve a relative `--from-backup` against the CALLER's cwd, mirroring Go's + // `StartDatabase` (`filepath.Join(utils.CurrentDirAbs, fromBackup)`, start.go:160-161) + // where `CurrentDirAbs` is captured before `ChangeWorkDir`. The seam spawns the Go child + // with cwd = the project workdir, so passing a relative path would resolve it against the + // project root (wrong file / not found) when `db start` runs from a subdirectory or with + // `--workdir`. Passing an absolute path makes the child's resolution a no-op. + const fromBackupFlag = Option.getOrUndefined(flags.fromBackup); + // An empty `--from-backup ""` is a normal no-backup start in Go (`len(fromBackup) == 0`), + // so treat it as absent rather than joining it to a directory path. + const fromBackup = + fromBackupFlag === undefined || fromBackupFlag === "" + ? undefined + : path.isAbsolute(fromBackupFlag) + ? fromBackupFlag + : path.join(runtimeInfo.cwd, fromBackupFlag); + yield* seam.startDatabase({ fromBackup }); + + if (output.format !== "text") { + yield* output.success("Started local database.", { status: "started" }); + } + }); + + // db start is local-only — no project ref, so no linked-project cache write. + // Telemetry still flushes on success and failure (Go's PersistentPostRun). + yield* body.pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts new file mode 100644 index 0000000000..bc560dd44e --- /dev/null +++ b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts @@ -0,0 +1,240 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; + +import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; +import { + mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyDbBootstrapError } from "../shared/legacy-db-bootstrap.errors.ts"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; +import { legacyDbStart } from "./start.handler.ts"; +import type { LegacyDbStartFlags } from "./start.command.ts"; + +const DEFAULT_FLAGS: LegacyDbStartFlags = { fromBackup: Option.none() }; + +/** + * Stateful mock of the container-bootstrap seam. `running` drives + * `AssertSupabaseDbIsRunning`; `runningFails` / `startFails` make the respective + * call fail (Docker daemon down / StartDatabase error). Records the args passed to + * `startDatabase`. + */ +function mockSeam(opts: { running?: boolean; runningFails?: boolean; startFails?: boolean } = {}) { + const startCalls: Array<{ fromBackup?: string }> = []; + const layer = Layer.succeed(LegacyDbBootstrapSeam, { + isDbRunning: () => + opts.runningFails === true + ? Effect.fail(new LegacyDbBootstrapError({ message: "failed to inspect service" })) + : Effect.succeed(opts.running ?? false), + startDatabase: (args: { fromBackup?: string }) => + opts.startFails === true + ? Effect.fail(new LegacyDbBootstrapError({ message: "failed to bootstrap" })) + : Effect.sync(() => { + startCalls.push(args); + }), + recreateDatabase: () => Effect.void, + awaitStorageReady: () => Effect.succeed(false), + }); + return { + layer, + get startCalls() { + return startCalls; + }, + }; +} + +function setup( + workdir: string, + opts: { + toml?: string; + format?: OutputFormat; + running?: boolean; + runningFails?: boolean; + startFails?: boolean; + /** Caller cwd (Go's `CurrentDirAbs`) for relative `--from-backup` resolution. */ + cwd?: string; + }, +) { + if (opts.toml !== undefined) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); + } + const out = mockOutput({ format: opts.format ?? "text" }); + const seam = mockSeam(opts); + const telemetry = mockLegacyTelemetryStateTracked(); + const layer = Layer.mergeAll( + out.layer, + seam.layer, + mockLegacyCliConfig({ workdir }), + telemetry.layer, + mockRuntimeInfo({ cwd: opts.cwd ?? workdir }), + BunServices.layer, + ); + return { layer, out, seam, telemetry }; +} + +describe("legacy db start", () => { + const tmp = useLegacyTempWorkdir("supabase-db-start-"); + + it.live("reports an already-running database without starting a container", () => { + const { layer, out, seam, telemetry } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: true, + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Postgres database is already running."); + expect(seam.startCalls).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }); + }); + + it.live("starts the database when it is not running", () => { + const { layer, out, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: false, + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.startCalls).toEqual([{ fromBackup: undefined }]); + // db start prints no "Finished" line and no status table. + expect(out.stderrText).not.toContain("Finished"); + }); + }); + + it.live("forwards an absolute --from-backup to the bootstrap seam unchanged", () => { + const { layer, seam } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + yield* legacyDbStart({ fromBackup: Option.some("/tmp/dump.sql") }).pipe( + Effect.provide(layer), + ); + expect(seam.startCalls).toEqual([{ fromBackup: "/tmp/dump.sql" }]); + }); + }); + + it.live("resolves a relative --from-backup against the caller cwd, not the workdir", () => { + // Go resolves a relative fromBackup against `CurrentDirAbs` (the caller cwd, captured + // before ChangeWorkDir), so the seam must receive the caller-relative absolute path even + // though its Go child runs with cwd = the project workdir. + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + cwd: "/caller/here", + }); + return Effect.gen(function* () { + yield* legacyDbStart({ fromBackup: Option.some("dump.sql") }).pipe(Effect.provide(layer)); + expect(seam.startCalls).toEqual([{ fromBackup: "/caller/here/dump.sql" }]); + }); + }); + + it.live("treats an empty --from-backup as a normal no-backup start", () => { + // Go's StartDatabase sees `len(fromBackup) == 0` and starts without a backup; an empty + // string must not be joined to the caller cwd and passed as a directory path. + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + cwd: "/caller/here", + }); + return Effect.gen(function* () { + yield* legacyDbStart({ fromBackup: Option.some("") }).pipe(Effect.provide(layer)); + expect(seam.startCalls).toEqual([{ fromBackup: undefined }]); + }); + }); + + it.live("proceeds with no config file (missing config is tolerated)", () => { + const { layer, seam } = setup(tmp.current, { running: false }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.startCalls).toHaveLength(1); + }); + }); + + it.live("fails fast on a malformed config.toml", () => { + const { layer, seam, telemetry } = setup(tmp.current, { + toml: 'project_id = "unterminated\n', + }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + } + // No container work attempted; telemetry still flushes on failure. + expect(seam.startCalls).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }); + }); + + it.live("fails fast on an undecryptable secret even when the db is already running", () => { + // Regression for the seam swallowing config-load errors: Go runs `flags.LoadConfig` + // (which decrypts every secret) BEFORE `AssertSupabaseDbIsRunning`, so a broken + // config aborts `db start` regardless of container state. Previously the handler's + // only config read was the seam's best-effort one, so an undecryptable secret with + // the container already up printed "already running" and exited 0. + const { layer, out } = setup(tmp.current, { + toml: '[db]\nroot_key = "encrypted:anything"\n', + running: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to parse config: missing private key"); + } + expect(out.stderrText).not.toContain("already running"); + }); + }); + + it.live("propagates a Docker inspect failure", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', runningFails: true }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to inspect service"); + } + }); + }); + + it.live("propagates a StartDatabase failure", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', startFails: true }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to bootstrap"); + } + }); + }); + + it.live("emits a json result when the database is already running", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: true, + format: "json", + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["status"]).toBe("already-running"); + }); + }); + + it.live("emits a json result after starting the database", () => { + const { layer, out, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: false, + format: "json", + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.startCalls).toHaveLength(1); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["status"]).toBe("started"); + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/start/start.layers.ts b/apps/cli/src/legacy/commands/db/start/start.layers.ts new file mode 100644 index 0000000000..71603292ee --- /dev/null +++ b/apps/cli/src/legacy/commands/db/start/start.layers.ts @@ -0,0 +1,27 @@ +import { Layer } from "effect"; + +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; +import { legacyDbBootstrapSeamLayer } from "../shared/legacy-db-bootstrap.seam.layer.ts"; + +/** + * Runtime layer for `supabase db start`. The command is local-only, so it needs + * far less than the remote-capable db commands: just the container-bootstrap seam + * (`db __db-bootstrap`), the CLI config (workdir + project id), and the telemetry + * flush. The seam's other dependencies (`LegacyNetworkIdFlag`, `LegacyProfileFlag`, + * `ChildProcessSpawner`, `FileSystem`, `Path`) are ambient from the root runtime, + * matching how `db diff` composes the `db __shadow` seam. `LegacyCliConfig` is + * provided to the seam explicitly (legacy CLAUDE.md rule 5). + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const seam = legacyDbBootstrapSeamLayer.pipe(Layer.provide(cliConfig)); + +export const legacyDbStartRuntimeLayer = Layer.mergeAll( + seam, + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer(["db", "start"]), +); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts index 9ddb8fdb86..24b2543055 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts @@ -1,138 +1,31 @@ -import { - loadProjectConfig, - type LoadProjectConfigOptions, - ProjectConfigSchema, -} from "@supabase/config"; -import { Effect, FileSystem, Option, Path, Schema } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; -import type { PlatformError } from "effect/PlatformError"; +import { Effect, Option } from "effect"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; -import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; -import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; -import { legacySeedChangedTargetFlags } from "./buckets.flags.ts"; -import { legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; -import { - legacyResolveStorageCredentials, - legacyStorageGatewayFetch, -} from "../../../shared/legacy-storage-credentials.ts"; -import { - legacyParseFileSizeLimit, - legacyResolveBucketProps, -} from "../../../shared/legacy-storage-bucket-config.ts"; -import { - type LegacyStorageGateway, - type LegacyUpsertBucketProps, - legacyMakeStorageGateway, -} from "../../../shared/legacy-storage-gateway.ts"; -import type { LegacyStorageGatewayError } from "../../../shared/legacy-storage-gateway.errors.ts"; -import { Output } from "../../../../shared/output/output.service.ts"; -import { - legacyIsLocalVectorBucketsUnavailable, - legacyIsVectorBucketsFeatureNotEnabled, -} from "./buckets.classify.ts"; -import { LegacySeedConfigLoadError } from "./buckets.errors.ts"; -import { legacyBucketObjectKey } from "./buckets.upload.ts"; -import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; -import { - legacyContentTypeForUpload, - legacyReadSniffBytes, -} from "../../../shared/legacy-storage-content-type.ts"; +import { legacySeedBucketsRun } from "../../../shared/legacy-seed-buckets.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacySeedChangedTargetFlags } from "./buckets.flags.ts"; import type { LegacyBucketsFlags } from "./buckets.command.ts"; -const CONFIG_PATH = "supabase/config.toml"; -const UPLOAD_CONCURRENCY = 5; - -/** - * Mirrors Go's `ValidateBucketName` regex (`apps/cli-go/pkg/config/config.go:1382`). - * Used to validate `[storage.buckets]` names before any Storage API call, matching - * Go's config-load-time check (`config.go:899-903`). Vector and analytics names are - * NOT validated here — Go only validates `[storage.buckets]`. - */ -const LEGACY_BUCKET_NAME_PATTERN = /^(?:[0-9A-Za-z_]|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/; - -/** - * Verbatim Go regex literal (`config.go:1382`) — used in the error message so it - * is byte-identical to Go's output. Do NOT derive from `LEGACY_BUCKET_NAME_PATTERN.source`. - */ -const LEGACY_BUCKET_NAME_PATTERN_SOURCE = - "^(\\w|!|-|\\.|\\*|'|\\(|\\)| |&|\\$|@|=|;|:|\\+|,|\\?)*$"; - -const legacyValidateBucketName = Effect.fnUntraced(function* (name: string) { - if (!LEGACY_BUCKET_NAME_PATTERN.test(name)) { - return yield* new LegacySeedConfigLoadError({ - message: `Invalid Bucket name: ${name}. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (${LEGACY_BUCKET_NAME_PATTERN_SOURCE})`, - }); - } -}); - -interface CollectedFile { - readonly absPath: string; - readonly displayPath: string; -} - -/** Mutable run summary, emitted as the structured result in json/stream-json mode. */ -interface SeedSummary { - readonly buckets_created: Array; - readonly buckets_updated: Array; - readonly buckets_skipped: Array; - readonly vector_created: Array; - readonly vector_pruned: Array; - vector_skipped: boolean; - readonly objects_uploaded: Array; - readonly analytics_created: Array; - readonly analytics_pruned: Array; -} - -function emptySummary(): SeedSummary { - return { - buckets_created: [], - buckets_updated: [], - buckets_skipped: [], - vector_created: [], - vector_pruned: [], - vector_skipped: false, - objects_uploaded: [], - analytics_created: [], - analytics_pruned: [], - }; -} - -/** - * Embedded-default project config, decoded from an empty object — the same - * `decodeUnknownSync(ProjectConfigSchema)({})` the loader uses internally - * (`packages/config/src/io.ts:54-56`). Go's `seed buckets` never aborts on a - * missing `config.toml`: it reads the package-global `utils.Config`, initialized - * to embedded defaults, and `config.Load` no-ops on a missing file. So "no - * config file" behaves like the embedded-default config. - */ -const legacyDecodeDefaultProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); - /** * `supabase seed buckets` — seeds Storage buckets from * `[storage.buckets]` / `[storage.vector]` in `supabase/config.toml`. * * Port of `apps/cli-go/internal/seed/buckets/buckets.go`. When `--linked` is * passed, the remote Storage gateway is used with the project's service-role key; - * otherwise the local stack is used. + * otherwise the local stack is used. The seeding work lives in the hoisted + * `legacySeedBucketsRun` (shared with `db reset --local`); this handler owns the + * target-flag resolution and the post-run cache + telemetry side effects. */ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( // Target is selected from the changed-flag set (Go's flag.Changed), not the // parsed value, so the flags arg itself is unused here. _flags: LegacyBucketsFlags, ) { - const output = yield* Output; - const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const cliArgs = yield* CliArgs; - // `--yes` OR `SUPABASE_YES` (Go's viper AutomaticEnv, root.go:318-320). - const yes = yield* legacyResolveYes; // Set once --linked resolves a ref; drives the post-run linked-project cache // write + org/project group identify, mirroring Go's `ensureProjectGroupsCached` @@ -141,135 +34,18 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( let linkedRef = ""; yield* Effect.gen(function* () { - // 1. Resolve the project ref for --linked BEFORE loading config, so that - // the matching `[remotes.]` override (whose `project_id == ref`) is - // merged over the base config by `loadProjectConfig`. Go selects the target - // from `flag.Changed`, not the flag value: `--linked` is the linked path - // whenever it's *set* (even `--linked=false`). + // Resolve the project ref for --linked BEFORE loading config, so that the + // matching `[remotes.]` override (whose `project_id == ref`) is merged + // over the base config by `loadProjectConfig`. Go selects the target from + // `flag.Changed`, not the flag value: `--linked` is the linked path whenever + // it's *set* (even `--linked=false`). const setFlags = legacySeedChangedTargetFlags(cliArgs.args); const projectRefResolver = yield* LegacyProjectRefResolver; const projectRef = setFlags.includes("linked") ? yield* projectRefResolver.loadProjectRef(Option.none()) : ""; linkedRef = projectRef; - - // 2. Load config.toml, passing projectRef so `[remotes.*]` overrides are - // merged for --linked. A parse failure aborts before any network call. - const loadOptions: LoadProjectConfigOptions | undefined = - projectRef !== "" ? { projectRef } : undefined; - const loaded = yield* loadProjectConfig(cliConfig.workdir, loadOptions).pipe( - Effect.catchTag( - "ProjectConfigParseError", - (cause) => - new LegacySeedConfigLoadError({ - message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, - }), - ), - ); - // A missing config file is NOT an early exit: Go uses embedded defaults and - // still gates the no-op on `len(projectRef) == 0`. So local + no-config falls - // into the no-op short-circuit; `--linked` + no-config falls through to the - // remote path so auth/project/API failures surface. - const config = loaded === null ? legacyDecodeDefaultProjectConfig({}) : loaded.config; - const document = loaded === null ? undefined : loaded.document; - - // Go prints this from inside config load (`config.go:513`) whenever a - // `[remotes.*]` block matched the linked ref. stderr in all output modes. - if (loaded !== null && loaded.appliedRemote !== undefined) { - yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); - } - const bucketsConfig = config.storage.buckets ?? {}; - const bucketNames = Object.keys(bucketsConfig); - const vectorEnabled = config.storage.vector.enabled; - const vectorBucketNames = Object.keys(config.storage.vector.buckets); - const hasVectorBuckets = vectorBucketNames.length > 0; - - // 3. Config-load-time validations run BEFORE the no-op short-circuit: Go - // decodes the whole config (storage.FileSizeLimit, bucket sizes) and runs - // ValidateBucketName during config.Load — before `buckets.Run` can take its - // no-op path — so an invalid value fails even when there's nothing to seed. - // - // 3a. Bucket names (Go ValidateBucketName, config.go:899-903). - for (const name of bucketNames) { - yield* legacyValidateBucketName(name); - } - - // 3b. Storage-level file_size_limit, parsed unconditionally. - const storageFileSizeLimitBytes = yield* parseFileSizeLimitOrFail( - config.storage.file_size_limit, - ); - - // 3c. Per-bucket props (sizes parsed before any Storage call). - const bucketPropsByName = new Map(); - for (const [name, bucket] of Object.entries(bucketsConfig)) { - bucketPropsByName.set( - name, - yield* computeBucketProps(document, name, bucket, storageFileSizeLimitBytes), - ); - } - - // 3d. Short-circuit: nothing to seed (ref present → never short-circuits). - if (projectRef === "" && bucketNames.length === 0 && !hasVectorBuckets) { - if (output.format !== "text") { - yield* output.success("", { ...emptySummary() }); - } - return; - } - - // 4. Build the Storage service-gateway client (local or remote). - const credentials = yield* legacyResolveStorageCredentials({ projectRef, config }); - - // All gateway operations run with an explicit non-DoH fetch (CA-trusting for - // local + https, plain `globalThis.fetch` otherwise). The api-keys lookup - // inside `legacyResolveStorageCredentials` runs BEFORE this scope, so it - // still honors `--dns-resolver https`, matching Go's `tenant.GetApiKeys`. - const gatewayOps = Effect.gen(function* () { - const gateway = yield* legacyMakeStorageGateway({ - baseUrl: credentials.baseUrl, - apiKey: credentials.apiKey, - userAgent: cliConfig.userAgent, - }); - - const summary = emptySummary(); - - // 5. Upsert configured buckets. - yield* upsertBuckets(output, yes, gateway, bucketPropsByName, summary); - - // 6. Upsert analytics buckets (remote --linked only). - if (config.storage.analytics.enabled && projectRef !== "") { - yield* output.raw("Updating analytics buckets...\n", "stderr"); - yield* upsertAnalyticsBuckets( - output, - yes, - gateway, - Object.keys(config.storage.analytics.buckets), - summary, - ); - } - - // 7. Upsert vector buckets (local), with graceful skip on unavailability. - if (vectorEnabled && hasVectorBuckets) { - yield* output.raw("Updating vector buckets...\n", "stderr"); - yield* upsertVectorBuckets(output, yes, gateway, vectorBucketNames, summary).pipe( - Effect.catch((error) => handleVectorError(output, error, summary)), - ); - } - - // 8. Upload objects for each bucket with a configured objects_path. - yield* uploadObjects(fs, path, output, gateway, cliConfig.workdir, bucketsConfig, summary); - - // 9. Machine-readable summary (Go has none; text mode emits nothing extra). - if (output.format !== "text") { - yield* output.success("", { ...summary }); - } - }); - - yield* gatewayOps.pipe( - Effect.provideService( - FetchHttpClient.Fetch, - legacyStorageGatewayFetch(credentials.localKongCa), - ), - ); + yield* legacySeedBucketsRun({ projectRef, emitSummary: true }); }).pipe( // Go's root `Execute` caches the linked project + fires org/project group // identify whenever `flags.ProjectRef` is set — only on the --linked path. @@ -279,315 +55,3 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( Effect.ensuring(telemetryState.flush), ); }); - -type BucketsConfig = Readonly< - Record< - string, - { - readonly public: boolean; - readonly file_size_limit: string; - readonly allowed_mime_types: ReadonlyArray; - readonly objects_path: string; - } - > ->; - -// Parse a `file_size_limit` string to bytes, mapping a parse failure to a -// config-load error (Go rejects an invalid `sizeInBytes` during `config.Load`, -// before NewStorageAPI). -const parseFileSizeLimitOrFail = (value: string) => - Effect.try({ - try: () => legacyParseFileSizeLimit(value), - catch: (cause) => - new LegacySeedConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); - -const computeBucketProps = ( - document: Record | undefined, - name: string, - bucket: BucketsConfig[string], - storageFileSizeLimitBytes: number, -) => - Effect.try({ - try: () => legacyResolveBucketProps({ document, name, bucket, storageFileSizeLimitBytes }), - catch: (cause) => - new LegacySeedConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); - -// Port of `pkg/storage/batch.go:UpsertBuckets`. `propsByName` is precomputed and -// size-validated before this runs (Go parses sizes at config-load, before any -// Storage call). -const upsertBuckets = Effect.fnUntraced(function* ( - output: typeof Output.Service, - yes: boolean, - gateway: LegacyStorageGateway, - propsByName: ReadonlyMap, - summary: SeedSummary, -) { - const existing = yield* gateway.listBuckets(); - const byName = new Map(existing.map((b) => [b.name, b.id])); - - for (const [name, props] of propsByName) { - const bucketId = byName.get(name); - if (bucketId !== undefined) { - const overwrite = yield* legacyPromptYesNo( - output, - yes, - `Bucket ${legacyBold(bucketId)} already exists. Do you want to overwrite its properties?`, - true, - ); - if (!overwrite) { - summary.buckets_skipped.push(bucketId); - continue; - } - yield* output.raw(`Updating Storage bucket: ${bucketId}\n`, "stderr"); - yield* gateway.updateBucket(bucketId, props); - summary.buckets_updated.push(bucketId); - } else { - yield* output.raw(`Creating Storage bucket: ${name}\n`, "stderr"); - yield* gateway.createBucket(name, props); - summary.buckets_created.push(name); - } - } -}); - -// Port of `pkg/storage/vector.go:UpsertVectorBuckets`. -const upsertVectorBuckets = Effect.fnUntraced(function* ( - output: typeof Output.Service, - yes: boolean, - gateway: LegacyStorageGateway, - configuredNames: ReadonlyArray, - summary: SeedSummary, -) { - const existing = yield* gateway.listVectorBuckets(); - const existingSet = new Set(existing); - const configuredSet = new Set(configuredNames); - const toDelete = existing.filter((name) => !configuredSet.has(name)); - - for (const name of configuredNames) { - if (existingSet.has(name)) { - yield* output.raw(`Bucket already exists: ${name}\n`, "stderr"); - continue; - } - yield* output.raw(`Creating vector bucket: ${name}\n`, "stderr"); - yield* gateway.createVectorBucket(name); - summary.vector_created.push(name); - } - - for (const name of toDelete) { - const prune = yield* legacyPromptYesNo( - output, - yes, - `Bucket ${legacyBold(name)} not found in ${legacyBold(CONFIG_PATH)}. Do you want to prune it?`, - false, - ); - if (!prune) { - continue; - } - yield* output.raw(`Pruning vector bucket: ${name}\n`, "stderr"); - yield* gateway.deleteVectorBucket(name); - summary.vector_pruned.push(name); - } -}); - -// Port of `pkg/storage/analytics.go:UpsertAnalyticsBuckets`. -const upsertAnalyticsBuckets = Effect.fnUntraced(function* ( - output: typeof Output.Service, - yes: boolean, - gateway: LegacyStorageGateway, - configuredNames: ReadonlyArray, - summary: SeedSummary, -) { - const existing = yield* gateway.listAnalyticsBuckets(); - const existingSet = new Set(existing); - const configuredSet = new Set(configuredNames); - const toDelete = existing.filter((name) => !configuredSet.has(name)); - - for (const name of configuredNames) { - if (existingSet.has(name)) { - yield* output.raw(`Bucket already exists: ${name}\n`, "stderr"); - continue; - } - yield* output.raw(`Creating analytics bucket: ${name}\n`, "stderr"); - yield* gateway.createAnalyticsBucket(name); - summary.analytics_created.push(name); - } - - for (const name of toDelete) { - const prune = yield* legacyPromptYesNo( - output, - yes, - `Bucket ${legacyBold(name)} not found in ${legacyBold(CONFIG_PATH)}. Do you want to prune it?`, - false, - ); - if (!prune) { - continue; - } - yield* output.raw(`Pruning analytics bucket: ${name}\n`, "stderr"); - yield* gateway.deleteAnalyticsBucket(name); - summary.analytics_pruned.push(name); - } -}); - -/** - * Vector graceful-skip (`buckets.go:57-66`): on `FeatureNotEnabled` / - * local-unavailable errors, print the matching WARNING and continue (object - * upload still runs). Any other error propagates. - */ -const handleVectorError = Effect.fnUntraced(function* ( - output: typeof Output.Service, - error: LegacyStorageGatewayError, - summary: SeedSummary, -) { - if (legacyIsVectorBucketsFeatureNotEnabled(error.message)) { - yield* output.raw( - `${legacyYellow("WARNING:")} Vector buckets are not available in this project's region yet. Skipping vector bucket seeding.\n`, - "stderr", - ); - summary.vector_skipped = true; - return; - } - if (legacyIsLocalVectorBucketsUnavailable(error.message)) { - yield* output.raw( - `${legacyYellow("WARNING:")} Vector buckets are not available in the local storage service. If this project is linked, run \`supabase link\` to update service versions, then restart the local stack. Skipping vector bucket seeding.\n`, - "stderr", - ); - summary.vector_skipped = true; - return; - } - return yield* Effect.fail(error); -}); - -// Port of `pkg/storage/batch.go:UpsertObjects` (+ object walk in objects.go). -const uploadObjects = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - output: typeof Output.Service, - gateway: LegacyStorageGateway, - workdir: string, - bucketsConfig: BucketsConfig, - summary: SeedSummary, -) { - for (const [name, bucket] of Object.entries(bucketsConfig)) { - const objectsPath = bucket.objects_path; - if (objectsPath.length === 0) { - continue; - } - // Go resolves a relative bucket objects_path against SupabaseDirPath at - // config-resolve time (`pkg/config/config.go:757-759`); absolute paths are - // left untouched. `displayRoot` (workdir-relative) drives the `Uploading:` - // stderr and the destination key so both stay byte-identical to Go. - const displayRoot = path.isAbsolute(objectsPath) - ? objectsPath - : path.join("supabase", objectsPath); - const absRoot = path.isAbsolute(objectsPath) - ? objectsPath - : path.join(workdir, "supabase", objectsPath); - const files = yield* collectFiles(fs, path, output, absRoot, displayRoot); - yield* Effect.forEach( - files, - (file) => - Effect.gen(function* () { - const dstPath = legacyBucketObjectKey(name, displayRoot, file.displayPath); - yield* output.raw(`Uploading: ${file.displayPath} => ${dstPath}\n`, "stderr"); - // Content-type is byte-driven: Go sniffs the first 512 bytes with - // http.DetectContentType, refining only a generic text/plain by - // extension (`pkg/storage/objects.go:77-108`). - const sniff = yield* legacyReadSniffBytes(fs, file.absPath); - // Go's seed upload always sets Cache-Control max-age=3600 and x-upsert - // (Overwrite) true (`pkg/storage/batch.go`). - yield* gateway.uploadObject(dstPath, file.absPath, { - contentType: legacyContentTypeForUpload(sniff, file.absPath), - cacheControl: "max-age=3600", - overwrite: true, - }); - summary.objects_uploaded.push(dstPath); - }), - { concurrency: UPLOAD_CONCURRENCY }, - ); - } -}); - -/** - * Collect uploadable files under `absRoot`, lexically ordered, mirroring Go's - * `fs.WalkDir` + `isUploadableEntry` (`pkg/storage/batch.go:65-131`). - * - * Parity details: - * - The **root** is resolved with a following stat (Go's `fs.Stat`), so a - * symlinked `objects_path` is followed; a missing/dangling root fails. - * - **Nested** entries use no-follow detection: real directories are descended; - * symlinks are NOT descended — Go's `isUploadableEntry` OPENS the symlink - * target then stats the handle, uploading only a regular file and skipping - * dangling symlinks / symlinks-to-directories / unreadable targets. - */ -const collectFiles = ( - fs: FileSystem.FileSystem, - path: Path.Path, - output: typeof Output.Service, - absRoot: string, - displayRoot: string, -): Effect.Effect, PlatformError> => - Effect.gen(function* () { - const info = yield* fs.stat(absRoot); - if (info.type === "Directory") { - return yield* collectDir(fs, path, output, absRoot, displayRoot); - } - if (info.type === "File") { - return [{ absPath: absRoot, displayPath: displayRoot }]; - } - yield* output.raw(`Skipping non-regular file: ${displayRoot}\n`, "stderr"); - return []; - }); - -const collectDir = ( - fs: FileSystem.FileSystem, - path: Path.Path, - output: typeof Output.Service, - absDir: string, - displayDir: string, -): Effect.Effect, PlatformError> => - Effect.gen(function* () { - const names = [...(yield* fs.readDirectory(absDir))].sort(); - const collected: Array = []; - for (const name of names) { - const absChild = path.join(absDir, name); - const displayChild = path.join(displayDir, name); - // `readLink` succeeds only on a symlink — our no-follow detector (Effect's - // `stat` follows symlinks and has no `lstat`). - const isSymlink = yield* fs.readLink(absChild).pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - ); - if (isSymlink) { - // Go `isUploadableEntry` (batch.go:73-84) OPENS the target then stats the - // handle; it uploads only a regular file. `stat` alone would queue an - // unreadable target and abort later at upload, so mirror Go: open + stat. - const targetType = yield* Effect.scoped( - Effect.gen(function* () { - const handle = yield* fs.open(absChild, { flag: "r" }); - const targetInfo = yield* handle.stat; - return targetInfo.type; - }), - ).pipe(Effect.catch(() => Effect.succeed("Unknown" as const))); - if (targetType === "File") { - collected.push({ absPath: absChild, displayPath: displayChild }); - } else { - yield* output.raw(`Skipping non-regular file: ${displayChild}\n`, "stderr"); - } - continue; - } - const childInfo = yield* fs.stat(absChild); - if (childInfo.type === "Directory") { - collected.push(...(yield* collectDir(fs, path, output, absChild, displayChild))); - } else if (childInfo.type === "File") { - collected.push({ absPath: absChild, displayPath: displayChild }); - } else { - yield* output.raw(`Skipping non-regular file: ${displayChild}\n`, "stderr"); - } - } - return collected; - }); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts index 986fcc4af6..c91d172f5e 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts @@ -25,6 +25,7 @@ import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; import { LegacyProjectRefResolver } from "../../../../legacy/config/legacy-project-ref.service.ts"; import { LegacyProjectNotLinkedError } from "../../../../legacy/config/legacy-project-ref.errors.ts"; +import { legacySeedBucketsRun } from "../../../shared/legacy-seed-buckets.ts"; import { legacySeedBuckets } from "./buckets.handler.ts"; import type { LegacyBucketsFlags } from "./buckets.command.ts"; import { LegacyPlatformApi } from "../../../../legacy/auth/legacy-platform-api.service.ts"; @@ -323,6 +324,36 @@ describe("legacy seed buckets", () => { }); }); + it.live( + "honors a piped decline for the overwrite prompt when non-interactive (db reset path)", + () => { + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [{ name: "test", id: "test" }] }, + ], + pipedAnswers: ["n"], + }); + return Effect.gen(function* () { + // db reset seeds buckets with interactive=false (Go's `buckets.Run(ctx, "", + // false, fsys)` forces console.IsTTY=false). Go does NOT silently take the + // default: the prompt still prints its label, scans one line, and honors a + // parsed answer — so the piped "n" must skip the overwrite (default is yes). + const exit = yield* legacySeedBucketsRun({ + projectRef: "", + emitSummary: false, + interactive: false, + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain( + "already exists. Do you want to overwrite its properties?", + ); + expect(out.stderrText).not.toContain("Updating Storage bucket"); + expect(requests.some((r) => r.method === "PUT")).toBe(false); + }); + }, + ); + it.live("creates configured vector buckets and leaves stale ones (prune default no)", () => { const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.documents-openai]\n[storage.vector.buckets.existing-vec]\n", @@ -397,6 +428,40 @@ describe("legacy seed buckets", () => { }); }); + it.live( + "prunes a stale vector bucket when the caller passes yes (db reset project-env path)", + () => { + // `db reset` resolves `yes` with the nested project `.env` and passes it into the runner + // with `interactive: false`; the pre-resolved `yes` must drive pruning even though no + // prompt is answered and the runner's own shell-only resolveYes would default to false. + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.keep-vec]\n", + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { + method: "POST", + match: VECTOR_LIST, + body: { + vectorBuckets: [{ vectorBucketName: "keep-vec" }, { vectorBucketName: "stale-vec" }], + }, + }, + { method: "POST", match: VECTOR_DELETE, body: {} }, + ], + // No `confirm` and no `--yes` flag — pruning is driven solely by the passed `yes`. + }); + return Effect.gen(function* () { + yield* legacySeedBucketsRun({ + projectRef: "", + emitSummary: false, + interactive: false, + yes: true, + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Pruning vector bucket: stale-vec"); + expect(requests.some((r) => r.url.includes(VECTOR_DELETE))).toBe(true); + }); + }, + ); + it.live("warns and continues when vector buckets are unavailable in the region", () => { const { layer, out } = setupLegacySeedBuckets(tmp.current, { toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.documents-openai]\n", diff --git a/apps/cli/src/legacy/commands/services/services.integration.test.ts b/apps/cli/src/legacy/commands/services/services.integration.test.ts index 7e03294354..c2e9b2eca8 100644 --- a/apps/cli/src/legacy/commands/services/services.integration.test.ts +++ b/apps/cli/src/legacy/commands/services/services.integration.test.ts @@ -71,6 +71,7 @@ function setup( apiUrl: "https://api.supabase.com", projectHost: "supabase.co", poolerHost: "supabase.com", + dashboardUrl: "https://supabase.com/dashboard", accessToken: Option.none(), projectId: Option.none(), workdir: opts.workdir ?? process.cwd(), diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts index 70ec567d30..9ced346d5f 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts @@ -2,7 +2,11 @@ import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; import { parse as parseYaml } from "yaml"; import { CLI_VERSION } from "../../shared/cli/version.ts"; import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts"; -import { legacyPoolerHost, legacyProjectHost } from "../shared/legacy-profile.ts"; +import { + legacyDashboardUrl, + legacyPoolerHost, + legacyProjectHost, +} from "../shared/legacy-profile.ts"; import { LegacyDebugLogger, type LegacyDebugLoggerShape, @@ -16,6 +20,7 @@ interface ResolvedProfile { readonly apiUrl: string; readonly projectHost: string; readonly poolerHost: string; + readonly dashboardUrl: string; } const BUILTIN_PROFILE_API_URLS: Record = { @@ -38,6 +43,7 @@ function resolvedBuiltin(name: LegacyProfileName): ResolvedProfile { apiUrl: BUILTIN_PROFILE_API_URLS[name], projectHost: legacyProjectHost(name), poolerHost: legacyPoolerHost(name), + dashboardUrl: legacyDashboardUrl(name), }; } @@ -47,6 +53,7 @@ function safeParseYaml(text: string): api_url?: unknown; project_host?: unknown; pooler_host?: unknown; + dashboard_url?: unknown; } | undefined { try { @@ -57,6 +64,7 @@ function safeParseYaml(text: string): api_url?: unknown; project_host?: unknown; pooler_host?: unknown; + dashboard_url?: unknown; }) : undefined; } catch { @@ -147,6 +155,13 @@ function resolveProfile( // that omits `pooler_host:` yields an empty host, which disables the MITM // domain assertion — it must NOT fall back to the production `supabase.com`. poolerHost: typeof parsed.pooler_host === "string" ? parsed.pooler_host : "", + // Go's `Profile.DashboardURL` is `required` (`profile.go:20`); a YAML profile + // that omits it falls back to the built-in `supabase` dashboard here rather + // than erroring, since it only feeds the connect-failure suggestion text. + dashboardUrl: + typeof parsed.dashboard_url === "string" + ? parsed.dashboard_url + : legacyDashboardUrl("supabase"), }; }); } @@ -200,6 +215,7 @@ export const legacyCliConfigLayer = Layer.unwrap( apiUrl, projectHost, poolerHost, + dashboardUrl, } = yield* resolveProfile( profileFlag, env["SUPABASE_PROFILE"], @@ -236,6 +252,7 @@ export const legacyCliConfigLayer = Layer.unwrap( apiUrl, projectHost, poolerHost, + dashboardUrl, accessToken, projectId, workdir, diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts index 541e2d4a9b..f6d8ca20f8 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts @@ -56,6 +56,7 @@ describe("legacyCliConfigLayer", () => { expect(config.apiUrl).toBe("https://api.supabase.com"); expect(config.projectHost).toBe("supabase.co"); expect(config.poolerHost).toBe("supabase.com"); + expect(config.dashboardUrl).toBe("https://supabase.com/dashboard"); }).pipe(Effect.provide(makeLayer({ cwd: tempRoot }))), ); @@ -154,7 +155,7 @@ describe("legacyCliConfigLayer", () => { ), ); - it.effect("loads api_url, name, and pooler_host from a YAML profile file", () => { + it.effect("loads api_url, name, pooler_host, and dashboard_url from a YAML profile file", () => { const profilePath = join(tempRoot, "profile.yaml"); writeFileSync( profilePath, @@ -163,6 +164,7 @@ describe("legacyCliConfigLayer", () => { 'api_url: "http://127.0.0.1:9999"', "project_host: localhost", "pooler_host: staging.example.com", + 'dashboard_url: "http://127.0.0.1:9999"', ].join("\n"), ); return Effect.gen(function* () { @@ -171,6 +173,9 @@ describe("legacyCliConfigLayer", () => { expect(config.apiUrl).toBe("http://127.0.0.1:9999"); expect(config.projectHost).toBe("localhost"); expect(config.poolerHost).toBe("staging.example.com"); + // Go reads `dashboard_url` from the profile (used by the connect-failure hint); + // the cli-e2e harness points it at the replay server for parity. + expect(config.dashboardUrl).toBe("http://127.0.0.1:9999"); }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }))); }); @@ -183,6 +188,8 @@ describe("legacyCliConfigLayer", () => { // Go's Profile.PoolerHost is `omitempty`: an absent pooler_host disables the // MITM domain assertion rather than falling back to supabase.com. expect(config.poolerHost).toBe(""); + // An omitted dashboard_url falls back to the built-in supabase dashboard. + expect(config.dashboardUrl).toBe("https://supabase.com/dashboard"); }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }))); }); diff --git a/apps/cli/src/legacy/config/legacy-cli-config.service.ts b/apps/cli/src/legacy/config/legacy-cli-config.service.ts index 3bef2e2a1c..432d3d267a 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.service.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.service.ts @@ -29,6 +29,14 @@ interface LegacyCliConfigShape { * db-config resolver's MITM domain check. */ readonly poolerHost: string; + /** + * Dashboard base URL for the active profile (Go's `Profile.DashboardURL`, + * `apps/cli-go/internal/utils/profile.go:20`). Sourced from the resolved profile — + * the built-in table for named profiles, or the `dashboard_url:` key of a YAML + * profile file — so staging/custom dashboards are honored. Used by the + * connect-failure suggestion (Go's `SetConnectSuggestion` network-restrictions hint). + */ + readonly dashboardUrl: string; readonly accessToken: Option.Option>; readonly projectId: Option.Option; readonly workdir: string; diff --git a/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts index 3990163113..04b64ff06b 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts @@ -24,6 +24,7 @@ function mockCliConfig(opts: { workdir: string; projectId?: string }) { apiUrl: "https://api.supabase.com", projectHost: "supabase.co", poolerHost: "supabase.com", + dashboardUrl: "https://supabase.com/dashboard", accessToken: Option.none(), projectId: opts.projectId === undefined ? Option.none() : Option.some(opts.projectId), workdir: opts.workdir, diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.ts index 504deb471e..5a269abf87 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.ts @@ -51,6 +51,97 @@ export function legacyIsIPv6ConnectivityError(message: string): boolean { return false; } +/** + * Go's `utils.SuggestEnvVar` (`internal/utils/connect.go:191`): the hint shown when + * a connection fails on password authentication, pointing users at the + * `SUPABASE_DB_PASSWORD` env var. + */ +export const LEGACY_SUGGEST_ENV_VAR = + "Connect to your database by setting the env var correctly: SUPABASE_DB_PASSWORD"; + +/** Context the connect-suggestion needs but cannot derive from the error alone. */ +export interface LegacyConnectSuggestionContext { + /** Active profile's dashboard URL (Go's `CurrentProfile.DashboardURL`). */ + readonly dashboardUrl: string; + /** Active profile name (Go's `CurrentProfile.Name`). */ + readonly profileName: string; + /** Whether `--debug` is set (Go's `viper.GetBool("DEBUG")`). */ + readonly debug: boolean; +} + +/** + * Flatten an error's `cause` chain and any `AggregateError.errors` into a single + * searchable string of every nested `message` and `code`. The `@effect/sql` + * `SqlError` wraps the node-postgres / node `net` driver error on its `cause`; a + * multi-address dial wraps an `AggregateError` whose `errors[]` carry the per-IP + * `ECONNREFUSED` / `ENETUNREACH` system errors. Including the `code` strings lets + * the matcher key off node's `ECONNREFUSED` the way Go keys off pgconn's + * `connect: connection refused`. + */ +function legacyCollectConnectErrorText(error: unknown): string { + const parts: string[] = []; + const seen = new Set(); + const visit = (node: unknown, depth: number): void => { + if (depth > 8 || typeof node !== "object" || node === null || seen.has(node)) return; + seen.add(node); + const message = Reflect.get(node, "message"); + if (typeof message === "string") parts.push(message); + const code = Reflect.get(node, "code"); + if (typeof code === "string") parts.push(code); + visit(Reflect.get(node, "cause"), depth + 1); + const errors = Reflect.get(node, "errors"); + if (Array.isArray(errors)) for (const child of errors) visit(child, depth + 1); + }; + visit(error, 0); + return parts.join("\n"); +} + +/** + * Port of Go's `SetConnectSuggestion` (`internal/utils/connect.go:313-335`): map a + * Postgres connect failure to an actionable hint that replaces the generic + * "Try rerunning the command with --debug" suggestion. Go matches `pgconn`'s + * error text; this matches the equivalent node-postgres / node `net` driver text + * and codes (e.g. `ECONNREFUSED` for `connect: connection refused`) gathered from + * the `SqlError` cause/aggregate chain. The branch order mirrors Go's `if/else if`. + * Returns `undefined` when no specific suggestion applies (the caller then falls + * back to the generic suggestion, like Go leaving `CmdSuggestion` empty). + */ +export function legacyConnectSuggestion( + error: unknown, + ctx: LegacyConnectSuggestionContext, +): string | undefined { + const text = legacyCollectConnectErrorText(error); + // connect: connection refused / Address not in tenant allow_list → network restrictions. + if ( + text.includes("ECONNREFUSED") || + text.includes("connection refused") || + text.includes("Address not in tenant allow_list") + ) { + return `Make sure your local IP is allowed in Network Restrictions and Network Bans.\n${ctx.dashboardUrl}/project/_/database/settings`; + } + // SSL connection is required (only under --debug, which disables TLS). + if (text.includes("SSL connection is required") && ctx.debug) { + return "SSL connection is not supported with --debug flag"; + } + // Wrong password (Go: "SCRAM exchange: Wrong password" / "failed SASL auth"; + // node-postgres surfaces the server's `28P01` "password authentication failed"). + if ( + text.includes("SCRAM exchange: Wrong password") || + text.includes("failed SASL auth") || + text.includes("password authentication failed") + ) { + return LEGACY_SUGGEST_ENV_VAR; + } + if (legacyIsIPv6ConnectivityError(text)) { + return legacyIpv6Suggestion(); + } + // no route to host / Tenant or user not found → wrong profile. + if (text.includes("no route to host") || text.includes("Tenant or user not found")) { + return `Make sure your project exists on profile: ${ctx.profileName}`; + } + return undefined; +} + function hasStringCode(error: unknown): error is { readonly code: string; readonly address?: unknown; diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts index 05ff60186c..844cda45e0 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import { + LEGACY_SUGGEST_ENV_VAR, + legacyConnectSuggestion, + legacyIpv6Suggestion, legacyIsIPv6ConnectivityError, legacyIsIPv6ConnectivityErrorCause, } from "./legacy-connect-errors.ts"; @@ -44,6 +47,77 @@ describe("legacyIsIPv6ConnectivityError", () => { }); }); +describe("legacyConnectSuggestion", () => { + const ctx = { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + debug: false, + } as const; + + // The @effect/sql SqlError wraps the node driver error on `.cause`; a multi-address + // dial wraps an AggregateError whose `.errors[]` carry the per-IP system errors. + const sqlError = (cause: unknown) => + Object.assign(new Error("PgClient: Failed to connect"), { cause }); + const systemError = (message: string, code: string) => + Object.assign(new Error(message), { code }); + + it("maps a refused connection (node ECONNREFUSED) to the network-restrictions hint", () => { + const err = sqlError(systemError("connect ECONNREFUSED 127.0.0.1:54322", "ECONNREFUSED")); + expect(legacyConnectSuggestion(err, ctx)).toBe( + "Make sure your local IP is allowed in Network Restrictions and Network Bans.\nhttps://supabase.com/dashboard/project/_/database/settings", + ); + }); + + it("maps an AggregateError of refused dials to the network-restrictions hint", () => { + const err = sqlError( + Object.assign(new AggregateError([], "all attempts failed"), { + errors: [systemError("connect ECONNREFUSED [::1]:54322", "ECONNREFUSED")], + }), + ); + expect(legacyConnectSuggestion(err, ctx)).toContain( + "Make sure your local IP is allowed in Network Restrictions and Network Bans.", + ); + }); + + it("maps the pooler allow_list rejection to the network-restrictions hint", () => { + const err = sqlError(new Error("Address not in tenant allow_list")); + expect(legacyConnectSuggestion(err, ctx)).toContain("Network Restrictions and Network Bans"); + }); + + it("maps a password-auth failure to the env-var suggestion", () => { + const err = sqlError( + Object.assign(new Error('password authentication failed for user "postgres"'), { + code: "28P01", + }), + ); + expect(legacyConnectSuggestion(err, ctx)).toBe(LEGACY_SUGGEST_ENV_VAR); + }); + + it("suggests the --debug SSL note only under --debug", () => { + const err = sqlError(new Error("SSL connection is required")); + expect(legacyConnectSuggestion(err, ctx)).toBeUndefined(); + expect(legacyConnectSuggestion(err, { ...ctx, debug: true })).toBe( + "SSL connection is not supported with --debug flag", + ); + }); + + it("maps an IPv6-only connectivity failure to the IPv6 pooler suggestion", () => { + const err = sqlError(new Error("dial tcp: network is unreachable")); + expect(legacyConnectSuggestion(err, ctx)).toBe(legacyIpv6Suggestion()); + }); + + it("maps a tenant-not-found error to the wrong-profile hint", () => { + const err = sqlError(new Error("Tenant or user not found")); + expect(legacyConnectSuggestion(err, ctx)).toBe( + "Make sure your project exists on profile: supabase", + ); + }); + + it("returns undefined for an unrecognized connect error", () => { + expect(legacyConnectSuggestion(sqlError(new Error("some other failure")), ctx)).toBeUndefined(); + }); +}); + describe("legacyIsIPv6ConnectivityErrorCause", () => { it("classifies Node getaddrinfo and network-unreachable errors", () => { expect( diff --git a/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts index 78549ac06f..c277d2930e 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts @@ -158,6 +158,13 @@ describe("legacyDbConfigResolver (local + db-url)", () => { user: "postgres", password: "hunter2", database: "postgres", + // The resolver attaches the connect-failure suggestion context (Go's + // ambient CurrentProfile) to every resolved connection. + suggestionContext: { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + debug: false, + }, }); expect(r.isLocal).toBe(true); rmSync(dir, { recursive: true, force: true }); @@ -206,6 +213,11 @@ describe("legacyDbConfigResolver (local + db-url)", () => { user: "alice", password: "p@ss", database: "appdb", + suggestionContext: { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + debug: false, + }, }); expect(r.isLocal).toBe(false); rmSync(dir, { recursive: true, force: true }); @@ -495,6 +507,11 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { user: `cli_login_role.${adHocRef}`, password: "temporary-role-password", database: "postgres", + suggestionContext: { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + debug: false, + }, }); expect(r.ref).toEqual(Option.some(adHocRef)); expect(requests).toEqual([ @@ -648,6 +665,11 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { user: `cli_login_role.${adHocRef}`, password: "temporary-role-password", database: "postgres", + suggestionContext: { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + debug: false, + }, }); } expect(requests).toEqual([ @@ -785,6 +807,11 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { user: `postgres.${linkedRef}`, password: "linked-password", database: "postgres", + suggestionContext: { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + debug: false, + }, }); } expect(requests).toEqual([ diff --git a/apps/cli/src/legacy/shared/legacy-db-config.layer.ts b/apps/cli/src/legacy/shared/legacy-db-config.layer.ts index 9bc7e00f45..45714feb97 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.layer.ts @@ -20,6 +20,10 @@ import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { Tty } from "../../shared/runtime/tty.service.ts"; import { Analytics } from "../../shared/telemetry/analytics.service.ts"; import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; +import { + type LegacyConnectSuggestionContext, + LEGACY_SUGGEST_ENV_VAR, +} from "./legacy-connect-errors.ts"; import { LegacyDbConnection, type LegacyPgConnInput } from "./legacy-db-connection.service.ts"; import { LegacyIdentityStitch } from "./legacy-identity-stitch.ts"; import { @@ -44,9 +48,6 @@ const TCP_PROBE_TIMEOUT = Duration.seconds(5); const MAX_RETRIES = 8; const BACKOFF_INITIAL = Duration.seconds(3); const BACKOFF_MAX = Duration.seconds(60); -// Go: utils.SuggestEnvVar (`apps/cli-go/internal/utils/connect.go:174`). -const SUGGEST_ENV_VAR = - "Connect to your database by setting the env var correctly: SUPABASE_DB_PASSWORD"; const loginRoleErrorMapper = mapLegacyHttpError({ networkError: Errors.LegacyDbConfigLoginRoleNetworkError, @@ -106,6 +107,16 @@ export const legacyDbConfigLayer = Layer.effect( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + // Profile context for the connect-failure suggestion (Go's `SetConnectSuggestion` + // reads the ambient `CurrentProfile` + `viper.GetBool("DEBUG")`). Snapshot it once + // and attach it to every resolved connection so the driver layer can render Go's + // hint on a refused/auth/IPv6 connect error. + const suggestionContext: LegacyConnectSuggestionContext = { + dashboardUrl: cliConfig.dashboardUrl, + profileName: cliConfig.profile, + debug: yield* LegacyDebugFlag, + }; + // Capture the ambient services the Management API stack needs, so the // lazily-built linked stack is fully self-provided and `resolve`'s R stays // `never` (handler tests can mock this resolver without wiring the whole @@ -203,7 +214,7 @@ export const legacyDbConfigLayer = Layer.effect( return Effect.fail( new Errors.LegacyDbConfigConnectTempRoleError({ message: `failed to connect as temp role: ${cause.message}`, - suggestion: SUGGEST_ENV_VAR, + suggestion: LEGACY_SUGGEST_ENV_VAR, }), ); } @@ -549,6 +560,18 @@ export const legacyDbConfigLayer = Layer.effect( ); }); - return LegacyDbConfigResolver.of({ resolve, resolvePoolerFallback }); + // Attach the connect-failure suggestion context to every resolved connection in + // one place (Go sets it ambiently via `CurrentProfile`), so each connecting + // command inherits Go's `SetConnectSuggestion` hint without per-call-site wiring. + const withSuggestion = (conn: LegacyPgConnInput): LegacyPgConnInput => ({ + ...conn, + suggestionContext, + }); + return LegacyDbConfigResolver.of({ + resolve: (flags) => + resolve(flags).pipe(Effect.map((r) => ({ ...r, conn: withSuggestion(r.conn) }))), + resolvePoolerFallback: (flags) => + resolvePoolerFallback(flags).pipe(Effect.map(Option.map(withSuggestion))), + }); }), ); diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index 0e4bc28028..71b989aaeb 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -77,6 +77,11 @@ interface LegacyDbTomlValues { readonly seed: LegacyDbSeedTomlConfig; /** `[db.vault]` secrets (name → resolved value) — upserted by `up`/`down`. */ readonly vault: ReadonlyArray; + /** + * The matched `[remotes.]` block name when a linked ref merged its override + * (Go's `Loading config override: [remotes.]` line), else `undefined`. + */ + readonly appliedRemote: string | undefined; } /** `[db.seed]` config surfaced for `migration down`'s seed step. */ @@ -180,6 +185,12 @@ function deepMergeDoc(base: RawDoc, override: RawDoc): RawDoc { */ interface LegacyRemoteOverride { readonly doc: RawDoc | undefined; + /** + * The name of the matched `[remotes.]` block whose `project_id` equals the + * resolved ref, or `undefined` when no block matched. Callers echo Go's + * `Loading config override: [remotes.]` stderr line from this. + */ + readonly appliedRemote?: string; /** * The config keys the matched remote block contributed at viper's OVERRIDE tier. Go's * `mergeRemoteConfig` applies every block key via `v.Set(...)` after `AutomaticEnv` @@ -302,10 +313,11 @@ function applyRemoteOverride( if (blockSeed?.["enabled"] === undefined) { return { doc: deepMergeDoc(merged, { db: { seed: { enabled: false } } }), + appliedRemote: name, remoteOverrideKeys, }; } - return { doc: merged, remoteOverrideKeys }; + return { doc: merged, appliedRemote: name, remoteOverrideKeys }; } } return { doc, remoteOverrideKeys: new Set() }; @@ -476,6 +488,18 @@ function legacyJoinSupabaseSeedPath(pattern: string): string { return out.length === 0 ? "." : out.join("/"); } +/** + * Resolves a single seed `sql_paths` entry to Go's config-load form: a relative + * pattern is joined under `supabase/` (Go's `path.Join`, `config.go:918-921`); an + * absolute (or empty) pattern is returned verbatim. Used by the reader for + * `[db.seed].sql_paths` and by `db reset` for its `--sql-paths` override (Go's + * `resolveSeedSqlPaths`, `cmd/db.go`) so both feed the glob the same resolved paths. + */ +export const legacyResolveSeedSqlPath = (pathSvc: Path.Path, pattern: string): string => + pattern.length === 0 || pathSvc.isAbsolute(pattern) + ? pattern + : legacyJoinSupabaseSeedPath(pattern); + /** `[db]` ports default through the development env unless `SUPABASE_ENV` overrides. */ const DEFAULT_SUPABASE_ENV = "development"; @@ -644,7 +668,12 @@ function resolveBool(value: unknown, fallback: boolean, lookup: EnvLookup): bool // width). A TOML number (`enabled = 0`) is therefore an explicit false, NOT absent — it // must not fall through to the schema default. if (typeof value === "number") return value !== 0; - return fallback; + // Absent → the schema default. A PRESENT non-scalar (array/inline table, e.g. + // `enabled = []`) is a decode error in Go's `UnmarshalExact` during `LoadConfig`, so it + // must NOT fall through to the default — otherwise `db reset` could accept the prompt and + // drop schemas on a config Go rejects up front. + if (value === undefined) return fallback; + return "invalid"; } /** @@ -717,45 +746,115 @@ const resolveOptionalBoolOrFail = Effect.fnUntraced(function* ( } return Option.some(parsed); } - return Option.none(); + // Absent → `None` (Go's `*bool` stays nil). A present non-scalar value fails Go's + // `UnmarshalExact`, so reject it here rather than silently treating it as absent. + if (value === undefined) return Option.none(); + return yield* Effect.fail( + new LegacyDbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }), + ); }); /** - * Recursively asserts every `encrypted:` secret in the (merged) config can be decrypted, - * mirroring Go's global `DecryptSecretHookFunc` (`pkg/config/secret.go:77-109`, - * `config.go:730`), which decrypts every `config.Secret` field during `UnmarshalExact` and - * aborts the load with `failed to parse config: ` when one cannot be decrypted (e.g. - * no `DOTENV_PRIVATE_KEY`). The reader's `[db.vault]` walk only covered vault secrets, so - * non-vault `Secret` fields (`db.root_key`, `auth.external.

.secret`, smtp `pass`, …) were - * silently passed through. A recursive string scan tracks Go's "decode the entire config" - * behaviour and stays robust as new `Secret` fields are added. The unset-`env(...)` and - * plain-string forms are returned verbatim by Go's hook (no error), so they are no-ops here. - * Returns the failure (or `undefined`); the caller surfaces it via `Effect.fail`. + * Dotted paths of every `config.Secret`-typed field Go decrypts via its global + * `DecryptSecretHookFunc` (`pkg/config/secret.go`, `config.go:730`) — the hook only runs + * while decoding INTO a `config.Secret`, never over arbitrary strings. `*` matches any map + * key (`auth.external.`, `auth.hook.`). `[db.vault]` (a `map[string]Secret`) + * is intentionally omitted — the reader decrypts it directly in the body with the same + * fail-on-undecryptable behaviour. Derived from the Go structs (`auth.go`, `db.go`, + * `config.go`); update alongside any new `Secret` field. + */ +const LEGACY_SECRET_PATHS: ReadonlyArray> = [ + ["db", "root_key"], + ["auth", "publishable_key"], + ["auth", "secret_key"], + ["auth", "jwt_secret"], + ["auth", "anon_key"], + ["auth", "service_role_key"], + ["auth", "email", "smtp", "pass"], + ["auth", "external", "*", "secret"], + ["auth", "hook", "*", "secrets"], + ["auth", "sms", "twilio", "auth_token"], + ["auth", "sms", "twilio_verify", "auth_token"], + ["auth", "sms", "messagebird", "access_key"], + ["auth", "sms", "textlocal", "api_key"], + ["auth", "sms", "vonage", "api_secret"], + ["auth", "captcha", "secret"], + ["studio", "openai_api_key"], + // Go decodes `[edge_runtime.secrets]` as `SecretsConfig map[string]Secret` (config.go:283,287), + // so every value is decrypted by the hook — `*` spans the arbitrary secret names. + ["edge_runtime", "secrets", "*"], +]; + +/** Collects the string leaves reachable from `node` along `segs` (`*` spans map keys). */ +const legacyCollectSecretStrings = ( + node: unknown, + segs: ReadonlyArray, + index: number, + out: Array, +): void => { + if (index === segs.length) { + if (typeof node === "string") out.push(node); + return; + } + const record = asRecord(node); + if (record === undefined) return; + const seg = segs[index]!; + if (seg === "*") { + for (const key of Object.keys(record)) { + legacyCollectSecretStrings(record[key], segs, index + 1, out); + } + } else { + legacyCollectSecretStrings(record[seg], segs, index + 1, out); + } +}; + +/** Fails when a single `encrypted:` secret value cannot be decrypted (Go's hook error). */ +const legacyAssertSecretValue = ( + value: string, + lookup: EnvLookup, + dotenvPrivateKeys: ReadonlyArray, +): LegacyDbConfigLoadError | undefined => { + const expanded = legacyExpandEnv(value, lookup); + // Unset `env(...)` and plain strings are returned verbatim by Go's hook (no error). + if (ENV_PATTERN.test(expanded) || !legacyIsEncryptedSecret(expanded)) return undefined; + const decrypted = legacyDecryptSecret(expanded, dotenvPrivateKeys); + return decrypted.ok + ? undefined + : new LegacyDbConfigLoadError({ message: `failed to parse config: ${decrypted.error}` }); +}; + +/** + * Asserts every `config.Secret`-typed `encrypted:` value in the (merged) config can be + * decrypted, mirroring Go's global `DecryptSecretHookFunc`, which aborts the load with + * `failed to parse config: ` when a secret cannot be decrypted. Only the actual + * `Secret` field paths ({@link LEGACY_SECRET_PATHS}) are scanned — a non-secret string that + * merely starts with `encrypted:` (e.g. an auth email-template `subject`) stays plain text + * in Go and must not block the load. Go decodes every `[remotes.]` block into the same + * struct, so the same paths are checked under each remote too. Returns the failure (or + * `undefined`); the caller surfaces it via `Effect.fail`. */ const legacyAssertDecryptableSecrets = ( - value: unknown, + doc: unknown, lookup: EnvLookup, dotenvPrivateKeys: ReadonlyArray, ): LegacyDbConfigLoadError | undefined => { - if (typeof value === "string") { - const expanded = legacyExpandEnv(value, lookup); - if (ENV_PATTERN.test(expanded) || !legacyIsEncryptedSecret(expanded)) return undefined; - const decrypted = legacyDecryptSecret(expanded, dotenvPrivateKeys); - return decrypted.ok - ? undefined - : new LegacyDbConfigLoadError({ message: `failed to parse config: ${decrypted.error}` }); - } - if (Array.isArray(value)) { - for (const item of value) { - const error = legacyAssertDecryptableSecrets(item, lookup, dotenvPrivateKeys); - if (error !== undefined) return error; + const scan = (node: unknown): LegacyDbConfigLoadError | undefined => { + for (const segs of LEGACY_SECRET_PATHS) { + const values: Array = []; + legacyCollectSecretStrings(node, segs, 0, values); + for (const value of values) { + const error = legacyAssertSecretValue(value, lookup, dotenvPrivateKeys); + if (error !== undefined) return error; + } } return undefined; - } - const record = asRecord(value); - if (record !== undefined) { - for (const key of Object.keys(record)) { - const error = legacyAssertDecryptableSecrets(record[key], lookup, dotenvPrivateKeys); + }; + const topLevel = scan(doc); + if (topLevel !== undefined) return topLevel; + const remotes = asRecord(asRecord(doc)?.["remotes"]); + if (remotes !== undefined) { + for (const name of Object.keys(remotes)) { + const error = scan(remotes[name]); if (error !== undefined) return error; } } @@ -1173,7 +1272,7 @@ const legacyValidateAuthConfig = Effect.fnUntraced(function* ( * Fails with `LegacyDbConfigLoadError` only when the config file is present but * unparseable; an absent file (and an absent/empty pooler-url file) is not an error. */ -export const legacyReadDbToml = Effect.fnUntraced(function* ( +const readDbTomlCore = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, workdir: string, @@ -1183,6 +1282,12 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( // `--local` / `--db-url` / declarative pass nothing and read the unmerged config, // matching Go (those paths never resolve a ref before config load). ref?: string, + // Internal: when true the on-disk `config.toml` is treated as absent so the body + // resolves pure defaults (still honoring `SUPABASE_*` env overrides, which Go binds + // regardless of a config file). The lenient `legacyReadDbToml({ validate: false })` + // wrapper uses this as its fallback after a config-load failure, mirroring the + // best-effort behavior the container-id seam relied on before. + ignoreConfigFile = false, ) { const supabaseDir = path.join(workdir, "supabase"); const configPath = path.join(supabaseDir, "config.toml"); @@ -1192,18 +1297,20 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( // is swallowed, every other read error aborts rather than silently running against the // default local database. Effect surfaces "not found" as `PlatformError` with a // `SystemError` reason tagged `"NotFound"`. - const maybeContent = yield* fs.readFileString(configPath).pipe( - Effect.map(Option.some), - Effect.catchTag("PlatformError", (error) => - error.reason._tag === "NotFound" - ? Effect.succeed(Option.none()) - : Effect.fail( - new LegacyDbConfigLoadError({ - message: `failed to read file config: ${error.message}`, - }), - ), - ), - ); + const maybeContent = ignoreConfigFile + ? Option.none() + : yield* fs.readFileString(configPath).pipe( + Effect.map(Option.some), + Effect.catchTag("PlatformError", (error) => + error.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : Effect.fail( + new LegacyDbConfigLoadError({ + message: `failed to read file config: ${error.message}`, + }), + ), + ), + ); // Resolve `env(VAR)` against the shell env first, then the project `.env` files // (Go's `loadNestedEnv` populates the process env before `LoadEnvHook`). Built @@ -1228,9 +1335,16 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( let functionsRaw: RawDoc | undefined; let analyticsRaw: RawDoc | undefined; let projectId = Option.none(); + // Whether `config.toml` set a top-level `project_id` string that env-expanded to empty + // (`project_id = ""`). Go keeps that empty override and `config.Validate` fails with + // `Missing required field in config: project_id` (config.go:991); tracked here so the + // check can run after the `SUPABASE_PROJECT_ID` env override below may still rescue it. + let projectIdExplicitEmpty = false; // Config keys a matched remote block contributed at viper's override tier (Go's // `v.Set`), so they must beat the matching `SUPABASE_*` env overrides below. let remoteOverrideKeys: ReadonlySet = new Set(); + // The matched `[remotes.]` block name, echoed as Go's config-override line. + let appliedRemote: string | undefined; if (Option.isSome(maybeContent)) { let doc: RawDoc | undefined; try { @@ -1271,6 +1385,7 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( : applyRemoteOverride(doc, ref, lookup); const effectiveDoc = remoteOverride.doc; remoteOverrideKeys = remoteOverride.remoteOverrideKeys; + appliedRemote = remoteOverride.appliedRemote; db = asRecord(effectiveDoc?.["db"]); experimentalRaw = asRecord(effectiveDoc?.["experimental"]); pgDeltaRaw = asRecord(experimentalRaw?.["pgdelta"]); @@ -1289,6 +1404,8 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( projectId = nonEmptyString( typeof rawProjectId === "string" ? legacyExpandEnv(rawProjectId, lookup) : rawProjectId, ); + // A present `project_id` string that resolves to empty is Go's "kept empty override". + projectIdExplicitEmpty = typeof rawProjectId === "string" && Option.isNone(projectId); // Go's `DecryptSecretHookFunc` is a global decode hook (config.go:730) that decrypts // EVERY `config.Secret` field during `UnmarshalExact`, so an `encrypted:` secret anywhere @@ -1344,6 +1461,16 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( projectId = nonEmptyString(legacyExpandEnv(projectIdEnv, lookup)); } + // Go's `config.Validate` rejects an empty top-level `project_id` (config.go:991). An + // absent field is tolerated here (deferred), but a present `project_id = ""` that the + // `SUPABASE_PROJECT_ID` override did not rescue is a load error, so a destructive command + // (e.g. remote `db reset`) fails fast rather than dropping schemas on a config Go rejects. + if (projectIdExplicitEmpty && Option.isNone(projectId)) { + return yield* Effect.fail( + new LegacyDbConfigLoadError({ message: "Missing required field in config: project_id" }), + ); + } + // A present-but-unmarshalable port aborts in Go rather than defaulting; mirror // that so `test db --local` never silently targets the default local database // while hiding a broken `[db]` config. @@ -1770,13 +1897,9 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( : typeof rawSqlPaths === "string" ? splitGoSeedPaths(rawSqlPaths) : ["seed.sql"]; - const seedSqlPaths = sqlPathPatterns.map((pattern) => { - // Patterns are already env-expanded above (Go's LoadEnvHook runs before the split), so - // an absolute path is used verbatim and a relative one is supabase-prefixed via Go's - // `path.Join("supabase", pattern)` → `path.Clean` (collapses `.`/`..`). - if (pattern.length === 0 || path.isAbsolute(pattern)) return pattern; - return legacyJoinSupabaseSeedPath(pattern); - }); + // Patterns are already env-expanded above (Go's LoadEnvHook runs before the split); + // resolve each to Go's config-load form (absolute verbatim, relative supabase-joined). + const seedSqlPaths = sqlPathPatterns.map((pattern) => legacyResolveSeedSqlPath(path, pattern)); // `[db.vault]` secrets: env-expand each value, then decrypt dotenvx `encrypted:` // ciphertext. `resolved` mirrors Go's `len(SHA256) > 0` gate (Go sets SHA256 only @@ -1860,10 +1983,53 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( migrationsEnabled, seed: { enabled: seedEnabled, sqlPaths: seedSqlPaths }, vault, + appliedRemote, }; return values; }); +/** + * Read + validate `config.toml` exactly like Go's `flags.LoadConfig` → `config.Load` + * → `Validate`: an absent file yields defaults, but a present config that is + * unreadable/malformed, references an undecryptable `encrypted:` secret, or fails any + * of Go's decode/Validate checks (remote refs, `db.port`, `db.major_version`, + * `edge_runtime.deno_version`, pgdelta gate, `format_options` JSON, bucket names, + * function slugs, auth, analytics) aborts with the matching Go error. Call this at the + * point Go fails fast — before asserting the stack is running, prompting, or any + * destructive work — so a broken config never runs against the default local database. + */ +export const legacyCheckDbToml = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + ref?: string, +) => readDbTomlCore(fs, path, workdir, ref, false); + +/** + * Read `config.toml`. Defaults to Go's validating behavior (identical to + * {@link legacyCheckDbToml}); pass `{ validate: false }` for a best-effort read that + * never throws on an invalid config — a config-load failure falls back to pure + * defaults (env overrides still applied), matching the tolerant behavior the + * container-id seam needs when it only wants `projectId` and the handler has already + * validated the config. The throwing default keeps every existing caller at Go parity. + */ +export const legacyReadDbToml = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + ref?: string, + opts?: { readonly validate?: boolean }, +) => + opts?.validate === false + ? readDbTomlCore(fs, path, workdir, ref, false).pipe( + // Fall back to the ignore-file defaults path (never re-reads the broken config) + // so a best-effort caller gets a well-formed defaults result instead of a throw. + Effect.catchTag("LegacyDbConfigLoadError", () => + readDbTomlCore(fs, path, workdir, ref, true), + ), + ) + : readDbTomlCore(fs, path, workdir, ref, false); + /** * The effective declarative schema directory: the configured * `declarative_schema_path` (already `supabase/`-prefixed when relative) or the diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts index 986c8a6075..3fd179eaf8 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts @@ -7,6 +7,7 @@ import { Effect, Exit, FileSystem, Option, Path } from "effect"; import { legacyApplyProjectEnv, + legacyCheckDbToml, legacyLoadProjectEnv, legacyReadDbToml, legacyResolveDeclarativeDir, @@ -46,6 +47,69 @@ const loadEnv = (workdir: string) => return yield* legacyLoadProjectEnv(fs, path, workdir); }).pipe(Effect.provide(BunServices.layer)); +describe("read (lenient) vs check (throws) split", () => { + const withServices = ( + dir: string, + run: (fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect, + ) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* run(fs, path); + }).pipe(Effect.provide(BunServices.layer)); + + it.effect("legacyCheckDbToml throws on an undecryptable secret", () => { + const dir = withConfig('[db]\nroot_key = "encrypted:anything"\n'); + return withServices(dir, (fs, path) => legacyCheckDbToml(fs, path, dir)).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "failed to parse config: missing private key", + ); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect( + "legacyReadDbToml({ validate: false }) tolerates the same secret, returning defaults", + () => { + const dir = withConfig('[db]\nroot_key = "encrypted:anything"\n'); + return withServices(dir, (fs, path) => + legacyReadDbToml(fs, path, dir, undefined, { validate: false }), + ).pipe( + Effect.tap((v) => + Effect.sync(() => { + // The broken config is swallowed → the ignore-file defaults path (no vault). + expect(v.vault).toEqual([]); + expect(v.port).toBeGreaterThan(0); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect("legacyReadDbToml({ validate: false }) still returns a valid config's values", () => { + const dir = withConfig('project_id = "lenientproj"\n'); + return withServices(dir, (fs, path) => + legacyReadDbToml(fs, path, dir, undefined, { validate: false }), + ).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.projectId).toEqual(Option.some("lenientproj")); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); +}); + describe("legacyReadDbToml", () => { it.effect("returns defaults when config.toml is absent", () => { const dir = withConfig(undefined); @@ -2190,6 +2254,102 @@ describe("legacyReadDbToml encrypted secret decryption (Go DecryptSecretHookFunc it.effect("treats an unset env() secret as a no-op (verbatim, like Go's hook)", () => expectLoads(["[db]", 'root_key = "env(SOME_UNSET_ROOT_KEY)"']), ); + it.effect("does NOT decrypt a non-secret string that starts with encrypted:", () => + // Go's hook only runs while decoding into `config.Secret`; a non-secret field like an + // email-template subject stays plain text, so `db push`/`reset`/`start` must not abort. + expectLoads([ + "[auth.email.template.invite]", + 'subject = "encrypted: your invite"', + "[db]", + 'root_key = "env(SOME_UNSET_ROOT_KEY)"', + ]), + ); + it.effect("fails on an undecryptable auth.captcha.secret (Secret-typed field)", () => + expectFails( + [ + "[auth.captcha]", + "enabled = false", + 'provider = "hcaptcha"', + 'secret = "encrypted:anything"', + ], + "failed to parse config: missing private key", + ), + ); + it.effect("fails on an undecryptable [edge_runtime.secrets] value (map[string]Secret)", () => + expectFails( + ["[edge_runtime.secrets]", 'MY_SECRET = "encrypted:anything"'], + "failed to parse config: missing private key", + ), + ); + it.effect("fails on an undecryptable Secret inside a [remotes.*] block", () => + // Go decodes every remote block into the same struct, so an undecryptable secret in any + // remote (matched or not) aborts the load. + expectFails( + [ + "[remotes.preview]", + 'project_id = "abcdefghijklmnopqrst"', + "[remotes.preview.db]", + 'root_key = "encrypted:anything"', + ], + "failed to parse config: missing private key", + ), + ); +}); + +describe("legacyReadDbToml non-scalar config booleans (Go UnmarshalExact parity)", () => { + // Go's `UnmarshalExact` fails to decode a bool field given an array/inline-table value, + // aborting `LoadConfig` before any destructive work. A present non-scalar must not fall + // through to the schema default (which would let `db reset` prompt + drop schemas). + const failsInvalid = (lines: ReadonlyArray, field: string) => + Effect.gen(function* () { + const dir = withConfig(lines.join("\n")); + const exit = yield* read(dir).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain(`failed to parse config: invalid ${field}.`); + } + rmSync(dir, { recursive: true, force: true }); + }); + it.effect("rejects an array value for [db.migrations] enabled", () => + failsInvalid(["[db.migrations]", "enabled = []"], "db.migrations.enabled"), + ); + it.effect("rejects an inline-table value for [db.seed] enabled", () => + failsInvalid(["[db.seed]", "enabled = {}"], "db.seed.enabled"), + ); +}); + +describe("legacyReadDbToml empty project_id (Go config.Validate parity)", () => { + it.effect("rejects a present-but-empty top-level project_id", () => { + // Go keeps the empty override and `config.Validate` fails "Missing required field in + // config: project_id" (config.go:991) before any destructive command runs. + const dir = withConfig('project_id = ""\n'); + return read(dir).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "Missing required field in config: project_id", + ); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("still tolerates an absent project_id (deferred broader requirement)", () => { + const dir = withConfig("[db]\nmajor_version = 15\n"); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.baseline).toBeDefined(); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); }); describe("legacyReadDbToml [analytics] validation (Go config.Validate parity)", () => { diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.service.ts b/apps/cli/src/legacy/shared/legacy-db-connection.service.ts index bbf1e81dbf..d2bfb2b261 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.service.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.service.ts @@ -1,4 +1,5 @@ import { Context, type Effect, type Scope } from "effect"; +import type { LegacyConnectSuggestionContext } from "./legacy-connect-errors.ts"; import type { LegacyDbConnectError, LegacyDbCopyError, @@ -71,6 +72,13 @@ export interface LegacyPgConnInput { * `ToPostgresURL`/`ConnectLocalPostgres`). */ readonly connectTimeoutSeconds?: number; + /** + * Profile context for the connect-failure suggestion (Go's `SetConnectSuggestion`, + * which reads the ambient `CurrentProfile` in `ConnectByUrl`). The resolver attaches + * it so the driver layer can map a refused/auth/IPv6 connect error to Go's actionable + * hint. Absent → the driver omits the suggestion (callers fall back to the generic one). + */ + readonly suggestionContext?: LegacyConnectSuggestionContext; } /** diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index fbd5bfca90..f291140c54 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -10,6 +10,7 @@ import * as Reactivity from "effect/unstable/reactivity/Reactivity"; // resolves, so the COPY path and the pooled path use the same driver. import * as Pg from "pg"; import { to as pgCopyTo } from "pg-copy-streams"; +import { legacyConnectSuggestion } from "./legacy-connect-errors.ts"; import { LegacyDbConnectError, LegacyDbCopyError, @@ -407,8 +408,20 @@ const connect = ( connectionTimeoutMillis: connectTimeoutSeconds * 1000, }); - const toConnectError = (error: unknown) => - new LegacyDbConnectError({ message: `failed to connect to postgres: ${error}` }); + // Go's `ConnectByUrl` calls `SetConnectSuggestion(err)` on every connect failure + // (`connect.go:187`), mapping the driver error to an actionable hint that replaces + // the generic "--debug" suggestion. The resolver attaches the profile context to + // `cfg.suggestionContext`; map it here so the suggestion travels on the error. + const toConnectError = (error: unknown) => { + const suggestion = + cfg.suggestionContext === undefined + ? undefined + : legacyConnectSuggestion(error, cfg.suggestionContext); + return new LegacyDbConnectError({ + message: `failed to connect to postgres: ${error}`, + ...(suggestion === undefined ? {} : { suggestion }), + }); + }; // Load the `sslrootcert` CA bundle (pgconn reads it into `RootCAs` at parse // time; a missing/unreadable file aborts). Skipped for local connections, which @@ -548,8 +561,7 @@ const connect = ( const fresh = new Pg.Client(winningRawConfig); yield* Effect.tryPromise({ try: () => fresh.connect(), - catch: (error) => - new LegacyDbConnectError({ message: `failed to connect to postgres: ${error}` }), + catch: toConnectError, }); if (!isLocal && needsRoleStepDown(cfg.user)) { yield* Effect.tryPromise({ diff --git a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts index 2f47d3c84e..eb891c924a 100644 --- a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts +++ b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts @@ -49,6 +49,8 @@ export interface LegacyDbTargetSelection { export const VALUE_CONSUMING_LONG_FLAGS = new Set([ // db-family command flags "db-url", + "password", // db push/pull/dump/remote (StringVarP, short -p) + "sql-paths", "schema", "level", "fail-on", @@ -79,7 +81,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ export const VALUE_CONSUMING_SHORT_FLAGS = new Set([ "s", // --schema / -s "o", // --output / -o - "p", // --password / -p + "p", // --password / -p (migration list, db push/pull/dump/remote) "j", // --jobs / -j (storage cp) ]); diff --git a/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts b/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts index f9b301821e..915268b704 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts @@ -7,13 +7,10 @@ import { legacyApplyBitbucketDockerFilter, } from "./legacy-docker-run.args.ts"; import { LegacyDockerRunError } from "./legacy-docker-run.errors.ts"; +import { LEGACY_SUGGEST_DOCKER_INSTALL } from "./legacy-docker-suggest.ts"; import { legacyGetRegistryImageUrlCandidates } from "./legacy-docker-registry.ts"; import { LegacyDockerRun, type LegacyDockerRunOpts } from "./legacy-docker-run.service.ts"; -// Go's prerequisite hint (`apps/cli-go/internal/utils/docker.go:248`). -const SUGGEST_DOCKER_INSTALL = - "Docker Desktop is a prerequisite for local development. Follow the official docs to install: https://docs.docker.com/desktop"; - // Go's `DockerStart` checks `os.Getenv("BITBUCKET_CLONE_DIR") != ""` // (`apps/cli-go/internal/utils/docker.go:289`) to drop named volumes / security-opts. const legacyIsBitbucketPipeline = (): boolean => { @@ -49,7 +46,9 @@ export const legacyDockerRunLayer: Layer.Layer< // Never embed the spawn error verbatim: it can leak the full argv and // environment of the failed exec (CWE-214/209). Emit a fixed, // credential-free message that still points at the likely cause. - new LegacyDockerRunError({ message: `failed to run docker. ${SUGGEST_DOCKER_INSTALL}` }); + new LegacyDockerRunError({ + message: `failed to run docker. ${LEGACY_SUGGEST_DOCKER_INSTALL}`, + }); const concat = (chunks: ReadonlyArray): Uint8Array => { const total = chunks.reduce((size, chunk) => size + chunk.length, 0); @@ -309,7 +308,7 @@ export const legacyDockerRunLayer: Layer.Layer< Effect.mapError( () => new LegacyDockerRunError({ - message: `failed to run docker. ${SUGGEST_DOCKER_INSTALL}`, + message: `failed to run docker. ${LEGACY_SUGGEST_DOCKER_INSTALL}`, }), ), ); diff --git a/apps/cli/src/legacy/shared/legacy-docker-suggest.ts b/apps/cli/src/legacy/shared/legacy-docker-suggest.ts new file mode 100644 index 0000000000..76397c3ab1 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-docker-suggest.ts @@ -0,0 +1,21 @@ +/** + * Go's Docker prerequisite hint (`apps/cli-go/internal/utils/docker.go:350`, + * `suggestDockerInstall`). Go sets it as `CmdSuggestion` — rendered as a separate + * "Suggestion:" line — whenever a container-runtime call fails because the daemon + * is unreachable (`client.IsErrConnectionFailed`, `misc.go:148-154`). + */ +export const LEGACY_SUGGEST_DOCKER_INSTALL = + "Docker Desktop is a prerequisite for local development. Follow the official docs to install: https://docs.docker.com/desktop"; + +/** + * Whether a container-CLI stderr indicates the daemon is unreachable — the + * subprocess-stderr equivalent of Go's `client.IsErrConnectionFailed` (which + * inspects the Docker API client error). The docker / podman CLIs print + * "Cannot connect to the Docker daemon …" / "Cannot connect to Podman …" (often + * followed by "Is the docker daemon running?") when the socket is down. + */ +export function legacyIsDockerDaemonUnreachable(stderr: string): boolean { + return /cannot connect to the docker daemon|cannot connect to podman|is the docker daemon running/iu.test( + stderr, + ); +} diff --git a/apps/cli/src/legacy/shared/legacy-docker-suggest.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-suggest.unit.test.ts new file mode 100644 index 0000000000..0fcadd4f22 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-docker-suggest.unit.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { + LEGACY_SUGGEST_DOCKER_INSTALL, + legacyIsDockerDaemonUnreachable, +} from "./legacy-docker-suggest.ts"; + +describe("legacyIsDockerDaemonUnreachable", () => { + it("detects the docker/podman daemon-down CLI messages (Go's IsErrConnectionFailed)", () => { + expect( + legacyIsDockerDaemonUnreachable( + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", + ), + ).toBe(true); + // Case-insensitive + the podman phrasing. + expect(legacyIsDockerDaemonUnreachable("cannot connect to podman")).toBe(true); + expect(legacyIsDockerDaemonUnreachable("Is the docker daemon running?")).toBe(true); + }); + + it("does not flag an unrelated inspect failure (e.g. a permission error)", () => { + expect(legacyIsDockerDaemonUnreachable("permission denied while trying to connect")).toBe( + false, + ); + expect(legacyIsDockerDaemonUnreachable("Error: No such container: supabase_db_x")).toBe(false); + expect(legacyIsDockerDaemonUnreachable("")).toBe(false); + }); + + it("exposes Go's install hint verbatim", () => { + expect(LEGACY_SUGGEST_DOCKER_INSTALL).toContain("https://docs.docker.com/desktop"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index b9b376bea5..3ca0812bdd 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -1,5 +1,6 @@ import { Data, Effect, type FileSystem, type Path } from "effect"; +import { Output } from "../../shared/output/output.service.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { INSERT_MIGRATION_VERSION, @@ -60,7 +61,7 @@ const legacyTrimLeadingSqlComments = (sql: string): string => { * Whether a migration statement cannot run inside a transaction block — `CREATE * [UNIQUE] INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, * `CLUSTER`. Such statements fail with SQLSTATE 25001 inside the `BEGIN`/`COMMIT` - * that wraps a migration, so `legacyApplyMigrationFile` runs them standalone. + * that wraps a migration, so `execMigrationBatch` runs them standalone. * Port of Go's `isPipelineIncompatible` (`pkg/migration/file.go`, supabase/cli#5156). */ export const legacyIsPipelineIncompatible = (sql: string): boolean => { @@ -79,55 +80,45 @@ type LegacyBatchItem = | { readonly kind: "exec"; readonly sql: string } | { readonly kind: "version" }; +const errMessage = (e: unknown): string => + typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" + ? e.message + : String(e); + /** - * Applies a single migration file to the connected database and records it in - * `supabase_migrations.schema_migrations`. Mirrors Go's `migration.ApplyMigrations` - * for one file (`pkg/migration/apply.go` + `(*MigrationFile).ExecBatch`): `RESET ALL` - * first to clear any session state leaked by a prior file, then create the history - * table, then run the file's statements + the history insert. - * - * Statements run inside a `BEGIN`/`COMMIT` batch, except pipeline-incompatible ones + * Runs a single migration/seed file's statements (plus the optional history insert). + * Mirrors Go's `(*MigrationFile).ExecBatch` (`pkg/migration/file.go`): statements run + * inside a `BEGIN`/`COMMIT` batch, except pipeline-incompatible ones * (`legacyIsPipelineIncompatible` — `CREATE INDEX CONCURRENTLY`, `VACUUM`, …) which - * cannot run in a transaction block: the batch is flushed (committed), the statement - * runs standalone, then batching resumes — mirroring Go's `ExecBatch` flush logic - * (supabase/cli#5156). The history insert goes in the final batch, so the migration - * is recorded only after every statement succeeds. A file with no such statements is - * a single `BEGIN`/`COMMIT` around everything, identical to the pre-fix behaviour. + * cannot run in a transaction block: the open batch is flushed (committed), the + * statement runs standalone, then batching resumes (supabase/cli#5156). The history + * insert goes in the final batch, so the migration is recorded only after every + * statement succeeds. A file with no such statements is a single `BEGIN`/`COMMIT`. * - * `mapError` lets the caller tag the failure (e.g. `LegacyDeclarativeApplyError`). + * Does NOT create the history table and does NOT `RESET ALL` — Go's `ExecBatch` does + * neither; those are the migration-apply path's responsibility (`ApplyMigrations`, + * apply.go:65-69), so role/globals files (`legacySeedGlobals`) stay reset-free like Go. + * When `forceNoVersion` is set the history insert is skipped regardless of filename + * (Go's `SeedGlobals` clears `Version`). */ -export const legacyApplyMigrationFile = ( +const execMigrationBatch = ( session: LegacyDbSession, fs: FileSystem.FileSystem, path: Path.Path, migrationPath: string, mapError: (message: string) => E, + forceNoVersion: boolean, ): Effect.Effect => Effect.gen(function* () { const content = yield* fs.readFileString(migrationPath); const statements = legacySplitAndTrim(content); const filename = path.basename(migrationPath); const matches = MIGRATE_FILE_PATTERN.exec(filename); - const version = matches?.[1] ?? ""; + const version = forceNoVersion ? "" : (matches?.[1] ?? ""); const name = matches?.[2] ?? ""; - // `RESET ALL` runs FIRST, before the history-table DDL: an earlier migration applied - // on this same connection may have left a session default (e.g. - // `SET default_transaction_read_only = on`) that would otherwise make this DDL fail - // before it is cleared. Go resets connection state at the top of each file's apply, - // ahead of any work (`apps/cli-go/pkg/migration/apply.go:65-69`). - yield* session.exec("RESET ALL"); - yield* legacyCreateMigrationTable(session); - // Mirror Go's `MigrationFile.ExecBatch` error context (`pkg/migration/file.go`): - // on a failed statement, append `At statement: ` and the statement text so the - // error (and the debug bundle) point at the exact failing SQL. (Go also adds a caret / - // pgErr.Detail / extension-type hint, which need the driver SQLSTATE the session does - // not currently surface — the statement number + text is the always-present context.) - const errMessage = (e: unknown): string => - typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" - ? e.message - : String(e); + // on a failed statement, append `At statement: ` and the statement text. const atStatement = (e: unknown, index: number, stat: string) => new Error(`${errMessage(e)}\nAt statement: ${index}\n${stat}`); @@ -183,10 +174,91 @@ export const legacyApplyMigrationFile = ( pending = [...pending, { kind: "version" }]; } yield* flushBatch; - }).pipe( - Effect.mapError((error) => - mapError( - "message" in error && typeof error.message === "string" ? error.message : String(error), - ), - ), - ); + }).pipe(Effect.mapError((error) => mapError(errMessage(error)))); + +/** + * Go's per-migration connection reset (`apply.go:65-69`): `RESET ALL` clears any + * connection settings a prior statement on the same session may have changed + * (e.g. `set_config('search_path', …)`), run before each migration's `ExecBatch`. + * Only the migration-apply path does this — `SeedGlobals` (role/globals files) + * must NOT, so this is a caller responsibility, never inside `execMigrationBatch`. + */ +const resetConnectionState = ( + session: LegacyDbSession, + mapError: (message: string) => E, +): Effect.Effect => + session.exec("RESET ALL").pipe(Effect.mapError((e) => mapError(errMessage(e)))); + +/** + * Applies a single migration file to the connected database and records it in + * `supabase_migrations.schema_migrations`. Mirrors Go's `migration.ApplyMigrations` + * for one file (`pkg/migration/apply.go` + `(*MigrationFile).ExecBatch`): `RESET ALL` + * first to clear any session state leaked by a prior file (e.g. + * `SET default_transaction_read_only = on`) before the history-table DDL, then create + * the history table, then run the file's statements + the history insert. + * + * `mapError` lets the caller tag the failure (e.g. `LegacyDeclarativeApplyError`). + */ +export const legacyApplyMigrationFile = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + migrationPath: string, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + yield* resetConnectionState(session, mapError); + yield* legacyCreateMigrationTable(session).pipe( + Effect.mapError((e) => mapError(errMessage(e))), + ); + yield* execMigrationBatch(session, fs, path, migrationPath, mapError, false); + }); + +/** + * Applies a list of pending migration files, mirroring Go's + * `migration.ApplyMigrations` (`pkg/migration/apply.go:56-77`): create the + * history table once when there is anything to apply, then for each file emit + * `Applying migration ...` to stderr, `RESET ALL`, and run it transactionally. + */ +export const legacyApplyMigrations = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + pending: ReadonlyArray, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + const output = yield* Output; + if (pending.length === 0) return; + yield* legacyCreateMigrationTable(session).pipe( + Effect.mapError((e) => mapError(errMessage(e))), + ); + for (const migrationPath of pending) { + yield* output.raw(`Applying migration ${path.basename(migrationPath)}...\n`, "stderr"); + // Go resets connection state per migration (apply.go:65-69) before ExecBatch. + yield* resetConnectionState(session, mapError); + yield* execMigrationBatch(session, fs, path, migrationPath, mapError, false); + } + }); + +/** + * Applies custom-role / globals files, mirroring Go's `migration.SeedGlobals` + * (`pkg/migration/seed.go:85-100`): for each file emit `Seeding globals from + * ...` to stderr and run it transactionally WITHOUT inserting a migration + * history row (Go clears `Version`), WITHOUT creating the history table, and WITHOUT + * `RESET ALL` (Go's `SeedGlobals` → `ExecBatch` never resets). + */ +export const legacySeedGlobals = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + globals: ReadonlyArray, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + const output = yield* Output; + for (const globalPath of globals) { + yield* output.raw(`Seeding globals from ${path.basename(globalPath)}...\n`, "stderr"); + yield* execMigrationBatch(session, fs, path, globalPath, mapError, true); + } + }); diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index 567be21e75..a5039843de 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -5,10 +5,12 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Data, Effect, Exit, FileSystem, Path } from "effect"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { legacyApplyMigrationFile, legacyIsPipelineIncompatible, + legacySeedGlobals, } from "./legacy-migration-apply.ts"; class TestError extends Data.TaggedError("TestError")<{ readonly message: string }> {} @@ -230,3 +232,27 @@ describe("legacyIsPipelineIncompatible", () => { expect(legacyIsPipelineIncompatible(sql)).toBe(want); }); }); + +describe("legacySeedGlobals", () => { + it.effect("runs the globals file WITHOUT RESET ALL and without a history insert", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-globals-")); + const file = join(dir, "roles.sql"); + writeFileSync(file, "CREATE ROLE my_role;"); + const { session, calls } = fakeSession(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacySeedGlobals(session, fs, path, [file], (message) => new TestError({ message })); + const execs = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + // Go's SeedGlobals calls ExecBatch directly — no RESET ALL (that's only the + // migration-apply path) and no schema-migrations history insert. + expect(execs).not.toContain("RESET ALL"); + expect(execs).toContain("CREATE ROLE my_role"); + expect(calls.some((c) => c.kind === "query")).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(BunServices.layer), + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts b/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts index 64ba6368a6..81499e2b72 100644 --- a/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts +++ b/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts @@ -29,6 +29,9 @@ export const legacyParseYesNo = (input: string): boolean | undefined => { * `db pull`, `seed buckets`, and `storage rm`: * - when `yes` is set, echoes `

Release notes

Sourced from google.golang.org/grpc's releases.

Release 1.82.0

Behavior Changes

  • server: Remove support for GRPC_GO_EXPERIMENTAL_DISABLE_STRICT_PATH_CHECKING environment varibale. Strict incoming RPC path validation (which has been the default since v1.79.3) can no longer be disabled. (#9112)
  • transport: Add environment variable to change the default max header list size from 16MB to 8KB. This may be enabled by setting GRPC_GO_EXPERIMENTAL_ENABLE_8KB_DEFAULT_HEADER_LIST_SIZE=true. This will be enabled by default in a subsequent release. (#9019)
  • balancer: Load Balancing policy registry is now case-sensitive. Set GRPC_GO_EXPERIMENTAL_CASE_SENSITIVE_BALANCER_REGISTRIES=false (and file an issue) to revert to case-insensitive behavior. (#9017)

New Features

  • experimental/stats: Expose a new API, NewContextWithLabelCallback, to register a callback that is invoked when telemetry labels are added. (#8877)
  • client: Return a portion of the response body in the error message, when the client receives an unexpected non-gRPC HTTP response, to make debugging easier. (#8929)
  • server: Add environment variable GRPC_GO_SERVER_GOROUTINE_LABELS that controls setting runtime/pprof.Labels on goroutines spawned by the server. Set GRPC_GO_SERVER_GOROUTINE_LABELS=grpc.method=true to add the grpc.method label on goroutines spawned to handle incoming requests. (#9082)

Bug Fixes

  • xds/server: Fix a memory leak of HTTP filter instances occurring when route configurations are updated in-place during a Route Discovery Service (RDS) update. (#9138)
  • grpc: In the deprecated gzip Compressor (used via the deprecated WithCompressor dial option), enforce the MaxRecvMsgSize limit on the decompressed message buffer, preventing excessive memory allocation from highly compressed payloads. (#9114)
  • stats/opentelemetry: Record retry attempts, grpc.previous-rpc-attempts, at the call level and not the attempt level. (#8923)
  • encoding: Ensure Close() is always called on readers returned from Compressor.Decompress if possible. (#9135)
  • channelz: Fix the LastMessageSentTimestamp and LastMessageReceivedTimestamp fields in SocketMetrics to ensure they contain correct timestamp values. (#9109)
Commits
  • bd23985 Change version to 1.82.0 (#9170)
  • 0f3086d Fix minor issues not covered by PR #9137 (#9147)
  • fef07fb internal: Split v3procservicepb import into pb and grpc for extproc (#9163)
  • 91dd64f transport: surface subsequent data when receiving non-gRPC header (#8929)
  • adc97de test/kokoro: add config for regional-td test (#9158)
  • 57c9ff1 xds: ensure full-string matching for RBAC Filter rules (#9148)
  • b58f32d server: Set a pprof label on new stream goroutines (#9082)
  • 6c98be3 refactor(transport): extract shared stream state handling logic in `loopyWrit...
  • bcaa6f4 rls: only reset backoff on recovery from TRANSIENT_FAILURE (#9137)
  • 429e6e0 balancer: expose endpoint weight and hostname as experimental APIs (#9074)
  • Additional commits viewable in compare view

Updates `google.golang.org/grpc` from 1.81.1 to 1.82.0
Release notes

Sourced from google.golang.org/grpc's releases.

Release 1.82.0

Behavior Changes

  • server: Remove support for GRPC_GO_EXPERIMENTAL_DISABLE_STRICT_PATH_CHECKING environment varibale. Strict incoming RPC path validation (which has been the default since v1.79.3) can no longer be disabled. (#9112)
  • transport: Add environment variable to change the default max header list size from 16MB to 8KB. This may be enabled by setting GRPC_GO_EXPERIMENTAL_ENABLE_8KB_DEFAULT_HEADER_LIST_SIZE=true. This will be enabled by default in a subsequent release. (#9019)
  • balancer: Load Balancing policy registry is now case-sensitive. Set GRPC_GO_EXPERIMENTAL_CASE_SENSITIVE_BALANCER_REGISTRIES=false (and file an issue) to revert to case-insensitive behavior. (#9017)

New Features

  • experimental/stats: Expose a new API, NewContextWithLabelCallback, to register a callback that is invoked when telemetry labels are added. (#8877)
  • client: Return a portion of the response body in the error message, when the client receives an unexpected non-gRPC HTTP response, to make debugging easier. (#8929)
  • server: Add environment variable GRPC_GO_SERVER_GOROUTINE_LABELS that controls setting runtime/pprof.Labels on goroutines spawned by the server. Set GRPC_GO_SERVER_GOROUTINE_LABELS=grpc.method=true to add the grpc.method label on goroutines spawned to handle incoming requests. (#9082)

Bug Fixes

  • xds/server: Fix a memory leak of HTTP filter instances occurring when route configurations are updated in-place during a Route Discovery Service (RDS) update. (#9138)
  • grpc: In the deprecated gzip Compressor (used via the deprecated WithCompressor dial option), enforce the MaxRecvMsgSize limit on the decompressed message buffer, preventing excessive memory allocation from highly compressed payloads. (#9114)
  • stats/opentelemetry: Record retry attempts, grpc.previous-rpc-attempts, at the call level and not the attempt level. (#8923)
  • encoding: Ensure Close() is always called on readers returned from Compressor.Decompress if possible. (#9135)
  • channelz: Fix the LastMessageSentTimestamp and LastMessageReceivedTimestamp fields in SocketMetrics to ensure they contain correct timestamp values. (#9109)
Commits
  • bd23985 Change version to 1.82.0 (#9170)
  • 0f3086d Fix minor issues not covered by PR #9137 (#9147)
  • fef07fb internal: Split v3procservicepb import into pb and grpc for extproc (#9163)
  • 91dd64f transport: surface subsequent data when receiving non-gRPC header (#8929)
  • adc97de test/kokoro: add config for regional-td test (#9158)
  • 57c9ff1 xds: ensure full-string matching for RBAC Filter rules (#9148)
  • b58f32d server: Set a pprof label on new stream goroutines (#9082)
  • 6c98be3 refactor(transport): extract shared stream state handling logic in `loopyWrit...
  • bcaa6f4 rls: only reset backoff on recovery from TRANSIENT_FAILURE (#9137)
  • 429e6e0 balancer: expose endpoint weight and hostname as experimental APIs (#9074)
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli-go/go.mod | 2 +- apps/cli-go/go.sum | 4 ++-- apps/cli-go/pkg/go.mod | 8 ++++---- apps/cli-go/pkg/go.sum | 16 ++++++++-------- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/cli-go/go.mod b/apps/cli-go/go.mod index 82b8ea4526..507f7f885f 100644 --- a/apps/cli-go/go.mod +++ b/apps/cli-go/go.mod @@ -58,7 +58,7 @@ require ( golang.org/x/net v0.56.0 golang.org/x/oauth2 v0.36.0 golang.org/x/term v0.44.0 - google.golang.org/grpc v1.81.1 + google.golang.org/grpc v1.82.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/apps/cli-go/go.sum b/apps/cli-go/go.sum index f929d36494..bdde20104c 100644 --- a/apps/cli-go/go.sum +++ b/apps/cli-go/go.sum @@ -1441,8 +1441,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.0.5/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/apps/cli-go/pkg/go.mod b/apps/cli-go/pkg/go.mod index 3de3c61ae8..eca10c84ab 100644 --- a/apps/cli-go/pkg/go.mod +++ b/apps/cli-go/pkg/go.mod @@ -26,7 +26,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tidwall/jsonc v0.3.3 golang.org/x/mod v0.37.0 - google.golang.org/grpc v1.81.1 + google.golang.org/grpc v1.82.0 ) require ( @@ -51,8 +51,8 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/apps/cli-go/pkg/go.sum b/apps/cli-go/pkg/go.sum index 8394b09a98..c622bcdf1e 100644 --- a/apps/cli-go/pkg/go.sum +++ b/apps/cli-go/pkg/go.sum @@ -217,8 +217,8 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= @@ -255,8 +255,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -272,8 +272,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= @@ -290,8 +290,8 @@ golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= From bfcb4c4537059970395127318013ff31e6001816 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:13:46 +0000 Subject: [PATCH 15/49] chore(ci): bump the actions-major group with 2 updates (#5824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the actions-major group with 2 updates: [docker/build-push-action](https://github.com/docker/build-push-action) and [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action). Updates `docker/build-push-action` from 7.2.0 to 7.3.0
Release notes

Sourced from docker/build-push-action's releases.

v7.3.0

Full Changelog: https://github.com/docker/build-push-action/compare/v7.2.0...v7.3.0

Commits
  • 53b7df9 Merge pull request #1572 from docker/dependabot/npm_and_yarn/docker/actions-t...
  • 154298c [dependabot skip] chore: update generated content
  • cb1238b chore(deps): Bump @​docker/actions-toolkit from 0.91.0 to 0.92.0
  • 24f845d Merge pull request #1566 from docker/dependabot/npm_and_yarn/js-yaml-4.2.0
  • 9c69730 [dependabot skip] chore: update generated content
  • bc3a3a5 Merge pull request #1574 from docker/dependabot/github_actions/aws-actions/co...
  • a82c504 chore(deps): Bump js-yaml from 4.1.1 to 4.3.0
  • 0285a75 Merge pull request #1573 from docker/dependabot/github_actions/actions/cache-...
  • c6ad2a3 Merge pull request #1575 from docker/dependabot/github_actions/actions/checko...
  • d37484f Merge pull request #1564 from docker/dependabot/npm_and_yarn/undici-6.27.0
  • Additional commits viewable in compare view

Updates `docker/setup-qemu-action` from 4.1.0 to 4.2.0
Release notes

Sourced from docker/setup-qemu-action's releases.

v4.2.0

Full Changelog: https://github.com/docker/setup-qemu-action/compare/v4.1.0...v4.2.0

Commits
  • 96fe6ef Merge pull request #315 from docker/dependabot/npm_and_yarn/docker/actions-to...
  • 31f08d3 [dependabot skip] chore: update generated content
  • 4e7017a build(deps): bump @​docker/actions-toolkit from 0.91.0 to 0.92.0
  • 0eca235 Merge pull request #314 from crazy-max/fix-yarn-preapprove-actions-toolkit
  • ea66a41 chore: allow actions-toolkit to bypass yarn age gate
  • 451542b Merge pull request #308 from docker/dependabot/npm_and_yarn/undici-6.27.0
  • 532ae00 [dependabot skip] chore: update generated content
  • b6f5af6 build(deps): bump undici from 6.26.0 to 6.27.0
  • cf96b86 Merge pull request #304 from docker/dependabot/npm_and_yarn/tmp-0.2.7
  • f0ba643 [dependabot skip] chore: update generated content
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cli-go-pg-prove.yml | 4 ++-- .github/workflows/cli-go-publish-migra.yml | 4 ++-- .github/workflows/release-shared.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cli-go-pg-prove.yml b/.github/workflows/cli-go-pg-prove.yml index 14202acc5c..4cd2b086e5 100644 --- a/.github/workflows/cli-go-pg-prove.yml +++ b/.github/workflows/cli-go-pg-prove.yml @@ -12,7 +12,7 @@ jobs: image_tag: supabase/pg_prove:${{ steps.version.outputs.pg_prove }} steps: - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: load: true context: https://github.com/horrendo/pg_prove.git @@ -50,7 +50,7 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - id: build - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: push: true context: https://github.com/horrendo/pg_prove.git diff --git a/.github/workflows/cli-go-publish-migra.yml b/.github/workflows/cli-go-publish-migra.yml index 5220a0562d..95449a8baa 100644 --- a/.github/workflows/cli-go-publish-migra.yml +++ b/.github/workflows/cli-go-publish-migra.yml @@ -12,7 +12,7 @@ jobs: image_tag: supabase/migra:${{ steps.version.outputs.migra }} steps: - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: load: true context: https://github.com/djrobstep/migra.git @@ -50,7 +50,7 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - id: build - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: push: true context: https://github.com/djrobstep/migra.git diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 79a3175805..a2e30ccfe0 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -160,7 +160,7 @@ jobs: - name: Setup QEMU for cross-platform Docker if: runner.os == 'Linux' - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - name: Verify linux/arm64 emulation is registered if: runner.os == 'Linux' From 38a2a98e47bd4121e8517c148a66f625f4e0cc15 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:14:24 +0000 Subject: [PATCH 16/49] fix(deps): bump the npm-major group with 6 updates (#5823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the npm-major group with 6 updates: | Package | From | To | | --- | --- | --- | | [@supabase/supabase-js](https://github.com/supabase/supabase-js/tree/HEAD/packages/core/supabase-js) | `2.108.2` | `2.110.0` | | [@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript) | `0.3.196` | `0.3.197` | | [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript) | `0.107.0` | `0.109.0` | | [posthog-node](https://github.com/PostHog/posthog-js/tree/HEAD/packages/node) | `5.38.8` | `5.39.1` | | [@typescript/native-preview](https://github.com/microsoft/typescript-go) | `7.0.0-dev.20260629.1` | `7.0.0-dev.20260630.1` | | [oxlint-tsgolint](https://github.com/oxc-project/tsgolint) | `0.23.0` | `0.24.0` | Updates `@supabase/supabase-js` from 2.108.2 to 2.110.0
Release notes

Sourced from @​supabase/supabase-js's releases.

v2.110.0

2.110.0 (2026-06-30)

🚀 Features

  • repo: drop Node.js 20 support (#2482)

❤️ Thank You

v2.110.0-canary.0

2.110.0-canary.0 (2026-06-30)

🚀 Features

  • repo: drop Node.js 20 support (#2482)

❤️ Thank You

v2.109.0

2.109.0 (2026-06-30)

🚀 Features

  • auth: add custom_claims_allowlist to custom providers admin API (#2473)
  • realtime: add postgres_changes filter builder, new operators and select (#2463)
  • storage: expose purgeCache for buckets and single objects (#2429)

🩹 Fixes

  • functions: honor a caller's Content-Type override regardless of casing (#2455)
  • realtime: pin @​supabase/phoenix and browser test CDN deps (#2457)
  • realtime: add replication connection system message option (#2470)
  • storage: keep sortBy defaults when list() is given a partial sortBy (#2454)

❤️ Thank You

v2.108.3-canary.2

2.108.3-canary.2 (2026-06-19)

... (truncated)

Changelog

Sourced from @​supabase/supabase-js's changelog.

2.110.0 (2026-06-30)

🚀 Features

  • repo: drop Node.js 20 support (#2482)

❤️ Thank You

2.109.0 (2026-06-30)

🩹 Fixes

  • realtime: pin @​supabase/phoenix and browser test CDN deps (#2457)

❤️ Thank You

Commits

Updates `@anthropic-ai/claude-agent-sdk` from 0.3.196 to 0.3.197
Release notes

Sourced from @​anthropic-ai/claude-agent-sdk's releases.

v0.3.197

What's changed

  • Updated to parity with Claude Code v2.1.197

Update

npm install @anthropic-ai/claude-agent-sdk@0.3.197
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.197
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.197
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.197
Changelog

Sourced from @​anthropic-ai/claude-agent-sdk's changelog.

0.3.197

  • Updated to parity with Claude Code v2.1.197
Commits

Updates `@anthropic-ai/sdk` from 0.107.0 to 0.109.0
Release notes

Sourced from @​anthropic-ai/sdk's releases.

sdk: v0.109.0

0.109.0 (2026-06-30)

Full Changelog: sdk-v0.108.0...sdk-v0.109.0

Features

  • api: add support for Managed Agents event delta streaming, agent overrides, reverse pagination, vault credential injection scoping, and agent and deployment webhook events (7f3211b)

sdk: v0.108.0

0.108.0 (2026-06-30)

Full Changelog: sdk-v0.107.0...sdk-v0.108.0

Features

  • api: add support for claude-sonnet-5 (4588db0)

Bug Fixes

  • agent-toolset: allow absolute paths that resolve inside workdir (#112) (e951fb2)

Chores

Changelog

Sourced from @​anthropic-ai/sdk's changelog.

0.109.0 (2026-06-30)

Full Changelog: sdk-v0.108.0...sdk-v0.109.0

Features

  • api: add support for Managed Agents event delta streaming, agent overrides, reverse pagination, vault credential injection scoping, and agent and deployment webhook events (7f3211b)

0.108.0 (2026-06-30)

Full Changelog: sdk-v0.107.0...sdk-v0.108.0

Features

  • api: add support for claude-sonnet-5 (4588db0)

Bug Fixes

  • agent-toolset: allow absolute paths that resolve inside workdir (#112) (e951fb2)

Chores

Commits
  • cb829c7 chore: release main
  • fd0341d feat(api): add support for Managed Agents event delta streaming, agent overri...
  • 90f767a codegen metadata
  • ed986cd chore: release main
  • d3ffad4 chore: format README.md (#176)
  • 6b46ad3 feat(api): add support for claude-sonnet-5
  • 8200ffa feat(vertex): bump google-auth-library to ^10.2.0 (SDK-91) (#30)
  • fa06d36 feat(bedrock): pass client logger to AWS credential provider chain (SDK-90) (...
  • 0fff7fa fix(agent-toolset): allow absolute paths that resolve inside workdir (#112)
  • See full diff in compare view

Updates `posthog-node` from 5.38.8 to 5.39.1
Release notes

Sourced from posthog-node's releases.

posthog-node@5.39.1

5.39.1

Patch Changes

  • #4029 b36b1cc Thanks @​marandaneto! - Call before_send for identify, group identify, and alias events. (2026-06-30)

  • #4027 ab118d2 Thanks @​marandaneto! - Safely serialize event batches with circular property references instead of crashing during flush. (2026-06-30)

  • Updated dependencies [ab118d2]:

    • @​posthog/core@​1.39.2

posthog-node@5.39.0

5.39.0

Minor Changes

  • #4006 0063128 Thanks @​github-actions! - Add groupIdentifyImmediate() to await the network request when identifying a group, mirroring captureImmediate/identifyImmediate/aliasImmediate. Useful in edge/serverless environments where the background queue may not flush. The Convex integration now uses it directly instead of routing $groupidentify through captureImmediate. (2026-06-30)

Patch Changes

  • Updated dependencies [0063128]:
    • @​posthog/core@​1.39.0
Changelog

Sourced from posthog-node's changelog.

5.39.1

Patch Changes

  • #4029 b36b1cc Thanks @​marandaneto! - Call before_send for identify, group identify, and alias events. (2026-06-30)

  • #4027 ab118d2 Thanks @​marandaneto! - Safely serialize event batches with circular property references instead of crashing during flush. (2026-06-30)

  • Updated dependencies [ab118d2]:

    • @​posthog/core@​1.39.2

5.39.0

Minor Changes

  • #4006 0063128 Thanks @​github-actions! - Add groupIdentifyImmediate() to await the network request when identifying a group, mirroring captureImmediate/identifyImmediate/aliasImmediate. Useful in edge/serverless environments where the background queue may not flush. The Convex integration now uses it directly instead of routing $groupidentify through captureImmediate. (2026-06-30)

Patch Changes

  • Updated dependencies [0063128]:
    • @​posthog/core@​1.39.0
Commits
  • a5181ba chore: update versions and lockfile [version bump]
  • b36b1cc fix(node): run before_send for all captured events (#4029)
  • ab118d2 fix(node): handle circular event properties during flush (#4027)
  • 0c95bce fix: satisfy SDK compliance harness 0.8.0 (#3998)
  • 254c5b1 chore: update versions and lockfile [version bump]
  • 0063128 feat: Add groupIdentifyImmediate() method to Node.js SDK (#4006)
  • See full diff in compare view

Updates `@typescript/native-preview` from 7.0.0-dev.20260629.1 to 7.0.0-dev.20260630.1
Commits

Updates `oxlint-tsgolint` from 0.23.0 to 0.24.0
Release notes

Sourced from oxlint-tsgolint's releases.

v0.24.0

What's Changed

... (truncated)

Commits
  • 5a37e89 fix(dot-notation): determine the relevant accessor (#1028)
  • 67a281f perf(consistent-return): defer per-function type resolution (#1031)
  • a5e2ff0 perf(no-unnecessary-qualifier): skip symbol resolution outside namespaces. (#...
  • a8fc668 perf(no-confusing-void-expression): check ancestor position before type query...
  • 03158cc perf(no-unnecessary-type-conversion): hoist constant builtin-name slices (#1040)
  • d9e645c perf(prefer-optional-chain): lazily allocate chain-processor caches (#1041)
  • 63f578a refactor(no-unnecessary-condition): remove dead containsUnguardedElementAcces...
  • f174876 chore(deps): update gomod (#1035)
  • 47de9cf chore(deps): update github actions (#1036)
  • e209b5b chore(deps): update actions/cache action to v6 (#1037)
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli/package.json | 6 +- packages/stack/package.json | 2 +- pnpm-lock.yaml | 330 ++++++++++++++++++------------------ pnpm-workspace.yaml | 4 +- 4 files changed, 171 insertions(+), 171 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index c184c44d66..345a397df7 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -43,8 +43,8 @@ "jose": "^6.2.3" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.196", - "@anthropic-ai/sdk": "^0.107.0", + "@anthropic-ai/claude-agent-sdk": "^0.3.197", + "@anthropic-ai/sdk": "^0.109.0", "@clack/prompts": "^1.6.0", "@effect/atom-react": "catalog:", "@effect/platform-bun": "catalog:", @@ -76,7 +76,7 @@ "oxlint-tsgolint": "catalog:", "pg": "^8.22.0", "pg-copy-streams": "^7.0.0", - "posthog-node": "^5.38.8", + "posthog-node": "^5.39.1", "react": "^19.2.7", "react-devtools-core": "^7.0.1", "semantic-release": "^25.0.5", diff --git a/packages/stack/package.json b/packages/stack/package.json index 284f92a896..3b6b7c3e9e 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -28,7 +28,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", - "@supabase/supabase-js": "^2.108.2", + "@supabase/supabase-js": "^2.110.0", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8a9506881..32618873a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,8 +37,8 @@ catalogs: specifier: ^1.3.14 version: 1.3.14 '@typescript/native-preview': - specifier: 7.0.0-dev.20260629.1 - version: 7.0.0-dev.20260629.1 + specifier: 7.0.0-dev.20260630.1 + version: 7.0.0-dev.20260630.1 '@vitest/coverage-istanbul': specifier: ^4.1.9 version: 4.1.9 @@ -58,8 +58,8 @@ catalogs: specifier: ^1.72.0 version: 1.73.0 oxlint-tsgolint: - specifier: ^0.23.0 - version: 0.23.0 + specifier: ^0.24.0 + version: 0.24.0 tldts: specifier: ^7.4.5 version: 7.4.5 @@ -97,11 +97,11 @@ importers: version: 6.2.3 devDependencies: '@anthropic-ai/claude-agent-sdk': - specifier: ^0.3.196 - version: 0.3.196(@anthropic-ai/sdk@0.107.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + specifier: ^0.3.197 + version: 0.3.197(@anthropic-ai/sdk@0.109.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@anthropic-ai/sdk': - specifier: ^0.107.0 - version: 0.107.0(zod@4.4.3) + specifier: ^0.109.0 + version: 0.109.0(zod@4.4.3) '@clack/prompts': specifier: ^1.6.0 version: 1.6.0 @@ -155,7 +155,7 @@ importers: version: 19.2.17 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260629.1 + version: 7.0.0-dev.20260630.1 '@vercel/detect-agent': specifier: ^1.2.3 version: 1.2.3 @@ -185,10 +185,10 @@ importers: version: 0.57.0 oxlint: specifier: 'catalog:' - version: 1.73.0(oxlint-tsgolint@0.23.0) + version: 1.73.0(oxlint-tsgolint@0.24.0) oxlint-tsgolint: specifier: 'catalog:' - version: 0.23.0 + version: 0.24.0 pg: specifier: ^8.22.0 version: 8.22.0 @@ -196,8 +196,8 @@ importers: specifier: ^7.0.0 version: 7.0.0 posthog-node: - specifier: ^5.38.8 - version: 5.38.8 + specifier: ^5.39.1 + version: 5.39.1 react: specifier: ^19.2.7 version: 19.2.7 @@ -259,7 +259,7 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260629.1 + version: 7.0.0-dev.20260630.1 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -271,10 +271,10 @@ importers: version: 0.57.0 oxlint: specifier: 'catalog:' - version: 1.73.0(oxlint-tsgolint@0.23.0) + version: 1.73.0(oxlint-tsgolint@0.24.0) oxlint-tsgolint: specifier: 'catalog:' - version: 0.23.0 + version: 0.24.0 vitest: specifier: 'catalog:' version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -339,7 +339,7 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260629.1 + version: 7.0.0-dev.20260630.1 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -351,10 +351,10 @@ importers: version: 0.57.0 oxlint: specifier: 'catalog:' - version: 1.73.0(oxlint-tsgolint@0.23.0) + version: 1.73.0(oxlint-tsgolint@0.24.0) oxlint-tsgolint: specifier: 'catalog:' - version: 0.23.0 + version: 0.24.0 vitest: specifier: 'catalog:' version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -381,7 +381,7 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260629.1 + version: 7.0.0-dev.20260630.1 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -393,10 +393,10 @@ importers: version: 0.57.0 oxlint: specifier: 'catalog:' - version: 1.73.0(oxlint-tsgolint@0.23.0) + version: 1.73.0(oxlint-tsgolint@0.24.0) oxlint-tsgolint: specifier: 'catalog:' - version: 0.23.0 + version: 0.24.0 vitest: specifier: 'catalog:' version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -431,7 +431,7 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260629.1 + version: 7.0.0-dev.20260630.1 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -443,10 +443,10 @@ importers: version: 0.57.0 oxlint: specifier: 'catalog:' - version: 1.73.0(oxlint-tsgolint@0.23.0) + version: 1.73.0(oxlint-tsgolint@0.24.0) oxlint-tsgolint: specifier: 'catalog:' - version: 0.23.0 + version: 0.24.0 vitest: specifier: 'catalog:' version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -471,7 +471,7 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260629.1 + version: 7.0.0-dev.20260630.1 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -483,10 +483,10 @@ importers: version: 0.57.0 oxlint: specifier: 'catalog:' - version: 1.73.0(oxlint-tsgolint@0.23.0) + version: 1.73.0(oxlint-tsgolint@0.24.0) oxlint-tsgolint: specifier: 'catalog:' - version: 0.23.0 + version: 0.24.0 vitest: specifier: 'catalog:' version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -513,8 +513,8 @@ importers: specifier: 'catalog:' version: 4.0.0-beta.93(effect@4.0.0-beta.93)(vitest@4.1.9) '@supabase/supabase-js': - specifier: ^2.108.2 - version: 2.108.2 + specifier: ^2.110.0 + version: 2.110.0 '@tsconfig/bun': specifier: 'catalog:' version: 1.0.10 @@ -523,7 +523,7 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260629.1 + version: 7.0.0-dev.20260630.1 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -535,10 +535,10 @@ importers: version: 0.57.0 oxlint: specifier: 'catalog:' - version: 1.73.0(oxlint-tsgolint@0.23.0) + version: 1.73.0(oxlint-tsgolint@0.24.0) oxlint-tsgolint: specifier: 'catalog:' - version: 0.23.0 + version: 0.24.0 vitest: specifier: 'catalog:' version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -570,60 +570,60 @@ packages: resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} engines: {node: '>=18'} - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.196': - resolution: {integrity: sha512-k1MKRDhSiNKpkTwhtU8QKGzfuRfr3YXS6oqsTuldMROX56L5iMXjzC7AYU1/KmPTeMl3SCQBQeDu0i8ciA0hQA==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.197': + resolution: {integrity: sha512-jC6WvH5Hr6APTfbMjo4nC6LlyMMqbpCMwiHXIw7/AsQXIHQhZ+cRRMesQlV6UFI1l3O53gLZHzsG9cXwfrPHKw==} cpu: [arm64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.196': - resolution: {integrity: sha512-bBwx/7yKZMQ9NSUt4bg8P+zp6pgd/O/DTkzdqsRIivBvARmwo1QY/2qGrLO8RH0T8CG2lTjFNfDfOlJWbAkvAg==} + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.197': + resolution: {integrity: sha512-ZQNvGkMrTyatBlHTIQ4w2i2aLBuvq355UP/FDLnVXIH8l23RsL1x/0w9P+dqB7EmY9OZi/cPxSrpskpo+dZWLA==} cpu: [x64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.196': - resolution: {integrity: sha512-BhLxfx4j6mC3Uzmve1IbhFS1uvNlwATeo6uWyYDOMW4n3XKjiSgjD8bTfkamijrxCMvJo5swLju2y14ayDsokA==} + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.197': + resolution: {integrity: sha512-VuIGXsLGK/aqSQ0tTBqqPVNzjefWS5SWnK8mlYyQitT4s5UDzHXJm0UZBTGxRtlcS0e2+QAHKwbGBCq1ZKSXjg==} cpu: [arm64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.196': - resolution: {integrity: sha512-fR5fy+pSQSpKZK0zTtAl3LZEGQTuwVK7svutH1bZUS5RGT2HdUWc71oltSZgW4upGaslj+gyusHGJHA5eAc+dw==} + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.197': + resolution: {integrity: sha512-pWhQgCtAft4EGM4Zn24HRad1a/k2u6oA+2uM/KCdjehfKtooDiHfMNd1yzXY/n9AEBWP0RHB2Vz3mJ30X2pVAg==} cpu: [arm64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.196': - resolution: {integrity: sha512-EOiNbxCXQLYzV7SQhMWkUC1ScWyTw/Qp+JyV2sEmIbzl/e7KMOxNE9J20k+lpPs6CXdxVzuMwH7yAGuDu5y+IQ==} + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.197': + resolution: {integrity: sha512-3Tuy7XhD4UIKE4A4RPmKJcbL7Q/3dcB1hEWQt2lKP7c/DlixeEv+tRzvpnFZKhFX2hy0tkBk3QjkozSAacMC/w==} cpu: [x64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.196': - resolution: {integrity: sha512-9spZON7/tn0q9J+jICrdfHi7o7Fmjs9pIohCCxL+Yv7HbBXWVtEYJbYipBGLTl8ICG3mgPeEVMNsvOuh/jDTuA==} + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.197': + resolution: {integrity: sha512-AUccrbdcv4Hy/GteP/gYLjG/zDP+fe2BFtDMctEfRFVz40DazYDcOyW1+nIgSTQtxf5jSTAVVf3cNuXB2CZwlw==} cpu: [x64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.196': - resolution: {integrity: sha512-0xXkAWlDof/qFi3k5KJZ5WYbgp1X8hZHjyasOWTZWAmldoYaENI0vkL+PVMarvoAFYRRqcWoS81FSgm0QgH2sA==} + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.197': + resolution: {integrity: sha512-Wx8uiAKBenDuL8lWQmrqnX5ppljaH5unQ9cKiCz2/9Kgf09dgnrwbX8n/FhndCZR8PmYw539eWwYVrSVc/bl6w==} cpu: [arm64] os: [win32] - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.196': - resolution: {integrity: sha512-FxWLA3aOYgDf2J0o6Ov1/wgg9X6RkrgnX4ifUWO1i3+6mbc4efNLHkDZLxCHEnQgW8yBidX+iro19f9k64vV8A==} + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.197': + resolution: {integrity: sha512-ZXJO/VvR3SI4G0gwthWeFXWdHB5RXPu3rtfGRcKZ/YgtDeW17rQ+LZIJTk2ywzbLb8EvlghR5JPgn293hC179Q==} cpu: [x64] os: [win32] - '@anthropic-ai/claude-agent-sdk@0.3.196': - resolution: {integrity: sha512-yqsp1/04T2/tJ54jz+7YLsTZOPeQ/myUCrv17/IVZ9dbl+izIMP9ULuLpPCH5pj5msXxR80es+hpVgHoFuNm0A==} + '@anthropic-ai/claude-agent-sdk@0.3.197': + resolution: {integrity: sha512-XNIi8W1tb+QfMkcK+5kepOC6BsxG8wtupd72H+pIPzIJypVQhHy7FoX+KBMtTRYwtl+5dsjKyABhjWXebeUilw==} engines: {node: '>=18.0.0'} peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' '@modelcontextprotocol/sdk': ^1.29.0 zod: ^4.0.0 - '@anthropic-ai/sdk@0.107.0': - resolution: {integrity: sha512-RWDWyvIeZnatUTzyX8+ayFzAqqLyoDHKnDEODFyW8H89zH+qEsh5h6XAmnbHY5DCoa58o3rjuNe3F3Hg851ayA==} + '@anthropic-ai/sdk@0.109.0': + resolution: {integrity: sha512-y7P4eLyW5uNut4fXpOUEHqhJwx7dnxrWAfCQE4Lcgm0hSFQuIeHa7CWEKE5dFolEjQJE/RFKkppjri05r2OK/Q==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -1852,33 +1852,33 @@ packages: cpu: [x64] os: [win32] - '@oxlint-tsgolint/darwin-arm64@0.23.0': - resolution: {integrity: sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw==} + '@oxlint-tsgolint/darwin-arm64@0.24.0': + resolution: {integrity: sha512-C2uMmwK5Bc4ri4ysZ6sA8Rcu+A5zBQTp6ml2u0CLLbRZp4kMFPV3yWk8B5DK9Aw7y9bbjogIm75tUwGLFzlsYQ==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.23.0': - resolution: {integrity: sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA==} + '@oxlint-tsgolint/darwin-x64@0.24.0': + resolution: {integrity: sha512-Wgvt/1lRbDxmoNqWQKKcL+UIiqLmdJ+EWLpQa1qzoNVAfNB0PJpa82/8dH1twT/3rSs4zrP5TXPWl4juB71WuQ==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.23.0': - resolution: {integrity: sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw==} + '@oxlint-tsgolint/linux-arm64@0.24.0': + resolution: {integrity: sha512-PB1rxII7KV83+ASY4sSkXtqvpij6ME66+QCRL49uksi/ofs2Rf/UVboYr095n0Rkbl2wgvlsHGl6DHC361jQUQ==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.23.0': - resolution: {integrity: sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA==} + '@oxlint-tsgolint/linux-x64@0.24.0': + resolution: {integrity: sha512-xcz3CxKmjTQLREtE/UShh+ruWmm9nAb7UM9zKcD65BStiuYgOakAKkPHl4YS5DztpVcDrE0+HqbOolTlRKYWmw==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.23.0': - resolution: {integrity: sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw==} + '@oxlint-tsgolint/win32-arm64@0.24.0': + resolution: {integrity: sha512-A2i6ZGBec3i20S7RaxkgHc6r3HYtD5Mn7j/mb22NkTz14u0JuudvTu6JggAnbGMcv8+dBKQI//EasxSPJLD8pw==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.23.0': - resolution: {integrity: sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ==} + '@oxlint-tsgolint/win32-x64@0.24.0': + resolution: {integrity: sha512-0ZbGd9qRB6zs82moekaKdEvncRANq49EAwfNX62JpTS46feXUhKAuoyVDvZMj6Rywejylrmmu79Wo6faYCo4Ew==} cpu: [x64] os: [win32] @@ -2647,32 +2647,32 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@supabase/auth-js@2.108.2': - resolution: {integrity: sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==} - engines: {node: '>=20.0.0'} + '@supabase/auth-js@2.110.0': + resolution: {integrity: sha512-Mi288WCTp6wxMFCOu/UgzgHEXODjdl2uVTLqK11eanzGZaldU3RyP8Am+ZbNuVzFP+5+iOvppxzv7N5Ym84xTg==} + engines: {node: '>=22.0.0'} - '@supabase/functions-js@2.108.2': - resolution: {integrity: sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==} - engines: {node: '>=20.0.0'} + '@supabase/functions-js@2.110.0': + resolution: {integrity: sha512-Fde5wlY8ZZy+9yqrWlQHo8MacSyUBArBEtN2boB4thJQigPnQD/cc61qZN0n3I1L0gwhWtHYwIMnOBKxSvF6Hw==} + engines: {node: '>=22.0.0'} '@supabase/phoenix@0.4.4': resolution: {integrity: sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==} - '@supabase/postgrest-js@2.108.2': - resolution: {integrity: sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==} - engines: {node: '>=20.0.0'} + '@supabase/postgrest-js@2.110.0': + resolution: {integrity: sha512-ZbC1QZL3jcvBUfVKjJbgRM27G4Mg3Zzqdm44m5pJafe1e52Cli793EOnwQucomBAGEUDd03Nzaf7XV3ji/XexQ==} + engines: {node: '>=22.0.0'} - '@supabase/realtime-js@2.108.2': - resolution: {integrity: sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==} - engines: {node: '>=20.0.0'} + '@supabase/realtime-js@2.110.0': + resolution: {integrity: sha512-Wn2AWpneZuDFTkp/65tqctvoh+3JvyTjMam8sTMqVWy5BgkU8zAvFwilPYPPPhkINeKF8NAJKP7FclJ2iGCUMw==} + engines: {node: '>=22.0.0'} - '@supabase/storage-js@2.108.2': - resolution: {integrity: sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==} - engines: {node: '>=20.0.0'} + '@supabase/storage-js@2.110.0': + resolution: {integrity: sha512-71+gU3HrhiylAhftY6FmO5PPdcsScnVcS766CVD+vTYK9qTDLbrx8FhgBYbqGm3iV/wkTfzrNJfjGsMeFRkJRQ==} + engines: {node: '>=22.0.0'} - '@supabase/supabase-js@2.108.2': - resolution: {integrity: sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==} - engines: {node: '>=20.0.0'} + '@supabase/supabase-js@2.110.0': + resolution: {integrity: sha512-8yI84VJiEVW4zxZpLUmxXmjzQ7O2St9X/ymzlBETDHTURPWG3LmvbSiibq+7dqAJmyoUfxZnSfXeM4HCM8s4XQ==} + engines: {node: '>=22.0.0'} '@swc-node/core@1.14.1': resolution: {integrity: sha512-jrt5GUaZUU6cmMS+WTJEvGvaB6j1YNKPHPzC2PUi2BjaFbtxURHj6641Az6xN7b665hNniAIdvjxWcRml5yCnw==} @@ -2864,50 +2864,50 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260629.1': - resolution: {integrity: sha512-wXRExZJweYoTzE4atRR7T5HwKJYkl6/KHxON0eF0iy2fvgLXDlyq4AQqhmV8mMx10PQKc/4sNbfhD4kjWWvm8A==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260630.1': + resolution: {integrity: sha512-zo1TYD32r+MKOTnzhB1YGr2Jrw14tzGR/rpfr6hRMDz0kV9k2CWVvtOYOmOtRdmuO4KWZcWtVX13QyaPGhA8Hw==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260629.1': - resolution: {integrity: sha512-xkL/tETzUuDxfRkk2rD0/CjjjGLyOLHeE103T52rpgj0VNSXMnn7tTiw+TSdxnS61b8ImOrWgQJujhPFTp0jng==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260630.1': + resolution: {integrity: sha512-57RYyZQQ6+drfu+CagVdqUvQwNesvuu/rJb/au66jv+figfpDOugD0S6ruQl1SxpFFfr0lCny3KEplsBdqFDEg==} engines: {node: '>=16.20.0'} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260629.1': - resolution: {integrity: sha512-y4ey82krq4iLNHML4G1dUObBWY6gNAjVqaUHFDv7+LBu5bfAJy8VClBaLtAJce3siQQeB0/4SkN4XsAa0oZktQ==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260630.1': + resolution: {integrity: sha512-QQxNyZ9rVbE6lUqctrrRiTtA5Z0w2FFBy8SkiP/eDkuRy2WXrVpMQYntNn6d4nO1nWTmWJR1z/CS+H2rsrl8gg==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260629.1': - resolution: {integrity: sha512-QlTxO+O6yELc0NYZZYIbncZhkhw+K/vBoVg+mKipbEIK5b1E6cwW7ivWIrDp1FfssQ0Wu7zxincPappoci7KNw==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260630.1': + resolution: {integrity: sha512-FLJSSt3bUmRpzWlr5cDE+td40FoDy/MT+VDYk4JGouisJo7g7GPjuF+fYOfsKI/RbD9f6gDFTyFPAoTLVVR/9g==} engines: {node: '>=16.20.0'} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260629.1': - resolution: {integrity: sha512-ckb4HyugC5beDpOMPOVpEx1H3l2Z2RgsGPxFOLGMU6LLIHQwlCaSdRjSfH8Hq8yffPgKuotTQLdA89cP1IC8xQ==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260630.1': + resolution: {integrity: sha512-WqNPvUBngGfnkunqn3IuIkkF6PA/HPSSbVucolO7stc9DxWZqfRals6/C8RTvuQaif9qt+bcY9U6lLXuDFyClA==} engines: {node: '>=16.20.0'} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260629.1': - resolution: {integrity: sha512-Ktgepu9p0blqrbiryzYrw11ddnbESXPdeGKURDG/wXESoHQETsMNxKWhqAo587ItNnWjKOBFxoEM22eP9OzKnw==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260630.1': + resolution: {integrity: sha512-awATkrGGm2L1IraQgaw21VXWtAqv79DJ57No/J65Y+bL8InOVR2zu+zCH+5E44m8bh9IXwnShI7cAdvp02WNEw==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260629.1': - resolution: {integrity: sha512-b2wmeIpFJs/peej3NG26320ya+iYQz21JzFYDrnvtsEeR/g66rB9uvp4Owo4J1AG/BcmRWC/nuwrBy3Jdb+UDA==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260630.1': + resolution: {integrity: sha512-M/+P66Gsss/CANV+MkKVFeNi9jljYYR3N2COf/WrVYcDwrHyiYHZYExfTw2cEbbkH8LcawXAekp9d23bevBR4Q==} engines: {node: '>=16.20.0'} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260629.1': - resolution: {integrity: sha512-KImWFkxGUa/V78bUEAgzeJQT+6wKQXDDYHHrOavof8wnbMCpj86KuPNyBIPQMtoHiz2If8aNTums2m50oE3oqw==} + '@typescript/native-preview@7.0.0-dev.20260630.1': + resolution: {integrity: sha512-5OTvy2YpoDsOwAPqj4LkI4mrlAtc3mRzNdHAPp/1JWUvsKSRTzqAB2JPVD4qHUkAd9qY89yb758kc7fGyn3gbw==} engines: {node: '>=16.20.0'} hasBin: true @@ -5386,8 +5386,8 @@ packages: vite-plus: optional: true - oxlint-tsgolint@0.23.0: - resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==} + oxlint-tsgolint@0.24.0: + resolution: {integrity: sha512-giCk5sEvG02d5tzPmFMX3hem8ndzEEu1xvGYS5OwNfO2WGl6ZVxt5LjE0yiMDoz94INI7XkXwgFAQiydPvVHDw==} hasBin: true oxlint@1.73.0: @@ -5667,8 +5667,8 @@ packages: postgres-range@1.1.4: resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} - posthog-node@5.38.8: - resolution: {integrity: sha512-AWsp9Tigf4iZepabPErAt/2sLTGwJ7w6631JP+3ifDqWzaBPyzcukD7cTPeoNDKGwJreQ69Ju4l53n9g2+X6nQ==} + posthog-node@5.39.1: + resolution: {integrity: sha512-ZvpNfbTg6mhuPScGXE+Yqh2mJpELHShHT98qjKD7cBdXVtIDOZ8Xoh7m3JkK9H1r0GiqEztnoBNe9aL1qOZCeg==} engines: {node: ^20.20.0 || >=22.22.0} peerDependencies: rxjs: ^7.0.0 @@ -6854,46 +6854,46 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.196': + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.197': optional: true - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.196': + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.197': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.196': + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.197': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.196': + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.197': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.196': + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.197': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.196': + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.197': optional: true - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.196': + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.197': optional: true - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.196': + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.197': optional: true - '@anthropic-ai/claude-agent-sdk@0.3.196(@anthropic-ai/sdk@0.107.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.197(@anthropic-ai/sdk@0.109.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: - '@anthropic-ai/sdk': 0.107.0(zod@4.4.3) + '@anthropic-ai/sdk': 0.109.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) zod: 4.4.3 optionalDependencies: - '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.196 - '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.196 - '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.196 - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.196 - '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.196 - '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.196 - '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.196 - '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.196 + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.197 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.197 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.197 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.197 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.197 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.197 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.197 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.197 - '@anthropic-ai/sdk@0.107.0(zod@4.4.3)': + '@anthropic-ai/sdk@0.109.0(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 standardwebhooks: 1.0.0 @@ -7858,22 +7858,22 @@ snapshots: '@oxfmt/binding-win32-x64-msvc@0.57.0': optional: true - '@oxlint-tsgolint/darwin-arm64@0.23.0': + '@oxlint-tsgolint/darwin-arm64@0.24.0': optional: true - '@oxlint-tsgolint/darwin-x64@0.23.0': + '@oxlint-tsgolint/darwin-x64@0.24.0': optional: true - '@oxlint-tsgolint/linux-arm64@0.23.0': + '@oxlint-tsgolint/linux-arm64@0.24.0': optional: true - '@oxlint-tsgolint/linux-x64@0.23.0': + '@oxlint-tsgolint/linux-x64@0.24.0': optional: true - '@oxlint-tsgolint/win32-arm64@0.23.0': + '@oxlint-tsgolint/win32-arm64@0.24.0': optional: true - '@oxlint-tsgolint/win32-x64@0.23.0': + '@oxlint-tsgolint/win32-x64@0.24.0': optional: true '@oxlint/binding-android-arm-eabi@1.73.0': @@ -8538,37 +8538,37 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@supabase/auth-js@2.108.2': + '@supabase/auth-js@2.110.0': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.108.2': + '@supabase/functions-js@2.110.0': dependencies: tslib: 2.8.1 '@supabase/phoenix@0.4.4': {} - '@supabase/postgrest-js@2.108.2': + '@supabase/postgrest-js@2.110.0': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.108.2': + '@supabase/realtime-js@2.110.0': dependencies: '@supabase/phoenix': 0.4.4 tslib: 2.8.1 - '@supabase/storage-js@2.108.2': + '@supabase/storage-js@2.110.0': dependencies: iceberg-js: 0.8.1 tslib: 2.8.1 - '@supabase/supabase-js@2.108.2': + '@supabase/supabase-js@2.110.0': dependencies: - '@supabase/auth-js': 2.108.2 - '@supabase/functions-js': 2.108.2 - '@supabase/postgrest-js': 2.108.2 - '@supabase/realtime-js': 2.108.2 - '@supabase/storage-js': 2.108.2 + '@supabase/auth-js': 2.110.0 + '@supabase/functions-js': 2.110.0 + '@supabase/postgrest-js': 2.110.0 + '@supabase/realtime-js': 2.110.0 + '@supabase/storage-js': 2.110.0 '@swc-node/core@1.14.1(@swc/core@1.15.43)(@swc/types@0.1.27)': dependencies: @@ -8748,36 +8748,36 @@ snapshots: dependencies: '@types/node': 26.0.1 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260629.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260630.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260629.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260630.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260629.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260630.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260629.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260630.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260629.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260630.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260629.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260630.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260629.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260630.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260629.1': + '@typescript/native-preview@7.0.0-dev.20260630.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260629.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260629.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260629.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260629.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260629.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260629.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260629.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260630.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260630.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260630.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260630.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260630.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260630.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260630.1 '@ungap/structured-clone@1.3.2': {} @@ -11799,16 +11799,16 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.57.0 '@oxfmt/binding-win32-x64-msvc': 0.57.0 - oxlint-tsgolint@0.23.0: + oxlint-tsgolint@0.24.0: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.23.0 - '@oxlint-tsgolint/darwin-x64': 0.23.0 - '@oxlint-tsgolint/linux-arm64': 0.23.0 - '@oxlint-tsgolint/linux-x64': 0.23.0 - '@oxlint-tsgolint/win32-arm64': 0.23.0 - '@oxlint-tsgolint/win32-x64': 0.23.0 - - oxlint@1.73.0(oxlint-tsgolint@0.23.0): + '@oxlint-tsgolint/darwin-arm64': 0.24.0 + '@oxlint-tsgolint/darwin-x64': 0.24.0 + '@oxlint-tsgolint/linux-arm64': 0.24.0 + '@oxlint-tsgolint/linux-x64': 0.24.0 + '@oxlint-tsgolint/win32-arm64': 0.24.0 + '@oxlint-tsgolint/win32-x64': 0.24.0 + + oxlint@1.73.0(oxlint-tsgolint@0.24.0): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.73.0 '@oxlint/binding-android-arm64': 1.73.0 @@ -11829,7 +11829,7 @@ snapshots: '@oxlint/binding-win32-arm64-msvc': 1.73.0 '@oxlint/binding-win32-ia32-msvc': 1.73.0 '@oxlint/binding-win32-x64-msvc': 1.73.0 - oxlint-tsgolint: 0.23.0 + oxlint-tsgolint: 0.24.0 p-cancelable@2.1.1: {} @@ -12070,7 +12070,7 @@ snapshots: postgres-range@1.1.4: {} - posthog-node@5.38.8: + posthog-node@5.39.1: dependencies: '@posthog/core': 1.39.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d42a59a931..eb8f8de15a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,14 +22,14 @@ catalog: "@swc/core": "^1.15.43" "@tsconfig/bun": "^1.0.10" "@types/bun": "^1.3.14" - "@typescript/native-preview": "7.0.0-dev.20260629.1" + "@typescript/native-preview": "7.0.0-dev.20260630.1" "@vitest/coverage-istanbul": "^4.1.9" "effect": "4.0.0-beta.93" "knip": "^6.17.1" "nx": "^23.0.0" "oxfmt": "^0.57.0" "oxlint": "^1.72.0" - "oxlint-tsgolint": "^0.23.0" + "oxlint-tsgolint": "^0.24.0" "tldts": "^7.4.5" "vitest": "^4.1.9" From e7b208458101209132ce64033bfe3c32bcff55cb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:24:37 +0000 Subject: [PATCH 17/49] chore(deps): bump golang.org/x/crypto from 0.50.0 to 0.52.0 in /apps/cli-go/pkg (#5825) Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.50.0 to 0.52.0.
Commits
  • a1c0d99 go.mod: update golang.org/x dependencies
  • 3c7c869 ssh: fix deadlock on unexpected channel responses
  • 533fb3f ssh: fix source-address critical option bypass
  • abbc44d ssh: fix incorrect operator order
  • e052873 ssh: fix infinite loop on large channel writes due to integer overflow
  • b61cf85 ssh: enforce user presence verification for security keys
  • 9c2cd33 ssh: enforce strict limits on DSA key parameters
  • 8907318 ssh: reject RSA keys with excessively large moduli
  • ffd87b4 ssh: fix panic when authority callbacks are nil
  • 4e7a738 ssh: fix deadlock on unexpected global responses
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=golang.org/x/crypto&package-manager=go_modules&previous-version=0.50.0&new-version=0.52.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/supabase/cli/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli-go/pkg/go.mod | 6 +++--- apps/cli-go/pkg/go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/cli-go/pkg/go.mod b/apps/cli-go/pkg/go.mod index eca10c84ab..8afe2e0de5 100644 --- a/apps/cli-go/pkg/go.mod +++ b/apps/cli-go/pkg/go.mod @@ -51,8 +51,8 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/apps/cli-go/pkg/go.sum b/apps/cli-go/pkg/go.sum index c622bcdf1e..08ff799d66 100644 --- a/apps/cli-go/pkg/go.sum +++ b/apps/cli-go/pkg/go.sum @@ -217,8 +217,8 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= @@ -255,8 +255,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -272,8 +272,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= From 815b0a9f202db70285e8bfec0c38f20ea6476791 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:59:09 +0530 Subject: [PATCH 18/49] fix(cli): colima socket (#5820) ## TL;DR adds Colima rootless docker socket fallback to vector start ## Ref - closes https://github.com/supabase/cli/issues/5073 --- apps/cli-go/internal/start/start.go | 10 ++++++++-- apps/cli-go/internal/start/start_test.go | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/cli-go/internal/start/start.go b/apps/cli-go/internal/start/start.go index 23aa631e3b..6264016e79 100644 --- a/apps/cli-go/internal/start/start.go +++ b/apps/cli-go/internal/start/start.go @@ -128,6 +128,13 @@ type vectorConfig struct { DbId string } +func shouldMountRootDockerSocket(host string) bool { + return strings.HasSuffix(host, "/.docker/run/docker.sock") || + strings.HasSuffix(host, "/.docker/desktop/docker.sock") || + (strings.Contains(host, "/.colima/") && strings.HasSuffix(host, "/docker.sock")) || + strings.HasSuffix(host, "/.colima/docker.sock") +} + var ( //go:embed templates/vector.yaml vectorConfigEmbed string @@ -424,8 +431,7 @@ EOF case "unix": if dindHost, err = client.ParseHostURL(client.DefaultDockerHost); err != nil { return errors.Errorf("failed to parse default host: %w", err) - } else if strings.HasSuffix(parsed.Host, "/.docker/run/docker.sock") || - strings.HasSuffix(parsed.Host, "/.docker/desktop/docker.sock") { + } else if shouldMountRootDockerSocket(parsed.Host) { // Docker will not mount rootless socket directly; // instead, specify root socket to have it handled under the hood binds = append(binds, fmt.Sprintf("%[1]s:%[1]s:ro", dindHost.Host)) diff --git a/apps/cli-go/internal/start/start_test.go b/apps/cli-go/internal/start/start_test.go index 2977c5ed79..a69f7bd8bc 100644 --- a/apps/cli-go/internal/start/start_test.go +++ b/apps/cli-go/internal/start/start_test.go @@ -162,6 +162,20 @@ func TestStartCommand(t *testing.T) { }) } +func TestShouldMountRootDockerSocket(t *testing.T) { + t.Run("returns true for Docker Desktop and Colima sockets", func(t *testing.T) { + assert.True(t, shouldMountRootDockerSocket("/Users/test/.docker/run/docker.sock")) + assert.True(t, shouldMountRootDockerSocket("/Users/test/.docker/desktop/docker.sock")) + assert.True(t, shouldMountRootDockerSocket("/Users/test/.colima/default/docker.sock")) + assert.True(t, shouldMountRootDockerSocket("/Users/test/.colima/local/docker.sock")) + assert.True(t, shouldMountRootDockerSocket("/Users/test/.colima/docker.sock")) + }) + + t.Run("returns false for directly mountable sockets", func(t *testing.T) { + assert.False(t, shouldMountRootDockerSocket("/Users/test/.orbstack/run/docker.sock")) + }) +} + func TestDatabaseStart(t *testing.T) { t.Run("starts database locally", func(t *testing.T) { // Setup in-memory fs From 5c8c144458adb0b707ab37101894116b6f4db768 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:29:40 +0200 Subject: [PATCH 19/49] fix(docker): bump the docker-minor group in /apps/cli-go/pkg/config/templates with 3 updates (#5822) Bumps the docker-minor group in /apps/cli-go/pkg/config/templates with 3 updates: supabase/studio, supabase/realtime and supabase/storage-api. Updates `supabase/studio` from 2026.07.06-sha-66cf431 to 2026.07.07-sha-a6a04f2 Updates `supabase/realtime` from v2.112.6 to v2.112.9 Updates `supabase/storage-api` from v1.62.5 to v1.63.1 Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 6 +++--- packages/stack/src/versions.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index ec7e643391..b2703b51fc 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -5,14 +5,14 @@ FROM library/kong:2.8.1 AS kong FROM axllent/mailpit:v1.30.2 AS mailpit FROM postgrest/postgrest:v14.14 AS postgrest FROM supabase/postgres-meta:v0.96.6 AS pgmeta -FROM supabase/studio:2026.07.06-sha-66cf431 AS studio +FROM supabase/studio:2026.07.07-sha-a6a04f2 AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy FROM supabase/edge-runtime:v1.74.2 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.7 AS supavisor FROM supabase/gotrue:v2.192.0 AS gotrue -FROM supabase/realtime:v2.112.6 AS realtime -FROM supabase/storage-api:v1.62.5 AS storage +FROM supabase/realtime:v2.112.9 AS realtime +FROM supabase/storage-api:v1.63.1 AS storage FROM supabase/logflare:1.46.0 AS logflare # Append to JobImages when adding new dependencies below FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ diff --git a/packages/stack/src/versions.ts b/packages/stack/src/versions.ts index ce7b086789..bd675abf2d 100644 --- a/packages/stack/src/versions.ts +++ b/packages/stack/src/versions.ts @@ -50,12 +50,12 @@ export const DEFAULT_VERSIONS: VersionManifest = { postgrest: "14.14", auth: "2.192.0", "edge-runtime": "1.74.2", - realtime: "2.112.6", - storage: "1.62.5", + realtime: "2.112.9", + storage: "1.63.1", imgproxy: "v3.8.0", mailpit: "v1.30.2", pgmeta: "0.96.6", - studio: "2026.07.06-sha-66cf431", + studio: "2026.07.07-sha-a6a04f2", analytics: "1.46.0", vector: "0.53.0-alpine", pooler: "2.9.7", From 970873372b1c65abaae38f32e83c3c77eefdd2b6 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 8 Jul 2026 11:08:33 +0100 Subject: [PATCH 20/49] fix(cli): remove "nano" from projects/branches create --size enum (#5799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Bug fix (Go parity). ## What is the current behavior? Go's `--size` flag on `projects create` (`apps/cli-go/cmd/projects.go:34-55`) is a single shared 18-value `EnumFlag` reused by `branches create` (`apps/cli-go/cmd/branches.go:212`), and does not include `"nano"` (or `"pico"`). The TS legacy port's `INSTANCE_SIZES` (`projects/create/create.command.ts`) and `BRANCH_SIZES` (`branches/create/create.command.ts`) both included `"nano"` as a valid `Flag.choice` value, so `--size nano` silently succeeded in TS while Go rejects it. Verified directly against the real, compiled Go binary (`apps/cli-go`) rather than just reading source — `projects create --size nano` genuinely errors with `invalid argument "nano" for "--size" flag: must be one of [...]`, confirming this is current, shipped Go behavior and not a stale enum. Note: the live Management API contract (`packages/api/src/generated/contracts.ts`) does list `"nano"`/`"pico"` as accepted `desired_instance_size` values, and `next/`'s independently-authored `branches create` already exposes both. Neither the Go CLI nor this legacy port surfaces them as CLI options, though — that's a separate product question (filed as CLI-1897) about whether these tiers should eventually be CLI-selectable, not a reason to keep an option the legacy shell's own Go-parity contract says should be rejected. ## What is the new behavior? `--size nano` is now rejected identically on both `projects create` and `branches create`, matching Go's 18-value enum exactly. Fixes CLI-1869. See also CLI-1897 (product decision on nano/pico) and CLI-1898 (unrelated pre-existing bug in `effect`'s `Flag.choice` error-message formatting, found during this PR's review). --- .../branches/create/create.command.ts | 1 - .../create/create.integration.test.ts | 46 ++++++++++++++- .../projects/create/create.command.ts | 15 +++-- .../create/create.integration.test.ts | 56 ++++++++++++++++++- 4 files changed, 105 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/legacy/commands/branches/create/create.command.ts b/apps/cli/src/legacy/commands/branches/create/create.command.ts index 192a9a3530..1cc85ecbce 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.command.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.command.ts @@ -44,7 +44,6 @@ const BRANCH_SIZES = [ "48xlarge_optimized_memory", "4xlarge", "8xlarge", - "nano", "small", "xlarge", ] as const; diff --git a/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts b/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts index fcbb44aa0f..8fb2a9e5d5 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts @@ -1,8 +1,10 @@ import type { V1CreateABranchOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Option } from "effect"; +import { Command } from "effect/unstable/cli"; import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../../shared/legacy/global-flags.ts"; import { LEGACY_VALID_REF, buildLegacyTestRuntime, @@ -13,7 +15,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import type { LegacyBranchesCreateFlags } from "./create.command.ts"; +import { legacyBranchesCreateCommand, type LegacyBranchesCreateFlags } from "./create.command.ts"; import { legacyBranchesCreate } from "./create.handler.ts"; type CreatedBranch = typeof V1CreateABranchOutput.Type; @@ -282,4 +284,44 @@ describe("legacy branches create integration", () => { expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); + + // Go parity (`apps/cli-go/cmd/projects.go:34-55`, reused by `branches create` + // at `apps/cli-go/cmd/branches.go:212`): Go's --size EnumFlag is an 18-value + // list that does not include "nano" (or "pico") and rejects any other value + // at flag-parse time. TS previously listed "nano" as a valid choice, silently + // succeeding where Go errors. + it.live("rejects --size nano at flag-parse time, matching Go's 18-value enum", () => { + const root = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), + Command.withSubcommands([legacyBranchesCreateCommand]), + ); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(root, { version: "0.0.0-test" })(["create", "--size", "nano"]), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(rejectsInvalidSizeChoice(Cause.squash(exit.cause))).toBe(true); + } + }) as Effect.Effect; + }); }); + +// Distinguishes "the --size flag itself was rejected at parse time" from any +// other failure (e.g. a missing runtime service in this minimal test setup), +// so the regression test above can't pass for the wrong reason. +function rejectsInvalidSizeChoice(error: unknown): boolean { + if (typeof error !== "object" || error === null || !("errors" in error)) return false; + const { errors } = error; + if (!Array.isArray(errors)) return false; + return errors.some( + (candidate: unknown) => + typeof candidate === "object" && + candidate !== null && + "_tag" in candidate && + candidate._tag === "InvalidValue" && + "option" in candidate && + candidate.option === "size", + ); +} 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 238f33c508..d1be7dbb51 100644 --- a/apps/cli/src/legacy/commands/projects/create/create.command.ts +++ b/apps/cli/src/legacy/commands/projects/create/create.command.ts @@ -27,25 +27,24 @@ const AWS_REGIONS = [ ] as const; const INSTANCE_SIZES = [ - "nano", - "micro", - "small", - "medium", "large", - "xlarge", - "2xlarge", - "4xlarge", - "8xlarge", + "medium", + "micro", "12xlarge", "16xlarge", "24xlarge", "24xlarge_high_memory", "24xlarge_optimized_cpu", "24xlarge_optimized_memory", + "2xlarge", "48xlarge", "48xlarge_high_memory", "48xlarge_optimized_cpu", "48xlarge_optimized_memory", + "4xlarge", + "8xlarge", + "small", + "xlarge", ] as const; const config = { diff --git a/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts b/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts index f21138df6c..d38c35c021 100644 --- a/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts @@ -1,8 +1,10 @@ import type { OrganizationResponseV1, V1CreateAProjectOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Option } from "effect"; +import { Command } from "effect/unstable/cli"; import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../../shared/legacy/global-flags.ts"; import { type LegacyApiResponse, type LegacyHttpMethod, @@ -13,7 +15,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import type { LegacyProjectsCreateFlags } from "./create.command.ts"; +import { legacyProjectsCreateCommand, type LegacyProjectsCreateFlags } from "./create.command.ts"; import { legacyProjectsCreate } from "./create.handler.ts"; const CREATED: typeof V1CreateAProjectOutput.Type = { @@ -451,4 +453,54 @@ describe("legacy projects create integration", () => { expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); + + // Go parity (`apps/cli-go/cmd/projects.go:34-55`): Go's --size EnumFlag is an + // 18-value list that does not include "nano" (or "pico") and rejects any other + // value at flag-parse time. TS previously listed "nano" as a valid choice, + // silently succeeding where Go errors. + it.live("rejects --size nano at flag-parse time, matching Go's 18-value enum", () => { + const root = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), + Command.withSubcommands([legacyProjectsCreateCommand]), + ); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(root, { version: "0.0.0-test" })([ + "create", + "alpha", + "--org-id", + "acme", + "--db-password", + "s3cret-pass", + "--region", + "us-east-1", + "--size", + "nano", + ]), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(rejectsInvalidSizeChoice(Cause.squash(exit.cause))).toBe(true); + } + }) as Effect.Effect; + }); }); + +// Distinguishes "the --size flag itself was rejected at parse time" from any +// other failure (e.g. a missing runtime service in this minimal test setup), +// so the regression test above can't pass for the wrong reason. +function rejectsInvalidSizeChoice(error: unknown): boolean { + if (typeof error !== "object" || error === null || !("errors" in error)) return false; + const { errors } = error; + if (!Array.isArray(errors)) return false; + return errors.some( + (candidate: unknown) => + typeof candidate === "object" && + candidate !== null && + "_tag" in candidate && + candidate._tag === "InvalidValue" && + "option" in candidate && + candidate.option === "size", + ); +} From ab241c95adca7c81c130923a83ff9a7ff0af385b Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 8 Jul 2026 11:10:12 +0100 Subject: [PATCH 21/49] docs(cli): disclose --high-availability as TS-only on projects create (#5800) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Docs update (Go parity disclosure) + telemetry-list correctness fix. ## What is the current behavior? `--high-availability` on `projects create` has no Go CLI equivalent — Go's `cmd/projects.go` never registers such a flag, and its `RunE` closure never sets `HighAvailability` on the create request body even though the underlying Management API field exists. Unlike `--reveal` on `projects api-keys` (which explicitly documents itself as a TS-only addition, in a code comment, `SIDE_EFFECTS.md`, and `docs/go-cli-porting-status.md`'s "Flag divergences" list), `--high-availability` was undisclosed everywhere. It was also listed in `safeFlags: ["org-id", "high-availability"]`, implying Go-parity telemetry-safety that doesn't exist (Go's `markFlagTelemetrySafe` only covers `org-id`). Since it's a boolean flag, this had no actual runtime effect — boolean flag values are always logged verbatim by the instrumentation regardless of `safeFlags` membership — but the array wrongly implied otherwise. The linked ticket (CLI-1870) offered two valid resolutions: remove the flag for strict parity, or disclose it as TS-only. This PR disclosure, since `--high-availability` is a real, working, user-facing capability that Go's CLI simply never got around to exposing (not a Go bug being ported forward) — removing it would regress anyone currently relying on it. ## What is the new behavior? `--high-availability` is now disclosed as TS-only in a code comment, `SIDE_EFFECTS.md`, and `docs/go-cli-porting-status.md`'s "Flag divergences" list, matching the `--reveal` precedent exactly. Removed from `safeFlags` (now just `["org-id"]`) to match Go's actual telemetry-safe set. Fixes CLI-1870. --- apps/cli/docs/go-cli-porting-status.md | 3 ++ .../commands/projects/create/SIDE_EFFECTS.md | 30 +++++++++++-------- .../projects/create/create.command.ts | 16 ++++++---- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 0e596515b4..125c1f795c 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -323,6 +323,9 @@ Flag divergences from the Go reference: `reveal=true` so the Management API returns the full secret keys (`sb_secret_...`) in full instead of redacting them, addressing issue #4775. Default behavior (omitted flag) matches Go exactly. +- `projects create` has a TS-only `--high-availability` flag (no Go equivalent). It sets + `high_availability` in the create request body. Default behavior (omitted flag) matches + Go exactly. Behavioral divergences from the Go reference: diff --git a/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md index 73d151ebd5..1a9d18c419 100644 --- a/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md @@ -40,22 +40,22 @@ ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------ | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--org-id`, `--high-availability` are telemetry-safe) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--org-id` is telemetry-safe, matching Go) | ## Flags -| Flag | Type | Required (non-interactive) | Description | -| --------------------- | ------ | -------------------------- | ----------------------------------------------- | -| `[project name]` | arg | yes (non-interactive) | Name of the project (positional argument) | -| `--org-id` | string | yes (non-interactive) | Organization ID (slug) to create the project in | -| `--db-password` | string | yes (non-interactive) | Database password for the project | -| `--region` | enum | yes (non-interactive) | AWS region for the project | -| `--size` | enum | no | Desired instance size | -| `--high-availability` | bool | no | Enable high availability for the project | -| `--interactive` | bool | no (default: true) | Enable interactive mode (hidden flag) | -| `--plan` | string | no | Plan selection (hidden flag) | +| Flag | Type | Required (non-interactive) | Description | +| --------------------- | ------ | -------------------------- | ---------------------------------------------------------------------------- | +| `[project name]` | arg | yes (non-interactive) | Name of the project (positional argument) | +| `--org-id` | string | yes (non-interactive) | Organization ID (slug) to create the project in | +| `--db-password` | string | yes (non-interactive) | Database password for the project | +| `--region` | enum | yes (non-interactive) | AWS region for the project | +| `--size` | enum | no | Desired instance size | +| `--high-availability` | bool | no | Enable high availability for the project (**TS-only, no Go CLI equivalent**) | +| `--interactive` | bool | no (default: true) | Enable interactive mode (hidden flag) | +| `--plan` | string | no | Plan selection (hidden flag) | ## Output @@ -93,4 +93,8 @@ One `result` event on success. flags and the positional project name argument are required. - The `--size` flag, when provided, sets the `desired_instance_size` field in the request body. - The `--high-availability` flag, when provided, sets the `high_availability` field in the request body. + This is a TS-only flag with no Go CLI equivalent: `apps/cli-go/cmd/projects.go`'s `init()` (~line 133) + never registers a `high-availability` flag, and the create command's `RunE` closure (~line 74) never sets + `HighAvailability` on the request body, even though the underlying API field exists — matching how + `--reveal` is disclosed on `projects api-keys`. - The `--plan` flag is hidden and reserved. 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 d1be7dbb51..29e63c37ad 100644 --- a/apps/cli/src/legacy/commands/projects/create/create.command.ts +++ b/apps/cli/src/legacy/commands/projects/create/create.command.ts @@ -68,6 +68,10 @@ const config = { Flag.withDescription("Select a desired instance size for your project."), Flag.optional, ), + // TS-only, no Go CLI equivalent: `cmd/projects.go`'s `init()` never registers a + // `high-availability` flag, and the `RunE` closure's `api.V1CreateProjectBody{...}` + // never sets `HighAvailability` even though the API field exists — disclosed in + // SIDE_EFFECTS.md, matching how `--reveal` is disclosed on `projects api-keys`. highAvailability: Flag.boolean("high-availability").pipe( Flag.withDescription("Enable high availability for the project."), Flag.optional, @@ -98,11 +102,13 @@ export const legacyProjectsCreateCommand = Command.make("create", config).pipe( ]), Command.withHandler((flags) => legacyProjectsCreate(flags).pipe( - withLegacyCommandInstrumentation({ - flags, - safeFlags: ["org-id", "high-availability"], - config, - }), + // `high-availability` is intentionally not in `safeFlags`: Go marks only + // `org-id` telemetry-safe (`markFlagTelemetrySafe`), and it's a boolean flag + // anyway — boolean values are always logged verbatim by the instrumentation + // regardless of `safeFlags`. See the same pattern on `projects api-keys`'s + // `--reveal`. `config` is passed so `region`/`size` (both `Flag.choice`) + // are auto-detected as telemetry-safe, matching Go's `isEnumFlag`. + withLegacyCommandInstrumentation({ flags, safeFlags: ["org-id"], config }), withJsonErrorHandling, ), ), From 85bf2ce503129146a9c07a4dddbe0de237e6894b Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 8 Jul 2026 15:37:09 +0100 Subject: [PATCH 22/49] fix(cli): run pg-delta/--experimental gate before mutex check in db schema declarative (#5828) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed `db schema declarative generate`/`sync` ran their mutual-exclusivity flag check (`db-url`/`linked`/`local`, `apply`/`no-apply`) BEFORE the pg-delta/`--experimental` gate (`legacyRequirePgDelta`). Go's cobra runs `PersistentPreRunE` (the gate) before `ValidateFlagGroups` (mutex check) — confirmed against `apps/cli-go/cmd/db_schema_declarative.go` and the actual `cobra@v1.10.2` source — so invoking either command with conflicting flags and no `--experimental` surfaced the wrong error in the TS shell vs Go. Same bug class already fixed for `storage ls/cp/mv/rm` in CLI-1855 (#5768); this mirrors that precedent as closely as the code structure allows. Declarative's gate needs a config read (`legacyReadDbToml`) that storage's didn't, so the check lives inline in each handler's body rather than at the `.command.ts` level — moving the config read ahead of the mutex check as part of the same reorder is also more correct (Go's `PersistentPreRunE` loads config unconditionally before validating flag groups too). Swaps the order in both handlers, fixes misleading ordering comments (and two stale Go line-number citations found nearby), documents the precedence in both commands' `SIDE_EFFECTS.md`, and adds regression coverage for the "mutex conflict without `--experimental`" case in both `generate` and `sync`. Fixes CLI-1876 --- .../schema/declarative/declarative.errors.ts | 4 +- .../db/schema/declarative/declarative.gate.ts | 16 ++- .../declarative/generate/SIDE_EFFECTS.md | 8 +- .../declarative/generate/generate.handler.ts | 41 +++--- .../generate/generate.integration.test.ts | 124 ++++++++++++++++- .../schema/declarative/sync/SIDE_EFFECTS.md | 8 +- .../schema/declarative/sync/sync.command.ts | 2 +- .../schema/declarative/sync/sync.handler.ts | 35 +++-- .../declarative/sync/sync.integration.test.ts | 126 +++++++++++++++++- .../legacy-experimental-gate.unit.test.ts | 23 +++- apps/cli/src/shared/legacy/global-flags.ts | 43 +++++- 11 files changed, 377 insertions(+), 53 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts index a97de5224d..4ff6cb9ab5 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts @@ -29,8 +29,8 @@ export class LegacyDeclarativeNonInteractiveError extends Data.TaggedError( /** * A mutually-exclusive flag group was violated. Reproduces cobra's * `MarkFlagsMutuallyExclusive` `ValidateFlagGroups` error byte-for-byte: - * - `generate`: `db-url`/`linked`/`local` (`apps/cli-go/cmd/db_schema_declarative.go:499`) - * - `sync`: `apply`/`no-apply` (`apps/cli-go/cmd/db_schema_declarative.go:490`) + * - `generate`: `db-url`/`linked`/`local` (`apps/cli-go/cmd/db_schema_declarative.go:570`) + * - `sync`: `apply`/`no-apply` (`apps/cli-go/cmd/db_schema_declarative.go:561`) * Both fail before any side effects run, matching cobra's pre-RunE validation. */ export class LegacyDeclarativeMutuallyExclusiveFlagsError extends Data.TaggedError( diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.ts index 506af3485d..bd4777e545 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.ts @@ -28,11 +28,17 @@ export function legacyPgDeltaSuggestion(configPath: string): string { } /** - * The Effect-CLI replacement for Go's `PersistentPreRunE` gate: invoke at the - * top of each declarative leaf handler. Fails with - * `LegacyDeclarativeNotEnabledError` (carrying the byte-exact message + - * suggestion) when neither `--experimental` nor `[experimental.pgdelta]` enables - * pg-delta. + * The Effect-CLI replacement for Go's `dbDeclarativeCmd.PersistentPreRunE` gate + * (`apps/cli-go/cmd/db_schema_declarative.go:49-99`). Cobra runs + * `PersistentPreRunE` BEFORE `ValidateFlagGroups()` (mutual-exclusivity checks) + * and `RunE` (`cobra@v1.10.2/command.go:985,1010,1014`), so this gate must run + * before the `MarkFlagsMutuallyExclusive` check in the same command — `db-url`/ + * `linked`/`local` on `generate` (`:570`), `apply`/`no-apply` on `sync` (`:561`) + * — a closed gate must win over a flag-group conflict, not the other way + * around. Invoke at the top of each declarative leaf handler's body, before + * that handler's mutex check. Fails with `LegacyDeclarativeNotEnabledError` + * (carrying the byte-exact message + suggestion) when neither `--experimental` + * nor `[experimental.pgdelta]` enables pg-delta. */ export const legacyRequirePgDelta = Effect.fnUntraced(function* (opts: { readonly experimental: boolean; diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md index de3e3b967f..bd009a4af3 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md @@ -47,11 +47,17 @@ pg-delta catalog (source) against the target database's catalog (target). | Code | Condition | | ---- | --------------------------------------------------------------------- | | `0` | success (files written, or skipped after a declined prompt) | -| `1` | conflicting `--db-url`/`--linked`/`--local` (mutually exclusive) | | `1` | pg-delta not enabled (no `--experimental` / `[experimental.pgdelta]`) | +| `1` | conflicting `--db-url`/`--linked`/`--local` (mutually exclusive) | | `1` | non-interactive mode with no explicit target | | `1` | shadow-database / edge-runtime / export failure | +The pg-delta gate and the mutex check are both raised before any side effects run, +but the gate wins when both conditions apply simultaneously: Go's +`PersistentPreRunE` runs before `ValidateFlagGroups()` +(`cobra@v1.10.2/command.go:985,1010`), so a closed gate (missing `--experimental`) +surfaces before a `--db-url`/`--linked`/`--local` conflict is ever checked. + ## Output Diagnostics (target resolution, prompts, `Declarative schema written to `) diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts index fbcb89a721..3cec545df7 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts @@ -1,8 +1,8 @@ import { Effect, FileSystem, Option, Path } from "effect"; import { - LegacyExperimentalFlag, LegacyYesFlag, + legacyResolveExperimentalWithProjectEnv, } from "../../../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../../../shared/output/output.service.ts"; import { Tty } from "../../../../../../shared/runtime/tty.service.ts"; @@ -10,6 +10,7 @@ import { LegacyCliConfig } from "../../../../../config/legacy-cli-config.service import { legacyBold } from "../../../../../shared/legacy-colors.ts"; import { legacyReadProjectRefFile } from "../../../../../shared/legacy-temp-paths.ts"; import { + legacyLoadProjectEnv, legacyReadDbToml, legacyResolveDeclarativeDir, } from "../../../../../shared/legacy-db-config.toml-read.ts"; @@ -44,7 +45,14 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; - const experimental = yield* LegacyExperimentalFlag; + // Go's `dbDeclarativeCmd.PersistentPreRunE` calls `flags.LoadConfig` — which runs + // `loadNestedEnv` and `os.Setenv`s each project-.env key — BEFORE reading + // `viper.GetBool("EXPERIMENTAL")` for the gate below (`apps/cli-go/cmd/ + // db_schema_declarative.go:73-78`, `pkg/config/config.go:789`). Load the project env + // first and resolve against it, as `db reset` does for its own experimental gate, so a + // `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` opens the gate too. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnv); const yes = yield* LegacyYesFlag; // The resolved linked ref (explicit `--linked` only), hoisted so the post-run @@ -52,9 +60,23 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec let linkedProjectRef: string | undefined; yield* Effect.gen(function* () { + const baseToml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); + // Gate before the mutex check below — order matters; see + // legacyRequirePgDelta's doc comment for why. The pg-delta gate also runs on + // the BASE config: Go's declarative `PersistentPreRunE` gates before the root + // `ParseDatabaseConfig` reloads any `[remotes.]` block, so a remote + // `experimental.pgdelta.enabled = true` must NOT enable a base-disabled + // command without `--experimental`. + yield* legacyRequirePgDelta({ + experimental, + pgDeltaEnabled: baseToml.pgDelta.enabled, + configPath: path.join("supabase", "config.toml"), + }); + // cobra `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` - // (`apps/cli-go/cmd/db_schema_declarative.go:499`) runs before PreRunE/RunE, - // so reject conflicting targets before reading config or the pg-delta gate. + // (`apps/cli-go/cmd/db_schema_declarative.go:570`) runs via + // `ValidateFlagGroups()`, which cobra invokes AFTER `PersistentPreRunE` (the + // gate above) — see legacyRequirePgDelta's doc comment for the full ordering. // "Set" follows cobra's `Changed`: Option set when `Some`, boolean when `true`. const exclusive: Array = []; if (Option.isSome(flags.dbUrl)) exclusive.push("db-url"); @@ -68,17 +90,6 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec ); } - const baseToml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); - // The pg-delta gate runs on the BASE config: Go's declarative `PersistentPreRunE` - // gates before the root `ParseDatabaseConfig` reloads any `[remotes.]` block, - // so a remote `experimental.pgdelta.enabled = true` must NOT enable a - // base-disabled command without `--experimental`. - yield* legacyRequirePgDelta({ - experimental, - pgDeltaEnabled: baseToml.pgDelta.enabled, - configPath: path.join("supabase", "config.toml"), - }); - // Explicit `--linked`: Go re-loads config with the resolved ref (root // `ParseDatabaseConfig` linked branch), so a matching `[remotes.]` block // overrides `experimental.pgdelta.*` (declarative_schema_path / format_options) diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts index 8241336bd5..48ade9b8a6 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -12,6 +12,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag, LegacyExperimentalFlag, @@ -48,6 +49,7 @@ const EXPORT_JSON = JSON.stringify({ interface SetupOpts { experimental?: boolean; + args?: ReadonlyArray; yes?: boolean; stdinIsTty?: boolean; promptConfirmResponses?: ReadonlyArray; @@ -147,6 +149,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockLegacyCliConfig({ workdir, projectId: opts.projectId ?? Option.some("test") }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? true), + Layer.succeed(CliArgs, { args: opts.args ?? ["db", "schema", "declarative", "generate"] }), Layer.succeed(LegacyYesFlag, opts.yes ?? false), Layer.succeed(LegacyNetworkIdFlag, opts.networkId ?? Option.none()), Layer.succeed(LegacyDnsResolverFlag, "native"), @@ -204,10 +207,11 @@ describe("legacy db schema declarative generate integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects conflicting targets (--local --linked) before the pg-delta gate", () => { - // cobra MarkFlagsMutuallyExclusive("db-url", "linked", "local") runs before - // PreRunE, so this fails even when pg-delta is not enabled. - const { layer } = setup(tmp.current, { experimental: false }); + it.effect("--local --linked with --experimental fails with the mutex error", () => { + // Go's declarative PersistentPreRunE gate (db_schema_declarative.go:49-99) runs + // BEFORE cobra's ValidateFlagGroups() mutex check (cobra@v1.10.2/command.go:985, + // 1010), so the mutex error only surfaces once the gate is open. + const { layer } = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacyDbSchemaDeclarativeGenerate( @@ -223,6 +227,118 @@ describe("legacy db schema declarative generate integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect( + "--local --linked without --experimental fails with the gate error, not the mutex error", + () => { + // Mirrors storage's experimental-gate-vs-mutex ordering fix (CLI-1855 / CLI-1876): + // the pg-delta gate runs before the mutex check, so an unopened gate wins even + // when the flags would also violate mutual exclusivity. + const { layer } = setup(tmp.current, { experimental: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeGenerate( + flags({ local: Option.some(true), linked: Option.some(true) }), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeNotEnabledError"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "--local --linked with SUPABASE_EXPERIMENTAL env (no --experimental flag) fails with the mutex error", + () => { + // Go's gate reads viper.GetBool("EXPERIMENTAL") (db_schema_declarative.go:78), + // which picks up SUPABASE_EXPERIMENTAL via viper.AutomaticEnv (root.go:318-334), + // so an env-only experimental session still opens the gate and lets the mutex + // check fire. legacyResolveExperimental (not the raw LegacyExperimentalFlag) is + // what makes the TS gate honor the env var the same way. + const { layer } = setup(tmp.current, { experimental: false }); + const ENV = "SUPABASE_EXPERIMENTAL"; + return Effect.gen(function* () { + const saved = process.env[ENV]; + process.env[ENV] = "1"; + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeGenerate( + flags({ local: Option.some(true), linked: Option.some(true) }), + ), + ); + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeMutuallyExclusiveFlagsError", + message: + "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "an explicit --experimental=false closes the gate even when SUPABASE_EXPERIMENTAL is set", + () => { + // viper's bound-pflag lookup returns the flag value whenever Changed is true — + // BEFORE falling back to AutomaticEnv (viper@v1.21.0/viper.go:1176-1178) — so an + // explicit --experimental=false must win over SUPABASE_EXPERIMENTAL=1, closing the + // gate instead of letting the env value override it. + const { layer } = setup(tmp.current, { + experimental: false, + args: ["db", "schema", "declarative", "generate", "--experimental=false"], + }); + const ENV = "SUPABASE_EXPERIMENTAL"; + return Effect.gen(function* () { + const saved = process.env[ENV]; + process.env[ENV] = "1"; + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })), + ); + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeNotEnabledError"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "--local --linked with SUPABASE_EXPERIMENTAL set only in the project .env fails with the mutex error", + () => { + // Go's flags.LoadConfig runs loadNestedEnv (which os.Setenv's each project-.env key) + // before dbDeclarativeCmd.PersistentPreRunE reads viper.GetBool("EXPERIMENTAL") + // (apps/cli-go/cmd/db_schema_declarative.go:73-78, pkg/config/config.go:789), so a + // SUPABASE_EXPERIMENTAL set only in supabase/.env opens the gate and lets the mutex + // check fire, same as the shell-env case above. + const saved = process.env["SUPABASE_EXPERIMENTAL"]; + delete process.env["SUPABASE_EXPERIMENTAL"]; + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_EXPERIMENTAL=true\n"); + const { layer } = setup(tmp.current, { experimental: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeGenerate( + flags({ local: Option.some(true), linked: Option.some(true) }), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeMutuallyExclusiveFlagsError", + message: + "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", + }); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + if (saved === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; + else process.env["SUPABASE_EXPERIMENTAL"] = saved; + }), + ), + ); + }, + ); + it.effect("explicit --local: provisions baseline, exports, writes declarative files", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 83250c0487..e2182911be 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -45,12 +45,18 @@ as a new timestamped migration. | Code | Condition | | ---- | --------------------------------------------------------------------------------------------------- | | `0` | success (migration created, applied, or "No schema changes found") | -| `1` | conflicting `--apply`/`--no-apply` (mutually exclusive) | | `1` | pg-delta not enabled | +| `1` | conflicting `--apply`/`--no-apply` (mutually exclusive) | | `1` | no declarative schema files found | | `1` | shadow-database / edge-runtime / diff failure | | `1` | apply failure (when applied) — propagated from the native migration apply (`applyMigrationToLocal`) | +The pg-delta gate and the mutex check are both raised before any side effects run, +but the gate wins when both conditions apply simultaneously: Go's +`PersistentPreRunE` runs before `ValidateFlagGroups()` +(`cobra@v1.10.2/command.go:985,1010`), so a closed gate (missing `--experimental`) +surfaces before an `--apply`/`--no-apply` conflict is ever checked. + ## Output Text mode only. The generated SQL, the created-migration path, drop-statement diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts index e06c092a1f..db9da924de 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts @@ -33,7 +33,7 @@ const config = { Flag.optional, ), // cobra's `MarkFlagsMutuallyExclusive("apply", "no-apply")` keys off `flag.Changed`, - // not the value (`cmd/db_schema_declarative.go:490`), so model presence with `Option` + // not the value (`cmd/db_schema_declarative.go:561`), so model presence with `Option` // so `--apply=false --no-apply` still trips the conflict. The apply decision below // reads the resolved value via `Option.getOrElse`. apply: Flag.boolean("apply").pipe( diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index 61f276e4c0..63f8d8a7e4 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts @@ -2,9 +2,9 @@ import { Cause, Clock, Effect, Exit, FileSystem, Option, Path } from "effect"; import { LegacyDnsResolverFlag, - LegacyExperimentalFlag, LegacyNetworkIdFlag, LegacyYesFlag, + legacyResolveExperimentalWithProjectEnv, } from "../../../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../../../shared/output/output.service.ts"; import { Tty } from "../../../../../../shared/runtime/tty.service.ts"; @@ -13,6 +13,7 @@ import { legacyBold, legacyRed, legacyYellow } from "../../../../../shared/legac import { LegacyDbConnection } from "../../../../../shared/legacy-db-connection.service.ts"; import { legacyGetHostname } from "../../../../../shared/legacy-hostname.ts"; import { + legacyLoadProjectEnv, legacyReadDbToml, legacyResolveDeclarativeDir, } from "../../../../../shared/legacy-db-config.toml-read.ts"; @@ -72,7 +73,14 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara const path = yield* Path.Path; const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; - const experimental = yield* LegacyExperimentalFlag; + // Go's `dbDeclarativeCmd.PersistentPreRunE` calls `flags.LoadConfig` — which runs + // `loadNestedEnv` and `os.Setenv`s each project-.env key — BEFORE reading + // `viper.GetBool("EXPERIMENTAL")` for the gate below (`apps/cli-go/cmd/ + // db_schema_declarative.go:73-78`, `pkg/config/config.go:789`). Load the project env + // first and resolve against it, as `db reset` does for its own experimental gate, so a + // `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` opens the gate too. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnv); const yes = yield* LegacyYesFlag; const networkId = yield* LegacyNetworkIdFlag; const dnsResolver = yield* LegacyDnsResolverFlag; @@ -89,10 +97,21 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara let linkedProjectRef: string | undefined; yield* Effect.gen(function* () { + const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); + // Gate before the mutex check below — order matters; see + // legacyRequirePgDelta's doc comment for why. + yield* legacyRequirePgDelta({ + experimental, + pgDeltaEnabled: toml.pgDelta.enabled, + configPath: path.join("supabase", "config.toml"), + }); + // cobra `MarkFlagsMutuallyExclusive("apply", "no-apply")` - // (`apps/cli-go/cmd/db_schema_declarative.go:490`) runs before PreRunE/RunE, - // so reject the conflict before reading config or the pg-delta gate, rather - // than letting `--no-apply` silently win in the apply-decision helper. + // (`apps/cli-go/cmd/db_schema_declarative.go:561`) runs via + // `ValidateFlagGroups()`, which cobra invokes AFTER `PersistentPreRunE` (the + // gate above) — see legacyRequirePgDelta's doc comment for the full ordering. + // Reject the conflict here rather than letting `--no-apply` silently win in + // the apply-decision helper. const exclusive: Array = []; if (Option.isSome(flags.apply)) exclusive.push("apply"); if (Option.isSome(flags.noApply)) exclusive.push("no-apply"); @@ -104,12 +123,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara ); } - const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); - yield* legacyRequirePgDelta({ - experimental, - pgDeltaEnabled: toml.pgDelta.enabled, - configPath: path.join("supabase", "config.toml"), - }); // `path.resolve` (not `path.join`) so an absolute `declarative_schema_path` is // used as-is, matching Go's `config.resolve` (which only prefixes the workdir onto // a relative path). `path.join(workdir, abs)` would mangle the absolute path. diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts index d1e83e7290..44dd7548b2 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -11,6 +11,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag, LegacyExperimentalFlag, @@ -44,6 +45,7 @@ const EXPORT_JSON = JSON.stringify({ interface SetupOpts { experimental?: boolean; + args?: ReadonlyArray; yes?: boolean; stdinIsTty?: boolean; diffSql?: string; @@ -151,6 +153,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockLegacyCliConfig({ workdir, projectId: opts.projectId ?? Option.some("test") }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? true), + Layer.succeed(CliArgs, { args: opts.args ?? ["db", "schema", "declarative", "sync"] }), Layer.succeed(LegacyYesFlag, opts.yes ?? false), Layer.succeed( LegacyNetworkIdFlag, @@ -199,10 +202,11 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects --apply and --no-apply together before the pg-delta gate", () => { - // cobra MarkFlagsMutuallyExclusive("apply", "no-apply") runs before PreRunE, - // so this fails even when pg-delta is not enabled. - const { layer } = setup(tmp.current, { experimental: false }); + it.effect("--apply and --no-apply together with --experimental fail with the mutex error", () => { + // Go's declarative PersistentPreRunE gate (db_schema_declarative.go:49-99) runs + // BEFORE cobra's ValidateFlagGroups() mutex check (cobra@v1.10.2/command.go:985, + // 1010), so the mutex error only surfaces once the gate is open. + const { layer } = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync( @@ -218,10 +222,122 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect( + "--apply and --no-apply together without --experimental fail with the gate error, not the mutex error", + () => { + // Mirrors storage's experimental-gate-vs-mutex ordering fix (CLI-1855 / CLI-1876): + // the pg-delta gate runs before the mutex check, so an unopened gate wins even + // when the flags would also violate mutual exclusivity. + const { layer } = setup(tmp.current, { experimental: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeSync( + flags({ apply: Option.some(true), noApply: Option.some(true) }), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeNotEnabledError"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "--apply and --no-apply together with SUPABASE_EXPERIMENTAL env (no --experimental flag) fail with the mutex error", + () => { + // Go's gate reads viper.GetBool("EXPERIMENTAL") (db_schema_declarative.go:78), + // which picks up SUPABASE_EXPERIMENTAL via viper.AutomaticEnv (root.go:318-334), + // so an env-only experimental session still opens the gate and lets the mutex + // check fire. legacyResolveExperimental (not the raw LegacyExperimentalFlag) is + // what makes the TS gate honor the env var the same way. + const { layer } = setup(tmp.current, { experimental: false }); + const ENV = "SUPABASE_EXPERIMENTAL"; + return Effect.gen(function* () { + const saved = process.env[ENV]; + process.env[ENV] = "1"; + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeSync( + flags({ apply: Option.some(true), noApply: Option.some(true) }), + ), + ); + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeMutuallyExclusiveFlagsError", + message: + "if any flags in the group [apply no-apply] are set none of the others can be; [apply no-apply] were all set", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "an explicit --experimental=false closes the gate even when SUPABASE_EXPERIMENTAL is set", + () => { + // viper's bound-pflag lookup returns the flag value whenever Changed is true — + // BEFORE falling back to AutomaticEnv (viper@v1.21.0/viper.go:1176-1178) — so an + // explicit --experimental=false must win over SUPABASE_EXPERIMENTAL=1, closing the + // gate instead of letting the env value override it. + const { layer } = setup(tmp.current, { + experimental: false, + args: ["db", "schema", "declarative", "sync", "--experimental=false"], + }); + const ENV = "SUPABASE_EXPERIMENTAL"; + return Effect.gen(function* () { + const saved = process.env[ENV]; + process.env[ENV] = "1"; + const exit = yield* Effect.exit(legacyDbSchemaDeclarativeSync(flags())); + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeNotEnabledError"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "--apply and --no-apply together with SUPABASE_EXPERIMENTAL set only in the project .env fail with the mutex error", + () => { + // Go's flags.LoadConfig runs loadNestedEnv (which os.Setenv's each project-.env key) + // before dbDeclarativeCmd.PersistentPreRunE reads viper.GetBool("EXPERIMENTAL") + // (apps/cli-go/cmd/db_schema_declarative.go:73-78, pkg/config/config.go:789), so a + // SUPABASE_EXPERIMENTAL set only in supabase/.env opens the gate and lets the mutex + // check fire, same as the shell-env case above. + const saved = process.env["SUPABASE_EXPERIMENTAL"]; + delete process.env["SUPABASE_EXPERIMENTAL"]; + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_EXPERIMENTAL=true\n"); + const { layer } = setup(tmp.current, { experimental: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeSync( + flags({ apply: Option.some(true), noApply: Option.some(true) }), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeMutuallyExclusiveFlagsError", + message: + "if any flags in the group [apply no-apply] are set none of the others can be; [apply no-apply] were all set", + }); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + if (saved === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; + else process.env["SUPABASE_EXPERIMENTAL"] = saved; + }), + ), + ); + }, + ); + it.effect("rejects --apply=false --no-apply as a conflict (Go flag.Changed)", () => { // cobra keys the mutex off flag.Changed, so an explicit `--apply=false` still // counts as set and conflicts with `--no-apply`, even though its value is false. - const { layer } = setup(tmp.current, { experimental: false }); + // The gate runs first (see legacyRequirePgDelta's doc comment), so --experimental + // is required here for the mutex error to be the one that surfaces. + const { layer } = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync( diff --git a/apps/cli/src/legacy/shared/legacy-experimental-gate.unit.test.ts b/apps/cli/src/legacy/shared/legacy-experimental-gate.unit.test.ts index a359ec4af7..bc07234903 100644 --- a/apps/cli/src/legacy/shared/legacy-experimental-gate.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-experimental-gate.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; +import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { LegacyExperimentalFlag } from "../../shared/legacy/global-flags.ts"; import { LegacyExperimentalRequiredError, @@ -8,7 +9,8 @@ import { } from "./legacy-experimental-gate.ts"; const ENV = "SUPABASE_EXPERIMENTAL"; -const withFlag = (value: boolean) => Layer.succeed(LegacyExperimentalFlag, value); +const withFlag = (value: boolean, args: ReadonlyArray = []) => + Layer.mergeAll(Layer.succeed(LegacyExperimentalFlag, value), Layer.succeed(CliArgs, { args })); describe("legacyRequireExperimental", () => { it.effect("passes when --experimental is set", () => @@ -43,4 +45,23 @@ describe("legacyRequireExperimental", () => { expect(exit._tag).toBe("Success"); }), ); + + it.effect( + "fails even with SUPABASE_EXPERIMENTAL=1 when --experimental=false is explicit (viper Changed wins)", + () => + Effect.gen(function* () { + // viper's bound-pflag lookup returns the flag value whenever Changed is true — + // BEFORE falling back to AutomaticEnv (viper@v1.21.0/viper.go:1176-1178) — so an + // explicit --experimental=false must win over SUPABASE_EXPERIMENTAL=1. + const saved = process.env[ENV]; + process.env[ENV] = "1"; + const error = yield* legacyRequireExperimental.pipe( + Effect.provide(withFlag(false, ["--experimental=false"])), + Effect.flip, + ); + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + expect(error).toBeInstanceOf(LegacyExperimentalRequiredError); + }), + ); }); diff --git a/apps/cli/src/shared/legacy/global-flags.ts b/apps/cli/src/shared/legacy/global-flags.ts index 03c4a37eea..c53c3ea999 100644 --- a/apps/cli/src/shared/legacy/global-flags.ts +++ b/apps/cli/src/shared/legacy/global-flags.ts @@ -151,29 +151,58 @@ export const legacyResolveYesWithProjectEnv = (projectEnv: Record` (pflag's `ParseBool` + * false set). Mirrors {@link legacyYesFlagExplicitlyFalse}: `--experimental` is bound to + * viper the same way `--yes` is (`apps/cli-go/cmd/root.go:318-334`), and viper's bound-pflag + * lookup returns the flag value whenever `Changed` is true — BEFORE falling back to + * `AutomaticEnv` — regardless of whether that value is `true` or `false` + * (`viper@v1.21.0/viper.go:1176-1178`). A plain boolean can't distinguish an explicit + * `--experimental=false` from the omitted default, so scan the raw argv. Only the `=false` + * form needs special handling: `--experimental` / `--experimental=true` are already `true`, + * so `flag || env` matches Go, and an omitted flag correctly falls through to the env value. + */ +const legacyExperimentalFlagExplicitlyFalse = (args: ReadonlyArray): boolean => + args.some( + (arg) => + arg.startsWith("--experimental=") && + PFLAG_FALSE_VALUES.has(arg.slice("--experimental=".length)), + ); + /** * `--experimental` resolved with Go's viper `AutomaticEnv` fallback: the gate in * `rootCmd.PersistentPreRunE` reads `viper.GetBool("EXPERIMENTAL")` * (`apps/cli-go/cmd/root.go:94`), so `SUPABASE_EXPERIMENTAL` enables experimental - * commands just like the flag. A passed `--experimental` wins over the env. + * commands just like the flag. An explicit `--experimental` — including + * `--experimental=false` — wins over the env, matching viper's bound-pflag precedence. */ export const legacyResolveExperimental = Effect.gen(function* () { const flag = yield* LegacyExperimentalFlag; + const cliArgs = yield* CliArgs; + if (legacyExperimentalFlagExplicitlyFalse(cliArgs.args)) { + return false; + } return flag || legacyViperEnvBool("SUPABASE_EXPERIMENTAL"); }); /** * `--experimental` resolved with the project `.env` consulted too, for commands that load the - * nested project env before branching on the experimental gate (`db reset`). Go's - * `ParseDatabaseConfig` runs `loadNestedEnv` — which `os.Setenv`s each project-.env key — - * before `reset.Run` reads `viper.GetBool("EXPERIMENTAL")`, so a `SUPABASE_EXPERIMENTAL` set - * only in `supabase/.env` enables the experimental path. The shell env still wins over the - * file value; a passed `--experimental` wins over both. `projectEnv` is the loaded map from - * `legacyLoadProjectEnv`. + * nested project env before branching on the experimental gate (`db reset`, + * `db schema declarative generate`/`sync`). Go's `ParseDatabaseConfig` / + * `dbDeclarativeCmd.PersistentPreRunE` run `loadNestedEnv` — which `os.Setenv`s each + * project-.env key — before reading `viper.GetBool("EXPERIMENTAL")`, so a + * `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` enables the experimental path. The + * shell env still wins over the file value; an explicit `--experimental` — including + * `--experimental=false` — wins over both, matching viper's bound-pflag precedence. + * `projectEnv` is the loaded map from `legacyLoadProjectEnv`. */ export const legacyResolveExperimentalWithProjectEnv = (projectEnv: Record) => Effect.gen(function* () { const flag = yield* LegacyExperimentalFlag; + const cliArgs = yield* CliArgs; + if (legacyExperimentalFlagExplicitlyFalse(cliArgs.args)) { + return false; + } return ( flag || legacyViperEnvBool("SUPABASE_EXPERIMENTAL") || From c0ed40c29bbd71a5ac8d385102dc7d5bb0cbb46a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 8 Jul 2026 16:28:04 +0100 Subject: [PATCH 23/49] fix(cli): exit 0 for bare group commands to match Go cobra parity (#5827) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed Invoking a legacy-shell group command that has subcommands but is itself not runnable — e.g. `supabase branches`, `supabase completion` — with no subcommand and no `--help` exited **1** in the TS legacy shell. Go's cobra CLI exits **0** for the identical invocation: a non-`Runnable()` command with no `RunE` internally returns `flag.ErrHelp`, which cobra's `ExecuteC()` maps to "print help, return nil error". Only explicit `--help` got exit 0 before this fix; the bare/missing-subcommand form did not, even though the printed help text was identical in both cases. `CliError.ShowHelp` (in `effect/unstable/cli`) already declares the correct exit code via Effect's own `Runtime.errorExitCode` marker (`0` for a clean `ShowHelp` with no errors, `1` otherwise) — `apps/cli/src/shared/cli/run.ts` now delegates to `Runtime.getErrorExitCode(Cause.squash(cause))` instead of hand-rolling `ShowHelp` classification, which is simpler and tracks the library's own source of truth for any future `CliError` additions. Also added an integration test driving the real `legacyBranchesCommand` through `Command.runWith` (confirming the actual `ShowHelp` cause shape), and an e2e test asserting the real compiled-binary exit code, since this bug is specifically about the real OS process exit code. ## Known related (not fixed here) A typo'd subcommand under a group (e.g. `supabase branches bogus`) still exits 1 where Go cobra exits 0 for a non-root command — this is pre-existing on `develop`, not a regression from this change, and out of scope for this fix. Filed as a follow-up. Fixes CLI-1906 --- apps/cli/src/shared/cli/run.e2e.test.ts | 24 +++++++ .../src/shared/cli/run.integration.test.ts | 66 +++++++++++++++++++ apps/cli/src/shared/cli/run.ts | 45 ++++++++----- apps/cli/src/shared/cli/run.unit.test.ts | 45 ++++++++++++- 4 files changed, 164 insertions(+), 16 deletions(-) create mode 100644 apps/cli/src/shared/cli/run.e2e.test.ts create mode 100644 apps/cli/src/shared/cli/run.integration.test.ts diff --git a/apps/cli/src/shared/cli/run.e2e.test.ts b/apps/cli/src/shared/cli/run.e2e.test.ts new file mode 100644 index 0000000000..1f399eb37e --- /dev/null +++ b/apps/cli/src/shared/cli/run.e2e.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "vitest"; +import { runSupabase } from "../../../tests/helpers/cli.ts"; + +/** + * CLI-1906: the real bug here is the actual OS process exit code — + * `ProcessControl.exit` calls real `process.exit(code)`, so only a genuine + * subprocess run proves the shipped binary's exit code changed. Everything + * else about this fix (`exitCodeForFailure`'s classification) is covered by + * `run.unit.test.ts` and `run.integration.test.ts`; this is the one minimal + * case that observes the real subprocess boundary. + */ +describe("legacy CLI process exit codes (CLI-1906)", () => { + test("bare `branches` (no subcommand, no --help) exits 0", async () => { + const { exitCode } = await runSupabase(["branches"], { entrypoint: "legacy" }); + expect(exitCode).toBe(0); + }); + + test("a genuine parse error still exits 1", async () => { + const { exitCode } = await runSupabase(["branches", "--this-flag-does-not-exist"], { + entrypoint: "legacy", + }); + expect(exitCode).toBe(1); + }); +}); diff --git a/apps/cli/src/shared/cli/run.integration.test.ts b/apps/cli/src/shared/cli/run.integration.test.ts new file mode 100644 index 0000000000..9ff4f7fd4e --- /dev/null +++ b/apps/cli/src/shared/cli/run.integration.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, Layer } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { legacyBranchesCommand } from "../../legacy/commands/branches/branches.command.ts"; +import { textCliOutputFormatter } from "../output/text-formatter.ts"; +import { CliArgs } from "./cli-args.service.ts"; +import { exitCodeForFailure } from "./run.ts"; + +/** + * CLI-1906: `supabase branches` (a legacy "group" command — subcommands, no + * runnable handler of its own) used to exit 1 when invoked bare, even though + * the printed help was identical to `supabase branches --help`, which already + * exited 0. These tests run the real `legacyBranchesCommand` definition + * through `Command.runWith` (same technique as `version.integration.test.ts`) + * so the `ShowHelp` cause shape is the one the real CLI actually produces, not + * a hand-rolled stand-in. `legacyBranchesCommand` is exercised directly + * (rather than nested under `legacyRoot`) because `legacyRoot`'s + * `Command.provide` (see `Command.ts`'s `provide`/`withSubcommands`) wraps its + * *entire* handle — including the bare/`--help`/parse-error paths exercised + * here — in the production output/proxy layer graph (`Layer.unwrap` reading + * every global flag, resolving the Go proxy binary, etc). `Effect.provide` + * still *builds* that layer graph before running the wrapped handle even on + * these runs; it just never gets *consumed*, because the `ShowHelp` failure + * fires before any leaf subcommand handler body executes. Exercising + * `legacyBranchesCommand` directly avoids needing to provide or mock that + * unused graph for a test that only cares about the `ShowHelp` cause shape. + */ +describe("legacy group command exit codes (CLI-1906)", () => { + const layerFor = (args: ReadonlyArray) => + Layer.mergeAll( + CliOutput.layer(textCliOutputFormatter()), + Layer.succeed(CliArgs, { args }), + BunServices.layer, + ); + + const runBranches = (args: ReadonlyArray) => + Effect.runPromiseExit( + Command.runWith(legacyBranchesCommand, { version: "0.0.0-test" })(args).pipe( + Effect.provide(layerFor(args)), + ), + ); + + test("bare `branches` (no subcommand, no --help) fails with a clean ShowHelp that maps to exit 0", async () => { + const exit = await runBranches([]); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + + expect(exitCodeForFailure(exit.cause)).toBe(0); + }); + + test("`branches --help` succeeds outright and exits 0", async () => { + const exit = await runBranches(["--help"]); + // The `--help` global flag is handled as a successful `GlobalFlag.Action`, so this + // never even reaches the ShowHelp-as-failure path bare `branches` goes through above. + expect(Exit.isSuccess(exit)).toBe(true); + }); + + test("`branches` with an unrecognized flag is a genuine parse error that still exits 1", async () => { + const exit = await runBranches(["--this-flag-does-not-exist"]); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + + expect(exitCodeForFailure(exit.cause)).toBe(1); + }); +}); diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 099c9a2ba8..48ab86fa05 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -1,7 +1,7 @@ import { BunServices } from "@effect/platform-bun"; import { ProjectConfigStore } from "@supabase/config"; import { unixHttpClientLayer } from "@supabase/stack"; -import { Cause, Effect, Exit, Fiber, Layer, Stdio } from "effect"; +import { Cause, Effect, Exit, Fiber, Layer, Runtime, Stdio } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; import { CLI_VERSION } from "./version.ts"; import { Credentials } from "../../next/auth/credentials.service.ts"; @@ -93,17 +93,26 @@ function formatterLayerFor( : CliOutput.layer(textCliOutputFormatter(context)); } -function isErrorRecord(error: unknown): error is Record { - return typeof error === "object" && error !== null; -} - -function isExplicitHelpCause(cause: Cause.Cause): boolean { - const error = Cause.findErrorOption(cause); - if (error._tag !== "Some" || !isErrorRecord(error.value)) return false; - if (error.value["_tag"] !== "ShowHelp") return false; - - const errors = error.value["errors"]; - return !Array.isArray(errors) || errors.length === 0; +/** + * Process exit code for a failed CLI run, matching Go cobra's exit-code + * mapping. Delegates to Effect's own `Runtime` exit-code protocol (the same + * one `Runtime.defaultTeardown` uses) rather than hand-rolling `ShowHelp` + * classification: `CliError.ShowHelp` declares + * `[Runtime.errorExitCode] = this.errors.length ? 1 : 0`, so a bare group + * command's default handler failing with `ShowHelp({ errors: [] })` (no + * subcommand given, e.g. `supabase branches`) reads as exit `0` here — matching + * Go cobra's non-`Runnable()` handling, which internally returns + * `flag.ErrHelp` and `ExecuteC()` maps that to "print help, return nil error". + * A `ShowHelp` with a non-empty `errors` array (a genuine parse/validation + * failure) reads as exit `1`, and any other failure (including a `Cause.die` + * defect with no typed `ShowHelp` marker at all) falls back to + * `Runtime.getErrorExitCode`'s default of `1`. An explicit `--help` invocation + * never reaches this function — it's handled earlier as a successful + * `GlobalFlag.Action` and exits 0 via the success path. + */ +export function exitCodeForFailure(cause: Cause.Cause): number { + if (Cause.hasInterruptsOnly(cause)) return 130; + return Runtime.getErrorExitCode(Cause.squash(cause)); } function projectContextLayerFor(runtimeLayer: Layer.Layer) { @@ -232,11 +241,17 @@ export async function runCli(rootCommand: Command.Command.Any, options: RunCliOp const output = yield* Output; const exit = yield* program.pipe(Effect.exit); if (Exit.isFailure(exit)) { - const interrupted = Cause.hasInterruptsOnly(exit.cause); - if (!interrupted && !isExplicitHelpCause(exit.cause)) { + const exitCode = exitCodeForFailure(exit.cause); + // Skip reporting for an interrupted run (130 — a signal, not a + // reportable error) and for a clean `ShowHelp` failure (0). Literal + // `--help` never reaches this branch — it's handled as a successful + // `GlobalFlag.Action` and exits 0 via the success path below. See + // `exitCodeForFailure` for why a "clean" ShowHelp failure (e.g. a bare + // group command with no subcommand) also maps to exit 0. + if (exitCode !== 0 && exitCode !== 130) { yield* output.fail(normalizeCause(exit.cause)); } - return yield* processControl.exit(interrupted ? 130 : 1); + return yield* processControl.exit(exitCode); } const exitCode = yield* processControl.getExitCode; return yield* processControl.exit(exitCode ?? 0); diff --git a/apps/cli/src/shared/cli/run.unit.test.ts b/apps/cli/src/shared/cli/run.unit.test.ts index bd2d80804e..f87d160533 100644 --- a/apps/cli/src/shared/cli/run.unit.test.ts +++ b/apps/cli/src/shared/cli/run.unit.test.ts @@ -1,6 +1,8 @@ +import { Cause } from "effect"; +import { CliError } from "effect/unstable/cli"; import { describe, expect, it } from "vitest"; -import { extractCommandPath, shouldUseGlobalSignalInterrupt } from "./run.ts"; +import { exitCodeForFailure, extractCommandPath, shouldUseGlobalSignalInterrupt } from "./run.ts"; describe("extractCommandPath", () => { it("returns positional command-path tokens", () => { @@ -45,3 +47,44 @@ describe("shouldUseGlobalSignalInterrupt", () => { expect(shouldUseGlobalSignalInterrupt([])).toBe(true); }); }); + +describe("exitCodeForFailure", () => { + // CLI-1906: a group command's default handler (e.g. bare `supabase branches`, which + // has subcommands but no runnable handler of its own) fails with exactly this shape: + // ShowHelp with an empty `errors` array. `CliError.ShowHelp` declares + // `[Runtime.errorExitCode] = this.errors.length ? 1 : 0`, so this reads as exit 0 — + // matching Go cobra's `flag.ErrHelp` handling for non-Runnable commands. Before + // CLI-1906, this case always returned 1. + it("exits 0 for a clean ShowHelp failure (bare group command)", () => { + const cause = Cause.fail(new CliError.ShowHelp({ commandPath: ["branches"], errors: [] })); + expect(exitCodeForFailure(cause)).toBe(0); + }); + + it("exits 1 for a ShowHelp cause carrying a genuine validation error", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ + commandPath: ["branches"], + errors: [new CliError.UnrecognizedOption({ option: "--bogus", suggestions: [] })], + }), + ); + expect(exitCodeForFailure(cause)).toBe(1); + }); + + it("exits 1 for a non-ShowHelp failure", () => { + const cause = Cause.fail(new Error("boom")); + expect(exitCodeForFailure(cause)).toBe(1); + }); + + // `Cause.squash` on a `Die` cause returns the raw defect (a plain `Error`, with no + // `Runtime.errorExitCode` marker at all). This must still fall back to the default + // failure exit code (1), not silently pass through as a "clean" exit — this is the real + // unexpected-crash path through `runCli` that must keep exiting 1. + it("exits 1 for a defect with no typed failure", () => { + const cause = Cause.die(new Error("unexpected crash")); + expect(exitCodeForFailure(cause)).toBe(1); + }); + + it("exits 130 when interrupted, regardless of any other failure reason", () => { + expect(exitCodeForFailure(Cause.interrupt())).toBe(130); + }); +}); From 02201c9fe52d59055cc3badaaec6e6b86548be11 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 8 Jul 2026 16:31:21 +0100 Subject: [PATCH 24/49] feat(cli): port supabase stop and status commands to native TypeScript (#5765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Ports `supabase stop` and `supabase status` from Go-proxy stubs to native TypeScript in the legacy CLI shell (CLI-1324). - Both commands now talk directly to Docker/Podman via subprocess, replicating Go's label-filtering and container-naming scheme byte-for-byte. Legacy `start` is still Go-proxied, so this intentionally does **not** route through `@supabase/stack/effect`'s daemon-based orchestration model — that substrate manages a different set of containers than the ones Go's binary actually creates, and using it would silently no-op against a real running stack. - New shared infrastructure (`legacy-docker-lifecycle`, `legacy-go-jwt`, `legacy-local-config-values`, `legacy-api-url`) is reused by both commands, matching Go's local-dev defaults exactly — including a Go-byte-exact JWT signer, since `@supabase/stack`'s own JWT generator uses a different issuer/claim order than what Go prints for local dev keys. - Adds `*.live.test.ts` as a documented test category (`AGENTS.md`) alongside unit/integration/e2e: black-box subprocess tests run by the `cli-e2e-ci` harness against a real platform. `stop`/`status` don't call the Management API, so their live tests spin up a real local Docker stack instead and verify against it directly (e.g. confirming Docker itself has no containers left after `stop`, not just trusting the CLI's exit code). ## Notable review findings fixed along the way - Table/status output was colorizing based on `stderr`'s TTY status while writing to `stdout` — piping stdout while stderr stayed a TTY (`supabase status | less`) would have corrupted output with ANSI escapes (the same bug class CLI-1546 fixed once before). - `--override-name` was leaking into pretty-mode output; Go's `PrettyPrint` rebuilds a fresh, un-overridden view and ignores it there. - `--backup`/`--no-backup`: Go's `--backup` flag is dead code (declared, never bound to a variable in `cmd/stop.go`) — the port now matches that exactly instead of an intended-but-never-true semantic. - Docker/Podman-both-missing errors now name the actual root cause instead of a generic "failed to ..." string. ## Post-review hardening: scoping and consolidation Two structural fixes on top of the port, addressing drift introduced while iterating on review feedback: - **`next/` no longer inherits Go-parity config semantics.** The four viper-compat behaviors added during review (unconditional `[remotes.*]` project-id checks, the deprecated-provider WARN, the widened `env()` reference pattern, and comma-split coercion into array-typed fields) are now gated behind an opt-in `goViperCompat` flag on `LoadProjectConfigOptions` — default off restores the pre-review behavior for `next/`, `packages/stack`, and other non-parity consumers, following the same opt-in pattern as `tomlOnly`/`skipEnvLocal`. Only legacy-shell callers opt in. Regression tests pin the default-off behavior, including the `next start` config-load path that would otherwise have started hard-failing on a malformed `[remotes.*].project_id`. - **Go's `Config.Validate` now has exactly one TS home.** `legacy/shared/legacy-config-validate.ts` replaces the validation orchestration previously duplicated between `legacy-db-config.toml-read.ts` (db/migration family) and `legacy-local-config-values.ts` (status/stop); both callers build a normalized `LegacyConfigValidationInput` from their own pipelines and call the shared validator, and a cross-caller parity test feeds identical broken configs through both real pipelines asserting identical error strings. Consolidating surfaced and fixed three real divergences between the two copies: `db.major_version = 0` now emits Go's `Missing required field in config: db.major_version` (the db loader previously emitted a non-Go message), and the captcha provider-enum and email `content`-vs-`content_path` checks are now present in both paths. CLOSES CLI-1324 --- apps/cli/AGENTS.md | 20 + apps/cli/docs/go-cli-porting-status.md | 4 +- .../commands/config/push/push.handler.ts | 5 +- .../db/reset/reset.integration.test.ts | 12 +- .../functions/deploy/deploy.handler.ts | 1 + .../commands/functions/new/new.handler.ts | 4 +- .../commands/functions/serve/serve.handler.ts | 1 + .../functions/serve/serve.integration.test.ts | 14 +- .../gen/signing-key/signing-key.handler.ts | 2 +- .../commands/gen/types/types.handler.ts | 4 +- .../commands/secrets/set/set.handler.ts | 20 +- .../secrets/set/set.integration.test.ts | 33 + .../commands/seed/buckets/buckets.handler.ts | 1 + .../legacy/commands/status/SIDE_EFFECTS.md | 205 +- .../legacy/commands/status/status.command.ts | 71 +- .../status/status.command.unit.test.ts | 75 + .../legacy/commands/status/status.errors.ts | 58 + .../legacy/commands/status/status.handler.ts | 354 +++- .../status/status.integration.test.ts | 972 +++++++++- .../commands/status/status.live.test.ts | 54 + .../legacy/commands/status/status.pretty.ts | 276 +++ .../status/status.pretty.unit.test.ts | 267 +++ .../legacy/commands/status/status.values.ts | 495 +++++ .../status/status.values.unit.test.ts | 793 ++++++++ .../src/legacy/commands/stop/SIDE_EFFECTS.md | 119 +- .../src/legacy/commands/stop/stop.command.ts | 36 +- .../src/legacy/commands/stop/stop.errors.ts | 62 + .../src/legacy/commands/stop/stop.handler.ts | 417 +++- .../commands/stop/stop.integration.test.ts | 1133 ++++++++++- .../legacy/commands/stop/stop.live.test.ts | 78 + .../legacy/commands/storage/storage.frame.ts | 4 +- .../legacy/config/legacy-cli-config.layer.ts | 20 +- .../legacy-cli-config.layer.unit.test.ts | 37 + apps/cli/src/legacy/shared/legacy-api-url.ts | 26 + apps/cli/src/legacy/shared/legacy-colors.ts | 33 +- .../legacy/shared/legacy-colors.unit.test.ts | 50 + ...legacy-config-validate.parity.unit.test.ts | 266 +++ .../legacy/shared/legacy-config-validate.ts | 778 ++++++++ .../legacy-config-validate.unit.test.ts | 1034 ++++++++++ .../src/legacy/shared/legacy-container-cli.ts | 134 +- .../shared/legacy-container-cli.unit.test.ts | 129 +- .../shared/legacy-db-config.toml-read.ts | 1017 +++++----- .../legacy-db-config.toml-read.unit.test.ts | 22 + .../src/legacy/shared/legacy-docker-ids.ts | 77 +- .../shared/legacy-docker-ids.unit.test.ts | 78 +- .../legacy/shared/legacy-docker-lifecycle.ts | 259 +++ .../legacy-docker-lifecycle.unit.test.ts | 344 ++++ apps/cli/src/legacy/shared/legacy-go-jwt.ts | 197 ++ .../legacy/shared/legacy-go-jwt.unit.test.ts | 179 ++ apps/cli/src/legacy/shared/legacy-hostname.ts | 136 +- .../shared/legacy-hostname.unit.test.ts | 124 +- .../shared/legacy-local-config-values.ts | 1573 +++++++++++++++ .../legacy-local-config-values.unit.test.ts | 1716 +++++++++++++++++ .../shared/legacy-project-environment.ts | 141 ++ .../legacy-project-environment.unit.test.ts | 300 +++ .../src/legacy/shared/legacy-seed-buckets.ts | 4 +- .../shared/legacy-storage-credentials.ts | 19 +- .../src/legacy/shared/legacy-storage-url.ts | 61 + .../shared/legacy-storage-url.unit.test.ts | 47 + .../shared/legacy-workdir-validation.ts | 52 + .../functions/deploy/deploy.handler.ts | 1 + .../dev/functions-dev-config.unit.test.ts | 77 +- .../commands/start/start.integration.test.ts | 70 + .../src/shared/cli/hidden-flag.unit.test.ts | 10 +- apps/cli/src/shared/functions/deploy.ts | 6 +- apps/cli/src/shared/functions/serve.ts | 22 +- apps/docs/public/cli/config.schema.json | 652 +++---- packages/cli-test-helpers/src/parity.ts | 32 +- packages/config/src/auth/email.ts | 23 +- packages/config/src/auth/providers.ts | 13 +- packages/config/src/auth/sms.ts | 213 +- packages/config/src/base.ts | 29 +- packages/config/src/errors.ts | 12 + packages/config/src/index.ts | 3 + packages/config/src/io.ts | 355 +++- packages/config/src/io.unit.test.ts | 794 +++++++- packages/config/src/lib/env.ts | 128 +- packages/config/src/paths.ts | 35 +- packages/config/src/project.ts | 154 +- packages/config/src/project.unit.test.ts | 248 ++- 80 files changed, 15908 insertions(+), 1412 deletions(-) create mode 100644 apps/cli/src/legacy/commands/status/status.command.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/status/status.errors.ts create mode 100644 apps/cli/src/legacy/commands/status/status.live.test.ts create mode 100644 apps/cli/src/legacy/commands/status/status.pretty.ts create mode 100644 apps/cli/src/legacy/commands/status/status.pretty.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/status/status.values.ts create mode 100644 apps/cli/src/legacy/commands/status/status.values.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/stop/stop.errors.ts create mode 100644 apps/cli/src/legacy/commands/stop/stop.live.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-api-url.ts create mode 100644 apps/cli/src/legacy/shared/legacy-colors.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-config-validate.parity.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-config-validate.ts create mode 100644 apps/cli/src/legacy/shared/legacy-config-validate.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts create mode 100644 apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-go-jwt.ts create mode 100644 apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-local-config-values.ts create mode 100644 apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-project-environment.ts create mode 100644 apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-workdir-validation.ts diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 9989e1e33f..3d136879cd 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -237,6 +237,10 @@ Concrete examples worth watching for as more commands land: This rule is consistent with the repo-wide **Refactoring Policy** ("delete obsolete helpers, shims, and parallel code paths as part of the refactor") — it just makes the policy concrete for the legacy-port workflow. +### `Config.Validate` parity has one home + +Go's `Config.Validate` (`apps/cli-go/pkg/config/config.go:989-1190`) is ported exactly once: `src/legacy/shared/legacy-config-validate.ts` (`legacyValidateResolvedConfig`). Both the db/migration loader (`legacy-db-config.toml-read.ts`) and the status/stop resolver (`legacy-local-config-values.ts`) build a `LegacyConfigValidationInput` from their own pipelines and call it — do not add per-command reimplementations of these checks. When a Go validation branch or message changes, change it there. `legacy-config-validate.parity.unit.test.ts` feeds the same broken configs through both real pipelines and asserts identical error strings; extend it when adding a branch both callers share. + --- ## Legacy Port: Go CLI Output Parity @@ -403,6 +407,7 @@ Read https://www.effect.solutions/testing for Effect testing patterns. Note that - `*.unit.test.ts` belongs to the `unit` Vitest project and is the default for unit-style and other fast in-process tests. - `*.integration.test.ts` belongs to the `integration` project and is for in-process integration tests that exercise real handler or service behavior with layered dependency replacement. - `*.e2e.test.ts` belongs to the `e2e` Vitest project and is for black-box CLI subprocess tests. +- `*.live.test.ts` belongs to the `live` Vitest project and is for black-box CLI subprocess tests that run against a **real, running Supabase platform or local Docker stack** — see "Live tests" below. ### Testing policy @@ -417,6 +422,21 @@ Read https://www.effect.solutions/testing for Effect testing patterns. Note that - Keep `*.e2e.test.ts` focused on golden paths, CLI surface behavior, and subprocess correctness, not branch-by-branch coverage. - **Forbidden pattern (do not add):** spawning the CLI to assert that `--help` renders a flag. Help text is dynamic over flag wiring and is exercised by the integration test's flag parser. The two backups e2e files removed alongside this guidance update are the canonical example of what not to write. +### Live tests (`*.live.test.ts`) + +Live tests are black-box CLI subprocess tests — like `*.e2e.test.ts`, but run against a **real backend** instead of local fakes/mocks: either the real Management API (a full [supabox](https://github.com/supabase/supabox) platform stack) or a real local Docker dev stack (`supabase start`'s actual containers). They are the highest-fidelity, most expensive tier — reserved for the small set of behaviors that only a genuinely running backend can prove (auth round-trips, real Docker label filtering, real container lifecycle), not for anything an integration test can already cover with mocks. + +- **Where they run:** authored in this repo, but executed by the [`supabase/cli-e2e-ci`](https://github.com/supabase/cli-e2e-ci) harness, which builds this CLI, brings up a full supabox stack (and has a real Docker daemon, since that's how supabox itself runs), and invokes the `live` Vitest project (`nx run-many -t test:live`). They never run as part of the default unit/integration/e2e loop, and locally they no-op unless the live environment is configured (see below) — there is no need to stand up supabox yourself to develop other code. +- **Add one whenever you add or change a command whose correctness genuinely depends on a real backend** — a new Management API command, or a change to `start`/`stop`/`status`'s real Docker interaction. Colocate it with the command, same as `*.e2e.test.ts`: `src/legacy/commands//[/].live.test.ts`. +- **Gating:** every live suite must be wrapped in one of `tests/helpers/live.ts`'s `describe.skipIf` gates so the file is inert (skipped, not failed) outside the cli-e2e-ci runner: + - `describeLive` — runs whenever `SUPABASE_ACCESS_TOKEN` is set (the live env is configured at all). Reuse this even for commands that don't call the Management API themselves (e.g. `stop`/`status`) — it doubles as the "we're in the full cli-e2e-ci runner, which also has a real Docker daemon" signal, and there is no dedicated Docker-availability gate today. + - `describeLiveProject` — additionally requires a provisioned project (`SUPABASE_LIVE_PROJECT_REF`); use for project-scoped Management API commands (branches, functions, project-scoped db). + - `describeLiveDataPlane` — additionally requires the project's own Postgres instance to be `ACTIVE_HEALTHY`; use for commands that talk to the project's data plane (migration, db, storage). +- **Invocation:** use `runSupabaseLive(args, options?)` (wraps `runSupabase` with the `legacy` entrypoint and the live profile/timeout defaults) rather than calling `runSupabase` directly, so every live test picks up the same environment plumbing. +- **Local-dev-stack live tests** (`start`/`stop`/`status`, and anything else that manages real Docker containers rather than calling the Management API) follow the same file/gating convention but don't need `SUPABASE_PROFILE`/project-ref machinery. Pattern: `mkdtemp` a project dir, `runSupabaseLive(["init"], { cwd })` to generate a real Go-schema `config.toml`, `runSupabaseLive(["start", ...])` to bring up (a lightweight subset of) the real stack, exercise the command under test, then clean up in `afterEach` (best-effort `stop --no-backup` + `rm` the temp dir) so a failed assertion never leaks containers onto the CI runner. See `commands/stop/stop.live.test.ts` and `commands/status/status.live.test.ts` for the canonical example. +- **Keep the suite small and golden-path only** — same philosophy as `*.e2e.test.ts`, but even more so given the cost of a real backend. One or two scenarios per command is normal; branch-by-branch coverage belongs in `*.integration.test.ts`. +- Timeouts are generous by default (`testTimeout`/`hookTimeout: 300_000` for the whole `live` project) because real platform/Docker operations are slow — pass an explicit per-`test()` timeout when a scenario needs less (or, for a real local-stack `start`, close to the full budget). + --- ## Go CLI Parity Tracking diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 125c1f795c..c1ac3b2c96 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -269,8 +269,8 @@ Legend: | `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | | `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | | `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | -| `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | -| `status` | `wrapped` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) | +| `stop` | `ported` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) — native; talks directly to Docker/Podman via subprocess, replicating Go's label-filter and container-naming scheme | +| `status` | `ported` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) — native; talks directly to Docker/Podman via subprocess, replicating Go's label-filter and container-naming scheme | | `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | | `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | | `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | diff --git a/apps/cli/src/legacy/commands/config/push/push.handler.ts b/apps/cli/src/legacy/commands/config/push/push.handler.ts index ebd07baabc..60e1869c1e 100644 --- a/apps/cli/src/legacy/commands/config/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/config/push/push.handler.ts @@ -116,7 +116,10 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( // Pass `ref` so a matching `[remotes.*]` block is merged over the base config // before decode (Go's `loadFromFile` with `Config.ProjectId` set). A duplicate // `project_id` across remotes surfaces Go's verbatim message. - const loaded = yield* loadProjectConfig(runtimeInfo.cwd, { projectRef: ref }).pipe( + const loaded = yield* loadProjectConfig(runtimeInfo.cwd, { + projectRef: ref, + goViperCompat: true, + }).pipe( Effect.catchTag( "ProjectConfigParseError", (cause) => diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts index 1ddec7f3b3..853f933134 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -347,12 +347,14 @@ describe("legacy db reset", () => { }); it.live("finishes a local reset when bucket seeding hits a strict-loader-rejected config", () => { - // The bucket-seeding core re-loads config via the strict `@supabase/config` loader, - // which rejects some Go-valid configs (e.g. `[db.seed] enabled = "env(SEED_ENABLED)"`). - // The seam's Go recreate already validated + rebuilt the DB, so aborting here would - // leave the reset half-done — warn and skip buckets so reset finishes like Go. + // The bucket-seeding core re-loads config via the strict `@supabase/config` loader. + // `SEED_ENABLED=maybe` is invalid for both Go's `strconv.ParseBool` and the TS + // loader's coercion, so the reload fails during decode (unlike e.g. `1`/`true`, + // which both now accept). The seam's Go recreate already validated + rebuilt the + // DB, so aborting here would leave the reset half-done — warn and skip buckets so + // reset finishes like Go. const previous = process.env["SEED_ENABLED"]; - process.env["SEED_ENABLED"] = "1"; + process.env["SEED_ENABLED"] = "maybe"; const { layer, out, seam } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n', args: ["db", "reset"], diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts index ece00daaa3..f1f8e6c4c9 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts @@ -41,6 +41,7 @@ export const legacyFunctionsDeploy = Effect.fn("legacy.functions.deploy")(functi projectRoot: cliConfig.workdir, supabaseDir: join(cliConfig.workdir, "supabase"), dashboardUrl: legacyDashboardUrl(cliConfig.profile), + goViperCompat: true, yes, rawArgs, edgeRuntimeVersion, diff --git a/apps/cli/src/legacy/commands/functions/new/new.handler.ts b/apps/cli/src/legacy/commands/functions/new/new.handler.ts index b448c0307a..5080e4416b 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.handler.ts @@ -89,7 +89,9 @@ const listExistingFunctionSlugs = Effect.fnUntraced(function* (workdir: string) }); const resolveTemplateInputs = Effect.fnUntraced(function* (workdir: string, slug: string) { - const loaded = yield* loadProjectConfig(workdir).pipe(Effect.orElseSucceed(() => null)); + const loaded = yield* loadProjectConfig(workdir, { goViperCompat: true }).pipe( + Effect.orElseSucceed(() => null), + ); const port = loaded?.config.api.port ?? DEFAULT_LOCAL_API_PORT; const publishableKey = loaded?.config.auth.publishable_key ?? defaultPublishableKey; return { diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts index 0718862e64..86b51f387b 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts @@ -33,5 +33,6 @@ export const legacyFunctionsServe = Effect.fn("legacy.functions.serve")(function debug, networkId, projectIdOverride: cliConfig.projectId, + goViperCompat: true, }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index 370af5aacf..8e3354fe2d 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -1695,7 +1695,7 @@ describe("legacy functions serve integration", () => { "verify_jwt = true", "", "[remotes.override]", - 'project_id = "override-project"', + 'project_id = "overrideprojectaaaaa"', "", "[remotes.override.functions.hello]", "verify_jwt = false", @@ -1710,7 +1710,7 @@ describe("legacy functions serve integration", () => { const { layer } = setupServe({ childSpawner, - projectId: Option.some("override-project"), + projectId: Option.some("overrideprojectaaaaa"), }); const error = yield* legacyFunctionsServe(baseFlags()).pipe( Effect.provide(layer), @@ -1724,20 +1724,20 @@ describe("legacy functions serve integration", () => { expect(deployMockState.volumeCalls).toEqual([ { - volumeName: "supabase_edge_runtime_override-project", - projectId: "override-project", + volumeName: "supabase_edge_runtime_overrideprojectaaaaa", + projectId: "overrideprojectaaaaa", }, ]); expect(deployMockState.networkCalls).toEqual([ { - networkMode: "supabase_network_override-project", - projectId: "override-project", + networkMode: "supabase_network_overrideprojectaaaaa", + projectId: "overrideprojectaaaaa", }, ]); expect(deployMockState.runCalls).toContainEqual( expect.objectContaining({ command: "docker", - args: ["container", "inspect", "supabase_db_override-project"], + args: ["container", "inspect", "supabase_db_overrideprojectaaaaa"], }), ); diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts index 775a39140e..defbaad723 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts @@ -162,7 +162,7 @@ const generatePrivateKey = Effect.fnUntraced(function* (algorithm: SigningAlgori const loadSigningKeysConfig = Effect.fnUntraced(function* (cwd: string) { const path = yield* Path.Path; - const loaded = yield* loadProjectConfig(cwd).pipe( + const loaded = yield* loadProjectConfig(cwd, { goViperCompat: true }).pipe( Effect.catchTag("ProjectConfigParseError", (cause) => Effect.fail( new LegacyGenSigningKeyConfigParseError({ diff --git a/apps/cli/src/legacy/commands/gen/types/types.handler.ts b/apps/cli/src/legacy/commands/gen/types/types.handler.ts index 912f1bb8a0..87113c6a42 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -248,9 +248,9 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le ); } - const loadConfig = () => loadProjectConfig(cliConfig.workdir); + const loadConfig = () => loadProjectConfig(cliConfig.workdir, { goViperCompat: true }); const loadConfigForRef = (projectRef: string) => - loadProjectConfig(cliConfig.workdir, { projectRef }); + loadProjectConfig(cliConfig.workdir, { projectRef, goViperCompat: true }); const schemasFromConfig = (apiSchemas: ReadonlyArray | undefined) => defaultSchemas(apiSchemas); diff --git a/apps/cli/src/legacy/commands/secrets/set/set.handler.ts b/apps/cli/src/legacy/commands/secrets/set/set.handler.ts index e7d9f72d0f..53a46ee15f 100644 --- a/apps/cli/src/legacy/commands/secrets/set/set.handler.ts +++ b/apps/cli/src/legacy/commands/secrets/set/set.handler.ts @@ -183,7 +183,13 @@ export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( // Without this, a schema-decode error on `--project-ref ` // would recover the *base* `[edge_runtime.secrets]` instead of the // explicitly selected remote's override. - const loadedConfig = yield* loadProjectConfig(runtimeInfo.cwd, { projectRef: ref }).pipe( + // `goViperCompat: true` opts into `applyRemoteOverride`'s duplicate- + // `project_id`/format checks (`packages/config/src/io.ts`) — required for + // the `DuplicateRemoteProjectIdError` catch below to ever fire. + const loadedConfig = yield* loadProjectConfig(runtimeInfo.cwd, { + projectRef: ref, + goViperCompat: true, + }).pipe( Effect.flatMap((loaded) => { if (loaded === null) { return Effect.succeed(null); @@ -265,6 +271,17 @@ export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( Effect.catchTag("DuplicateRemoteProjectIdError", (cause) => debugLogger.debug(cause.message).pipe(Effect.as(null)), ), + // A `[remotes.*]` block's `project_id` fails Go's ref-pattern check — + // raised from `Config.Validate` (`pkg/config/config.go:996-1001`), which + // runs inside the same `Config.Load()` call as the duplicate check above + // (`config.go:882`). Go's `flags.LoadConfig` swallows this the same + // non-fatal way (`internal/secrets/set/set.go:22-24`), so a malformed + // remote block must not abort an otherwise-valid `secrets set`. + // `cause.message` already matches Go's string verbatim (see + // `InvalidRemoteProjectIdError`'s field doc). + Effect.catchTag("InvalidRemoteProjectIdError", (cause) => + debugLogger.debug(cause.message).pipe(Effect.as(null)), + ), ); if (loadedConfig !== null) { const projectEnv = yield* loadProjectEnvironment({ @@ -276,6 +293,7 @@ export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( loadedConfig.edge_runtime, projectEnv, "edge_runtime", + { goViperCompat: true }, ); for (const [name, value] of Object.entries(resolved.secrets ?? {})) { // Go's `DecryptSecretHookFunc` (`pkg/config/secret.go:98`) never diff --git a/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts b/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts index 91f4fd18a8..4e6a3e4dfd 100644 --- a/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts +++ b/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts @@ -724,6 +724,39 @@ project_id = "dupe-project-id" }, ); + it.live( + "tolerates a [remotes.*] block with a malformed project_id and still sets CLI-arg secrets (Go parity)", + () => { + // Go's `flags.LoadConfig` swallows *any* `Load()` error non-fatally + // (`internal/secrets/set/set.go:22-24`), including the invalid-format + // error `Config.Validate` raises for every `[remotes.*].project_id` + // that doesn't match Go's ref pattern (`pkg/config/config.go:996-1001`), + // which runs inside the same `Config.Load()` call (`config.go:882`) as + // the duplicate check above. There is no parsed document to recover a + // subtree from, so config-sourced secrets are dropped entirely — only + // CLI-arg secrets survive. + writeConfig( + `[edge_runtime.secrets] +FROM_CONFIG = "config-value" + +[remotes.a] +project_id = "not-a-valid-ref" +`, + ); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([{ name: "FOO", value: "bar" }]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).toContain("Invalid config for remotes.a.project_id"); + }).pipe(Effect.provide(layer)); + }, + ); + it.live( "does not echo a literal secret value from config.toml into the debug log on a syntax error", () => { diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts index 24b2543055..f5d04275b7 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts @@ -45,6 +45,7 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( ? yield* projectRefResolver.loadProjectRef(Option.none()) : ""; linkedRef = projectRef; + yield* legacySeedBucketsRun({ projectRef, emitSummary: true }); }).pipe( // Go's root `Execute` caches the linked project + fires org/project group diff --git a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md index 089b2340c3..bc43278256 100644 --- a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md @@ -2,15 +2,17 @@ ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | ---------------------------------------- | -| `/supabase/config.toml` | TOML | always, to resolve project configuration | +| Path | Format | When | +| ------------------------------------------------------------------------------------------------------------------- | --------- | ----------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, to resolve project configuration | +| `auth.signing_keys_path` (config-relative or absolute) | JSON | only when `auth.signing_keys_path` is set in config.toml | +| `api.tls.cert_path` / `api.tls.key_path` (unconditionally joined with `/supabase`, no absolute-path guard) | raw bytes | only when `api.enabled` and `api.tls.enabled`, and the respective path is set | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------- | ------ | ------ | +| `~/.supabase/telemetry.json` | JSON | always | ## API Routes @@ -18,67 +20,186 @@ | ------ | ---- | ---- | ------------ | ---------------------- | | — | — | — | — | — | +Neither this command nor any of its dependencies make a Management API call — everything is +resolved from local `config.toml` and the local Docker daemon. + ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| — | — | — | +| Variable | Purpose | Required? | +| -------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | overrides the resolved local project id | no (falls back to config.toml `project_id` → workdir basename) | +| `SUPABASE_WORKDIR` | overrides the resolved project workdir | no (falls back to `--workdir` → walk-up search for `config.toml` → cwd) | +| `SUPABASE_SERVICES_HOSTNAME` | overrides the hostname used to build local service URLs | no (falls back to `DOCKER_HOST`'s tcp host → `127.0.0.1`) | +| `SUPABASE_AUTH_JWT_SECRET` | overrides `auth.jwt_secret` | no | +| `SUPABASE_AUTH_PUBLISHABLE_KEY` | overrides `auth.publishable_key` | no | +| `SUPABASE_AUTH_SECRET_KEY` | overrides `auth.secret_key` | no | +| `SUPABASE_AUTH_ANON_KEY` | overrides `auth.anon_key` | no | +| `SUPABASE_AUTH_SERVICE_ROLE_KEY` | overrides `auth.service_role_key` | no | + +The `SUPABASE_AUTH_*` vars mirror Go's Viper `AutomaticEnv` (`SetEnvPrefix("SUPABASE")` + +`.`→`_` key replacer, `pkg/config/config.go:529-535`) and take precedence over the corresponding +`config.toml` value, matching Viper's real precedence order. + +`docker` (or `podman` as a fallback) must be on `PATH`. ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — status displayed | -| `1` | malformed config | -| `1` | Docker daemon not running or connection error | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `0` | success — status displayed | +| `0` | **`--ignore-health-check` is set** — skips the health assertion below entirely, so an unhealthy/not-running db never fails the command | +| `1` | `supabase/config.toml` missing or malformed | +| `1` | a malformed `--override-name` entry | +| `1` | listing running containers failed (Docker daemon unreachable, etc.) | +| `1` | the db container inspect call failed (including "not found") — health assertion, skipped by `--ignore-health-check` above | +| `1` | the db container is present but not in the `running` state — health assertion, skipped by `--ignore-health-check` above | +| `1` | the db container is running but its Docker health check isn't `healthy` — health assertion, skipped by `--ignore-health-check` above | +| `1` | `auth.jwt_secret` is configured but shorter than 16 characters (Go's `Config.Validate` rejects this at config-load time) | +| `1` | `auth.signing_keys_path` is configured but the file is missing/malformed, or its first key's algorithm is not `RS256`/`ES256` | +| `1` | `api.enabled` and `api.tls.enabled` are true and only one of `api.tls.cert_path`/`key_path` is set (Go's `Config.Validate` rejects this at config-load time) | +| `1` | `api.enabled` and `api.tls.enabled` are true, both `cert_path` and `key_path` are set, but one of the files can't be read | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | ## Output ### `--output-format text` (Go CLI compatible) -Prints a table of service names, container IDs, images, and URLs. +Default (`-o` unset or `-o pretty`): a stderr banner, then 5 grouped rounded-border tables on +stdout. Empty rows (a value with nothing resolved) and entirely empty groups are skipped; a +blank line follows every group, rendered or not. ``` - supabase local development setup is running. - - API URL: http://127.0.0.1:54321 - GraphQL URL: http://127.0.0.1:54321/graphql/v1 - S3 Storage URL: http://127.0.0.1:54321/storage/v1/s3 - DB URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres - Studio URL: http://127.0.0.1:54323 - Inbucket URL: http://127.0.0.1:54324 - JWT secret: super-secret-jwt-token-with-at-least-32-characters-long - anon key: ... -service_role key: ... - S3 Access Key: 625729a08b95bf1b7ff351a663f3a23c - S3 Secret Key: 850181e4652dd023b7a98c58ae0d2d34bd487ee0ead3abe0 - S3 Region: local +supabase local development setup is running. + +╭──────────────────────────────────────╮ +│ 🔧 Development Tools │ +├─────────┬────────────────────────────┤ +│ Studio │ http://127.0.0.1:54323 │ +│ Mailpit │ http://127.0.0.1:54324 │ +│ MCP │ http://127.0.0.1:54321/mcp │ +╰─────────┴────────────────────────────╯ + +╭──────────────────────────────────────────────────────╮ +│ 🌐 APIs │ +├────────────────┬─────────────────────────────────────┤ +│ Project URL │ http://127.0.0.1:54321 │ +│ REST │ http://127.0.0.1:54321/rest/v1 │ +│ GraphQL │ http://127.0.0.1:54321/graphql/v1 │ +│ Edge Functions │ http://127.0.0.1:54321/functions/v1 │ +╰────────────────┴─────────────────────────────────────╯ + +╭───────────────────────────────────────────────────────────────╮ +│ ⛁ Database │ +├─────┬─────────────────────────────────────────────────────────┤ +│ URL │ postgresql://postgres:postgres@127.0.0.1:54322/postgres │ +╰─────┴─────────────────────────────────────────────────────────╯ + +╭──────────────────────────────────────────────────────────────╮ +│ 🔑 Authentication Keys │ +├─────────────┬────────────────────────────────────────────────┤ +│ Publishable │ sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH │ +│ Secret │ sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz │ +╰─────────────┴────────────────────────────────────────────────╯ + +╭───────────────────────────────────────────────────────────────────────────────╮ +│ 📦 Storage (S3) │ +├────────────┬──────────────────────────────────────────────────────────────────┤ +│ URL │ http://127.0.0.1:54321/storage/v1/s3 │ +│ Access Key │ 625729a08b95bf1b7ff351a663f3a23c │ +│ Secret Key │ 850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907 │ +│ Region │ local │ +╰────────────┴──────────────────────────────────────────────────────────────────╯ ``` -### `--output-format json` +Group table cells are colored on a TTY (Aqua for links, Yellow for keys, Green for labels, bold +headers); colors are stripped on non-TTY/piped output. + +`Stopped services: [ ...]` is written to stderr (Go slice format, e.g. +`[supabase_storage_test supabase_studio_test]`) whenever one of the 13 expected service +containers isn't in the running set. + +### `-o env` + +`KEY="VALUE"` lines (unquoted for integer-looking values), one per resolved field, sorted by +key — see `legacy-go-output.encoders.ts`'s `encodeEnv`. + +### `-o json` ```json { "API_URL": "http://127.0.0.1:54321", - "DB_URL": "postgresql://...", + "DB_URL": "postgresql://postgres:postgres@127.0.0.1:54322/postgres", "ANON_KEY": "...", "SERVICE_ROLE_KEY": "...", + "PUBLISHABLE_KEY": "...", + "SECRET_KEY": "...", "JWT_SECRET": "...", - "S3_ACCESS_KEY": "...", - "S3_SECRET_KEY": "...", - "S3_REGION": "local" + "S3_PROTOCOL_ACCESS_KEY_ID": "625729a08b95bf1b7ff351a663f3a23c", + "S3_PROTOCOL_ACCESS_KEY_SECRET": "...", + "S3_PROTOCOL_REGION": "local" } ``` -### `--output-format stream-json` +Top-level keys sorted alphabetically, 2-space indent, trailing newline (Go `encoding/json` +parity). Fields whose owning service is disabled or excluded are omitted entirely (not emitted +as `null`/`""`). + +### `-o yaml` / `-o toml` + +Same value set as `-o json`, encoded via `encodeYaml`/`encodeToml`. + +### `--output-format json` / `stream-json` (when `-o` is unset or `pretty`) -Not applicable. +Additive — no Go CLI equivalent. Emits the same resolved value map via +`output.success("", values)` / the NDJSON `result` event. ## Notes -- `--override-name` flag overrides specific variable names in env output. -- `-o env` output format uses KEY=VALUE pairs. -- `-o json` output format uses a JSON object. -- `-o pretty` (default) uses the human-readable table format. -- `--exclude` (hidden, repeatable) omits named containers from the output; matches Go's `cmd/status.go:39-40`. -- `--ignore-health-check` (hidden, boolean) exits `0` even when a service is unhealthy; matches Go's `cmd/status.go:41-42`. +- `-o`/`--output` (`env|pretty|json|toml|yaml`) takes priority over `--output-format` whenever + it is set, matching the Go-parity checklist's dual-output-flag rule. `-o pretty` (or `-o` + unset) falls through to `--output-format`'s text/json/stream-json handling. +- `--override-name api.url=NEXT_PUBLIC_SUPABASE_URL` remaps a single field's output KEY; the + value and group layout are unaffected. An unknown key or a malformed (non `KEY=VALUE`) entry + fails with `LegacyStatusOverrideParseError`. This only affects the `env`/`json`/`toml`/`yaml` + (`printStatus`) output path — matching Go, the pretty table (`-o pretty` or unset) always + renders with un-overridden names, since Go's `PrettyPrint` unmarshals a fresh, empty `EnvSet{}` + rather than reusing the CLI-supplied, override-populated `CustomName` (`status.go:236-243`). +- When neither `docker` nor `podman` can be spawned at all, the error message names the actual + root cause (e.g. "docker: command not found (podman also not found) — install Docker Desktop or + Podman and ensure it is on PATH") rather than a generic "failed to ..." string. +- `--exclude ` (hidden) omits a service from the value map when `value` matches either its + container id or its default Docker image short name (Go's `ShortContainerImageName`, e.g. + `storage-api` for the storage service, `edge-runtime` for edge functions) — the default image + is read from the same embedded Dockerfile manifest Go parses, so a version bump there is picked + up automatically without needing to read the `.temp/-version` pin file. +- `--ignore-health-check` (hidden) skips the db container health assertion entirely and always + exits `0`, matching Go's early-return in `Run()`. +- Default `auth.anon_key`/`auth.service_role_key`/`auth.jwt_secret` values are generated via a + Go-byte-exact HS256 signer (`legacy-go-jwt.ts`), not `@supabase/stack`'s `generateJwt` — the + latter uses a different issuer, expiry, and claim order that would not match what Go prints + for local dev keys. A configured `auth.jwt_secret` shorter than 16 characters fails the command + (`LegacyStatusInvalidConfigError`), matching Go's `Config.Validate` rejecting it at config-load + time before any command can render output. +- When `auth.signing_keys_path` is set and resolves to a non-empty JWK array, `anon_key`/ + `service_role_key` are instead signed asymmetrically (RS256/ES256) with the file's first key, + matching Go's `generateJWT` (`pkg/config/apikeys.go:76-113`) — a relative path resolves against + `/supabase`. This path is skipped entirely when `auth.anon_key`/`auth.service_role_key` + are explicitly configured. A missing/malformed file, or a first key with an algorithm other than + `RS256`/`ES256`, fails the command (`LegacyStatusInvalidConfigError`). +- `SUPABASE_AUTH_JWT_SECRET`/`SUPABASE_AUTH_PUBLISHABLE_KEY`/`SUPABASE_AUTH_SECRET_KEY`/ + `SUPABASE_AUTH_ANON_KEY`/`SUPABASE_AUTH_SERVICE_ROLE_KEY` override the corresponding + `config.toml` value at higher precedence, matching Go's Viper `AutomaticEnv` — an empty env var + is treated as unset. This is scoped to exactly the 5 auth fields `status` reads; it is not a + general `@supabase/config` port of Viper's `AutomaticEnv` (which applies to every config field). +- `db.password` and the `storage.s3_credentials` triple have no `@supabase/config` schema field; + Go hardcodes both (`"postgres"` and the S3 access key/secret/region seen above), reproduced + identically in `legacy-local-config-values.ts`. +- No e2e test is planned for this command: there is no Docker-daemon-free golden path, and the + e2e harness (`runSupabase()`) does not provision a real local stack. This is a scope reduction + relative to the Linear issue's "E2E compatibility test added" checkbox; see the port plan for + the full justification. diff --git a/apps/cli/src/legacy/commands/status/status.command.ts b/apps/cli/src/legacy/commands/status/status.command.ts index 201bce98f0..12be889ebb 100644 --- a/apps/cli/src/legacy/commands/status/status.command.ts +++ b/apps/cli/src/legacy/commands/status/status.command.ts @@ -1,19 +1,47 @@ +import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../shared/legacy-go-output-flag.ts"; +import { legacyParseStringSliceFlag } from "../../shared/legacy-string-slice-flag.ts"; +import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; import { legacyStatus } from "./status.handler.ts"; -const config = { - overrideName: Flag.string("override-name").pipe( - Flag.atLeast(0), - Flag.withDescription("Override specific variable names."), - Flag.withDefault([] as ReadonlyArray), - ), - exclude: Flag.string("exclude").pipe( +/** + * Go registers both `--override-name` and `--exclude` as pflag `StringSliceVar` + * (`cmd/status.go:36-37`), which CSV-splits each occurrence and accumulates + * across repeats — `--override-name a=1,b=2` is two overrides, not one. Effect's + * `Flag.atLeast(0)` only handles repetition, so every occurrence needs the same + * `legacyParseStringSliceFlag` normalization already used for `sso`/`postgres-config`. + */ +function csvStringSliceFlag(name: string) { + return Flag.string(name).pipe( Flag.atLeast(0), - Flag.withDescription("Names of containers to omit from output."), + Flag.mapTryCatch( + (rawValues) => legacyParseStringSliceFlag(rawValues), + (err) => (err instanceof Error ? err.message : String(err)), + ), Flag.withDefault([] as ReadonlyArray), - Flag.withHidden, - ), + ); +} + +export const legacyStatusOverrideNameFlag = csvStringSliceFlag("override-name").pipe( + Flag.withDescription("Override specific variable names."), +); + +export const legacyStatusExcludeFlag = csvStringSliceFlag("exclude").pipe( + Flag.withDescription("Names of containers to omit from output."), + Flag.withHidden, +); + +const config = { + overrideName: legacyStatusOverrideNameFlag, + exclude: legacyStatusExcludeFlag, ignoreHealthCheck: Flag.boolean("ignore-health-check").pipe( Flag.withDescription("Ignore unhealthy services and exit 0"), Flag.withHidden, @@ -22,6 +50,18 @@ const config = { export type LegacyStatusFlags = CliCommand.Command.Config.Infer; +// `status` makes no Management API calls (Go's status needs no access token), so +// it deliberately avoids `legacyManagementApiRuntimeLayer` — mirrors `unlink`'s +// runtime shape. `legacyCliConfigLayer` is exposed at the top level directly +// (nothing else in this runtime needs to consume it internally). +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const legacyStatusRuntimeLayer = Layer.mergeAll( + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer(["status"]), +); + export const legacyStatusCommand = Command.make("status", config).pipe( Command.withDescription("Show status of local Supabase containers."), Command.withShortDescription("Show status of local Supabase containers"), @@ -35,5 +75,14 @@ export const legacyStatusCommand = Command.make("status", config).pipe( description: "Output status as JSON", }, ]), - Command.withHandler((flags) => legacyStatus(flags)), + Command.withHandler((flags) => + legacyStatus(flags).pipe( + withLegacyCommandInstrumentation({ + flags, + outputFormats: LEGACY_RESOURCE_OUTPUT_FORMATS, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyStatusRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/status/status.command.unit.test.ts b/apps/cli/src/legacy/commands/status/status.command.unit.test.ts new file mode 100644 index 0000000000..257f805382 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.command.unit.test.ts @@ -0,0 +1,75 @@ +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit } from "effect"; +import { describe, expect, test } from "vitest"; +import { legacyStatusExcludeFlag, legacyStatusOverrideNameFlag } from "./status.command.ts"; + +describe("legacy status --override-name flag (pflag StringSlice parity)", () => { + test("splits a comma-separated value into multiple overrides", async () => { + const [, overrideName] = await Effect.runPromise( + legacyStatusOverrideNameFlag + .parse({ + flags: { "override-name": ["api.url=FOO,db.url=BAR"] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(overrideName).toEqual(["api.url=FOO", "db.url=BAR"]); + }); + + test("accumulates repeated occurrences, each CSV-split", async () => { + const [, overrideName] = await Effect.runPromise( + legacyStatusOverrideNameFlag + .parse({ + flags: { "override-name": ["api.url=FOO,db.url=BAR", "studio.url=BAZ"] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(overrideName).toEqual(["api.url=FOO", "db.url=BAR", "studio.url=BAZ"]); + }); + + test("defaults to an empty array when unset", async () => { + const [, overrideName] = await Effect.runPromise( + legacyStatusOverrideNameFlag + .parse({ flags: {}, arguments: [] }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(overrideName).toEqual([]); + }); + + test("rejects malformed CSV (unterminated quote)", async () => { + const exit = await Effect.runPromise( + legacyStatusOverrideNameFlag + .parse({ flags: { "override-name": ['"api.url=FOO'] }, arguments: [] }) + .pipe(Effect.provide(BunServices.layer)) + .pipe(Effect.exit), + ); + + expect(Exit.isFailure(exit)).toBe(true); + }); +}); + +describe("legacy status --exclude flag (pflag StringSlice parity)", () => { + test("splits a comma-separated value into multiple exclusions", async () => { + const [, exclude] = await Effect.runPromise( + legacyStatusExcludeFlag + .parse({ flags: { exclude: ["kong,auth"] }, arguments: [] }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(exclude).toEqual(["kong", "auth"]); + }); + + test("defaults to an empty array when unset", async () => { + const [, exclude] = await Effect.runPromise( + legacyStatusExcludeFlag + .parse({ flags: {}, arguments: [] }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(exclude).toEqual([]); + }); +}); diff --git a/apps/cli/src/legacy/commands/status/status.errors.ts b/apps/cli/src/legacy/commands/status/status.errors.ts new file mode 100644 index 0000000000..9e72e14fbb --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.errors.ts @@ -0,0 +1,58 @@ +import { Data } from "effect"; + +/** + * An explicit `--workdir`/`SUPABASE_WORKDIR` path doesn't exist or isn't a + * directory. Mirrors Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go: + * 231-250`), which unconditionally `os.Chdir(workdir)`s in `PersistentPreRunE` + * (`apps/cli-go/cmd/root.go:93-105`) — before `status`'s own `PreRunE` + * (override-name parsing) or `RunE`, so a bad explicit workdir must fail here + * first, before config load or any Docker access. + */ +export class LegacyStatusWorkdirError extends Data.TaggedError("LegacyStatusWorkdirError")<{ + readonly message: string; +}> {} + +/** `loadProjectConfig` rejected `supabase/config.toml` (malformed TOML/JSON). */ +export class LegacyStatusConfigLoadError extends Data.TaggedError("LegacyStatusConfigLoadError")<{ + readonly message: string; +}> {} + +/** A `--override-name KEY=VALUE` entry did not parse, mirroring `env.EnvironToEnvSet`. */ +export class LegacyStatusOverrideParseError extends Data.TaggedError( + "LegacyStatusOverrideParseError", +)<{ + readonly message: string; +}> {} + +/** Inspecting the db container failed for a reason other than "not found". */ +export class LegacyStatusDbInspectError extends Data.TaggedError("LegacyStatusDbInspectError")<{ + readonly message: string; +}> {} + +/** The db container is absent or present but not in the `running` state. */ +export class LegacyStatusDbNotRunningError extends Data.TaggedError( + "LegacyStatusDbNotRunningError", +)<{ + readonly message: string; +}> {} + +/** The db container is running but its Docker health check is not `healthy`. */ +export class LegacyStatusDbNotReadyError extends Data.TaggedError("LegacyStatusDbNotReadyError")<{ + readonly message: string; +}> {} + +/** Listing running containers by label failed. */ +export class LegacyStatusListError extends Data.TaggedError("LegacyStatusListError")<{ + readonly message: string; +}> {} + +/** + * `config.toml` resolved to a value `Config.Validate` would reject before status + * ever renders — e.g. an `auth.jwt_secret` shorter than 16 characters + * (`pkg/config/apikeys.go:45-47`). + */ +export class LegacyStatusInvalidConfigError extends Data.TaggedError( + "LegacyStatusInvalidConfigError", +)<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 566427260c..7e984a0f93 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -1,12 +1,350 @@ -import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { loadProjectConfig, loadProjectEnvironment, ProjectConfigSchema } from "@supabase/config"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { Effect, FileSystem, Option, Schema } from "effect"; + +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { legacyAqua } from "../../shared/legacy-colors.ts"; +import { + legacyCliProjectFilterValue, + legacyResolveLocalProjectId, + legacySanitizeProjectId, + legacyServiceContainerIds, + localDbContainerId, +} from "../../shared/legacy-docker-ids.ts"; +import { + legacyInspectContainerState, + legacyListContainersByLabel, +} from "../../shared/legacy-docker-lifecycle.ts"; +import { + encodeEnv, + encodeGoJson, + encodeToml, + encodeYaml, +} from "../../shared/legacy-go-output.encoders.ts"; +import { legacyGetHostname } from "../../shared/legacy-hostname.ts"; +import { legacyResolveProjectEnvironmentValues } from "../../shared/legacy-project-environment.ts"; +import { legacyValidateWorkdirIsDirectory } from "../../shared/legacy-workdir-validation.ts"; import type { LegacyStatusFlags } from "./status.command.ts"; +import { + LegacyStatusConfigLoadError, + LegacyStatusDbInspectError, + LegacyStatusDbNotReadyError, + LegacyStatusDbNotRunningError, + LegacyStatusInvalidConfigError, + LegacyStatusListError, + LegacyStatusOverrideParseError, + LegacyStatusWorkdirError, +} from "./status.errors.ts"; +import { legacyRenderStatusPretty } from "./status.pretty.ts"; +import { + LEGACY_STATUS_FIELDS, + legacyGateStatusState, + legacyResolveStatusLocalState, + legacyStatusContainerIds, + legacyStatusValuesFromState, +} from "./status.values.ts"; + +/** + * Parses `--override-name api.url=NEXT_PUBLIC_SUPABASE_URL` entries into a + * `fieldKey -> outputName` map, mirroring Go's `env.EnvironToEnvSet` + + * `env.Unmarshal` (`cmd/status.go:21-27`): each entry must be a `KEY=VALUE` + * pair. `env.EnvironToEnvSet` only validates that shape (`go-env`'s + * `ErrInvalidEnviron`); the Netflix `go-env` library's `Unmarshal` then walks + * `CustomName`'s own struct fields and looks up each field's tag in the + * resulting map — it never checks the map for leftover/unmatched keys, so an + * entry whose `KEY` isn't one of the 18 known `CustomName` field keys is + * silently ignored, not an error (verified against `go-env@v0.1.2`'s + * `env.go`/`transform.go`). + */ +function parseOverrides( + entries: ReadonlyArray, +): Effect.Effect, LegacyStatusOverrideParseError> { + const knownKeys = new Set(LEGACY_STATUS_FIELDS.map((field) => field.fieldKey)); + const overrides = new Map(); + for (const entry of entries) { + const separatorIndex = entry.indexOf("="); + if (separatorIndex <= 0) { + return Effect.fail( + new LegacyStatusOverrideParseError({ + message: `invalid override-name entry, expected KEY=VALUE: ${entry}`, + }), + ); + } + const key = entry.slice(0, separatorIndex); + const value = entry.slice(separatorIndex + 1); + if (!knownKeys.has(key)) { + continue; + } + overrides.set(key, value); + } + return Effect.succeed(overrides); +} + +/** Go's `fmt.Fprintln(os.Stderr, "Stopped services:", stopped)` slice format. */ +function formatGoStringSlice(items: ReadonlyArray): string { + return `[${items.join(" ")}]`; +} export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyStatusFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["status"]; - for (const override of flags.overrideName) args.push("--override-name", override); - for (const name of flags.exclude) args.push("--exclude", name); - if (flags.ignoreHealthCheck) args.push("--ignore-health-check"); - yield* proxy.exec(args); + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fs = yield* FileSystem.FileSystem; + + yield* Effect.gen(function* () { + // 0. Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) + // unconditionally `os.Chdir`s the resolved `--workdir`/`SUPABASE_WORKDIR` + // in `PersistentPreRunE` (`cmd/root.go:93-105`) — before `status`'s own + // `PreRunE` (override-name parsing) or `RunE`. A missing or non-directory + // path fails immediately, so this must win over every later error. + yield* legacyValidateWorkdirIsDirectory(cliConfig.workdir, fs).pipe( + Effect.mapError((error) => new LegacyStatusWorkdirError({ message: error.message })), + ); + + // 1. `--override-name KEY=VALUE` parsing — mirroring Go's Cobra wiring, + // where override validation runs in `PreRunE` (`cmd/status.go:21-27`) and + // Cobra's execute loop returns as soon as `PreRunE` errors, never calling + // `RunE` (`spf13/cobra@v1.10.2/command.go:999-1015`). So a malformed + // `--override-name` entry fails before `status.Run` ever loads config or + // touches Docker (`internal/status/status.go:101-116`) — it must win over + // a config-load error or a Docker/DB health-check error, not be masked by + // either. `overrides` itself is only consumed much later, by + // `legacyStatusValuesFromState` below. + const overrides = yield* parseOverrides(flags.overrideName); + + // 2. `status` always needs config, unlike `stop` (status.go:99-103). An + // ABSENT config.toml is not a hard failure in Go: `flags.LoadConfig` -> + // `Config.Load` -> `loadFromFile` -> `mergeFileConfig` treats a missing + // file as a no-op (`os.ErrNotExist` -> nil, pkg/config/config.go:655-656) + // and proceeds with template defaults (`mergeDefaultValues`, + // pkg/config/config.go:639-648). Only a MALFORMED file is a hard error. + // Mirror that by decoding an empty document through the schema for its + // defaults (matching `packages/config/src/functions-manifest.ts`'s + // `decodeProjectConfig({})` pattern) instead of failing. + // `search: false` on both loaders below: `cliConfig.workdir` already IS + // Go's fully-resolved chdir target (`legacy-cli-config.layer.ts`'s + // `resolveWorkdir` mirrors `ChangeWorkDir`'s explicit-exact-vs-default- + // searched resolution, `apps/cli-go/internal/utils/misc.go:231-247`), so + // letting `@supabase/config`'s `findProjectPaths` climb ancestors again on + // top of that would let an unrelated ancestor project's config.toml win + // when `--workdir`/`SUPABASE_WORKDIR` points at a subdirectory with no + // `supabase/config.toml` of its own — Go never searches past the exact + // (explicit or defaulted) workdir (`NewPathBuilder`, `pkg/config/utils.go: + // 43-48`). + const projectEnv = yield* loadProjectEnvironment({ + cwd: cliConfig.workdir, + baseEnv: process.env, + search: false, + // Go's `loadDefaultEnv` (`apps/cli-go/pkg/config/config.go:1243-1250`) + // omits `.env.local` from its candidate list whenever + // `SUPABASE_ENV=test` — a malformed or intentionally non-test + // `supabase/.env.local` is then invisible to Go and must not fail + // config loading here either. `legacyResolveProjectEnvironmentValues` + // below already applies this same gate for the project-root pass (see + // its `candidateDotenvFilenames`); this mirrors it for the + // `supabase/`-dir pass `loadProjectEnvironment` itself performs. + skipEnvLocal: (process.env["SUPABASE_ENV"] || "development") === "test", + }).pipe( + Effect.mapError( + (cause) => + new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + ), + ); + + // `legacyResolveProjectEnvironmentValues` fills the gap between + // `loadProjectEnvironment` (supabase/.env(.local) + ambient only) and Go's + // `loadNestedEnv`, which also loads project-root and `SUPABASE_ENV`-selected + // dotenv files (`pkg/config/config.go:1169-1207`) — see its doc comment for + // the full precedence chain. Resolved BEFORE `loadProjectConfig` decodes + // config.toml (not after) because Go's `Config.Load` runs `loadNestedEnv` + // before `LoadEnvHook` decodes `env(...)` references (`config.go:735-738`); + // an `env(...)` value sourced only from a project-root/`SUPABASE_ENV`- + // selected file must already be visible to the decoder, not just to the + // `SUPABASE_PROJECT_ID`/`SUPABASE_AUTH_*` overrides read further below. + // A malformed extra dotenv file throws here (see `readDotEnvFile`), + // matching Go's `loadNestedEnv` propagating `godotenv`'s parse error + // instead of silently skipping the bad line. `workdir` is passed through so + // dotenv files under `/supabase`/`workdir` are still discovered + // even when `projectEnv` is `null` (no config.toml there) — Go's own + // `loadNestedEnv` runs unconditionally, before `config.toml` is ever + // opened (`pkg/config/config.go:786-793`). + const projectEnvValues = yield* Effect.try({ + try: () => legacyResolveProjectEnvironmentValues(projectEnv, cliConfig.workdir), + catch: (cause) => + new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + }); + + const loaded = yield* loadProjectConfig(cliConfig.workdir, { + projectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, + search: false, + // Go's `NewPathBuilder`/`Config.Load` (`pkg/config/utils.go:43-48`) only + // ever resolves `supabase/config.toml` — it has no concept of a JSON + // project config file. Without this, a workdir with a stray + // `config.json` would make `loadProjectConfig` prefer it over + // `config.toml`, reporting ports/keys for a config Go never reads. + tomlOnly: true, + goViperCompat: true, + }).pipe( + Effect.mapError( + (cause) => + new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + ), + ); + const config = loaded?.config ?? Schema.decodeUnknownSync(ProjectConfigSchema)({}); + + // 3. Resolve + VALIDATE config-derived state before any Docker call — + // matching Go's `flags.LoadConfig` (config load + `Validate`, + // `internal/utils/flags/config_path.go:12` -> `pkg/config/config.go:882`), + // which runs entirely before `assertContainerHealthy`/container listing + // (`internal/status/status.go:101-116`). `legacyResolveStatusLocalState` + // can throw `LegacyInvalidJwtSecretError` (a short `auth.jwt_secret`), + // `LegacyInvalidPortEnvOverrideError`/`LegacyInvalidBoolEnvOverrideError` + // (a malformed `SUPABASE_*_PORT`/`SUPABASE_*_ENABLED` override), or a + // signing-keys-file read/parse error — all of these must fail here, not + // be masked by a Docker/DB error when the local stack happens to be + // unavailable. `hostname` has no Docker dependency either, so it's + // resolved here rather than later. + const hostname = legacyGetHostname(); + const localState = yield* Effect.try({ + try: () => + legacyResolveStatusLocalState( + config, + hostname, + cliConfig.workdir, + projectEnvValues, + loaded?.document, + ), + catch: (cause) => + new LegacyStatusInvalidConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + + // 4. status has no --project-id flag; resolution is always env → toml → + // workdir basename, then sanitized to match the singleton Go's + // `Config.Validate` produces once at config-load time + // (`pkg/config/config.go:938-944`) — every reader, including the Docker + // LABEL `start` writes (`internal/utils/docker.go:375`), sees that same + // sanitized string, so `status` must filter on it too (see + // `legacyCliProjectFilterValue`'s doc comment). + const projectId = legacySanitizeProjectId( + legacyResolveLocalProjectId( + projectEnvValues["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], + config.project_id, + cliConfig.workdir, + ), + ); + const dbContainerId = localDbContainerId(projectId); + + // 5. Health check, skipped entirely with --ignore-health-check (status.go:104-108). + // Go's `assertContainerHealthy` never special-cases "not found" — an absent + // container fails `ContainerInspect` itself, which surfaces as the generic + // inspect error (status.go:147-150), not the "not running" branch (which + // only applies to a present-but-stopped container, status.go:150-151). + // `legacyInspectContainerState` mirrors that: a missing container is just + // another non-zero exit, mapped below with the real Docker stderr text. + if (!flags.ignoreHealthCheck) { + const state = yield* legacyInspectContainerState(spawner, dbContainerId).pipe( + Effect.mapError((cause) => new LegacyStatusDbInspectError({ message: cause.message })), + ); + if (!state.running) { + return yield* Effect.fail( + new LegacyStatusDbNotRunningError({ + message: `${dbContainerId} container is not running: ${state.status}`, + }), + ); + } + if (state.health !== undefined && state.health !== "healthy") { + return yield* Effect.fail( + new LegacyStatusDbNotReadyError({ + message: `${dbContainerId} container is not ready: ${state.health}`, + }), + ); + } + } + + // 6. List running containers, diff against the 13 expected service ids + // (status.go:125-145), and report any that are stopped. + const filterValue = legacyCliProjectFilterValue(projectId); + const runningNames = yield* legacyListContainersByLabel(spawner, { + projectIdFilter: filterValue, + all: false, + format: "names", + }).pipe(Effect.mapError((cause) => new LegacyStatusListError({ message: cause.message }))); + const runningSet = new Set(runningNames); + const serviceIds = legacyServiceContainerIds(projectId); + const stopped = serviceIds.filter((id) => !runningSet.has(id)); + if (stopped.length > 0) { + yield* output.raw(`Stopped services: ${formatGoStringSlice(stopped)}\n`, "stderr"); + } + + // 7. Merge health-derived exclusions with the user's --exclude flag. + const excluded = [...stopped, ...flags.exclude]; + + // 8. Apply the exclude-based gating on top of the already-validated + // `localState` (Go's `toValues()` exclude filtering, `status.go:55-61`). + // Pure/non-throwing — see `legacyGateStatusState`'s doc comment. Reused + // for both the real and pretty-mode (empty-override) value maps below, + // matching this handler's pre-split behavior. + const containerIds = legacyStatusContainerIds(projectId); + const state = legacyGateStatusState(localState, containerIds, excluded); + const { values } = legacyStatusValuesFromState(state, overrides); + + // Go's `PrettyPrint` (`status.go:236-243`) unmarshals a FRESH, empty + // `EnvSet{}` into a brand-new `CustomName{}` rather than reusing the + // CLI-supplied, override-populated `names` — `--override-name` only ever + // affects `printStatus`'s env/json/toml/yaml path, never the pretty table. + // Remap names from the already-resolved `state` (empty override map) so the + // rendered table matches Go exactly without leaking `--override-name` into + // pretty-mode output, and without a second (throwing) state resolution. + const renderPretty = Effect.fnUntraced(function* () { + yield* output.raw( + `${legacyAqua("supabase")} local development setup is running.\n\n`, + "stderr", + ); + const pretty = legacyStatusValuesFromState(state, new Map()); + yield* output.raw(legacyRenderStatusPretty(pretty.values, pretty.names)); + }); + + // 9. Output branching: Go's -o (env|json|toml|yaml|pretty) is a complete + // format choice and takes priority over --output-format (root.ts:119-121, + // matching functions/list's list.handler.ts:115-118) — only an ABSENT -o + // defers to --output-format for json/stream-json. + const goFmt = Option.getOrUndefined(goOutputFlag); + + if (goFmt === "env") { + yield* output.raw(encodeEnv(values) + "\n"); + return; + } + if (goFmt === "json") { + yield* output.raw(encodeGoJson(values)); + return; + } + if (goFmt === "toml") { + yield* output.raw(encodeToml(values) + "\n"); + return; + } + if (goFmt === "yaml") { + yield* output.raw(encodeYaml(values)); + return; + } + if (goFmt === "pretty") { + yield* renderPretty(); + return; + } + + // goFmt is undefined — defer to TS --output-format for json/stream-json, + // otherwise render the grouped rounded-table (Go's `-o pretty` default). + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", values); + return; + } + + yield* renderPretty(); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index 1c87049978..d1988d5569 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -1,58 +1,958 @@ +import { generateKeyPairSync } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; -import { legacyStatus } from "./status.handler.ts"; +import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { afterEach, vi } from "vitest"; + +import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { + mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; +import { legacyServiceContainerIds, localDbContainerId } from "../../shared/legacy-docker-ids.ts"; import type { LegacyStatusFlags } from "./status.command.ts"; +import { legacyStatus } from "./status.handler.ts"; + +const tempRoot = useLegacyTempWorkdir("supabase-status-int-"); + +afterEach(() => { + delete process.env["SUPABASE_AUTH_JWT_SECRET"]; +}); + +function flags(overrides: Partial = {}): LegacyStatusFlags { + return { + overrideName: [], + exclude: [], + ignoreHealthCheck: false, + ...overrides, + }; +} + +function writeConfig(workdir: string, contents = 'project_id = "demo"\n') { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "config.toml"), contents); +} + +interface SpawnRecord { + readonly command: string; + readonly args: ReadonlyArray; +} + +type RouteResult = { + readonly exitCode?: number; + readonly stdout?: ReadonlyArray; + readonly stderr?: ReadonlyArray; +}; + +/** Same routing-by-argv mock spawner shape as `stop.integration.test.ts`. */ +function mockRoutedContainerCliSpawner( + route: (args: ReadonlyArray) => RouteResult, + opts: { + readonly dockerMissing?: boolean; + readonly failSpawnFor?: (args: ReadonlyArray) => boolean; + } = {}, +) { + const spawned: Array = []; + + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const cmd = command._tag === "StandardCommand" ? command.command : ""; + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ command: cmd, args }); + + if (opts.dockerMissing === true && cmd === "docker") { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "docker not found", + }), + ); + } -function setupLegacyStatus() { - const calls: Array> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args) => - Effect.sync(() => { - calls.push(args); + if (opts.failSpawnFor?.(args) === true) { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }), + ); + } + + const encoder = new TextEncoder(); + const result = route(args); + const exitDeferred = yield* Deferred.make(); + yield* Effect.forkDetach( + Effect.gen(function* () { + yield* Effect.sleep("5 millis"); + yield* Deferred.succeed( + exitDeferred, + ChildProcessSpawner.ExitCode(result.exitCode ?? 0), + ); + }), + ); + const stdoutBytes = (result.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); + const stderrBytes = (result.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(5000 + spawned.length), + stdout: Stream.fromIterable(stdoutBytes), + stderr: Stream.fromIterable(stderrBytes), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); }), - execCapture: () => Effect.succeed(""), + ), + ); + + return { + layer, + get spawned() { + return spawned; + }, + }; +} + +const ALL_RUNNING_NAMES = legacyServiceContainerIds("demo"); +const HEALTHY_DB_STATE = JSON.stringify({ + Status: "running", + Running: true, + Health: { Status: "healthy" }, +}); + +/** + * Default happy-path router: db container inspect reports healthy+running, `ps` + * (names format) lists every one of the 13 expected services as running. + */ +function defaultRoute( + opts: { + readonly runningNames?: ReadonlyArray; + readonly dbInspectStdout?: string; + readonly dbInspectExitCode?: number; + readonly dbInspectStderr?: ReadonlyArray; + } = {}, +) { + const runningNames = opts.runningNames ?? ALL_RUNNING_NAMES; + return (args: ReadonlyArray): RouteResult => { + if (args[0] === "container" && args[1] === "inspect") { + return { + exitCode: opts.dbInspectExitCode ?? 0, + stdout: [opts.dbInspectStdout ?? HEALTHY_DB_STATE], + stderr: opts.dbInspectStderr, + }; + } + if (args[0] === "ps") return { stdout: runningNames }; + return { exitCode: 0 }; + }; +} + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: Option.Option<"env" | "pretty" | "json" | "toml" | "yaml">; + readonly route?: (args: ReadonlyArray) => RouteResult; + readonly dockerMissing?: boolean; + readonly failSpawnFor?: (args: ReadonlyArray) => boolean; + readonly skipConfig?: boolean; + readonly configContents?: string; + /** Defaults to `tempRoot.current` — override for `--workdir`-resolution tests. */ + readonly workdir?: string; +} + +function setup(opts: SetupOpts = {}) { + const workdir = opts.workdir ?? tempRoot.current; + if (opts.skipConfig !== true) { + writeConfig(workdir, opts.configContents); + } + const out = mockOutput({ + format: opts.format ?? "text", + interactive: (opts.format ?? "text") === "text", + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cliConfig = mockLegacyCliConfig({ workdir, projectId: Option.none() }); + const child = mockRoutedContainerCliSpawner(opts.route ?? defaultRoute(), { + dockerMissing: opts.dockerMissing, + failSpawnFor: opts.failSpawnFor, }); - return { layer, calls }; + + const layer = Layer.mergeAll( + BunServices.layer, + out.layer, + cliConfig, + telemetry.layer, + child.layer, + Layer.succeed(LegacyOutputFlag, opts.goOutput ?? Option.none()), + ); + + return { workdir, out, telemetry, child, layer }; } -const baseFlags: LegacyStatusFlags = { - overrideName: [], - exclude: [], - ignoreHealthCheck: false, -}; +describe("legacy status integration", () => { + it.live("shows the running stack as a pretty table", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stderrText).toContain("local development setup is running."); + expect(out.stdoutText).toContain("🔧 Development Tools"); + expect(out.stdoutText).toContain("🌐 APIs"); + expect(out.stdoutText).toContain("⛁ Database"); + expect(out.stdoutText).toContain("🔑 Authentication Keys"); + expect(out.stdoutText).toContain("📦 Storage (S3)"); + expect(out.stdoutText).toContain("postgresql://postgres:postgres@"); + expect(out.stderrText).not.toContain("Stopped services:"); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "sanitizes a dirty config.toml project_id before filtering, matching start's label", + () => { + // Go's Config.Validate rewrites Config.ProjectId to its sanitized form once + // at config-load time (pkg/config/config.go:938-944); every later reader — + // including the Docker label `start` writes — sees that same sanitized + // string. Filtering/inspecting with the raw value here would target + // containers `start` never created. + const { layer, child } = setup({ configContents: 'project_id = "My App!!"\n' }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args[2]).toBe(localDbContainerId("My_App_")); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toContain("label=com.supabase.cli.project=My_App_"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("skips the db health check with --ignore-health-check", () => { + const { layer, child } = setup({ + route: (args) => { + // db inspect would fail if called; ps still needs to succeed. + if (args[0] === "container" && args[1] === "inspect") return { exitCode: 1 }; + if (args[0] === "ps") return { stdout: ALL_RUNNING_NAMES }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + yield* legacyStatus(flags({ ignoreHealthCheck: true })); + expect(child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "inspect")).toBe( + false, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "succeeds against an unhealthy db when --ignore-health-check is set (status.go:104-108)", + () => { + // Pairs with "fails when the db container is unhealthy" below (ignoreHealthCheck: false, + // the default) to cover both sides of Go's `if !ignoreHealthCheck { assertContainerHealthy }`. + const { layer, child } = setup({ + route: defaultRoute({ + dbInspectStdout: JSON.stringify({ + Status: "running", + Running: true, + Health: { Status: "starting" }, + }), + }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags({ ignoreHealthCheck: true })); + expect( + child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "inspect"), + ).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("reports stopped services on stderr", () => { + const { layer, out } = setup({ + route: defaultRoute({ runningNames: ALL_RUNNING_NAMES.slice(1) }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const missing = ALL_RUNNING_NAMES[0]; + expect(out.stderrText).toContain(`Stopped services: [${missing}]`); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when config.toml is malformed", () => { + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), "not valid toml ====="); + const { layer, child } = setup({ skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusConfigLoadError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); -describe("legacy status", () => { - it.live("forwards no extra flags when defaults are used", () => { - const { layer, calls } = setupLegacyStatus(); + it.live("fails when [remotes.*] has a duplicate project_id, even with no projectRef", () => { + // Go's duplicate-project_id check (config.go:594-602) runs unconditionally + // on every config load, inside the same loop that resolves the [remotes.*] + // override — it is not gated on a caller actually selecting a remote. + // `status` never binds a --project-ref flag, so it must still fail on a + // config-wide duplicate, before ever reaching Docker. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.a] +project_id = "previewrefaaaaaaaaaa" + +[remotes.b] +project_id = "previewrefaaaaaaaaaa" +`, + ); + const { layer, child } = setup({ skipConfig: true }); return Effect.gen(function* () { - yield* legacyStatus(baseFlags); - expect(calls).toEqual([["status"]]); + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusConfigLoadError"); + } + expect(child.spawned).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.live("forwards --override-name for each provided override", () => { - const { layer, calls } = setupLegacyStatus(); + it.live("fails when a [remotes.*] project_id is not a valid 20-letter ref", () => { + // Go's Config.Validate (config.go:996-1001) checks every [remotes.*].project_id + // against refPattern unconditionally on every config load — not only a + // remote that ends up selected — so this must fail closed before status + // reaches Docker, even with no --project-ref requested. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.bad] +project_id = "short" +`, + ); + const { layer, child } = setup({ skipConfig: true }); return Effect.gen(function* () { - yield* legacyStatus({ - ...baseFlags, - overrideName: ["api.url=NEXT_PUBLIC_SUPABASE_URL"], + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusConfigLoadError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "decodes a comma-separated string into an array field ([]string) for status to proceed", + () => { + // Go's StringToSliceHookFunc (mapstructure) splits a plain string literal + // into a []string for a []string field like additional_redirect_urls — + // this only runs when goViperCompat is on. Pin that status still proceeds + // past config load (and on to a successful Docker inspect/list) rather + // than treating the string literal as a decode error. + const { layer } = setup({ + configContents: + 'project_id = "demo"\n[auth]\nadditional_redirect_urls = "http://a,http://b"\n', }); - expect(calls).toEqual([["status", "--override-name", "api.url=NEXT_PUBLIC_SUPABASE_URL"]]); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("warns on stderr for a deprecated auth.external provider", () => { + // Go's `external.validate()` (config.go:1418-1423) disables a bare + // [auth.external.slack] block and warns — mirrored by + // `normalizeDeprecatedExternalProviders` in packages/config's io.ts, gated + // on `goViperCompat` (confirmed already wired in status.handler.ts). The + // WARN goes out via Effect's `Console.error`, not this file's `Output` + // service, so it must be observed with a raw console.error spy — same + // idiom as packages/config/src/io.unit.test.ts's deprecated-provider tests. + const { layer } = setup({ + configContents: 'project_id = "demo"\n[auth.external.slack]\nenabled = true\n', + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('WARN: disabling deprecated "slack" provider'), + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => errorSpy.mockRestore()))); + }); + + it.live("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => { + // Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) + // `os.Chdir`s the explicit workdir in `PersistentPreRunE`, before config + // load or any Docker call — a missing path must fail immediately, not + // fall through to the workdir-basename default and inspect Docker. + const missingWorkdir = join(tempRoot.current, "does-not-exist"); + const { layer, child } = setup({ workdir: missingWorkdir, skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusWorkdirError"); + expect(JSON.stringify(exit.cause)).toContain( + `failed to change workdir: chdir ${missingWorkdir}: no such file or directory`, + ); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when --workdir/SUPABASE_WORKDIR points at a file, not a directory", () => { + const filePath = join(tempRoot.current, "not-a-directory"); + writeFileSync(filePath, ""); + const { layer, child } = setup({ workdir: filePath, skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusWorkdirError"); + expect(JSON.stringify(exit.cause)).toContain( + `failed to change workdir: chdir ${filePath}: not a directory`, + ); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when auth.jwt_secret is configured but shorter than 16 characters", () => { + // Go's Config.Validate rejects this at config-load time + // (pkg/config/apikeys.go:45-47), entirely before assertContainerHealthy/ + // container listing (internal/status/status.go:101-116) — so no Docker + // call happens, same as the malformed config.toml case above. + const { layer, child } = setup({ + configContents: 'project_id = "demo"\n[auth]\njwt_secret = "too-short"\n', + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusInvalidConfigError"); + expect(JSON.stringify(exit.cause)).toContain( + "Invalid config for auth.jwt_secret. Must be at least 16 characters", + ); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors SUPABASE_AUTH_JWT_SECRET over a config.toml value with -o env", () => { + // Go's Viper AutomaticEnv gives env vars higher precedence than config.toml + // (pkg/config/config.go:529-535) — a stack started with this env var set + // must report the env-derived secret, not the one in config.toml. + const { layer, out } = setup({ + goOutput: Option.some("env"), + configContents: `project_id = "demo"\n[auth]\njwt_secret = "${"a".repeat(32)}"\n`, + }); + process.env["SUPABASE_AUTH_JWT_SECRET"] = "b".repeat(32); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain(`JWT_SECRET="${"b".repeat(32)}"`); + expect(out.stdoutText).not.toContain("a".repeat(32)); + }).pipe(Effect.provide(layer)); + }); + + it.live("signs anon/service_role keys asymmetrically when signing_keys_path is set", () => { + // Go's generateJWT signs with the first key in auth.signing_keys_path + // (RS256/ES256) instead of HMAC when that file resolves to a non-empty JWK + // array (pkg/config/apikeys.go:76-113). + const { layer, out, workdir } = setup({ + goOutput: Option.some("json"), + configContents: 'project_id = "demo"\n[auth]\nsigning_keys_path = "signing_keys.json"\n', + }); + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = { ...privateKey.export({ format: "jwk" }), alg: "RS256", kid: "test-kid" }; + writeFileSync(join(workdir, "supabase", "signing_keys.json"), JSON.stringify([jwk])); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const parsed = JSON.parse(out.stdoutText) as Record; + const [headerSegment] = parsed.ANON_KEY?.split(".") ?? []; + const header = JSON.parse(Buffer.from(headerSegment ?? "", "base64url").toString()); + expect(header).toEqual({ alg: "RS256", kid: "test-kid", typ: "JWT" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("reports status using schema defaults when config.toml is missing entirely", () => { + // Matches Go: `flags.LoadConfig` -> `Config.Load` -> `loadFromFile` -> + // `mergeFileConfig` treats a missing file as a no-op (`os.ErrNotExist` -> + // nil, pkg/config/config.go:655-656), not an error — `status` proceeds + // using template defaults. Only a malformed file is a hard failure (see + // the sibling "malformed" test above). + // + // Without config.toml, the resolved project id falls back to the workdir + // basename (not the module-level `ALL_RUNNING_NAMES`, which is fixed to + // "demo") — route `ps` off that so the expected services actually show as + // running rather than all appearing "stopped" and excluded. + const projectId = basename(tempRoot.current); + const { layer, out } = setup({ + skipConfig: true, + route: defaultRoute({ runningNames: legacyServiceContainerIds(projectId) }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stderrText).toContain("local development setup is running."); + expect(out.stdoutText).toContain("Project URL"); + expect(out.stdoutText).toContain("Database"); + }).pipe(Effect.provide(layer)); + }); + + it.live("resolves SUPABASE_PROJECT_ID from supabase/.env over config.toml", () => { + // Go's Config.Load runs loadNestedEnv (supabase/.env(.local) via godotenv) + // before loadFromFile's AutomaticEnv reads SUPABASE_PROJECT_ID + // (pkg/config/config.go:735-738) — an env-file-only value overrides + // config.toml's project_id too, not just an ambient shell export. + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=env-file-project\n"); + const { layer, child } = setup({ + configContents: 'project_id = "toml-project"\n', + route: defaultRoute({ runningNames: legacyServiceContainerIds("env-file-project") }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args).toContain(localDbContainerId("env-file-project")); }).pipe(Effect.provide(layer)); }); - it.live("forwards the hidden --exclude and --ignore-health-check flags", () => { - const { layer, calls } = setupLegacyStatus(); + it.live("prefers ambient SUPABASE_PROJECT_ID over supabase/.env", () => { + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=env-file-project\n"); + process.env["SUPABASE_PROJECT_ID"] = "ambient-project"; + const { layer, child } = setup({ + configContents: 'project_id = "toml-project"\n', + route: defaultRoute({ runningNames: legacyServiceContainerIds("ambient-project") }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args).toContain(localDbContainerId("ambient-project")); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => delete process.env["SUPABASE_PROJECT_ID"])), + ); + }); + + it.live("resolves SUPABASE_PROJECT_ID from a project-root .env file", () => { + // Go's loadNestedEnv walks past supabase/ one more level, to the project + // root/workdir (pkg/config/config.go:1169-1190) — a project-root-only + // dotenv value must override config.toml too, not just supabase/.env. + writeFileSync(join(tempRoot.current, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); + const { layer, child } = setup({ + configContents: 'project_id = "toml-project"\n', + route: defaultRoute({ runningNames: legacyServiceContainerIds("root-env-project") }), + }); return Effect.gen(function* () { - yield* legacyStatus({ - overrideName: [], - exclude: ["db", "kong"], - ignoreHealthCheck: true, + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args).toContain(localDbContainerId("root-env-project")); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "does not climb to an ancestor project's config.toml when workdir has none of its own", + () => { + // Go's ChangeWorkDir uses an explicit/defaulted workdir exactly, with no + // ancestor search (apps/cli-go/internal/utils/misc.go:231-247) — mirrored + // here by `search: false`. A workdir with no supabase/config.toml of its + // own must fall back to defaults (workdir-basename project id), not an + // ancestor project's config.toml, even though `cliConfig.workdir` sits + // right inside one. + const nestedWorkdir = join(tempRoot.current, "nested"); + mkdirSync(nestedWorkdir, { recursive: true }); + writeConfig(tempRoot.current, 'project_id = "ancestor-project"\n'); + const projectId = basename(nestedWorkdir); + const { layer, child } = setup({ + workdir: nestedWorkdir, + skipConfig: true, + route: defaultRoute({ runningNames: legacyServiceContainerIds(projectId) }), }); - expect(calls).toEqual([ - ["status", "--exclude", "db", "--exclude", "kong", "--ignore-health-check"], - ]); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args).toContain(localDbContainerId(projectId)); + expect(inspectCall?.args).not.toContain(localDbContainerId("ancestor-project")); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("resolves SUPABASE_PROJECT_ID from supabase/.env even when config.toml is absent", () => { + // Go's loadNestedEnv runs unconditionally, before config.toml is ever + // opened (pkg/config/config.go:786-793) — a supabase/.env-only project id + // must still be honored even when there's no config.toml to fall back to + // template defaults from. + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=no-config-project\n"); + const { layer, child } = setup({ + skipConfig: true, + route: defaultRoute({ runningNames: legacyServiceContainerIds("no-config-project") }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args).toContain(localDbContainerId("no-config-project")); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors SUPABASE_AUTH_JWT_SECRET from supabase/.env, not just the ambient shell", () => { + // Go's Config.Load runs loadNestedEnv (supabase/.env(.local) via godotenv) + // before AutomaticEnv reads SUPABASE_AUTH_JWT_SECRET (config.go:735-738) — + // a dotenv-file-only value must be visible here too, not just an ambient + // shell export (see the sibling "-o env" ambient test above). + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, ".env"), `SUPABASE_AUTH_JWT_SECRET=${"c".repeat(32)}\n`); + const { layer, out } = setup({ + goOutput: Option.some("env"), + configContents: `project_id = "demo"\n[auth]\njwt_secret = "${"a".repeat(32)}"\n`, + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain(`JWT_SECRET="${"c".repeat(32)}"`); + expect(out.stdoutText).not.toContain("a".repeat(32)); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when both docker and podman are missing", () => { + // Neither container runtime can be spawned at all — distinct from a spawned + // process exiting non-zero (covered by the malformed/unhealthy scenarios + // above). + const { layer } = setup({ failSpawnFor: () => true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbInspectError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("falls back to podman when docker is absent", () => { + const { layer, child } = setup({ dockerMissing: true }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + // The failed `docker` attempt is recorded before the `podman` fallback fires + // (`spawnContainerCli`'s `Effect.catch` retries the same argv), so the last + // matching record for a given argv is the successful one. + const psCalls = child.spawned.filter((s) => s.args[0] === "ps"); + expect(psCalls.at(-1)?.command).toBe("podman"); + expect(psCalls.some((s) => s.command === "docker")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when listing running containers errors", () => { + const { layer } = setup({ + route: (args) => { + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: [HEALTHY_DB_STATE] }; + } + if (args[0] === "ps") return { exitCode: 1, stderr: ["daemon down"] }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusListError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the db container is not running", () => { + const { layer } = setup({ + route: defaultRoute({ + dbInspectStdout: JSON.stringify({ Status: "exited", Running: false }), + }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const serialized = JSON.stringify(exit.cause); + expect(serialized).toContain("LegacyStatusDbNotRunningError"); + expect(serialized).toContain(localDbContainerId("demo")); + } + }).pipe(Effect.provide(layer)); + }); + + it.live( + "succeeds against a paused-but-healthy db, matching Go's boolean-based running gate", + () => { + // Go's `assertContainerHealthy` (`status.go:150`) gates on the boolean + // `resp.State.Running`, not the status string — a paused container can + // report `Running: true` alongside `Status: "paused"`, and Go continues + // past the not-running branch to the health check in that case. + const { layer } = setup({ + route: defaultRoute({ + dbInspectStdout: JSON.stringify({ + Status: "paused", + Running: true, + Health: { Status: "healthy" }, + }), + }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("fails when the db container is absent, preserving the real Docker stderr text", () => { + // Go's `assertContainerHealthy` never special-cases "not found" — it wraps + // whatever `ContainerInspect` returns (`status.go:148-149`), so the real + // Docker stderr must flow through rather than a hardcoded TS string. + const { layer } = setup({ + route: defaultRoute({ + dbInspectExitCode: 1, + dbInspectStderr: ["Error response from daemon: No such container: x"], + }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const serialized = JSON.stringify(exit.cause); + expect(serialized).toContain("LegacyStatusDbInspectError"); + expect(serialized).toContain( + "failed to inspect container health: Error response from daemon: No such container: x", + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the db container is unhealthy", () => { + const { layer } = setup({ + route: defaultRoute({ + dbInspectStdout: JSON.stringify({ + Status: "running", + Running: true, + Health: { Status: "starting" }, + }), + }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbNotReadyError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when db inspect errors for a reason other than not-found", () => { + const { layer } = setup({ + route: defaultRoute({ dbInspectExitCode: 1, dbInspectStderr: ["permission denied"] }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbInspectError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("outputs env vars with -o env", () => { + const { layer, out } = setup({ goOutput: Option.some("env") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain('API_URL="http://127.0.0.1:54321"'); + expect(out.stdoutText).toContain("DB_URL="); + }).pipe(Effect.provide(layer)); + }); + + it.live("outputs a json object with -o json", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.API_URL).toBe("http://127.0.0.1:54321"); + expect(parsed.DB_URL).toContain("postgresql://postgres:postgres@"); + }).pipe(Effect.provide(layer)); + }); + + it.live("omits excluded services from -o json", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + const storageId = legacyServiceContainerIds("demo")[5]!; + yield* legacyStatus(flags({ exclude: [storageId] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.STORAGE_S3_URL).toBeUndefined(); + expect(parsed.API_URL).toBeDefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("omits every service named across multiple --exclude entries", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + const authId = legacyServiceContainerIds("demo")[1]!; + const storageId = legacyServiceContainerIds("demo")[5]!; + yield* legacyStatus(flags({ exclude: [authId, storageId] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.PUBLISHABLE_KEY).toBeUndefined(); + expect(parsed.STORAGE_S3_URL).toBeUndefined(); + expect(parsed.API_URL).toBeDefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("merges an auto-detected stopped service with a --exclude entry (status.go:116)", () => { + // Go's `excluded := append(stopped, exclude...)` merges the health-derived + // stopped list with the user-supplied --exclude list — both must take effect + // together, not just whichever one the command would have applied alone. + const { layer, out } = setup({ + goOutput: Option.some("json"), + // kong (index 0) is absent from the running set, so it's auto-detected as stopped. + route: defaultRoute({ runningNames: ALL_RUNNING_NAMES.slice(1) }), + }); + return Effect.gen(function* () { + const authId = legacyServiceContainerIds("demo")[1]!; + yield* legacyStatus(flags({ exclude: [authId] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.API_URL).toBeUndefined(); // excluded via the auto-detected stopped kong + expect(parsed.PUBLISHABLE_KEY).toBeUndefined(); // excluded via --exclude + expect(parsed.DB_URL).toBeDefined(); // db.url is set unconditionally, before any gating + }).pipe(Effect.provide(layer)); + }); + + it.live("outputs yaml with -o yaml", () => { + const { layer, out } = setup({ goOutput: Option.some("yaml") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain("API_URL:"); + }).pipe(Effect.provide(layer)); + }); + + it.live("outputs toml with -o toml", () => { + const { layer, out } = setup({ goOutput: Option.some("toml") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain("API_URL ="); + }).pipe(Effect.provide(layer)); + }); + + it.live("remaps an output key with --override-name api.url=NEXT_PUBLIC_SUPABASE_URL", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + yield* legacyStatus(flags({ overrideName: ["api.url=NEXT_PUBLIC_SUPABASE_URL"] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.NEXT_PUBLIC_SUPABASE_URL).toBe("http://127.0.0.1:54321"); + expect(parsed.API_URL).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails on a malformed --override-name entry", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags({ overrideName: ["not-a-kv-pair"] }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusOverrideParseError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("silently ignores an --override-name entry with an unknown field key", () => { + // Matches Go: `env.Unmarshal` (Netflix go-env) walks CustomName's own struct + // fields and looks up each field's tag in the override map — it never checks + // for leftover/unmatched keys, so an unrecognized key is a no-op, not an error. + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + yield* legacyStatus(flags({ overrideName: ["not.a.real.field=NAME"] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.NAME).toBeUndefined(); + expect(parsed.API_URL).toBe("http://127.0.0.1:54321"); + }).pipe(Effect.provide(layer)); + }); + + it.live("applies a valid --override-name entry alongside an unknown one", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + yield* legacyStatus( + flags({ overrideName: ["not.a.real.field=NAME", "api.url=NEXT_PUBLIC_SUPABASE_URL"] }), + ); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.NEXT_PUBLIC_SUPABASE_URL).toBe("http://127.0.0.1:54321"); + expect(parsed.NAME).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a machine result with --output-format json when -o is unset", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ API_URL: "http://127.0.0.1:54321" }); + expect(out.stdoutText).not.toContain("\x1b[?25l"); + }).pipe(Effect.provide(layer)); + }); + + it.live("-o takes priority over --output-format when both are passed", () => { + const { layer, out } = setup({ format: "json", goOutput: Option.some("env") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + // -o env wins: raw KEY="VALUE" text on stdout, not a structured success message. + expect(out.stdoutText).toContain('API_URL="http://127.0.0.1:54321"'); + expect(out.messages.find((m) => m.type === "success")).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("lets --output pretty win over --output-format json", () => { + // Explicit `-o pretty` is a complete Go format choice (root.ts:119-121, + // matching functions/list) and must render the table, not defer to the + // TS-only --output-format json/stream-json branch. + const { layer, out } = setup({ format: "json", goOutput: Option.some("pretty") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stderrText).toContain("local development setup is running."); + expect(out.stdoutText).toContain("🌐 APIs"); + expect(out.messages.find((m) => m.type === "success")).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry via ensuring even on failure", () => { + const { layer, telemetry } = setup({ + route: (args) => + args[0] === "container" && args[1] === "inspect" ? { exitCode: 1 } : { exitCode: 0 }, + }); + return Effect.gen(function* () { + yield* Effect.exit(legacyStatus(flags())); + expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/legacy/commands/status/status.live.test.ts b/apps/cli/src/legacy/commands/status/status.live.test.ts new file mode 100644 index 0000000000..64569c118c --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.live.test.ts @@ -0,0 +1,54 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; + +import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts"; + +const START_TIMEOUT_MS = 280_000; + +// See stop.live.test.ts for why `describeLive` (not a Management-API gate) is +// the right reuse here: `status` never calls the Management API, only the real +// Docker daemon the cli-e2e-ci runner provides. See AGENTS.md's "Live tests" +// section for the full convention. +describeLive("supabase status (live)", () => { + let projectDir: string | undefined; + + afterEach(async () => { + if (projectDir === undefined) return; + await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); + await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); + projectDir = undefined; + }); + + test( + "reports a running local stack in pretty and json modes", + { timeout: START_TIMEOUT_MS }, + async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-status-live-")); + + const init = await runSupabaseLive(["init"], { cwd: projectDir }); + expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + + const start = await runSupabaseLive( + ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], + { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, + ); + expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); + + const pretty = await runSupabaseLive(["status"], { cwd: projectDir }); + expect(pretty.exitCode, `stdout:\n${pretty.stdout}\nstderr:\n${pretty.stderr}`).toBe(0); + expect(`${pretty.stdout}${pretty.stderr}`).toContain("is running"); + expect(pretty.stdout).toContain("Project URL"); + expect(pretty.stdout).toContain("Database"); + + const json = await runSupabaseLive(["status", "-o", "json"], { cwd: projectDir }); + expect(json.exitCode, `stdout:\n${json.stdout}\nstderr:\n${json.stderr}`).toBe(0); + const parsed: unknown = JSON.parse(json.stdout); + expect(parsed).toMatchObject({ + API_URL: expect.stringContaining("http"), + DB_URL: expect.stringContaining("postgresql://"), + }); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/status/status.pretty.ts b/apps/cli/src/legacy/commands/status/status.pretty.ts new file mode 100644 index 0000000000..2999e4ef50 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.pretty.ts @@ -0,0 +1,276 @@ +import { legacyAqua, legacyBold, legacyGreen, legacyYellow } from "../../shared/legacy-colors.ts"; +import type { LegacyStatusOutputNames } from "./status.values.ts"; + +/** + * Port of Go's `PrettyPrint` / `OutputGroup.printTable` + * (`apps/cli-go/internal/status/status.go:236-392`), reproducing + * `tablewriter.NewTable` with `tw.StyleRounded` byte-for-byte for the fixed + * 5-group, 2-column layout `status` needs. This is not a general tablewriter + * port — column sizing, wrapping, and merge behavior are only implemented to the + * extent this command's rounded box needs them. + * + * Column 0 (the label column) is capped at 16 display columns + * (`ColMaxWidths.PerColumn[0] = 16`, `status.go:344`); a label wider than that + * word-wraps across multiple lines, leaving column 1 blank on the continuation + * lines (verified against a real `tablewriter@v1.1.4` render — see the port + * plan). None of the fixed labels below reach 17 characters today, so this is + * defensive parity rather than an observed case. + * + * This does not reuse `legacy/output/legacy-glamour-table.ts` — that helper + * byte-matches Go's `glamour.RenderTable(..., AsciiStyle)`, a single ASCII table + * with a different border style used by other commands. `status`'s Go source + * renders with `tablewriter`/`tw.StyleRounded` into 5 separate grouped, colored, + * Unicode-rounded-box tables, which is a different rendering contract entirely. + * + * Every color call below styles text written to **stdout** (via `output.raw` + * with no stream argument in `status.handler.ts`), so each one explicitly passes + * `process.stdout` to `legacy-colors.ts`'s helpers — they default to + * `process.stderr`, which would check the wrong stream's TTY status here. + */ + +type OutputKind = "text" | "link" | "key"; + +interface OutputItem { + readonly label: string; + readonly value: string; + readonly kind: OutputKind; +} + +interface OutputGroup { + readonly name: string; + readonly items: ReadonlyArray; +} + +const COLUMN_0_MAX_WIDTH = 16; + +/** + * Builds the 5 fixed groups Go's `PrettyPrint` declares (`status.go:245-285`), + * looking up each label's value by output KEY from the resolved value map — + * `--override-name` remaps the KEY but never the group layout, matching Go + * (`values[names.StudioURL]`, not a hardcoded default name). + */ +function buildGroups( + values: Readonly>, + names: LegacyStatusOutputNames, +): ReadonlyArray { + const at = (key: string) => values[key] ?? ""; + return [ + { + name: "🔧 Development Tools", + items: [ + { label: "Studio", value: at(names.studioUrl), kind: "link" }, + { label: "Mailpit", value: at(names.mailpitUrl), kind: "link" }, + { label: "MCP", value: at(names.mcpUrl), kind: "link" }, + ], + }, + { + name: "🌐 APIs", + items: [ + { label: "Project URL", value: at(names.apiUrl), kind: "link" }, + { label: "REST", value: at(names.restUrl), kind: "link" }, + { label: "GraphQL", value: at(names.graphqlUrl), kind: "link" }, + { label: "Edge Functions", value: at(names.functionsUrl), kind: "link" }, + ], + }, + { + name: "⛁ Database", + items: [{ label: "URL", value: at(names.dbUrl), kind: "link" }], + }, + { + name: "🔑 Authentication Keys", + items: [ + { label: "Publishable", value: at(names.publishableKey), kind: "key" }, + { label: "Secret", value: at(names.secretKey), kind: "key" }, + ], + }, + { + name: "📦 Storage (S3)", + items: [ + { label: "URL", value: at(names.storageS3Url), kind: "link" }, + { label: "Access Key", value: at(names.storageS3AccessKeyId), kind: "key" }, + { label: "Secret Key", value: at(names.storageS3SecretAccessKey), kind: "key" }, + { label: "Region", value: at(names.storageS3Region), kind: "text" }, + ], + }, + ]; +} + +/** + * Display width, matching `go-runewidth`'s treatment closely enough for this + * command's inputs: URLs/keys/labels are always plain ASCII, so every rune is + * width 1. The only non-ASCII runes ever rendered are the 5 fixed group-title + * emoji, whose exact rendered widths are hardcoded in {@link HEADER_DISPLAY_WIDTH} + * below rather than computed generically (avoids taking a full Unicode + * East-Asian-Width dependency for a 5-value constant table). + */ +function displayWidth(text: string): number { + return [...text].length; +} + +/** Go-rendered display width of each fixed group title (see `status.pretty.unit.test.ts`). */ +const HEADER_DISPLAY_WIDTH: Readonly> = { + "🔧 Development Tools": 20, + "🌐 APIs": 7, + "⛁ Database": 10, + "🔑 Authentication Keys": 22, + "📦 Storage (S3)": 15, +}; + +/** + * Exported only for direct unit coverage of the fallback branch (a group title + * outside the 5-entry {@link HEADER_DISPLAY_WIDTH} table) — every call site in + * this file only ever passes one of those 5 fixed titles. + */ +export function legacyStatusHeaderWidth(name: string): number { + return HEADER_DISPLAY_WIDTH[name] ?? displayWidth(name); +} + +/** + * Greedy word-wrap to `width` columns, mirroring tablewriter's column wrapping. + * Exported only for direct unit coverage of the >16-char defensive-wrap branch + * (see the file-level doc comment) — none of this command's real labels reach + * that width today, so `legacyRenderStatusPretty` never exercises it end to end. + */ +export function legacyWrapStatusLabel(text: string, width: number): ReadonlyArray { + if (displayWidth(text) <= width) return [text]; + const words = text.split(" "); + const lines: string[] = []; + let current = ""; + for (const word of words) { + const candidate = current.length === 0 ? word : `${current} ${word}`; + if (displayWidth(candidate) <= width) { + current = candidate; + } else { + if (current.length > 0) lines.push(current); + current = word; + } + } + if (current.length > 0) lines.push(current); + return lines.length > 0 ? lines : [text]; +} + +/** + * Value coloring, mirroring the `switch row.Type` in `printTable` + * (`status.go:372-377`): `Link` → Aqua, `Key` → Yellow, `Text` → unstyled (the + * switch has no `Text` case, so `value` keeps its raw pre-switch assignment). + */ +function colorValue(kind: OutputKind, value: string): string { + switch (kind) { + case "link": + return legacyAqua(value, process.stdout); + case "key": + return legacyYellow(value, process.stdout); + case "text": + return value; + } +} + +interface ColumnLayout { + readonly col0Padded: number; + readonly col1Padded: number; + readonly targetInner: number; +} + +/** + * Computes the padded column widths and total inner (header) width for a group, + * mirroring tablewriter's column-sizing pass: each column is sized from its + * widest content cell (col 0 capped at 16), then both columns widen evenly + * (col 0 taking the larger half of an odd remainder) if the header text is + * wider than the data-driven layout. Exported only for direct unit coverage of + * the header-widens-the-table branch — none of this command's 5 fixed group + * titles are wider than their data today, so `legacyRenderStatusPretty` never + * exercises it end to end (see the file-level doc comment). + */ +export function legacyStatusColumnLayout( + headerWidthValue: number, + col0Contents: ReadonlyArray, + col1Contents: ReadonlyArray, +): ColumnLayout { + const col0Content = Math.min( + COLUMN_0_MAX_WIDTH, + Math.max(...col0Contents.map((text) => displayWidth(text))), + ); + const col1Content = Math.max(...col1Contents.map((text) => displayWidth(text))); + + let col0Padded = col0Content + 2; + let col1Padded = col1Content + 2; + const dataInner = col0Padded + 1 + col1Padded; + const targetInner = Math.max(dataInner, headerWidthValue + 2); + const extra = targetInner - dataInner; + if (extra > 0) { + col0Padded += Math.ceil(extra / 2); + col1Padded += Math.floor(extra / 2); + } + return { col0Padded, col1Padded, targetInner }; +} + +function renderGroupTable(group: OutputGroup): string | undefined { + const rows = group.items.filter((item) => item.value.length > 0); + if (rows.length === 0) return undefined; + + // Column 0 wraps at 16; column 1 is never capped (Go only sets PerColumn[0]). + // Kept as plain text here — color is applied only after padding, below, so an + // ANSI escape is never counted toward the padded display width. + const wrappedRows = rows.map((row) => ({ + lines: legacyWrapStatusLabel(row.label, COLUMN_0_MAX_WIDTH), + kind: row.kind, + value: row.value, + })); + + const { col0Padded, col1Padded, targetInner } = legacyStatusColumnLayout( + legacyStatusHeaderWidth(group.name), + rows.map((row) => row.label), + rows.map((row) => row.value), + ); + const col0Width = col0Padded - 2; + const col1Width = col1Padded - 2; + + // Pad on the plain text first, then apply color/bold — an active ANSI escape + // must never be counted toward the padded display width. + const pad = (text: string, width: number) => + text + " ".repeat(Math.max(0, width - displayWidth(text))); + // The header uses `headerWidth` (the hardcoded emoji-aware width table) rather + // than `displayWidth`, so its padding lines up with the border math above, + // which sized `targetInner` off the same `headerWidth` call. + const padHeader = (text: string, width: number) => + text + " ".repeat(Math.max(0, width - legacyStatusHeaderWidth(text))); + + const lines: string[] = []; + lines.push(`╭${"─".repeat(col0Padded + 1 + col1Padded)}╮`); + lines.push(`│ ${legacyBold(padHeader(group.name, targetInner - 2), process.stdout)} │`); + lines.push(`├${"─".repeat(col0Padded)}┬${"─".repeat(col1Padded)}┤`); + for (const row of wrappedRows) { + row.lines.forEach((line, index) => { + // Only the first wrapped line carries the value; Go's continuation lines + // (from a >16-char label wrapping) leave column 1 blank. + const labelCell = legacyGreen(pad(line, col0Width), process.stdout); + const paddedValue = pad(index === 0 ? row.value : "", col1Width); + const valueCell = index === 0 ? colorValue(row.kind, paddedValue) : paddedValue; + lines.push(`│ ${labelCell} │ ${valueCell} │`); + }); + } + lines.push(`╰${"─".repeat(col0Padded)}┴${"─".repeat(col1Padded)}╯`); + return lines.join("\n"); +} + +/** + * Port of Go's `PrettyPrint` (`status.go:236-294`): renders the 5 fixed groups + * as rounded-border tables, skipping empty rows and empty groups, with a blank + * line after every group (rendered or not — Go's loop always + * `fmt.Fprintln(w)`s after a nil-error `printTable`, even when nothing rendered). + */ +export function legacyRenderStatusPretty( + values: Readonly>, + names: LegacyStatusOutputNames, +): string { + const groups = buildGroups(values, names); + const lines: string[] = []; + for (const group of groups) { + const table = renderGroupTable(group); + if (table !== undefined) { + lines.push(table); + } + lines.push(""); + } + return lines.join("\n"); +} diff --git a/apps/cli/src/legacy/commands/status/status.pretty.unit.test.ts b/apps/cli/src/legacy/commands/status/status.pretty.unit.test.ts new file mode 100644 index 0000000000..be44ac4c75 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.pretty.unit.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it } from "vitest"; + +import { + legacyRenderStatusPretty, + legacyStatusColumnLayout, + legacyStatusHeaderWidth, + legacyWrapStatusLabel, +} from "./status.pretty.ts"; +import type { LegacyStatusOutputNames } from "./status.values.ts"; + +// The renderer applies Go-parity ANSI styling via `legacy-colors.ts`, which +// no-ops on a real non-TTY stream but the vitest process presents its stderr +// as color-capable. Strip escapes so these assertions target the plain +// structural output — the golden contract per the port plan — not whichever +// TTY heuristic the test runner happens to report. +// eslint-disable-next-line no-control-regex +const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); + +// Default (un-overridden) output names, matching `status.values.ts`'s +// `resolveOutputNames` with an empty override map — the KEYs the pretty +// renderer looks values up by. +const NAMES: LegacyStatusOutputNames = { + apiUrl: "API_URL", + restUrl: "REST_URL", + graphqlUrl: "GRAPHQL_URL", + storageS3Url: "STORAGE_S3_URL", + mcpUrl: "MCP_URL", + functionsUrl: "FUNCTIONS_URL", + dbUrl: "DB_URL", + studioUrl: "STUDIO_URL", + mailpitUrl: "MAILPIT_URL", + publishableKey: "PUBLISHABLE_KEY", + secretKey: "SECRET_KEY", + storageS3AccessKeyId: "S3_PROTOCOL_ACCESS_KEY_ID", + storageS3SecretAccessKey: "S3_PROTOCOL_ACCESS_KEY_SECRET", + storageS3Region: "S3_PROTOCOL_REGION", +}; + +const FULL_VALUES: Record = { + API_URL: "http://127.0.0.1:54321", + REST_URL: "http://127.0.0.1:54321/rest/v1", + GRAPHQL_URL: "http://127.0.0.1:54321/graphql/v1", + STORAGE_S3_URL: "http://127.0.0.1:54321/storage/v1/s3", + MCP_URL: "http://127.0.0.1:54321/mcp", + FUNCTIONS_URL: "http://127.0.0.1:54321/functions/v1", + DB_URL: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + STUDIO_URL: "http://127.0.0.1:54323", + MAILPIT_URL: "http://127.0.0.1:54324", + PUBLISHABLE_KEY: "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH", + SECRET_KEY: "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz", + S3_PROTOCOL_ACCESS_KEY_ID: "625729a08b95bf1b7ff351a663f3a23c", + S3_PROTOCOL_ACCESS_KEY_SECRET: "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + S3_PROTOCOL_REGION: "local", +}; + +describe("legacyRenderStatusPretty", () => { + // Byte-for-byte parity with a real `tablewriter@v1.1.4` + `tw.StyleRounded` + // render of Go's `PrettyPrint` group layout (verified by running the actual + // vendored Go module against this exact value set — see the port plan). + it("matches the Go rounded-table fixture for a fully running stack", () => { + const out = stripAnsi(legacyRenderStatusPretty(FULL_VALUES, NAMES)); + + const expected = [ + "╭──────────────────────────────────────╮", + "│ 🔧 Development Tools │", + "├─────────┬────────────────────────────┤", + "│ Studio │ http://127.0.0.1:54323 │", + "│ Mailpit │ http://127.0.0.1:54324 │", + "│ MCP │ http://127.0.0.1:54321/mcp │", + "╰─────────┴────────────────────────────╯", + "", + "╭──────────────────────────────────────────────────────╮", + "│ 🌐 APIs │", + "├────────────────┬─────────────────────────────────────┤", + "│ Project URL │ http://127.0.0.1:54321 │", + "│ REST │ http://127.0.0.1:54321/rest/v1 │", + "│ GraphQL │ http://127.0.0.1:54321/graphql/v1 │", + "│ Edge Functions │ http://127.0.0.1:54321/functions/v1 │", + "╰────────────────┴─────────────────────────────────────╯", + "", + "╭───────────────────────────────────────────────────────────────╮", + "│ ⛁ Database │", + "├─────┬─────────────────────────────────────────────────────────┤", + "│ URL │ postgresql://postgres:postgres@127.0.0.1:54322/postgres │", + "╰─────┴─────────────────────────────────────────────────────────╯", + "", + "╭──────────────────────────────────────────────────────────────╮", + "│ 🔑 Authentication Keys │", + "├─────────────┬────────────────────────────────────────────────┤", + "│ Publishable │ sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH │", + "│ Secret │ sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz │", + "╰─────────────┴────────────────────────────────────────────────╯", + "", + "╭───────────────────────────────────────────────────────────────────────────────╮", + "│ 📦 Storage (S3) │", + "├────────────┬──────────────────────────────────────────────────────────────────┤", + "│ URL │ http://127.0.0.1:54321/storage/v1/s3 │", + "│ Access Key │ 625729a08b95bf1b7ff351a663f3a23c │", + "│ Secret Key │ 850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907 │", + "│ Region │ local │", + "╰────────────┴──────────────────────────────────────────────────────────────────╯", + "", + ].join("\n"); + + expect(out).toBe(expected); + }); + + // Byte-for-byte parity with a real render of a single-row group (Database), + // confirming the header-vs-single-short-row column sizing. All other groups + // are empty in this fixture, so only the Database box should appear. + it("matches the Go rounded-table fixture for a single-row group", () => { + const out = stripAnsi( + legacyRenderStatusPretty({ DB_URL: FULL_VALUES.DB_URL ?? "" }, { ...NAMES, dbUrl: "DB_URL" }), + ); + + const expectedTable = [ + "╭───────────────────────────────────────────────────────────────╮", + "│ ⛁ Database │", + "├─────┬─────────────────────────────────────────────────────────┤", + "│ URL │ postgresql://postgres:postgres@127.0.0.1:54322/postgres │", + "╰─────┴─────────────────────────────────────────────────────────╯", + ].join("\n"); + + expect(out).toContain(expectedTable); + expect(out).toBe(["", "", expectedTable, "", "", ""].join("\n")); + }); + + // All other groups are empty in this fixture, so only the APIs box appears + // (only Project URL, the rest of the group's rows are excluded/disabled). + it("matches the Go rounded-table fixture for a partial APIs group", () => { + const out = stripAnsi(legacyRenderStatusPretty({ API_URL: "http://127.0.0.1:54321" }, NAMES)); + + const expectedTable = [ + "╭──────────────────────────────────────╮", + "│ 🌐 APIs │", + "├─────────────┬────────────────────────┤", + "│ Project URL │ http://127.0.0.1:54321 │", + "╰─────────────┴────────────────────────╯", + ].join("\n"); + + expect(out).toBe(["", expectedTable, "", "", "", ""].join("\n")); + }); + + it("skips a row whose value is missing from the value map", () => { + // Only Studio present; Mailpit/MCP absent from the map entirely (excluded + // or disabled upstream in `status.values.ts`) — same as an empty string. + const out = stripAnsi( + legacyRenderStatusPretty({ STUDIO_URL: "http://127.0.0.1:54323" }, NAMES), + ); + + expect(out).toContain("Studio"); + expect(out).not.toContain("Mailpit"); + expect(out).not.toContain("MCP"); + }); + + it("skips an entirely empty group but still emits its trailing blank line", () => { + // Nothing present for Development Tools; only the Database URL is set. + const out = stripAnsi(legacyRenderStatusPretty({ DB_URL: FULL_VALUES.DB_URL ?? "" }, NAMES)); + const lines = out.split("\n"); + + // No rounded-box characters before the Database group's own box. + expect(lines[0]).not.toMatch(/[╭│╰]/); + expect(lines[0]).toBe(""); + expect(out).not.toContain("Development Tools"); + expect(out).toContain("⛁ Database"); + }); + + it("returns only blank lines when every group is empty", () => { + const out = stripAnsi(legacyRenderStatusPretty({}, NAMES)); + // One blank line per group (5 groups), none of them rendering a table. + expect(out).toBe(["", "", "", "", ""].join("\n")); + }); + + // `legacyRenderStatusPretty` is a pure lookup: it renders whatever `values` + // are reachable through `names`' keys, with no opinion on how the caller + // derived either. This is NOT asserting that `--override-name` reaches + // pretty-mode output in production — `status.handler.ts` deliberately always + // calls this function with un-overridden names (matching Go's `PrettyPrint`, + // which unmarshals a fresh empty `EnvSet{}` rather than the CLI's overridden + // `CustomName`). This test only proves the renderer's KEY-based lookup itself + // works correctly for an arbitrary names/values pairing. + it("resolves values through whatever KEY the names parameter specifies", () => { + const overriddenNames: LegacyStatusOutputNames = { + ...NAMES, + apiUrl: "NEXT_PUBLIC_SUPABASE_URL", + }; + const out = stripAnsi( + legacyRenderStatusPretty( + { NEXT_PUBLIC_SUPABASE_URL: "http://127.0.0.1:54321" }, + overriddenNames, + ), + ); + expect(out).toContain("http://127.0.0.1:54321"); + }); +}); + +// None of `status`'s 18 fixed field labels or 5 fixed group titles are wide +// enough to exercise these two branches through the public +// `legacyRenderStatusPretty` API today (see the file-level doc comment on +// `status.pretty.ts`) — covered directly here as defensive Go-parity logic. +describe("legacyWrapStatusLabel", () => { + it("returns the text unwrapped when it fits within the width", () => { + expect(legacyWrapStatusLabel("Edge Functions", 16)).toEqual(["Edge Functions"]); + }); + + it("word-wraps a label wider than the column width", () => { + expect(legacyWrapStatusLabel("This Is A Very Long Label Name", 16)).toEqual([ + "This Is A Very", + "Long Label Name", + ]); + }); + + it("hard-breaks a single word wider than the column width", () => { + expect(legacyWrapStatusLabel("ThisIsAVeryLongSingleWordLabel", 16)).toEqual([ + "ThisIsAVeryLongSingleWordLabel", + ]); + }); + + it("does not emit a leading empty line when the very first word already overflows", () => { + expect(legacyWrapStatusLabel("SuperLongFirstWord Short", 10)).toEqual([ + "SuperLongFirstWord", + "Short", + ]); + }); + + it("returns the input unchanged for an empty label", () => { + expect(legacyWrapStatusLabel("", 10)).toEqual([""]); + }); + + it("returns the input unchanged for a whitespace-only label wider than the column", () => { + // Every "word" from splitting on spaces is itself empty, so `current` never + // accumulates anything to flush after the loop — the `lines` array stays + // empty and the function falls back to the original text. + expect(legacyWrapStatusLabel(" ", 2)).toEqual([" "]); + }); +}); + +describe("legacyStatusColumnLayout", () => { + it("sizes columns from content alone when the header already fits", () => { + const layout = legacyStatusColumnLayout(10, ["URL"], ["postgresql://short"]); + expect(layout.targetInner).toBe(3 + 2 + 1 + "postgresql://short".length + 2); + }); + + it("widens both columns evenly when the header is wider than the data", () => { + // Base data-driven layout: col0="a"(1+2=3), col1="b"(1+2=3), dataInner=3+1+3=7. + // A 10-char header needs innerWidth=12, so 5 extra columns split 3/2. + const layout = legacyStatusColumnLayout(10, ["a"], ["b"]); + expect(layout.targetInner).toBe(12); + expect(layout.col0Padded).toBe(6); + expect(layout.col1Padded).toBe(5); + }); + + it("caps column 0's content width at 16 even when a label is longer", () => { + const layout = legacyStatusColumnLayout(0, ["a".repeat(30)], ["b"]); + expect(layout.col0Padded).toBe(18); + }); +}); + +describe("legacyStatusHeaderWidth", () => { + it("uses the hardcoded emoji-aware width for a known fixed group title", () => { + expect(legacyStatusHeaderWidth("⛁ Database")).toBe(10); + }); + + it("falls back to code-point length for a title outside the fixed table", () => { + expect(legacyStatusHeaderWidth("Plain Title")).toBe("Plain Title".length); + }); +}); diff --git a/apps/cli/src/legacy/commands/status/status.values.ts b/apps/cli/src/legacy/commands/status/status.values.ts new file mode 100644 index 0000000000..4759a75af6 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.values.ts @@ -0,0 +1,495 @@ +import type { ProjectConfig } from "@supabase/config"; + +import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; +import { legacyServiceContainerIds } from "../../shared/legacy-docker-ids.ts"; +import { + legacyEnvOverrideBool, + legacyResolveLocalConfigValues, + type LegacyLocalConfigValues, +} from "../../shared/legacy-local-config-values.ts"; + +/** + * Port of Go's `status.CustomName` + `toValues()` (`internal/status/status.go:29-97`). + * Each field's Go `env:"..."` tag carries two things: the dotted key + * `--override-name =` matches against (`fieldKey` below), and the + * default output env-var name (`defaultName`). `deprecated` fields (`inbucket`, + * `jwt_secret`, `anon_key`, `service_role_key`) are still emitted — Go's + * `deprecated` tag only affects a startup warning it never wires up for `status` + * (only `env.Unmarshal` reads the tag, and it does not warn), so no divergence here. + */ +export interface LegacyStatusField { + readonly fieldKey: string; + readonly defaultName: string; +} + +const API_URL: LegacyStatusField = { fieldKey: "api.url", defaultName: "API_URL" }; +const REST_URL: LegacyStatusField = { fieldKey: "api.rest_url", defaultName: "REST_URL" }; +const GRAPHQL_URL: LegacyStatusField = { fieldKey: "api.graphql_url", defaultName: "GRAPHQL_URL" }; +const STORAGE_S3_URL: LegacyStatusField = { + fieldKey: "api.storage_s3_url", + defaultName: "STORAGE_S3_URL", +}; +const MCP_URL: LegacyStatusField = { fieldKey: "api.mcp_url", defaultName: "MCP_URL" }; +const FUNCTIONS_URL: LegacyStatusField = { + fieldKey: "api.functions_url", + defaultName: "FUNCTIONS_URL", +}; +const DB_URL: LegacyStatusField = { fieldKey: "db.url", defaultName: "DB_URL" }; +const STUDIO_URL: LegacyStatusField = { fieldKey: "studio.url", defaultName: "STUDIO_URL" }; +const INBUCKET_URL: LegacyStatusField = { fieldKey: "inbucket.url", defaultName: "INBUCKET_URL" }; +const MAILPIT_URL: LegacyStatusField = { fieldKey: "mailpit.url", defaultName: "MAILPIT_URL" }; +const PUBLISHABLE_KEY: LegacyStatusField = { + fieldKey: "auth.publishable_key", + defaultName: "PUBLISHABLE_KEY", +}; +const SECRET_KEY: LegacyStatusField = { fieldKey: "auth.secret_key", defaultName: "SECRET_KEY" }; +const JWT_SECRET: LegacyStatusField = { fieldKey: "auth.jwt_secret", defaultName: "JWT_SECRET" }; +const ANON_KEY: LegacyStatusField = { fieldKey: "auth.anon_key", defaultName: "ANON_KEY" }; +const SERVICE_ROLE_KEY: LegacyStatusField = { + fieldKey: "auth.service_role_key", + defaultName: "SERVICE_ROLE_KEY", +}; +const STORAGE_S3_ACCESS_KEY_ID: LegacyStatusField = { + fieldKey: "storage.s3_access_key_id", + defaultName: "S3_PROTOCOL_ACCESS_KEY_ID", +}; +const STORAGE_S3_SECRET_ACCESS_KEY: LegacyStatusField = { + fieldKey: "storage.s3_secret_access_key", + defaultName: "S3_PROTOCOL_ACCESS_KEY_SECRET", +}; +const STORAGE_S3_REGION: LegacyStatusField = { + fieldKey: "storage.s3_region", + defaultName: "S3_PROTOCOL_REGION", +}; + +/** All 18 fields, in `CustomName` struct declaration order. */ +export const LEGACY_STATUS_FIELDS: ReadonlyArray = [ + API_URL, + REST_URL, + GRAPHQL_URL, + STORAGE_S3_URL, + MCP_URL, + FUNCTIONS_URL, + DB_URL, + STUDIO_URL, + INBUCKET_URL, + MAILPIT_URL, + PUBLISHABLE_KEY, + SECRET_KEY, + JWT_SECRET, + ANON_KEY, + SERVICE_ROLE_KEY, + STORAGE_S3_ACCESS_KEY_ID, + STORAGE_S3_SECRET_ACCESS_KEY, + STORAGE_S3_REGION, +]; + +/** The subset of {@link LEGACY_STATUS_FIELDS} the pretty renderer looks up by field. */ +export interface LegacyStatusOutputNames { + readonly apiUrl: string; + readonly restUrl: string; + readonly graphqlUrl: string; + readonly storageS3Url: string; + readonly mcpUrl: string; + readonly functionsUrl: string; + readonly dbUrl: string; + readonly studioUrl: string; + readonly mailpitUrl: string; + readonly publishableKey: string; + readonly secretKey: string; + readonly storageS3AccessKeyId: string; + readonly storageS3SecretAccessKey: string; + readonly storageS3Region: string; +} + +/** + * Resolves each field's output KEY, applying `--override-name =` + * remaps over the Go default names. `overrides` maps `fieldKey` (e.g. `"api.url"`) + * to the replacement output name, mirroring `env.Unmarshal`'s `default=` override. + */ +function resolveOutputNames(overrides: ReadonlyMap): LegacyStatusOutputNames { + const nameFor = (field: LegacyStatusField) => overrides.get(field.fieldKey) ?? field.defaultName; + return { + apiUrl: nameFor(API_URL), + restUrl: nameFor(REST_URL), + graphqlUrl: nameFor(GRAPHQL_URL), + storageS3Url: nameFor(STORAGE_S3_URL), + mcpUrl: nameFor(MCP_URL), + functionsUrl: nameFor(FUNCTIONS_URL), + dbUrl: nameFor(DB_URL), + studioUrl: nameFor(STUDIO_URL), + mailpitUrl: nameFor(MAILPIT_URL), + publishableKey: nameFor(PUBLISHABLE_KEY), + secretKey: nameFor(SECRET_KEY), + storageS3AccessKeyId: nameFor(STORAGE_S3_ACCESS_KEY_ID), + storageS3SecretAccessKey: nameFor(STORAGE_S3_SECRET_ACCESS_KEY), + storageS3Region: nameFor(STORAGE_S3_REGION), + }; +} + +/** + * Container ids `toValues()` gates each group on, taken from + * `legacyServiceContainerIds`'s alias order (`kong`, `auth`, `inbucket`, ..., + * `edge_runtime`, ...) — see `legacy-docker-ids.ts`. + */ +export interface LegacyStatusContainerIds { + readonly kong: string; + readonly auth: string; + readonly inbucket: string; + readonly rest: string; + readonly storage: string; + readonly studio: string; + readonly edgeRuntime: string; +} + +// Positional indices into `legacyServiceContainerIds`'s fixed 13-element +// array (`legacy-docker-ids.ts`'s `GetDockerIds()` order), named so a caller +// never has to destructure the array positionally. +const CONTAINER_INDEX = { + kong: 0, + auth: 1, + inbucket: 2, + rest: 4, + storage: 5, + studio: 8, + edgeRuntime: 9, +} as const; + +/** + * Derives {@link LegacyStatusContainerIds} from `legacyServiceContainerIds`'s + * flat array for a given project id. The array's length and order are a fixed + * Go-parity contract (13 elements, `GetDockerIds()` order), so every named + * index here is guaranteed present — this only exists to give the handler a + * named-field view instead of positional array destructuring. + */ +export function legacyStatusContainerIds(projectId: string): LegacyStatusContainerIds { + const ids = legacyServiceContainerIds(projectId); + const at = (index: number) => ids[index] ?? ""; + return { + kong: at(CONTAINER_INDEX.kong), + auth: at(CONTAINER_INDEX.auth), + inbucket: at(CONTAINER_INDEX.inbucket), + rest: at(CONTAINER_INDEX.rest), + storage: at(CONTAINER_INDEX.storage), + studio: at(CONTAINER_INDEX.studio), + edgeRuntime: at(CONTAINER_INDEX.edgeRuntime), + }; +} + +/** + * Port of Go's `utils.ShortContainerImageName` (`internal/utils/misc.go:33-39,75`): + * extracts the repo name between the (first) `/` and the (last) `:`, falling back to + * the full string when the image ref doesn't match (no slash, or no tag). + */ +export function legacyShortContainerImageName(imageName: string): string { + const match = /\/(.*):/.exec(imageName); + return match?.[1] ?? imageName; +} + +// Default image short names Go's `--exclude` also matches against +// (`internal/status/status.go:55-61`), one per gated service. Sourced from the same +// embedded Dockerfile manifest Go parses (`dockerfileServiceImage`), so a version bump +// there is picked up automatically. Pinned-version substitution +// (`legacy-db-image.ts`'s `replaceImageTag`) only ever rewrites the portion after the +// first `:`, which `legacyShortContainerImageName` discards — so these are invariant to +// version pinning and no `.temp/-version` file needs to be read here. +const KONG_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("kong")); +const POSTGREST_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("postgrest")); +const STUDIO_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("studio")); +const GOTRUE_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("gotrue")); +const MAILPIT_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("mailpit")); +const STORAGE_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("storage")); +const EDGE_RUNTIME_IMAGE_NAME = legacyShortContainerImageName( + dockerfileServiceImage("edgeruntime"), +); + +export interface LegacyStatusValuesResult { + readonly values: Record; + readonly names: LegacyStatusOutputNames; + readonly local: LegacyLocalConfigValues; +} + +/** + * Everything `toValues()` needs that does NOT depend on `--override-name` — + * i.e. every field except the output KEY remapping. Resolving this once and + * reusing it for both the env/json/toml/yaml values (real overrides) and the + * pretty-table values (Go always recomputes with an empty override map, + * `status.go:236-243`) avoids re-reading `auth.signing_keys_path` and + * re-signing the anon/service_role JWTs a second time per invocation. + */ +export interface LegacyStatusState { + readonly config: ProjectConfig; + readonly local: LegacyLocalConfigValues; + readonly kongEnabled: boolean; + readonly postgrestEnabled: boolean; + readonly studioEnabled: boolean; + readonly authEnabled: boolean; + readonly inbucketEnabled: boolean; + readonly storageEnabled: boolean; + readonly functionsEnabled: boolean; + readonly storageS3ProtocolEnabled: boolean; +} + +/** + * The config-load/`Validate`-equivalent half of {@link LegacyStatusState} — + * everything that can THROW, and none of it depends on `excluded`/ + * `containerIds`. Split out so `status.handler.ts` can resolve and validate + * this before any Docker call, matching Go's `flags.LoadConfig` (config load + * + `Validate`, `internal/utils/flags/config_path.go:12` -> + * `pkg/config/config.go:882`) running entirely before `assertContainerHealthy`/ + * container listing (`internal/status/status.go:101-116`) — a bad + * `auth.jwt_secret` or malformed `SUPABASE_*_PORT`/`SUPABASE_*_ENABLED` + * override must fail here, not be masked by a Docker/DB error when the local + * stack happens to be unavailable. + */ +export interface LegacyStatusLocalState { + readonly config: ProjectConfig; + readonly local: LegacyLocalConfigValues; + readonly apiEnabled: boolean; + readonly studioSectionEnabled: boolean; + readonly authSectionEnabled: boolean; + readonly inbucketSectionEnabled: boolean; + readonly storageSectionEnabled: boolean; + readonly edgeRuntimeEnabled: boolean; + readonly storageS3ProtocolEnabled: boolean; +} + +/** + * Port of the throwing, non-Docker-dependent half of Go's + * `(*CustomName).toValues(exclude...)` (`internal/status/status.go:50-97`): + * resolves local config values (URLs, keys — can throw, see + * {@link legacyResolveLocalConfigValues}) and the per-service `.enabled` gates, + * with NO reference to `excluded`/`containerIds` — see {@link legacyGateStatusState} + * for the Docker-dependent, non-throwing half this composes with (in + * `status.handler.ts`, or via {@link legacyStatusValues} for callers that + * don't need to run validation before Docker calls). + * + * Each `.enabled` gate is read through {@link legacyEnvOverrideBool}, not the + * raw decoded `config.
.enabled`, because Go's `status.toValues()` + * (`status.go:55-61`) reads `utils.Config.*.Enabled` — a package-level struct + * Viper has already applied any `SUPABASE_
_ENABLED` env/dotenv + * override to (`SetEnvPrefix("SUPABASE")` + `AutomaticEnv()` + + * `ExperimentalBindStruct()`, `pkg/config/config.go:580-586`) — generically, + * not just for `auth.enabled`. Skipping this would mean a stack Go started + * with e.g. `SUPABASE_API_ENABLED=true` over a `false` TOML value has Kong/ + * PostgREST running while native `status` omits them entirely. + * + * @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. + * @throws {LegacyInvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv + * override doesn't parse as a valid port. + * @throws {LegacyInvalidBoolEnvOverrideError} when a `SUPABASE_*_ENABLED` env/dotenv + * override doesn't parse as a valid bool. + * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, + * or its first key is unsupported — see {@link legacyGenerateAsymmetricGoJwt}. + */ +export function legacyResolveStatusLocalState( + config: ProjectConfig, + hostname: string, + workdir: string, + projectEnvValues: Readonly> | undefined = undefined, + /** `LoadedProjectConfig.document` — see {@link legacyResolveLocalConfigValues}'s doc comment. */ + document: Readonly> | undefined = undefined, +): LegacyStatusLocalState { + const local = legacyResolveLocalConfigValues( + config, + hostname, + workdir, + projectEnvValues, + document, + ); + + const apiEnabled = legacyEnvOverrideBool( + "SUPABASE_API_ENABLED", + config.api.enabled, + "api.enabled", + projectEnvValues, + ); + const studioSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_STUDIO_ENABLED", + config.studio.enabled, + "studio.enabled", + projectEnvValues, + ); + const authSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ); + const inbucketSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_LOCAL_SMTP_ENABLED", + config.local_smtp.enabled, + "local_smtp.enabled", + projectEnvValues, + ); + const storageSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_STORAGE_ENABLED", + config.storage.enabled, + "storage.enabled", + projectEnvValues, + ); + const edgeRuntimeEnabled = legacyEnvOverrideBool( + "SUPABASE_EDGE_RUNTIME_ENABLED", + config.edge_runtime.enabled, + "edge_runtime.enabled", + projectEnvValues, + ); + const storageS3ProtocolEnabled = legacyEnvOverrideBool( + "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", + config.storage.s3_protocol.enabled, + "storage.s3_protocol.enabled", + projectEnvValues, + ); + + return { + config, + local, + apiEnabled, + studioSectionEnabled, + authSectionEnabled, + inbucketSectionEnabled, + storageSectionEnabled, + edgeRuntimeEnabled, + storageS3ProtocolEnabled, + }; +} + +/** + * The Docker-dependent, non-throwing half of Go's `toValues()`: applies + * `excluded` (matching each gated service by its container id + * (`legacyStatusContainerIds`) OR its default Docker image short name + * (`legacyShortContainerImageName` above) — the 6 relevant Go config fields + * (`Api.KongImage`, `Api.Image`, `Studio.Image`, `Auth.Image`, `Inbucket.Image`, + * `Storage.Image`, `EdgeRuntime.Image`) all carry `toml:"-"`, so they're never + * user-overridable and the default image is always the one to check) on top of + * an already-resolved {@link LegacyStatusLocalState}. Pure: every throwing + * concern already ran in {@link legacyResolveStatusLocalState}. + */ +export function legacyGateStatusState( + localState: LegacyStatusLocalState, + containerIds: LegacyStatusContainerIds, + excluded: ReadonlyArray, +): LegacyStatusState { + const { config, local } = localState; + const { apiEnabled, studioSectionEnabled, authSectionEnabled } = localState; + const { inbucketSectionEnabled, storageSectionEnabled } = localState; + const { edgeRuntimeEnabled, storageS3ProtocolEnabled } = localState; + const isExcluded = (id: string) => excluded.includes(id); + + const kongEnabled = apiEnabled && !isExcluded(containerIds.kong) && !isExcluded(KONG_IMAGE_NAME); + const postgrestEnabled = + kongEnabled && !isExcluded(containerIds.rest) && !isExcluded(POSTGREST_IMAGE_NAME); + const studioEnabled = + studioSectionEnabled && !isExcluded(containerIds.studio) && !isExcluded(STUDIO_IMAGE_NAME); + const authEnabled = + authSectionEnabled && !isExcluded(containerIds.auth) && !isExcluded(GOTRUE_IMAGE_NAME); + const inbucketEnabled = + inbucketSectionEnabled && !isExcluded(containerIds.inbucket) && !isExcluded(MAILPIT_IMAGE_NAME); + const storageEnabled = + storageSectionEnabled && !isExcluded(containerIds.storage) && !isExcluded(STORAGE_IMAGE_NAME); + const functionsEnabled = + edgeRuntimeEnabled && + !isExcluded(containerIds.edgeRuntime) && + !isExcluded(EDGE_RUNTIME_IMAGE_NAME); + + return { + config, + local, + kongEnabled, + postgrestEnabled, + studioEnabled, + authEnabled, + inbucketEnabled, + storageEnabled, + functionsEnabled, + storageS3ProtocolEnabled, + }; +} + +/** + * Applies `--override-name` remapping to an already-resolved {@link LegacyStatusState}. + * Pure and non-throwing — every failure mode of `toValues()` lives in + * {@link legacyResolveStatusLocalState}, which runs once per `status` invocation. + */ +export function legacyStatusValuesFromState( + state: LegacyStatusState, + overrides: ReadonlyMap, +): LegacyStatusValuesResult { + const { local, kongEnabled, postgrestEnabled, studioEnabled, authEnabled } = state; + const { inbucketEnabled, storageEnabled, functionsEnabled, storageS3ProtocolEnabled } = state; + const names = resolveOutputNames(overrides); + + // Go always sets db.url unconditionally, before any gating (status.go:52). + const values: Record = { + [names.dbUrl]: local.dbUrl, + }; + + if (kongEnabled) { + values[names.apiUrl] = local.apiUrl; + if (postgrestEnabled) { + values[names.restUrl] = local.restUrl; + values[names.graphqlUrl] = local.graphqlUrl; + } + if (functionsEnabled) { + values[names.functionsUrl] = local.functionsUrl; + } + if (studioEnabled) { + values[names.mcpUrl] = local.mcpUrl; + } + } + if (studioEnabled) { + values[names.studioUrl] = local.studioUrl; + } + if (authEnabled) { + values[names.publishableKey] = local.publishableKey; + values[names.secretKey] = local.secretKey; + values[overrides.get(JWT_SECRET.fieldKey) ?? JWT_SECRET.defaultName] = local.jwtSecret; + values[overrides.get(ANON_KEY.fieldKey) ?? ANON_KEY.defaultName] = local.anonKey; + values[overrides.get(SERVICE_ROLE_KEY.fieldKey) ?? SERVICE_ROLE_KEY.defaultName] = + local.serviceRoleKey; + } + if (inbucketEnabled) { + values[names.mailpitUrl] = local.mailpitUrl; + values[overrides.get(INBUCKET_URL.fieldKey) ?? INBUCKET_URL.defaultName] = local.mailpitUrl; + } + if (storageEnabled && storageS3ProtocolEnabled) { + values[names.storageS3Url] = local.storageS3Url; + values[names.storageS3AccessKeyId] = local.storageS3AccessKeyId; + values[names.storageS3SecretAccessKey] = local.storageS3SecretAccessKey; + values[names.storageS3Region] = local.storageS3Region; + } + + return { values, names, local }; +} + +/** + * Convenience wrapper combining {@link legacyResolveStatusLocalState} + + * {@link legacyGateStatusState} + {@link legacyStatusValuesFromState} in one + * call — used directly by tests that only need a single override map. + * `status.handler.ts` calls the three separately instead, so it can resolve + + * validate `localState` before any Docker call (see + * {@link legacyResolveStatusLocalState}'s doc comment), and reuse the gated + * `state` for both the real and pretty-mode (empty-override) value maps + * without recomputing `local`. + */ +export function legacyStatusValues( + config: ProjectConfig, + containerIds: LegacyStatusContainerIds, + hostname: string, + excluded: ReadonlyArray, + overrides: ReadonlyMap, + workdir: string, + projectEnvValues: Readonly> | undefined = undefined, + /** `LoadedProjectConfig.document` — see {@link legacyResolveLocalConfigValues}'s doc comment. */ + document: Readonly> | undefined = undefined, +): LegacyStatusValuesResult { + const localState = legacyResolveStatusLocalState( + config, + hostname, + workdir, + projectEnvValues, + document, + ); + const state = legacyGateStatusState(localState, containerIds, excluded); + return legacyStatusValuesFromState(state, overrides); +} diff --git a/apps/cli/src/legacy/commands/status/status.values.unit.test.ts b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts new file mode 100644 index 0000000000..3ae5f60004 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts @@ -0,0 +1,793 @@ +import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + legacyShortContainerImageName, + legacyStatusContainerIds, + legacyStatusValues, + type LegacyStatusContainerIds, +} from "./status.values.ts"; + +const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +function baseConfig(overrides: Record = {}): ProjectConfig { + return decodeConfig({ project_id: "test", ...overrides }); +} + +const CONTAINER_IDS: LegacyStatusContainerIds = { + kong: "supabase_kong_test", + auth: "supabase_auth_test", + inbucket: "supabase_inbucket_test", + rest: "supabase_rest_test", + storage: "supabase_storage_test", + studio: "supabase_studio_test", + edgeRuntime: "supabase_edge_runtime_test", +}; + +const HOSTNAME = "127.0.0.1"; +const NONE: ReadonlyArray = []; +const NO_OVERRIDES = new Map(); +const WORKDIR = "/tmp/status-values-test"; + +describe("legacyStatusValues", () => { + it("emits DB_URL unconditionally, even when every other service is disabled/excluded", () => { + const config = baseConfig({ + api: { enabled: false }, + studio: { enabled: false }, + auth: { enabled: false }, + local_smtp: { enabled: false }, + storage: { enabled: false }, + edge_runtime: { enabled: false }, + }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(Object.keys(values)).toEqual(["DB_URL"]); + expect(values.DB_URL).toContain("postgresql://postgres:postgres@127.0.0.1"); + }); + + describe("api / kong gating", () => { + it("includes API_URL when api.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.API_URL).toBeDefined(); + }); + + it("omits API_URL when api.enabled is false", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.API_URL).toBeUndefined(); + }); + + it("omits API_URL when the kong container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.kong], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.API_URL).toBeUndefined(); + }); + + it("omits API_URL when the kong image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["kong"], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.API_URL).toBeUndefined(); + }); + + it("omits REST/GraphQL when kong is disabled even though postgrest is enabled", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.REST_URL).toBeUndefined(); + expect(values.GRAPHQL_URL).toBeUndefined(); + }); + + it("omits REST/GraphQL when only the rest container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.rest], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.API_URL).toBeDefined(); + expect(values.REST_URL).toBeUndefined(); + expect(values.GRAPHQL_URL).toBeUndefined(); + }); + + it("includes REST/GraphQL when kong and postgrest are both enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.REST_URL).toBeDefined(); + expect(values.GRAPHQL_URL).toBeDefined(); + }); + + it("omits REST/GraphQL when the postgrest image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["postgrest"], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.API_URL).toBeDefined(); + expect(values.REST_URL).toBeUndefined(); + expect(values.GRAPHQL_URL).toBeUndefined(); + }); + }); + + describe("functions gating", () => { + it("includes FUNCTIONS_URL when kong and edge_runtime are both enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.FUNCTIONS_URL).toBeDefined(); + }); + + it("omits FUNCTIONS_URL when edge_runtime.enabled is false", () => { + const config = baseConfig({ edge_runtime: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.FUNCTIONS_URL).toBeUndefined(); + }); + + it("omits FUNCTIONS_URL when kong is disabled even though edge_runtime is enabled", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.FUNCTIONS_URL).toBeUndefined(); + }); + + it("omits FUNCTIONS_URL when the edge_runtime container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.edgeRuntime], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.FUNCTIONS_URL).toBeUndefined(); + }); + + it("omits FUNCTIONS_URL when the edge-runtime image short name is excluded", () => { + // The image repo name (`supabase/edge-runtime`) differs from the Dockerfile's + // build alias (`edgeruntime`) — the short name Go matches against is the former. + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["edge-runtime"], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.FUNCTIONS_URL).toBeUndefined(); + }); + }); + + describe("studio / mcp gating", () => { + it("includes STUDIO_URL when studio.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STUDIO_URL).toBeDefined(); + }); + + it("omits STUDIO_URL when studio.enabled is false", () => { + const config = baseConfig({ studio: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STUDIO_URL).toBeUndefined(); + }); + + it("omits STUDIO_URL when the studio container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.studio], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STUDIO_URL).toBeUndefined(); + }); + + it("omits STUDIO_URL when the studio image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["studio"], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STUDIO_URL).toBeUndefined(); + }); + + it("includes MCP_URL only when both kong and studio are enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.MCP_URL).toBeDefined(); + }); + + it("omits MCP_URL when kong is disabled", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.MCP_URL).toBeUndefined(); + }); + + it("omits MCP_URL when studio is disabled", () => { + const config = baseConfig({ studio: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.MCP_URL).toBeUndefined(); + }); + }); + + describe("auth gating", () => { + it("includes all 5 auth fields when auth.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.PUBLISHABLE_KEY).toBeDefined(); + expect(values.SECRET_KEY).toBeDefined(); + expect(values.JWT_SECRET).toBeDefined(); + expect(values.ANON_KEY).toBeDefined(); + expect(values.SERVICE_ROLE_KEY).toBeDefined(); + }); + + it("omits all 5 auth fields when auth.enabled is false", () => { + const config = baseConfig({ auth: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.PUBLISHABLE_KEY).toBeUndefined(); + expect(values.SECRET_KEY).toBeUndefined(); + expect(values.JWT_SECRET).toBeUndefined(); + expect(values.ANON_KEY).toBeUndefined(); + expect(values.SERVICE_ROLE_KEY).toBeUndefined(); + }); + + it("omits all 5 auth fields when the auth container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.auth], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.PUBLISHABLE_KEY).toBeUndefined(); + }); + + it("omits all 5 auth fields when the gotrue image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["gotrue"], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.PUBLISHABLE_KEY).toBeUndefined(); + }); + }); + + describe("inbucket/mailpit gating", () => { + it("includes MAILPIT_URL and the deprecated INBUCKET_URL alias when local_smtp.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.MAILPIT_URL).toBeDefined(); + expect(values.INBUCKET_URL).toBe(values.MAILPIT_URL); + }); + + it("omits MAILPIT_URL/INBUCKET_URL when local_smtp.enabled is false", () => { + const config = baseConfig({ local_smtp: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.MAILPIT_URL).toBeUndefined(); + expect(values.INBUCKET_URL).toBeUndefined(); + }); + + it("omits MAILPIT_URL/INBUCKET_URL when the inbucket container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.inbucket], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.MAILPIT_URL).toBeUndefined(); + }); + + it("omits MAILPIT_URL/INBUCKET_URL when the mailpit image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["mailpit"], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.MAILPIT_URL).toBeUndefined(); + }); + }); + + describe("storage / s3 gating", () => { + it("includes all 4 storage S3 fields when storage.enabled and s3_protocol.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STORAGE_S3_URL).toBeDefined(); + expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeDefined(); + expect(values.S3_PROTOCOL_ACCESS_KEY_SECRET).toBeDefined(); + expect(values.S3_PROTOCOL_REGION).toBeDefined(); + }); + + it("omits storage S3 fields when storage.enabled is false", () => { + const config = baseConfig({ storage: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STORAGE_S3_URL).toBeUndefined(); + }); + + it("omits storage S3 fields when the storage container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.storage], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STORAGE_S3_URL).toBeUndefined(); + }); + + it("omits storage S3 fields when the storage-api image short name is excluded", () => { + // The image repo name (`supabase/storage-api`) differs from the Dockerfile's + // build alias (`storage`) — the short name Go matches against is the former. + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["storage-api"], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STORAGE_S3_URL).toBeUndefined(); + }); + + it("omits storage S3 fields when storage.s3_protocol.enabled is false", () => { + const config = baseConfig({ storage: { s3_protocol: { enabled: false } } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STORAGE_S3_URL).toBeUndefined(); + expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeUndefined(); + }); + }); + + describe("SUPABASE_*_ENABLED env overrides", () => { + // Go's `status.toValues()` (`status.go:55-61`) reads `utils.Config.*.Enabled` + // AFTER Viper's `SetEnvPrefix("SUPABASE")` + `AutomaticEnv()` binding + // (`pkg/config/config.go:580-586`) has already applied any + // `SUPABASE_
_ENABLED` override — generically, not just for auth. + // `legacyResolveStatusLocalState` must read the same post-override value + // for every gate, not the raw decoded `config.
.enabled`. + + it("includes API_URL/REST_URL when SUPABASE_API_ENABLED overrides a disabled api.enabled", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_API_ENABLED: "true", + }, + ); + expect(values.API_URL).toBeDefined(); + expect(values.REST_URL).toBeDefined(); + }); + + it("omits API_URL when SUPABASE_API_ENABLED=false overrides an enabled api.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { SUPABASE_API_ENABLED: "false" }, + ); + expect(values.API_URL).toBeUndefined(); + }); + + it("includes STUDIO_URL when SUPABASE_STUDIO_ENABLED overrides a disabled studio.enabled", () => { + const config = baseConfig({ studio: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_STUDIO_ENABLED: "true", + }, + ); + expect(values.STUDIO_URL).toBeDefined(); + }); + + it("includes the 5 auth fields when SUPABASE_AUTH_ENABLED overrides a disabled auth.enabled", () => { + // Reproduces the exact scenario a Go-started stack can hit: TOML says + // auth is disabled, but the running stack was actually started with + // SUPABASE_AUTH_ENABLED=true from the shell/dotenv, so Auth is up and + // status must still print its credentials. + const config = baseConfig({ auth: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_AUTH_ENABLED: "true", + }, + ); + expect(values.PUBLISHABLE_KEY).toBeDefined(); + expect(values.ANON_KEY).toBeDefined(); + expect(values.SERVICE_ROLE_KEY).toBeDefined(); + }); + + it("omits the 5 auth fields when SUPABASE_AUTH_ENABLED=false overrides an enabled auth.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { SUPABASE_AUTH_ENABLED: "false" }, + ); + expect(values.PUBLISHABLE_KEY).toBeUndefined(); + }); + + it("includes MAILPIT_URL when SUPABASE_LOCAL_SMTP_ENABLED overrides a disabled local_smtp.enabled", () => { + const config = baseConfig({ local_smtp: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_LOCAL_SMTP_ENABLED: "true", + }, + ); + expect(values.MAILPIT_URL).toBeDefined(); + }); + + it("includes storage S3 fields when SUPABASE_STORAGE_ENABLED overrides a disabled storage.enabled", () => { + const config = baseConfig({ storage: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_STORAGE_ENABLED: "true", + }, + ); + expect(values.STORAGE_S3_URL).toBeDefined(); + }); + + it("includes FUNCTIONS_URL when SUPABASE_EDGE_RUNTIME_ENABLED overrides a disabled edge_runtime.enabled", () => { + const config = baseConfig({ edge_runtime: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_EDGE_RUNTIME_ENABLED: "true", + }, + ); + expect(values.FUNCTIONS_URL).toBeDefined(); + }); + + it("includes storage S3 fields when SUPABASE_STORAGE_S3_PROTOCOL_ENABLED overrides a disabled s3_protocol.enabled", () => { + const config = baseConfig({ storage: { s3_protocol: { enabled: false } } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_STORAGE_S3_PROTOCOL_ENABLED: "true", + }, + ); + expect(values.STORAGE_S3_URL).toBeDefined(); + }); + + it("omits storage S3 fields when SUPABASE_STORAGE_S3_PROTOCOL_ENABLED=false overrides an enabled s3_protocol.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { SUPABASE_STORAGE_S3_PROTOCOL_ENABLED: "false" }, + ); + expect(values.STORAGE_S3_URL).toBeUndefined(); + }); + }); + + describe("--override-name remapping", () => { + it("remaps a field's output KEY while leaving the value unchanged", () => { + const overrides = new Map([["api.url", "NEXT_PUBLIC_SUPABASE_URL"]]); + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + overrides, + WORKDIR, + ); + expect(values.API_URL).toBeUndefined(); + expect(values.NEXT_PUBLIC_SUPABASE_URL).toBe("http://127.0.0.1:54321"); + }); + + it("remaps every field independently when multiple overrides are given", () => { + const overrides = new Map([ + ["api.url", "CUSTOM_API_URL"], + ["db.url", "CUSTOM_DB_URL"], + ]); + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + overrides, + WORKDIR, + ); + expect(values.CUSTOM_API_URL).toBeDefined(); + expect(values.CUSTOM_DB_URL).toBeDefined(); + expect(values.API_URL).toBeUndefined(); + expect(values.DB_URL).toBeUndefined(); + }); + + it("leaves unrelated fields at their default name when only one is overridden", () => { + const overrides = new Map([["api.url", "CUSTOM_API_URL"]]); + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + overrides, + WORKDIR, + ); + expect(values.REST_URL).toBeDefined(); + }); + + it("remaps the deprecated auth.jwt_secret/anon_key/service_role_key keys", () => { + const overrides = new Map([ + ["auth.jwt_secret", "CUSTOM_JWT_SECRET"], + ["auth.anon_key", "CUSTOM_ANON_KEY"], + ["auth.service_role_key", "CUSTOM_SERVICE_ROLE_KEY"], + ]); + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + overrides, + WORKDIR, + ); + expect(values.CUSTOM_JWT_SECRET).toBeDefined(); + expect(values.CUSTOM_ANON_KEY).toBeDefined(); + expect(values.CUSTOM_SERVICE_ROLE_KEY).toBeDefined(); + expect(values.JWT_SECRET).toBeUndefined(); + expect(values.ANON_KEY).toBeUndefined(); + expect(values.SERVICE_ROLE_KEY).toBeUndefined(); + }); + + it("remaps the deprecated inbucket.url key independently of mailpit.url", () => { + const overrides = new Map([["inbucket.url", "CUSTOM_INBUCKET_URL"]]); + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + overrides, + WORKDIR, + ); + expect(values.CUSTOM_INBUCKET_URL).toBeDefined(); + expect(values.MAILPIT_URL).toBeDefined(); + expect(values.INBUCKET_URL).toBeUndefined(); + }); + }); + + it("combines stopped-service exclusions with --exclude flag exclusions", () => { + // Both `stopped` (from the health-check diff) and `--exclude` (user flag) + // funnel into the same `excluded` array in the handler; the pure function + // only sees the merged list. + const excluded = [CONTAINER_IDS.storage, CONTAINER_IDS.studio]; + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + excluded, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STORAGE_S3_URL).toBeUndefined(); + expect(values.STUDIO_URL).toBeUndefined(); + expect(values.API_URL).toBeDefined(); + }); +}); + +describe("legacyShortContainerImageName", () => { + it("extracts the repo name between the first slash and the last colon", () => { + expect(legacyShortContainerImageName("supabase/storage-api:v1.61.9")).toBe("storage-api"); + expect(legacyShortContainerImageName("library/kong:2.8.1")).toBe("kong"); + }); + + it("falls back to the full string when there is no slash/tag to extract", () => { + expect(legacyShortContainerImageName("kong")).toBe("kong"); + }); +}); + +describe("legacyStatusContainerIds", () => { + it("derives every named field from legacyServiceContainerIds's fixed array order", () => { + const ids = legacyStatusContainerIds("demo"); + expect(ids).toEqual({ + kong: "supabase_kong_demo", + auth: "supabase_auth_demo", + inbucket: "supabase_inbucket_demo", + rest: "supabase_rest_demo", + storage: "supabase_storage_demo", + studio: "supabase_studio_demo", + edgeRuntime: "supabase_edge_runtime_demo", + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md index 09cf725258..31de3f8e06 100644 --- a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md @@ -1,16 +1,21 @@ # `supabase stop` +Native TypeScript port of Go's `internal/stop`. Talks directly to Docker via subprocess +(`docker`/`podman`), replicating Go's label-filtering and container-naming scheme +byte-for-byte — it does not go through `@supabase/stack/effect`'s orchestration model +(see the CLI-1324 plan's "Critical architectural finding" for why). + ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | ---------------------------------------- | -| `/supabase/config.toml` | TOML | always, to resolve project configuration | +| Path | Format | When | +| -------------------------------- | ------ | -------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | default path only — skipped entirely when `--project-id` or `--all` is set | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------- | ------ | ----------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command — Go parity | ## API Routes @@ -18,37 +23,109 @@ | ------ | ---- | ---- | ------------ | ---------------------- | | — | — | — | — | — | +Neither `stop` nor its Go counterpart make any Management API call. Everything is local +Docker + local `config.toml`. + ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| — | — | — | +| Variable | Purpose | Required? | +| --------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | overrides the resolved local project id on the default path (env → config.toml → workdir basename) | no | +| `SUPABASE_WORKDIR` | resolves `LegacyCliConfig.workdir`, which locates `config.toml` on the default path | no (falls back to walking up from cwd for `supabase/config.toml`) | + +`docker`/`podman` must be resolvable on `PATH` (or reachable via the configured Docker +context) — `spawnContainerCli` tries `docker` first and falls back to `podman`. When +neither can be spawned at all, the error message names the actual root cause (e.g. +"docker: command not found (podman also not found) — install Docker Desktop or Podman +and ensure it is on PATH") rather than a generic "failed to ..." string. ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — all containers stopped | -| `1` | Docker daemon not running or connection error | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------- | +| `0` | success — containers/volumes/networks pruned | +| `1` | `--project-id` and `--all` both set (`LegacyStopMutuallyExclusiveError`) | +| `1` | `config.toml` present but malformed (`LegacyStopConfigLoadError`) — an **absent** file is not an error | +| `1` | listing containers failed (`LegacyStopListError`) | +| `1` | stopping one or more containers failed (`LegacyStopContainerError`) | +| `1` | `docker container prune` failed (`LegacyStopContainerPruneError`) | +| `1` | `docker volume prune` failed, only reached when volumes are being deleted (`LegacyStopVolumePruneError`) | +| `1` | `docker network prune` failed (`LegacyStopNetworkPruneError`) | +| `1` | `docker`/`podman` both absent from `PATH` (surfaces as one of the errors above) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +Matches `apps/cli-go/internal/stop/`. Go does not fire any custom telemetry event for +this command. ## Output +Go's `stop.RunE` never reads `-o`/`--output` itself, but the flag is still registered +on the root command as a `PersistentFlags()` enum (`cmd/root.go:330`, +`env|pretty|json|toml|yaml`) that every subcommand inherits, so `stop -o csv`/`-o table` +is rejected by pflag at parse time, before `RunE` runs — Go never reaches this command's +body with an unsupported value. `stop.command.ts` matches this: it wraps the handler +with `withLegacyCommandInstrumentation`, whose default `outputFormats` +(`LEGACY_RESOURCE_OUTPUT_FORMATS`, same `env|pretty|json|toml|yaml` set) validates and +rejects the flag before the handler runs — not a divergence, just enforced one layer up +rather than read inside this handler. Only the TS-native `--output-format` is consulted +by this handler's own logic below. + ### `--output-format text` (Go CLI compatible) -Prints "Stopped supabase local development setup." on success. +- stdout: `Stopping containers...` (printed unconditionally before any Docker call, + matching Go's `fmt.Fprintln` — see `docker.go:97`) +- stdout: `Stopped supabase local development setup.` (`supabase` rendered in Aqua/cyan + when the output stream is a TTY, plain otherwise) +- stderr (conditional): when any Docker volume still carries the project's + `com.supabase.cli.project` label after stopping, an additional suggestion line: + - with a project id filter: `Local data are backed up to docker volume. Use docker to show them: docker volume ls --filter label=com.supabase.cli.project=` + - with `--all` (empty filter): `Local data are backed up to docker volume. Use docker to show them: docker volume ls --filter label=com.supabase.cli.project` ### `--output-format json` -Not applicable — stop is a local-dev workflow command. +Additive — no Go CLI equivalent. Single JSON object via `Output.success`: + +```json +{ "project_id_filter": "demo", "backup": true } +``` ### `--output-format stream-json` -Not applicable — stop is a local-dev workflow command. +Same payload as `json`, delivered as a `result` NDJSON event. ## Notes -- `--no-backup` deletes all data volumes after stopping. -- `--project-id` targets a specific local project ID to stop. -- `--all` stops all local Supabase instances across all projects on the machine. -- `--project-id` and `--all` are mutually exclusive. -- The hidden `--backup` flag (default true) is the inverse of `--no-backup`. +- `--project-id` and `--all` are **directory-independent** pure Docker-label filters — + neither reads `config.toml`. Only the no-flags default path resolves the project id + from `LegacyCliConfig.workdir` (env → config.toml `project_id` → workdir basename). +- The hidden `--backup` flag exists only for Go CLI surface parity — it has **no effect**. + Go declares it via `flags.Bool("backup", true, ...)` (`cmd/stop.go:26`) but never binds + the return value to a variable, so `RunE` always passes `!noBackup` to `stop.Run` + regardless of `--backup`. The TS port matches this exactly: `deleteVolumes = +flags.noBackup`. `--backup=false` alone does **not** delete volumes; only + `--no-backup` does. +- Volume prune gates `--all` on the Docker daemon's API version (`legacy-container-cli.ts`'s + `legacyDockerSupportsVolumePruneAllFlag`, checked via `docker version --format +'{{.Server.APIVersion}}'`), matching Go's `Docker.ClientVersion() >= "1.42"` check + (`docker.go:126-133`) exactly. This isn't cosmetic: Docker CLI's own `--all` flag on + `volume prune` is annotated `version: "1.42"` and enforced by Cobra's `Args` validator + _before_ pruning runs, so sending it unconditionally on a pre-1.42 daemon hard-fails the + whole call instead of just pruning a narrower set. On the Podman fallback, `--all` is + omitted unconditionally instead: no released Podman `volume prune` (checked v4.3 through + the current v5.7) accepts that flag, and Podman already prunes every unused volume by + default, so dropping it there is lossless. Podman itself is a TS-only fallback (Go never + shells out to a `docker`/`podman` binary), so this has no Go-parity implication either way. +- Containers are stopped concurrently (`Effect.all(..., { concurrency: "unbounded" })`), + mirroring Go's `WaitAll` goroutine fan-out. Every container's failure is checked before + failing the command (rather than stopping at the first failure), matching Go's + `errors.Join` over the full result set — though the surfaced message is a single fixed + string rather than a joined list of per-container errors, since Docker CLI subprocess + stderr isn't captured per-container the way Go's SDK error is. +- No e2e test is planned: there is no Docker-daemon-free golden path for this command, + and the e2e harness (`runSupabase()`) does not provision a real local stack. See the + CLI-1324 plan's "E2e tests" section for the full justification. diff --git a/apps/cli/src/legacy/commands/stop/stop.command.ts b/apps/cli/src/legacy/commands/stop/stop.command.ts index c89e0c3d1d..64fb443c6b 100644 --- a/apps/cli/src/legacy/commands/stop/stop.command.ts +++ b/apps/cli/src/legacy/commands/stop/stop.command.ts @@ -1,5 +1,13 @@ +import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; +import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; import { legacyStop } from "./stop.handler.ts"; const config = { @@ -17,15 +25,41 @@ const config = { noBackup: Flag.boolean("no-backup").pipe( Flag.withDescription("Deletes all data volumes after stopping."), ), + // Modelled as `Option` (presence = pflag `Changed`), not a plain + // boolean: Cobra's `MarkFlagsMutuallyExclusive("project-id", "all")` + // (`apps/cli-go/cmd/stop.go:31`) rejects the command whenever BOTH flags + // were explicitly set, regardless of the value `--all` was set to — the + // vendored cobra@v1.10.2 `flag_groups.go:139` check is + // `groupStatus[group][name] = flag.Changed`, not the flag's boolean value. + // A plain `Flag.boolean` here would make `--project-id x --all=false` + // indistinguishable from `--project-id x` (no `--all` at all), silently + // accepting a combination Go rejects. all: Flag.boolean("all").pipe( Flag.withDescription("Stop all local Supabase instances from all projects across the machine."), + Flag.optional, ), } as const; export type LegacyStopFlags = CliCommand.Command.Config.Infer; +// `stop` makes no Management API calls (Go's stop needs no access token) and talks +// directly to Docker, so it deliberately avoids `legacyManagementApiRuntimeLayer` — +// it provides only the services the handler + instrumentation consume. +// `ChildProcessSpawner` is not listed here: it comes from `BunServices` in the root +// runtime (`shared/cli/run.ts`), the same way `gen types`/`unlink` rely on it. +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const legacyStopRuntimeLayer = Layer.mergeAll( + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer(["stop"]), +); + export const legacyStopCommand = Command.make("stop", config).pipe( Command.withDescription("Stop all local Supabase containers."), Command.withShortDescription("Stop all local Supabase containers"), - Command.withHandler((flags) => legacyStop(flags)), + Command.withHandler((flags) => + legacyStop(flags).pipe(withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling), + ), + Command.provide(legacyStopRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/stop/stop.errors.ts b/apps/cli/src/legacy/commands/stop/stop.errors.ts new file mode 100644 index 0000000000..ac8d49db97 --- /dev/null +++ b/apps/cli/src/legacy/commands/stop/stop.errors.ts @@ -0,0 +1,62 @@ +import { Data } from "effect"; + +/** + * An explicit `--workdir`/`SUPABASE_WORKDIR` path doesn't exist or isn't a + * directory. Mirrors Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go: + * 231-250`), which unconditionally `os.Chdir(workdir)`s in `PersistentPreRunE` + * (`apps/cli-go/cmd/root.go:93-105`) — before `stop`'s own flag validation or + * `RunE`, so a bad explicit workdir must fail here first, before config load + * or any Docker access. + */ +export class LegacyStopWorkdirError extends Data.TaggedError("LegacyStopWorkdirError")<{ + readonly message: string; +}> {} + +/** + * `--project-id` and `--all` were both set. Best-effort match of cobra's + * `MarkFlagsMutuallyExclusive` message shape (`stopCmd.MarkFlagsMutuallyExclusive("project-id", + * "all")`, `apps/cli-go/cmd/stop.go`). Cobra isn't vendored in this repo, so the exact + * wording could not be verified against source; this mirrors the same phrasing already + * used for `gen types`'s mutually-exclusive flag groups (`types.handler.ts`). + */ +export class LegacyStopMutuallyExclusiveError extends Data.TaggedError( + "LegacyStopMutuallyExclusiveError", +)<{ + readonly message: string; +}> {} + +/** Loading `config.toml` failed for a reason other than the file being absent (malformed TOML). */ +export class LegacyStopConfigLoadError extends Data.TaggedError("LegacyStopConfigLoadError")<{ + readonly message: string; +}> {} + +/** + * Listing containers to stop failed. `stop`-specific wrapper over + * `LegacyDockerLifecycleListError` (see `legacy-docker-lifecycle.ts`) so this command's + * errors are all in one file with a `LegacyStop*` tag, matching the plan's error list. + */ +export class LegacyStopListError extends Data.TaggedError("LegacyStopListError")<{ + readonly message: string; +}> {} + +/** Stopping one or more containers failed (`DockerRemoveAll`'s `WaitAll` step). */ +export class LegacyStopContainerError extends Data.TaggedError("LegacyStopContainerError")<{ + readonly message: string; +}> {} + +/** `docker container prune` failed. */ +export class LegacyStopContainerPruneError extends Data.TaggedError( + "LegacyStopContainerPruneError", +)<{ + readonly message: string; +}> {} + +/** `docker volume prune` failed (only run when `--no-backup`/`--backup=false`). */ +export class LegacyStopVolumePruneError extends Data.TaggedError("LegacyStopVolumePruneError")<{ + readonly message: string; +}> {} + +/** `docker network prune` failed. */ +export class LegacyStopNetworkPruneError extends Data.TaggedError("LegacyStopNetworkPruneError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index ab7f5f9ad8..3e31196de6 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -1,15 +1,410 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { loadProjectConfig, loadProjectEnvironment, ProjectConfigSchema } from "@supabase/config"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { Effect, FileSystem, Option, Result, Schema } from "effect"; + +import { Output } from "../../../shared/output/output.service.ts"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyAqua } from "../../shared/legacy-colors.ts"; +import { + containerCliExitCode, + legacyDescribeContainerCliFailure, + legacyDockerSupportsVolumePruneAllFlag, +} from "../../shared/legacy-container-cli.ts"; +import { + legacyCliProjectFilterValue, + legacyResolveLocalProjectId, + legacySanitizeProjectId, +} from "../../shared/legacy-docker-ids.ts"; +import { + legacyListContainersByLabel, + legacyListVolumesByLabel, +} from "../../shared/legacy-docker-lifecycle.ts"; +import { legacyGetHostname } from "../../shared/legacy-hostname.ts"; +import { legacyResolveLocalConfigValues } from "../../shared/legacy-local-config-values.ts"; +import { legacyResolveProjectEnvironmentValues } from "../../shared/legacy-project-environment.ts"; +import { legacyValidateWorkdirIsDirectory } from "../../shared/legacy-workdir-validation.ts"; import type { LegacyStopFlags } from "./stop.command.ts"; +import { + LegacyStopConfigLoadError, + LegacyStopContainerError, + LegacyStopContainerPruneError, + LegacyStopListError, + LegacyStopMutuallyExclusiveError, + LegacyStopNetworkPruneError, + LegacyStopVolumePruneError, + LegacyStopWorkdirError, +} from "./stop.errors.ts"; + +/** + * Resolve the Docker label filter `stop` searches on. Go's flag precedence + * (`stop.go:14-22`): `--all` bypasses config entirely with an empty filter; + * `--project-id` overrides `Config.ProjectId` directly, also bypassing + * config.toml; otherwise `flags.LoadConfig` reads config.toml and + * `Config.ProjectId` (env → toml → workdir basename) is used. + * + * "env" is Go's post-`loadNestedEnv` value, not just the ambient shell + * environment: `Config.Load` loads `supabase/.env`/`.env.local` *and* + * project-root/`SUPABASE_ENV`-selected dotenv files into the process env via + * `godotenv.Load` (`pkg/config/config.go:735-738,1169-1207`; godotenv never + * overrides an already-set var) *before* Viper's `AutomaticEnv` reads + * `SUPABASE_PROJECT_ID` (`config.go:534-535`) — so an env-file-only value + * overrides config.toml too, not only an ambient shell export. + * `legacyResolveProjectEnvironmentValues` implements that full precedence + * chain (see its doc comment) on top of `loadProjectEnvironment`'s + * `supabase/`-dir-only result, so it's used here instead of reading + * `process.env` directly. It still returns a usable map (falling back to + * `/supabase`/`workdir` and `process.env` itself) even when no + * `supabase/` config file exists at `workdir`, matching Go's `loadNestedEnv` + * running unconditionally before `config.toml` is ever opened + * (`pkg/config/config.go:786-793`) — the `?? process.env[...]` fallback below + * only still matters for keys neither source produced. + * + * The config/env-derived (default) branch is sanitized with + * {@link legacySanitizeProjectId} before it's used as a filter value, + * matching Go's `Config.Validate` sanitizing the `Config.ProjectId` + * singleton once at config-load time (`pkg/config/config.go:938-944`) — every + * later reader, including the Docker LABEL `start` writes + * (`internal/utils/docker.go:375`), sees that same sanitized string. The + * explicit `--project-id` bypass stays RAW to match: Go assigns the flag + * value straight to `Config.ProjectId` without going through `Validate` + * (`internal/stop/stop.go:19-20`). + * + * Go's check is `len(projectId) > 0` (`internal/stop/stop.go:18`), not merely + * "was the flag set" — an explicit but empty `--project-id ""` falls through + * to the config.toml branch exactly like an absent flag, so that's mirrored + * here with a non-empty check rather than `Option.isSome` alone. + */ +const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProjectIdFilter")( + function* (flags: LegacyStopFlags, cliConfig: LegacyCliConfig["Service"]) { + // `internal/stop/stop.go:17`'s `if !all` reads the resolved value (not + // presence), so this branch stays value-based — `Option.getOrElse` mirrors + // Cobra's `BoolVar` default of `false` when `--all` was never passed. + if (Option.getOrElse(flags.all, () => false)) return ""; + if (Option.isSome(flags.projectId) && flags.projectId.value.length > 0) { + return flags.projectId.value; + } + + // `search: false`: `cliConfig.workdir` already IS Go's fully-resolved chdir + // target (`legacy-cli-config.layer.ts`'s `resolveWorkdir` mirrors + // `ChangeWorkDir`'s explicit-exact-vs-default-searched resolution, + // `apps/cli-go/internal/utils/misc.go:231-247`), so letting + // `@supabase/config`'s `findProjectPaths` climb ancestors again on top of + // that would let an unrelated ancestor project's config.toml win when + // `--workdir`/`SUPABASE_WORKDIR` points at a subdirectory with no + // `supabase/config.toml` of its own — Go never searches past the exact + // (explicit or defaulted) workdir (`NewPathBuilder`, `pkg/config/utils.go: + // 43-48`). + const projectEnv = yield* loadProjectEnvironment({ + cwd: cliConfig.workdir, + baseEnv: process.env, + search: false, + // Go's `loadDefaultEnv` (`apps/cli-go/pkg/config/config.go:1243-1250`) + // omits `.env.local` from its candidate list whenever + // `SUPABASE_ENV=test` — a malformed or intentionally non-test + // `supabase/.env.local` is then invisible to Go and must not fail + // config loading here either. `legacyResolveProjectEnvironmentValues` + // below already applies this same gate for the project-root pass (see + // its `candidateDotenvFilenames`); this mirrors it for the + // `supabase/`-dir pass `loadProjectEnvironment` itself performs. + skipEnvLocal: (process.env["SUPABASE_ENV"] || "development") === "test", + }).pipe( + Effect.mapError( + (cause) => + new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + ), + ); + + // Resolved BEFORE `loadProjectConfig` decodes config.toml (not after): + // Go's `Config.Load` runs `loadNestedEnv` before `LoadEnvHook` decodes + // `env(...)` references (`config.go:735-738`), so an `env(...)`-valued + // `project_id` sourced only from a project-root/`SUPABASE_ENV`-selected + // file must already be visible to the decoder, not just to the + // `SUPABASE_PROJECT_ID` override read below. A malformed extra dotenv + // file throws here (see `readDotEnvFile`), matching Go's `loadNestedEnv` + // propagating `godotenv`'s parse error instead of silently skipping the + // bad line. `workdir` is passed through so dotenv files under + // `/supabase`/`workdir` are still discovered even when + // `projectEnv` is `null` (no config.toml there) — Go's own `loadNestedEnv` + // runs unconditionally, before `config.toml` is ever opened + // (`pkg/config/config.go:786-793`). + const projectEnvValues = yield* Effect.try({ + try: () => legacyResolveProjectEnvironmentValues(projectEnv, cliConfig.workdir), + catch: (cause) => + new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + }); + + // An absent config.toml is not a failure — Go's `flags.LoadConfig` still + // resolves a project id via the workdir basename default. Only a + // malformed file (`loadProjectConfig` failing rather than returning + // `null`) is a hard error, matching `gen types`'s `loadConfig()` pattern. + const loaded = yield* loadProjectConfig(cliConfig.workdir, { + projectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, + search: false, + // Go's `NewPathBuilder`/`Config.Load` (`pkg/config/utils.go:43-48`) only + // ever resolves `supabase/config.toml` — it has no concept of a JSON + // project config file. Without this, a workdir with a stray + // `config.json` would make `loadProjectConfig` prefer it over + // `config.toml`, potentially stopping containers for the wrong project. + tomlOnly: true, + goViperCompat: true, + }).pipe( + Effect.mapError( + (cause) => + new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + ), + ); + const config = loaded?.config ?? Schema.decodeUnknownSync(ProjectConfigSchema)({}); + + // VALIDATE config before any Docker call, matching Go's `flags.LoadConfig` + // (config load + `Validate`, `internal/utils/flags/config_path.go:10-14` -> + // `pkg/config/config.go:882`), which the default `stop` path runs in full + // (`internal/stop/stop.go:15-25`) before ever touching Docker — unlike the + // `--all`/`--project-id` branches above, which bypass config loading + // entirely and so must NOT run this. `legacyResolveLocalConfigValues` is + // reused purely for its throwing side effects (its resolved URLs/keys are + // discarded); it gives `stop` the same partial-but-growing `Config.Validate` + // parity `status` already has (`status.handler.ts`), rather than a one-off + // re-implementation. `legacyGetHostname` has no Docker dependency, so it's + // safe to call speculatively here too. + yield* Effect.try({ + try: () => + legacyResolveLocalConfigValues( + config, + legacyGetHostname(), + cliConfig.workdir, + projectEnvValues, + loaded?.document, + ), + catch: (cause) => + new LegacyStopConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + + const resolved = legacyResolveLocalProjectId( + projectEnvValues["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], + config.project_id, + cliConfig.workdir, + ); + return legacySanitizeProjectId(resolved); + }, +); export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopFlags) { - 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); + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fs = yield* FileSystem.FileSystem; + + yield* Effect.gen(function* () { + // Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) + // unconditionally `os.Chdir`s the resolved `--workdir`/`SUPABASE_WORKDIR` + // in `PersistentPreRunE` (`cmd/root.go:93-105`) — before any of `stop`'s + // own flag validation or `RunE`. A missing or non-directory path fails + // immediately, so this must win over every later error, including the + // `--project-id`/`--all` mutual-exclusivity check below. + yield* legacyValidateWorkdirIsDirectory(cliConfig.workdir, fs).pipe( + Effect.mapError((error) => new LegacyStopWorkdirError({ message: error.message })), + ); + + // Presence-based, matching Cobra's `Changed` check (see the doc comment on + // `all`'s flag definition in `stop.command.ts`) — `--project-id x --all=false` + // must reject too, not just `--all`/`--all=true`. + if (Option.isSome(flags.projectId) && Option.isSome(flags.all)) { + return yield* Effect.fail( + new LegacyStopMutuallyExclusiveError({ + // Cobra's `validateExclusiveFlagGroups` (spf13/cobra flag_groups.go): + // the group name keeps declaration order (`strings.Join(flagNames, " ")`), + // but the "were all set" list is `sort.Strings`-ed — verified against + // the vendored cobra@v1.10.2 source, not guessed. + message: + "if any flags in the group [project-id all] are set none of the others can be; [all project-id] were all set", + }), + ); + } + + const searchProjectIdFilter = yield* resolveSearchProjectIdFilter(flags, cliConfig); + // Go's hidden `--backup` flag is declared via `flags.Bool("backup", true, ...)` + // (`cmd/stop.go:26`) but its return value is discarded — never bound to a + // variable, so `RunE` always passes `!noBackup` to `stop.Run` regardless of + // `--backup`'s value. `--backup=false` is a no-op in the real Go binary + // today; only `--no-backup` deletes volumes. Matching that exactly (not the + // seemingly-intended-but-dead semantics of the flag's own description). + const deleteVolumes = flags.noBackup; + const filterValue = legacyCliProjectFilterValue(searchProjectIdFilter); + + // Go prints this line unconditionally and immediately — `docker.go:97`'s + // `fmt.Fprintln(w, "Stopping containers...")`, where `w` is a + // `StatusWriter` that `fmt.Println`s straight to stdout in non-interactive + // mode (`tea.go:59-60,87-90`) before any Docker call runs. The debounced + // `output.task` spinner used elsewhere in this codebase gates its message + // behind a delay, which drops this line whenever the underlying calls + // resolve faster than that threshold — exactly what happens against the + // mocked/replayed Docker CLI. Print it directly so it always appears. + if (output.format === "text") { + yield* output.raw("Stopping containers...\n"); + } + + yield* Effect.gen(function* () { + const containerIds = yield* legacyListContainersByLabel(spawner, { + projectIdFilter: filterValue, + all: true, + format: "id", + }).pipe(Effect.mapError((cause) => new LegacyStopListError({ message: cause.message }))); + + // Go stops containers concurrently via `WaitAll`, joining every failure + // rather than short-circuiting on the first one (`docker.go:96-146`). + // + // `stdout`/`stderr: "ignore"` on every exit-code-only call below: none of + // these read the child's own output, and the default `"pipe"` stdio + // otherwise leaves an OS pipe unread — once `docker`/`podman` write + // enough to it (e.g. `container prune`'s "Deleted Containers" ID list on + // a host with many stale containers, most likely under `stop --all`), + // the child blocks on write() and `stop` hangs. Matches the existing + // `stdio: "ignore"` precedent for the same "exit-code-only" shape in + // `legacy-pgdelta.seam.layer.ts`. + const stopResults = yield* Effect.all( + containerIds.map((id) => + containerCliExitCode(spawner, ["stop", id], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }).pipe(Effect.result), + ), + { concurrency: "unbounded" }, + ); + const failedStop = stopResults.find( + (result) => Result.isFailure(result) || result.success !== 0, + ); + if (failedStop !== undefined) { + return yield* Effect.fail( + new LegacyStopContainerError({ + message: `failed to stop container: ${ + Result.isFailure(failedStop) + ? legacyDescribeContainerCliFailure(failedStop.failure) + : `exit ${failedStop.success}` + }`, + }), + ); + } + + const containerPruneExitCode = yield* containerCliExitCode( + spawner, + ["container", "prune", "--force", "--filter", `label=${filterValue}`], + { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, + ).pipe( + Effect.mapError( + (cause) => + new LegacyStopContainerPruneError({ + message: `failed to prune containers: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + if (containerPruneExitCode !== 0) { + return yield* Effect.fail( + new LegacyStopContainerPruneError({ message: "failed to prune containers" }), + ); + } + + if (deleteVolumes) { + // Go gates the `--all` filter arg on Docker API >= 1.42 + // (`docker.go:126-133`, `Docker.ClientVersion() >= "1.42"`): Docker + // CLI's own `volume prune --all` flag is annotated `version: "1.42"` + // (`docker/cli@v28.5.2` `cli/command/volume/prune.go:53`) and enforced + // by Cobra's `Args` validator *before* `RunE` runs + // (`cmd/docker/docker.go:659-660`) — on an older daemon, passing + // `--all` unconditionally would hard-fail this whole call and prune + // nothing, not just prune a narrower set. There's no persistent + // Engine API client here to ask the negotiated version directly (Go + // talks to the Docker Engine API, never a `docker` binary), so + // {@link legacyDockerSupportsVolumePruneAllFlag} asks the `docker` CLI + // itself via `docker version` and mirrors Go's gate exactly. + // + // Podman is a Docker-CLI-compatible fallback this port adds, not something + // Go itself has, so there's no Go behavior to match on that path — but + // `--all` isn't a real flag on any released Podman `volume prune` (only + // `--filter`/`--force`/`--help`, checked v4.3 through the current v5.7; + // `--all` only exists in unreleased dev docs), so it hard-fails on a real + // Podman-only host. Podman already prunes every unused volume by default, + // so omitting `--all` on the Podman fallback is a lossless fix. + const dockerSupportsAll = yield* legacyDockerSupportsVolumePruneAllFlag(spawner); + const volumePruneExitCode = yield* containerCliExitCode( + spawner, + [ + "volume", + "prune", + "--force", + ...(dockerSupportsAll ? ["--all"] : []), + "--filter", + `label=${filterValue}`, + ], + { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, + ["volume", "prune", "--force", "--filter", `label=${filterValue}`], + ).pipe( + Effect.mapError( + (cause) => + new LegacyStopVolumePruneError({ + message: `failed to prune volumes: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + if (volumePruneExitCode !== 0) { + return yield* Effect.fail( + new LegacyStopVolumePruneError({ message: "failed to prune volumes" }), + ); + } + } + + const networkPruneExitCode = yield* containerCliExitCode( + spawner, + ["network", "prune", "--force", "--filter", `label=${filterValue}`], + { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, + ).pipe( + Effect.mapError( + (cause) => + new LegacyStopNetworkPruneError({ + message: `failed to prune networks: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + if (networkPruneExitCode !== 0) { + return yield* Effect.fail( + new LegacyStopNetworkPruneError({ message: "failed to prune networks" }), + ); + } + }); + + if (output.format === "text") { + // Written to stdout (no stream arg): `legacyAqua` must target stdout's own + // TTY status, not stderr's — see `legacy-colors.ts`'s doc comment. + yield* output.raw( + `Stopped ${legacyAqua("supabase", process.stdout)} local development setup.\n`, + ); + } else { + yield* output.success("Stopped supabase local development setup.", { + project_id_filter: searchProjectIdFilter, + backup: !deleteVolumes, + }); + } + + // Post-run suggestion (stop.go:26-37): only meaningful in text mode — json/ + // stream-json payloads have no equivalent field to carry this hint. + if (output.format === "text") { + const remainingVolumes = yield* legacyListVolumesByLabel(spawner, filterValue).pipe( + Effect.orElseSucceed(() => []), + ); + if (remainingVolumes.length > 0) { + const listVolumeCommand = + searchProjectIdFilter.length > 0 + ? `docker volume ls --filter label=com.supabase.cli.project=${searchProjectIdFilter}` + : "docker volume ls --filter label=com.supabase.cli.project"; + yield* output.raw( + `Local data are backed up to docker volume. Use docker to show them: ${legacyAqua(listVolumeCommand)}\n`, + "stderr", + ); + } + } + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index c9c9d83fea..3ac32c623c 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -1,55 +1,1122 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { vi } from "vitest"; + +import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { + mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.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); +const tempRoot = useLegacyTempWorkdir("supabase-stop-int-"); + +function flags(overrides: Partial = {}): LegacyStopFlags { + return { + projectId: Option.none(), + backup: true, + noBackup: false, + all: Option.none(), + ...overrides, + }; +} + +function writeConfig(workdir: string, projectId: string) { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "config.toml"), `project_id = "${projectId}"\n`); +} + +function writeEnvFile(workdir: string, fileName: ".env" | ".env.local", contents: string) { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, fileName), contents); +} + +interface SpawnRecord { + readonly command: string; + readonly args: ReadonlyArray; +} + +type RouteResult = { + readonly exitCode?: number; + readonly stdout?: ReadonlyArray; + readonly stderr?: ReadonlyArray; +}; + +/** + * Routes each spawned invocation to a caller-supplied result by matching argv + * (rather than a fixed call sequence): `stop` issues five distinct docker + * subcommands (`ps`, `stop`, `container prune`, `volume prune`, `network prune`, + * `volume ls`) whose relative order/count varies per scenario (N `stop` calls for + * N listed containers), so a routing table is a better fit than the sequential + * step-array mock `gen types` uses for its single linear pipeline. + */ +function mockRoutedContainerCliSpawner( + route: (args: ReadonlyArray) => RouteResult, + opts: { + readonly dockerMissing?: boolean; + // Fails BOTH docker and podman spawn attempts for argv matching this predicate, + // simulating a runtime that cannot be spawned at all (as opposed to a spawned + // process exiting non-zero) — exercises the `Effect.mapError`/`orElseSucceed` + // spawn-failure branches distinct from the exit-code-checking branches. + readonly failSpawnFor?: (args: ReadonlyArray) => boolean; + } = {}, +) { + const spawned: Array = []; + + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const cmd = command._tag === "StandardCommand" ? command.command : ""; + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ command: cmd, args }); + + if (opts.dockerMissing === true && cmd === "docker") { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "docker not found", + }), + ); + } + + if (opts.failSpawnFor?.(args) === true) { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }), + ); + } + + const encoder = new TextEncoder(); + const result = route(args); + const exitDeferred = yield* Deferred.make(); + yield* Effect.forkDetach( + Effect.gen(function* () { + yield* Effect.sleep("5 millis"); + yield* Deferred.succeed( + exitDeferred, + ChildProcessSpawner.ExitCode(result.exitCode ?? 0), + ); + }), + ); + const stdoutBytes = (result.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); + const stderrBytes = (result.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(4000 + spawned.length), + stdout: Stream.fromIterable(stdoutBytes), + stderr: Stream.fromIterable(stderrBytes), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); }), - execCapture: () => Effect.succeed(""), + ), + ); + + return { + layer, + get spawned() { + return spawned; + }, + }; +} + +/** + * Default happy-path router: `ps` lists one container, `docker version` reports + * an API version comfortably at/above the `volume prune --all` gate (1.42, see + * `legacyDockerSupportsVolumePruneAllFlag`), everything else succeeds empty. + */ +function defaultRoute( + opts: { + readonly containerIds?: ReadonlyArray; + readonly volumeNames?: ReadonlyArray; + readonly dockerApiVersion?: string; + } = {}, +) { + const containerIds = opts.containerIds ?? ["c1"]; + const volumeNames = opts.volumeNames ?? []; + const dockerApiVersion = opts.dockerApiVersion ?? "1.45"; + return (args: ReadonlyArray): RouteResult => { + if (args[0] === "ps") return { stdout: containerIds }; + if (args[0] === "volume" && args[1] === "ls") return { stdout: volumeNames }; + if (args[0] === "version") return { stdout: [dockerApiVersion] }; + return { exitCode: 0 }; + }; +} + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly route?: (args: ReadonlyArray) => RouteResult; + readonly dockerMissing?: boolean; + readonly failSpawnFor?: (args: ReadonlyArray) => boolean; + readonly configuredProjectId?: string; + readonly skipConfig?: boolean; + /** Defaults to `tempRoot.current` — override for `--workdir`-resolution tests. */ + readonly workdir?: string; +} + +function setup(opts: SetupOpts = {}) { + const workdir = opts.workdir ?? tempRoot.current; + if (opts.skipConfig !== true) { + writeConfig(workdir, opts.configuredProjectId ?? "demo"); + } + const out = mockOutput({ + format: opts.format ?? "text", + interactive: (opts.format ?? "text") === "text", + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cliConfig = mockLegacyCliConfig({ workdir, projectId: Option.none() }); + const child = mockRoutedContainerCliSpawner(opts.route ?? defaultRoute(), { + dockerMissing: opts.dockerMissing, + failSpawnFor: opts.failSpawnFor, }); - return { layer, calls }; + + const layer = Layer.mergeAll( + BunServices.layer, + out.layer, + cliConfig, + telemetry.layer, + child.layer, + ); + + return { workdir, out, telemetry, child, layer }; } -const baseFlags: LegacyStopFlags = { - projectId: Option.none(), - backup: true, - noBackup: false, - all: false, -}; +describe("legacy stop integration", () => { + it.live( + "stops the current project's containers with backup and suggests the volume command", + () => { + const { layer, out, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute({ containerIds: ["c1", "c2"], volumeNames: ["supabase_db_demo"] }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=demo", + "--all", + "--format", + "{{.ID}}", + ]); + const stopCalls = child.spawned.filter((s) => s.args[0] === "stop"); + expect(stopCalls.map((s) => s.args)).toEqual([ + ["stop", "c1"], + ["stop", "c2"], + ]); + expect(out.stdoutText).toContain("Stopping containers..."); + expect(out.stdoutText).toContain("Stopped"); + expect(out.stdoutText).toContain("local development setup."); + expect(out.stderrText).toContain( + "Local data are backed up to docker volume. Use docker to show them:", + ); + expect(out.stderrText).toContain( + "docker volume ls --filter label=com.supabase.cli.project=demo", + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "sanitizes a dirty config.toml project_id before filtering, matching start's label", + () => { + // Go's Config.Validate rewrites Config.ProjectId to its sanitized form once + // at config-load time (pkg/config/config.go:938-944); every later reader — + // including the Docker label `start` writes — sees that same sanitized + // string. Filtering on the raw value here would match nothing `start` + // ever labeled. + const { layer, child } = setup({ + configuredProjectId: "My App!!", + route: defaultRoute(), + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=My_App_", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("keeps an explicit --project-id raw, unsanitized (Go's bypass)", () => { + // Go assigns the --project-id flag value straight to Config.ProjectId + // without going through Validate (internal/stop/stop.go:19-20), so this + // path must NOT sanitize even though the default (config-derived) path does. + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ projectId: Option.some("Raw Value!!") })); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=Raw Value!!", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("stops every project's containers with --all without reading config.toml", () => { + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ all: Option.some(true) })); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project", + "--all", + "--format", + "{{.ID}}", + ]); + const pruneCalls = child.spawned.filter( + (s) => s.args[0] === "container" && s.args[1] === "prune", + ); + expect(pruneCalls[0]?.args).toEqual([ + "container", + "prune", + "--force", + "--filter", + "label=com.supabase.cli.project", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("suggests the bare-label volume command with --all when volumes remain", () => { + const { layer, out } = setup({ + skipConfig: true, + route: defaultRoute({ volumeNames: ["supabase_db_demo"] }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags({ all: Option.some(true) })); + expect(out.stderrText).toContain( + "Local data are backed up to docker volume. Use docker to show them:", + ); + expect(out.stderrText).toContain("docker volume ls --filter label=com.supabase.cli.project"); + expect(out.stderrText).not.toContain("com.supabase.cli.project="); + }).pipe(Effect.provide(layer)); + }); + + it.live("stops a named project with --project-id without reading config.toml", () => { + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ projectId: Option.some("other-project") })); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=other-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("falls back to config.toml when --project-id is an empty string", () => { + // Go's check is `len(projectId) > 0` (internal/stop/stop.go:18), not just + // "was --project-id set" — an empty value must fall through to config.toml + // exactly like an absent flag, not resolve to the bare/all-projects filter. + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ projectId: Option.some("") })); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=demo", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("resolves SUPABASE_PROJECT_ID from supabase/.env over config.toml", () => { + // Go's Config.Load runs loadNestedEnv (supabase/.env(.local) via godotenv) + // before loadFromFile's AutomaticEnv reads SUPABASE_PROJECT_ID + // (pkg/config/config.go:735-738) — an env-file-only value overrides + // config.toml's project_id too, not just an ambient shell export. + const { layer, child } = setup({ configuredProjectId: "toml-project", route: defaultRoute() }); + writeEnvFile(tempRoot.current, ".env", "SUPABASE_PROJECT_ID=env-file-project\n"); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=env-file-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("prefers ambient SUPABASE_PROJECT_ID over supabase/.env", () => { + const { layer, child } = setup({ configuredProjectId: "toml-project", route: defaultRoute() }); + writeEnvFile(tempRoot.current, ".env", "SUPABASE_PROJECT_ID=env-file-project\n"); + process.env["SUPABASE_PROJECT_ID"] = "ambient-project"; + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=ambient-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => delete process.env["SUPABASE_PROJECT_ID"])), + ); + }); + + it.live( + "does not climb to an ancestor project's config.toml when workdir has none of its own", + () => { + // Go's ChangeWorkDir uses an explicit/defaulted workdir exactly, with no + // ancestor search (apps/cli-go/internal/utils/misc.go:231-247) — mirrored + // here by `search: false`. A workdir with no supabase/config.toml of its + // own must fall back to defaults (workdir-basename project id), not an + // ancestor project's config.toml, even though `cliConfig.workdir` sits + // right inside one. + const nestedWorkdir = join(tempRoot.current, "nested"); + mkdirSync(nestedWorkdir, { recursive: true }); + writeConfig(tempRoot.current, "ancestor-project"); + const projectId = basename(nestedWorkdir); + const { layer, child } = setup({ + workdir: nestedWorkdir, + skipConfig: true, + route: defaultRoute(), + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + `label=com.supabase.cli.project=${projectId}`, + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("resolves SUPABASE_PROJECT_ID from supabase/.env even when config.toml is absent", () => { + // Go's loadNestedEnv runs unconditionally, before config.toml is ever + // opened (pkg/config/config.go:786-793) — a supabase/.env-only project id + // must still be honored even when there's no config.toml to fall back to + // template defaults from. + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + writeEnvFile(tempRoot.current, ".env", "SUPABASE_PROJECT_ID=no-config-project\n"); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=no-config-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("resolves SUPABASE_PROJECT_ID from a project-root .env file", () => { + // Go's loadNestedEnv walks past supabase/ one more level, to the project + // root/workdir (pkg/config/config.go:1169-1190) — a project-root-only + // dotenv value must override config.toml too, not just supabase/.env. + const { layer, child } = setup({ configuredProjectId: "toml-project", route: defaultRoute() }); + writeFileSync(join(tempRoot.current, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=root-env-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => { + // Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) + // `os.Chdir`s the explicit workdir in `PersistentPreRunE`, before any of + // `stop`'s own flag validation, config load, or Docker access — a missing + // path must fail immediately, not fall through to the workdir-basename + // default and prune under that name. + const missingWorkdir = join(tempRoot.current, "does-not-exist"); + const { layer, child } = setup({ workdir: missingWorkdir, skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopWorkdirError"); + expect(JSON.stringify(exit.cause)).toContain( + `failed to change workdir: chdir ${missingWorkdir}: no such file or directory`, + ); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when --workdir/SUPABASE_WORKDIR points at a file, not a directory", () => { + const filePath = join(tempRoot.current, "not-a-directory"); + writeFileSync(filePath, ""); + const { layer, child } = setup({ workdir: filePath, skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopWorkdirError"); + expect(JSON.stringify(exit.cause)).toContain( + `failed to change workdir: chdir ${filePath}: not a directory`, + ); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects --project-id together with --all", () => { + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyStop(flags({ projectId: Option.some("other-project"), all: Option.some(true) })), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopMutuallyExclusiveError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + // Cobra's `MarkFlagsMutuallyExclusive` mutex is presence-based (`Changed`), + // not value-based — `--all=false` still counts as "set" alongside + // `--project-id`, so this must reject too, not just `--all`/`--all=true`. + it.live("rejects --project-id together with an explicit --all=false", () => { + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyStop(flags({ projectId: Option.some("other-project"), all: Option.some(false) })), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopMutuallyExclusiveError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("deletes data volumes with --no-backup", () => { + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ noBackup: true })); + const volumePrune = child.spawned.find( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune?.args).toEqual([ + "volume", + "prune", + "--force", + "--all", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "omits --all from docker's volume prune on a pre-1.42 API host, matching Go's gate", + () => { + // Docker CLI's own `volume prune --all` flag requires API >= 1.42 and + // hard-fails (pruning nothing) on an older daemon — Go avoids ever + // sending it by checking `Docker.ClientVersion() >= "1.42"` + // (docker.go:126-133). This mirrors that gate via `docker version`. + const { layer, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute({ dockerApiVersion: "1.41" }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags({ noBackup: true })); + const volumePrune = child.spawned.find( + (s) => s.command === "docker" && s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune?.args).toEqual([ + "volume", + "prune", + "--force", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("includes --all in docker's volume prune when the API is exactly 1.42", () => { + const { layer, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute({ dockerApiVersion: "1.42" }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags({ noBackup: true })); + const volumePrune = child.spawned.find( + (s) => s.command === "docker" && s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune?.args).toEqual([ + "volume", + "prune", + "--force", + "--all", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("--backup=false alone does not delete data volumes, matching Go's dead flag", () => { + // Go's `--backup` is declared but never bound to a variable (`cmd/stop.go:26`) — + // `RunE` always passes `!noBackup`, so `--backup=false` has zero effect in the + // real Go binary today. Only `--no-backup` deletes volumes. + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ backup: false })); + const volumePrune = child.spawned.find( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("--no-backup still deletes data volumes even when --backup stays true", () => { + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ backup: true, noBackup: true })); + const volumePrune = child.spawned.find( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune?.args).toEqual([ + "volume", + "prune", + "--force", + "--all", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("keeps data volumes by default (no volume prune call)", () => { + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const volumePrune = child.spawned.find( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when config.toml is malformed", () => { + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), "not valid toml ====="); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopConfigLoadError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); -describe("legacy stop", () => { - it.live("forwards no extra flags when defaults are used", () => { - const { layer, calls } = setupLegacyStop(); + it.live("fails when [remotes.*] has a duplicate project_id, even with no projectRef", () => { + // Go's Config.Validate builds the duplicate map across all [remotes.*] + // blocks unconditionally (config.go:503-518), so this must fail before + // stop ever selects a remote or touches Docker — not just when a + // matching --project-ref is requested. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.a] +project_id = "aaaaaaaaaaaaaaaaaaaa" + +[remotes.b] +project_id = "aaaaaaaaaaaaaaaaaaaa" +`, + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { - yield* legacyStop(baseFlags); - expect(calls).toEqual([["stop"]]); + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopConfigLoadError"); + } + expect(child.spawned).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.live("forwards --backup=false when the hidden --backup flag is disabled", () => { - const { layer, calls } = setupLegacyStop(); + it.live("fails when a [remotes.*] project_id is not a valid 20-letter ref", () => { + // Go's Config.Validate (config.go:996-1001) checks every [remotes.*].project_id + // against refPattern unconditionally on every config load, so an invalid + // format must fail closed before stop reaches Docker. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.bad] +project_id = "short" +`, + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { - yield* legacyStop({ ...baseFlags, backup: false }); - expect(calls).toEqual([["stop", "--backup=false"]]); + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopConfigLoadError"); + } + expect(child.spawned).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.live("forwards --no-backup, --project-id and --all", () => { - const { layer, calls } = setupLegacyStop(); + it.live( + "decodes a comma-separated string into an array field ([]string) for stop to proceed", + () => { + // Go's `newDecodeHook` wires `mapstructure.StringToSliceHookFunc(",")` + // unconditionally, so a plain string value for a `[]string` field like + // `additional_redirect_urls` decodes fine and must not block stop from + // reaching Docker. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "demo" + +[auth] +additional_redirect_urls = "http://a,http://b" +`, + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=demo", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("warns on stderr for a deprecated auth.external provider", () => { + // `normalizeDeprecatedExternalProviders` (packages/config/src/io.ts) emits + // this WARN via `Console.error` only when `goViperCompat` is set — verify + // legacy stop keeps that Go-parity behavior wired on. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "demo" + +[auth.external.slack] +enabled = true +`, + ); + const { layer } = setup({ skipConfig: true, route: defaultRoute() }); + const warnings: Array = []; + const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { + warnings.push(args.map((a) => String(a)).join(" ")); + }); return Effect.gen(function* () { - yield* legacyStop({ - projectId: Option.some("abc"), - backup: true, - noBackup: true, - all: true, + yield* legacyStop(flags()); + expect(warnings.some((m) => m.includes('WARN: disabling deprecated "slack" provider'))).toBe( + true, + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => errorSpy.mockRestore()))); + }); + + it.live( + "fails and never spawns docker when config.toml has an unsupported db.major_version", + () => { + // Matches Go's default `stop` path, which runs `flags.LoadConfig` (config + // load + `Validate`) entirely before any Docker call + // (`internal/stop/stop.go:15-25` -> `pkg/config/config.go:882`) — a config + // Go rejects must fail `stop` before it touches containers, not just when + // reading `project_id`. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + 'project_id = "demo"\n[db]\nmajor_version = 12\n', + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopConfigLoadError"); + expect(JSON.stringify(exit.cause)).toContain("Postgres version 12.x is unsupported"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("does not run config Validate for --all (bypasses config entirely)", () => { + // `internal/stop/stop.go:15-25`: the `--all` branch never calls + // `flags.LoadConfig`, so an otherwise-invalid config.toml must not block it. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + 'project_id = "demo"\n[db]\nmajor_version = 12\n', + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags({ all: Option.some(true) }))); + expect(Exit.isSuccess(exit)).toBe(true); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toContain("label=com.supabase.cli.project"); + }).pipe(Effect.provide(layer)); + }); + + it.live("does not run config Validate for --project-id (bypasses config entirely)", () => { + // `internal/stop/stop.go:15-25`: an explicit `--project-id` sets + // `Config.ProjectId` directly and never calls `flags.LoadConfig`. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + 'project_id = "demo"\n[db]\nmajor_version = 12\n', + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags({ projectId: Option.some("explicit") }))); + expect(Exit.isSuccess(exit)).toBe(true); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toContain("label=com.supabase.cli.project=explicit"); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when stopping a container errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "ps") return { stdout: ["c1"] }; + if (args[0] === "stop") return { exitCode: 1, stderr: ["boom"] }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when a container cannot be spawned to stop it at all", () => { + // Distinct from a spawned `docker stop` exiting non-zero (covered above) — + // this exercises the branch where docker AND podman both fail to spawn for + // the `stop ` argv specifically. + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => (args[0] === "ps" ? { stdout: ["c1"] } : { exitCode: 0 }), + failSpawnFor: (args) => args[0] === "stop", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live( + "fails the same way in json mode, where 'Stopping containers...' is never printed", + () => { + // The `output.format === "text"` gate around the "Stopping containers..." + // line means json mode skips it entirely; this exercises that the + // list/stop/prune failure path is unaffected by that gate. + const { layer } = setup({ + format: "json", + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "ps") return { stdout: ["c1"] }; + if (args[0] === "stop") return { exitCode: 1, stderr: ["boom"] }; + return { exitCode: 0 }; + }, }); - expect(calls).toEqual([["stop", "--project-id", "abc", "--no-backup", "--all"]]); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("fails when container prune errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "container" && args[1] === "prune") return { exitCode: 1 }; + return defaultRoute()(args); + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerPruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when volume prune errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "volume" && args[1] === "prune") return { exitCode: 1 }; + return defaultRoute()(args); + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags({ noBackup: true }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopVolumePruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when network prune errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "network" && args[1] === "prune") return { exitCode: 1 }; + return defaultRoute()(args); + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopNetworkPruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the container list errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "ps") return { exitCode: 1, stderr: ["daemon down"] }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopListError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("falls back to podman when docker is absent", () => { + const { layer, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + dockerMissing: true, + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + // The failed `docker` attempt is recorded before the `podman` fallback fires + // (`spawnContainerCli`'s `Effect.catch` retries the same argv), so the + // successful call is the LAST matching record, not the first. + const psCalls = child.spawned.filter((s) => s.args[0] === "ps"); + expect(psCalls.at(-1)?.command).toBe("podman"); + expect(psCalls.some((s) => s.command === "docker")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("omits --all from podman's volume prune (not a real Podman flag)", () => { + // No released Podman `volume prune` accepts `--all` (only `--filter`/`--force`/ + // `--help`), so passing Docker's `--all` argv straight through to the Podman + // fallback would hard-fail after containers are already stopped. Podman prunes + // every unused volume by default, so dropping `--all` there is lossless. + const { layer, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + dockerMissing: true, + }); + return Effect.gen(function* () { + yield* legacyStop(flags({ noBackup: true })); + const volumePruneCalls = child.spawned.filter( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePruneCalls.at(-1)?.command).toBe("podman"); + expect(volumePruneCalls.at(-1)?.args).toEqual([ + "volume", + "prune", + "--force", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a machine result in json mode without spinner text", () => { + const { layer, out } = setup({ + format: "json", + configuredProjectId: "demo", + route: defaultRoute({ volumeNames: ["supabase_db_demo"] }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ project_id_filter: "demo", backup: true }); + expect(out.stdoutText).not.toContain("\x1b[?25l"); + // json mode has no volume-suggestion equivalent — only text mode emits it. + expect(out.stderrText).not.toContain("Local data are backed up"); + }).pipe(Effect.provide(layer)); + }); + + it.live("shows no volume suggestion when no volumes remain", () => { + const { layer, out } = setup({ + configuredProjectId: "demo", + route: defaultRoute({ volumeNames: [] }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + expect(out.stderrText).not.toContain("Local data are backed up"); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry via ensuring even on failure", () => { + const { layer, telemetry } = setup({ + configuredProjectId: "demo", + route: (args) => (args[0] === "ps" ? { exitCode: 1 } : { exitCode: 0 }), + }); + return Effect.gen(function* () { + yield* Effect.exit(legacyStop(flags())); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when container prune cannot spawn any container runtime", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + failSpawnFor: (args) => args[0] === "container" && args[1] === "prune", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerPruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when volume prune cannot spawn any container runtime", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + failSpawnFor: (args) => args[0] === "volume" && args[1] === "prune", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags({ noBackup: true }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopVolumePruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when network prune cannot spawn any container runtime", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + failSpawnFor: (args) => args[0] === "network" && args[1] === "prune", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopNetworkPruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("still reports success when the post-run volume listing fails", () => { + // The volume-suggestion check is best-effort (`Effect.orElseSucceed`): a + // failure listing volumes after a successful stop must not fail the command, + // matching Go's `if resp, err := ...; err == nil && ...` (stop.go:29) — a + // listing error there is silently ignored, not surfaced. + const { layer, out } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + failSpawnFor: (args) => args[0] === "volume" && args[1] === "ls", + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + expect(out.stdoutText).toContain("Stopped"); + expect(out.stderrText).not.toContain("Local data are backed up"); }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/legacy/commands/stop/stop.live.test.ts b/apps/cli/src/legacy/commands/stop/stop.live.test.ts new file mode 100644 index 0000000000..95452c78c7 --- /dev/null +++ b/apps/cli/src/legacy/commands/stop/stop.live.test.ts @@ -0,0 +1,78 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, expect, test } from "vitest"; + +import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts"; + +const execFileAsync = promisify(execFile); + +const START_TIMEOUT_MS = 280_000; + +// `stop` never calls the Management API — it talks directly to the real local +// Docker stack `start` (still a Go-proxy) creates. `describeLive` is reused +// purely as the "we're in the full cli-e2e-ci runner" signal (it also has a +// real Docker daemon, since that's how supabox itself runs); the +// SUPABASE_ACCESS_TOKEN it gates on is otherwise irrelevant here. See +// AGENTS.md's "Live tests" section for the full convention. +describeLive("supabase stop (live)", () => { + let projectDir: string | undefined; + let projectId: string | undefined; + + afterEach(async () => { + if (projectDir === undefined) return; + // Best-effort cleanup even if an assertion above failed mid-lifecycle — a + // leaked local stack would otherwise pollute the CI runner for later jobs. + await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); + await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); + projectDir = undefined; + projectId = undefined; + }); + + test( + "starts a real local stack, then stops it and removes its containers", + { timeout: START_TIMEOUT_MS }, + async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-stop-live-")); + // No `project_id` override, so the cli resolves it from the workdir + // basename — matching Go's precedence exactly (see legacy-docker-ids.ts). + projectId = path.basename(projectDir); + + const init = await runSupabaseLive(["init"], { cwd: projectDir }); + expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + + // Exclude the heaviest, least relevant services (Next.js Studio build, the + // logging pipeline) — `stop`'s Docker label-filtering logic doesn't care + // which services are running, only that at least one real container + // exists to stop. + const start = await runSupabaseLive( + ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], + { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, + ); + expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); + + // Sanity: confirm the stack is actually up before testing `stop` against it. + const before = await runSupabaseLive(["status"], { cwd: projectDir }); + expect(before.exitCode, `stdout:\n${before.stdout}\nstderr:\n${before.stderr}`).toBe(0); + + const stop = await runSupabaseLive(["stop"], { cwd: projectDir }); + expect(stop.exitCode, `stdout:\n${stop.stdout}\nstderr:\n${stop.stderr}`).toBe(0); + expect(stop.stdout).toContain("Stopped"); + + // The real Docker daemon must agree: no container carrying this project's + // label survives `stop` — the actual behavior under test, not just the + // cli's own exit code. + const { stdout: remaining } = await execFileAsync("docker", [ + "ps", + "-a", + "--filter", + `label=com.supabase.cli.project=${projectId}`, + "--format", + "{{.ID}}", + ]); + expect(remaining.trim()).toBe(""); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/storage/storage.frame.ts b/apps/cli/src/legacy/commands/storage/storage.frame.ts index f4d5fd15f9..dfad46cac2 100644 --- a/apps/cli/src/legacy/commands/storage/storage.frame.ts +++ b/apps/cli/src/legacy/commands/storage/storage.frame.ts @@ -50,8 +50,8 @@ export const legacyLoadStorageConfig = Effect.fnUntraced(function* ( workdir: string, projectRef: string, ) { - const loadOptions: LoadProjectConfigOptions | undefined = - projectRef !== "" ? { projectRef } : undefined; + const loadOptions: LoadProjectConfigOptions = + projectRef !== "" ? { projectRef, goViperCompat: true } : { goViperCompat: true }; const loaded = yield* loadProjectConfig(workdir, loadOptions).pipe( Effect.catchTag( "ProjectConfigParseError", diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts index 9ced346d5f..843059bf59 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts @@ -166,6 +166,22 @@ function resolveProfile( }); } +/** + * Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) always + * `os.Chdir(workdir)`s using the raw `--workdir`/`SUPABASE_WORKDIR` string, + * which can be relative (e.g. `.`) — but every later reader of the resolved + * workdir (including the `Config.ProjectId` cwd-basename default, `Eject`, + * `pkg/config/config.go:561-570`, run on every `Config.Load()` via + * `mergeDefaultValues`, `config.go:690-699`) reads `os.Getwd()`, the real + * ABSOLUTE directory, never the raw configured string. `os.Chdir(".")` is a + * no-op syscall-wise, so Go's `cwd` is unaffected by the flag/env value being + * relative. This resolves the flag/env value against the real process `cwd` + * the same way, so `LegacyCliConfig.workdir` is always absolute — matching + * Go's invariant that basename-ing it (e.g. `legacyResolveLocalProjectId`'s + * workdir-basename fallback) operates on a real directory name, not a + * relative-path fragment like `.` (which would sanitize to an empty project + * id and build a bare, all-projects-matching Docker label filter). + */ function resolveWorkdir( flagValue: Option.Option, envValue: string | undefined, @@ -175,10 +191,10 @@ function resolveWorkdir( ): Effect.Effect { return Effect.gen(function* () { if (Option.isSome(flagValue) && flagValue.value.length > 0) { - return flagValue.value; + return path.resolve(cwd, flagValue.value); } if (envValue !== undefined && envValue.length > 0) { - return envValue; + return path.resolve(cwd, envValue); } let current = cwd; // Walk up until we hit a directory containing supabase/config.toml or the FS root. diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts index f6d8ca20f8..0bb042b435 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts @@ -278,6 +278,43 @@ describe("legacyCliConfigLayer", () => { ), ); + // Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) always + // `os.Chdir`s the raw flag/env value, but every later reader — including the + // `Config.ProjectId` cwd-basename default (`Eject`, `pkg/config/config.go: + // 561-570`) — reads `os.Getwd()`, the real absolute directory, never the raw + // string. A relative `--workdir .`/`SUPABASE_WORKDIR=.` must therefore resolve + // to an absolute path here too, not stay `"."` (which would later basename to + // an empty project id). + it.effect("resolves a relative --workdir flag against the real cwd", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe(tempRoot); + }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some("."), cwd: tempRoot }))), + ); + + it.effect("resolves a relative --workdir flag with a subdirectory against the real cwd", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe(join(tempRoot, "sub")); + }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some("sub"), cwd: tempRoot }))), + ); + + it.effect("resolves a relative SUPABASE_WORKDIR env value against the real cwd", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe(tempRoot); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_WORKDIR: "." }, cwd: tempRoot }))), + ); + + it.effect("keeps an absolute --workdir flag unchanged", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe("/flag/workdir"); + }).pipe( + Effect.provide(makeLayer({ workdirFlag: Option.some("/flag/workdir"), cwd: tempRoot })), + ), + ); + it.effect("walks up from CWD looking for supabase/config.toml", () => { const projectRoot = join(tempRoot, "project"); const nested = join(projectRoot, "deep", "child"); diff --git a/apps/cli/src/legacy/shared/legacy-api-url.ts b/apps/cli/src/legacy/shared/legacy-api-url.ts new file mode 100644 index 0000000000..c48b73ac44 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-api-url.ts @@ -0,0 +1,26 @@ +/** + * Local API URL derivation, mirroring Go's `config.go:634-644` + `misc.go:298`: + * an explicit `api.external_url` wins, otherwise `://:` + * where the scheme follows `api.tls.enabled` and the port is `api.port`. + * Hoisted here because `legacy-storage-credentials.ts` and + * `legacy-local-config-values.ts` both need this exact computation. + */ +export function legacyResolveApiExternalUrl( + config: { + readonly external_url?: string; + readonly port: number; + readonly tls: { readonly enabled: boolean }; + }, + hostname: string, +): string { + if (config.external_url !== undefined && config.external_url.length > 0) { + return config.external_url; + } + const scheme = config.tls.enabled ? "https" : "http"; + // Go builds host:port with net.JoinHostPort (config.go:636-638), bracketing an + // IPv6 host. + const hostPort = hostname.includes(":") + ? `[${hostname}]:${config.port}` + : `${hostname}:${config.port}`; + return `${scheme}://${hostPort}`; +} diff --git a/apps/cli/src/legacy/shared/legacy-colors.ts b/apps/cli/src/legacy/shared/legacy-colors.ts index c41cfae7d4..25d32d7c42 100644 --- a/apps/cli/src/legacy/shared/legacy-colors.ts +++ b/apps/cli/src/legacy/shared/legacy-colors.ts @@ -6,27 +6,38 @@ import { styleText } from "node:util"; * Go uses lipgloss, which auto-detects the output profile and renders **plain** * text when the stream is not a TTY (piped output, CI, tests). `styleText` * mirrors that: with `validateStream` (the default) it checks the target stream - * and `NO_COLOR`, returning the unstyled string when colour is unsupported. We - * point it at `process.stderr` because the bootstrap progress / suggestion lines - * these style are written to stderr. + * and `NO_COLOR`, returning the unstyled string when colour is unsupported. + * + * `stream` defaults to `process.stderr` because every original call site styles + * progress/suggestion lines written to stderr. A caller styling content that is + * itself written to **stdout** (e.g. `status`'s pretty table) must pass + * `process.stdout` explicitly — otherwise the TTY check runs against the wrong + * stream, and piping stdout while stderr stays a TTY (`supabase status | less`) + * would corrupt the piped output with ANSI escapes (the same bug class CLI-1546 + * fixed for the progress spinner). * * lipgloss colour "14" is bright cyan; `"cyan"` is the closest faithful match, * matching `branches.prompt.ts`'s existing port of `utils.Aqua`. */ -export function legacyAqua(text: string): string { - return styleText("cyan", text, { stream: process.stderr }); +export function legacyAqua(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("cyan", text, { stream }); } -export function legacyBold(text: string): string { - return styleText("bold", text, { stream: process.stderr }); +export function legacyBold(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("bold", text, { stream }); } /** Port of Go's `utils.Yellow` — lipgloss colour "11" (bright yellow). */ -export function legacyYellow(text: string): string { - return styleText("yellow", text, { stream: process.stderr }); +export function legacyYellow(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("yellow", text, { stream }); } /** Port of Go's `utils.Red` — lipgloss colour "9" (bright red). */ -export function legacyRed(text: string): string { - return styleText("red", text, { stream: process.stderr }); +export function legacyRed(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("red", text, { stream }); +} + +/** Port of Go's `utils.Green` — lipgloss colour "10" (bright green). */ +export function legacyGreen(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("green", text, { stream }); } diff --git a/apps/cli/src/legacy/shared/legacy-colors.unit.test.ts b/apps/cli/src/legacy/shared/legacy-colors.unit.test.ts new file mode 100644 index 0000000000..e1f5e7873b --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-colors.unit.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { legacyAqua, legacyBold, legacyGreen, legacyRed, legacyYellow } from "./legacy-colors.ts"; + +// These tests only assert that each helper runs without throwing and returns a +// string containing the input text — actual color application depends on the +// stream's live TTY/NO_COLOR state, which isn't controllable from a test +// process. The behavior worth protecting here is the `stream` parameter +// threading through to `styleText`, not a specific ANSI byte sequence. +describe("legacy-colors", () => { + it("legacyAqua defaults to stderr when no stream is given", () => { + expect(legacyAqua("supabase")).toContain("supabase"); + }); + + it("legacyAqua accepts an explicit stream", () => { + expect(legacyAqua("supabase", process.stdout)).toContain("supabase"); + }); + + it("legacyBold defaults to stderr when no stream is given", () => { + expect(legacyBold("text")).toContain("text"); + }); + + it("legacyBold accepts an explicit stream", () => { + expect(legacyBold("text", process.stdout)).toContain("text"); + }); + + it("legacyYellow defaults to stderr when no stream is given", () => { + expect(legacyYellow("warning")).toContain("warning"); + }); + + it("legacyYellow accepts an explicit stream", () => { + expect(legacyYellow("warning", process.stdout)).toContain("warning"); + }); + + it("legacyRed defaults to stderr when no stream is given", () => { + expect(legacyRed("error")).toContain("error"); + }); + + it("legacyRed accepts an explicit stream", () => { + expect(legacyRed("error", process.stdout)).toContain("error"); + }); + + it("legacyGreen defaults to stderr when no stream is given", () => { + expect(legacyGreen("label")).toContain("label"); + }); + + it("legacyGreen accepts an explicit stream", () => { + expect(legacyGreen("label", process.stdout)).toContain("label"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.parity.unit.test.ts b/apps/cli/src/legacy/shared/legacy-config-validate.parity.unit.test.ts new file mode 100644 index 0000000000..d62db6a815 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-config-validate.parity.unit.test.ts @@ -0,0 +1,266 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; +import { Effect, Exit, FileSystem, Path, Schema } from "effect"; + +import { legacyReadDbToml } from "./legacy-db-config.toml-read.ts"; +import { legacyResolveLocalConfigValues } from "./legacy-local-config-values.ts"; + +/** + * Cross-caller parity coverage: for a table of Go-parity misconfigurations, drives BOTH real + * pipelines — D (`legacyReadDbToml`, Effect/raw-TOML) and L (`legacyResolveLocalConfigValues`, + * `@supabase/config`-decoded) — and asserts they fail with the SAME shared error-message + * substring, since both now route through the single `legacyValidateResolvedConfig`. The two + * pipelines don't need byte-identical exception wrapping, just the same core Go-parity message + * text (`.toContain(...)` on both sides with the same expected string). + * + * D's harness replicates the `withConfig`/`read`/`failsWith` pattern from + * `legacy-db-config.toml-read.unit.test.ts` (file-local there, not exported — faithfully + * reproduced here rather than imported). L's harness replicates the `baseConfig`/`WORKDIR` + * pattern from `legacy-local-config-values.unit.test.ts` (same reasoning). + */ + +function withConfig(content: string) { + const dir = mkdtempSync(join(tmpdir(), "legacy-config-validate-parity-")); + mkdirSync(join(dir, "supabase"), { recursive: true }); + writeFileSync(join(dir, "supabase", "config.toml"), content); + return dir; +} + +const readD = (workdir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyReadDbToml(fs, path, workdir); + }).pipe(Effect.provide(BunServices.layer)); + +/** Drives D's real pipeline and asserts the failure message contains `message`. */ +function failsWithD(tomlLines: ReadonlyArray, message: string) { + return Effect.gen(function* () { + const dir = withConfig(tomlLines.join("\n")); + const exit = yield* readD(dir).pipe(Effect.exit); + expect(Exit.isFailure(exit), `D: expected failure containing: ${message}`).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain(message); + } + rmSync(dir, { recursive: true, force: true }); + }); +} + +const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); +const WORKDIR = "/tmp/legacy-config-validate-parity-test"; + +function baseConfig(overrides: Record = {}): ProjectConfig { + return decodeConfig({ project_id: "test", ...overrides }); +} + +/** Drives L's real pipeline and asserts the failure message contains `message`. */ +function failsWithL( + overrides: Record, + message: string, + document?: Readonly>, +) { + const config = baseConfig(overrides); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow(message); +} + +interface ParityScenario { + readonly name: string; + readonly toml: ReadonlyArray; + readonly overrides: Record; + readonly document?: Readonly>; + readonly message: string; +} + +const scenarios: ReadonlyArray = [ + { + name: "db.port = 0", + toml: ["[db]", "port = 0"], + overrides: { db: { port: 0 } }, + message: "Missing required field in config: db.port", + }, + { + name: "db.major_version = 0", + toml: ["[db]", "major_version = 0"], + overrides: { db: { major_version: 0 } }, + message: "Missing required field in config: db.major_version", + }, + { + name: "db.major_version unsupported (16)", + toml: ["[db]", "major_version = 16"], + overrides: { db: { major_version: 16 } }, + message: "Failed reading config: Invalid db.major_version: 16.", + }, + { + name: "storage bucket name with an invalid pattern", + toml: ['[storage.buckets."bad#name"]'], + overrides: { storage: { buckets: { "bad#name": {} } } }, + message: + "Invalid Bucket name: bad#name. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed.", + }, + { + name: "function slug with an invalid pattern", + toml: ["[functions.123]"], + overrides: { functions: { "123": {} } }, + message: + "Invalid Function name: 123. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens.", + }, + { + name: "edge_runtime.deno_version = 0", + toml: ["[edge_runtime]", "deno_version = 0"], + overrides: { edge_runtime: { deno_version: 0 } }, + message: "Missing required field in config: edge_runtime.deno_version", + }, + { + name: "edge_runtime.deno_version unsupported (3)", + toml: ["[edge_runtime]", "deno_version = 3"], + overrides: { edge_runtime: { deno_version: 3 } }, + message: "Failed reading config: Invalid edge_runtime.deno_version: 3.", + }, + { + name: "auth.site_url empty with auth enabled", + toml: ["[auth]", "enabled = true", 'site_url = ""'], + overrides: { auth: { enabled: true, site_url: "" } }, + message: "Missing required field in config: auth.site_url", + }, + { + name: "auth.captcha enabled without a provider", + toml: ["[auth.captcha]", "enabled = true"], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + captcha: { enabled: true }, + }, + }, + message: "Missing required field in config: auth.captcha.provider", + }, + { + name: "auth.captcha enabled with a provider but no secret", + toml: ["[auth.captcha]", "enabled = true", 'provider = "hcaptcha"'], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + captcha: { enabled: true, provider: "hcaptcha" }, + }, + }, + message: "Missing required field in config: auth.captcha.secret", + }, + { + name: "auth.hook.* with a badly-formatted secret", + toml: [ + "[auth.hook.custom_access_token]", + "enabled = true", + 'uri = "https://example.test/hook"', + 'secrets = "not-a-valid-secret"', + ], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + hook: { + custom_access_token: { + enabled: true, + uri: "https://example.test/hook", + secrets: "not-a-valid-secret", + }, + }, + }, + }, + // D's assertion goes through `JSON.stringify(exit.cause)`, which backslash-escapes the + // message's embedded double quotes — trim the substring to the quote-free prefix, same + // convention D's own suite uses for this message. + message: "auth.hook.custom_access_token.secrets must be formatted as", + }, + { + name: "auth.mfa.* enroll_enabled without verify_enabled", + toml: ["[auth.mfa.totp]", "enroll_enabled = true", "verify_enabled = false"], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + mfa: { totp: { enroll_enabled: true, verify_enabled: false } }, + }, + }, + message: "Invalid MFA config: auth.mfa.totp.enroll_enabled requires verify_enabled", + }, + { + name: "auth.third_party.* enabled without its required field", + toml: ["[auth.third_party.firebase]", "enabled = true"], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + third_party: { firebase: { enabled: true } }, + }, + }, + message: "Invalid config: auth.third_party.firebase is enabled but without a project_id.", + }, + { + name: "auth.third_party.* more than one provider enabled", + toml: [ + "[auth.third_party.firebase]", + "enabled = true", + 'project_id = "proj"', + "[auth.third_party.auth0]", + "enabled = true", + 'tenant = "tenant"', + ], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + third_party: { + firebase: { enabled: true, project_id: "proj" }, + auth0: { enabled: true, tenant: "tenant" }, + }, + }, + }, + message: "Invalid config: Only one third_party provider allowed to be enabled at a time.", + }, + { + name: "auth.email.smtp present table missing a required field", + // Both pipelines read every smtp field straight off the raw TOML/document rather than a + // schema-decoded, always-defaulted value (Go's presence-based `enabled` default, + // config.go:743-748) — L needs the raw `document` (5th param) for this, matching D's raw + // smol-toml document. + toml: ["[auth.email.smtp]", 'user = "u"'], + overrides: { auth: { enabled: true, site_url: "http://localhost:3000" } }, + document: { auth: { email: { smtp: { user: "u" } } } }, + message: "Missing required field in config: auth.email.smtp.host", + }, + { + name: "experimental.pgdelta.format_options invalid JSON", + toml: ["[experimental.pgdelta]", 'format_options = "{not json"'], + overrides: { experimental: { pgdelta: { format_options: "{not json" } } }, + message: "Invalid config for experimental.pgdelta.format_options: must be valid JSON", + }, +]; + +// Explicitly SKIPPED (only one caller runs the branch, or the branch isn't exercised the same +// way by both — see the module header in `legacy-config-validate.ts` for the full explicitly +// out-of-scope list): +// - `remotes[*].project_id`, `auth.sms`, `auth.external` — D-only, never part of the shared +// validator (`LegacyConfigValidationInput` has no fields for these at all). +// - `api.tls`, `project_id`, `studio`, `local_smtp` — L-only, D has no equivalent sections. +// - `experimental.webhooks` — L reads webhooks presence from a raw `document` that D's +// `legacyReadDbToml` doesn't thread through `legacyValidateResolvedConfig` at all (D has no +// `experimental` presence-based input field for this — verified: `legacy-db-config.toml-read.ts` +// never sets `experimental.webhooksPresent`/`webhooksEnabled` on its `LegacyExperimentalInput`), +// so this branch is D-unreachable and skipped here too. +describe("legacyValidateResolvedConfig cross-caller parity (D vs L)", () => { + for (const scenario of scenarios) { + it.effect(`${scenario.name}: D and L fail with the same message`, () => + Effect.gen(function* () { + yield* failsWithD(scenario.toml, scenario.message); + failsWithL(scenario.overrides, scenario.message, scenario.document); + }), + ); + } +}); diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.ts b/apps/cli/src/legacy/shared/legacy-config-validate.ts new file mode 100644 index 0000000000..f833f95300 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-config-validate.ts @@ -0,0 +1,778 @@ +import { isAbsolute, join } from "node:path"; + +import { legacyGoUrlParse } from "./legacy-storage-url.ts"; + +/** + * Single home for Go's `Config.Validate` parity (`apps/cli-go/pkg/config/config.go:989-1192`), + * consolidating the two independent TypeScript ports of that logic: + * + * - **D** = `legacy-db-config.toml-read.ts` — raw smol-toml document + `EnvLookup`, + * Effect-based, fails with `LegacyDbConfigLoadError`. Feeds ~15 db/migration commands via + * `legacy-db-config.layer.ts`. + * - **L** = `legacy-local-config-values.ts` — decoded `@supabase/config` `ProjectConfig`, + * synchronous `node:fs`, throws plain `Error`. Feeds `status/status.values.ts` and + * `stop/stop.handler.ts`. + * + * **This file is the SINGLE home for `Config.Validate` parity going forward. + * Per-command reimplementations of any branch below are forbidden** — hoist here instead, + * per `apps/cli/AGENTS.md`'s "Hoist Before You Duplicate" policy. + * + * ## Status of this commit + * + * {@link legacyValidateResolvedConfig} is now IMPLEMENTED and fully wired into BOTH callers: L + * (`legacy-local-config-values.ts`'s `legacyResolveLocalConfigValues`) and D + * (`legacy-db-config.toml-read.ts`'s `legacyReadDbToml`) each build a + * {@link LegacyConfigValidationInput} from their own decoded config (a `ProjectConfig` + raw + * `document` for L, a raw smol-toml document + `EnvLookup` for D) and call this function once, + * at the correct Go position. Wiring D through this module also fixed D's `db.major_version + * === 0` divergence (D used to fall through to the generic invalid-value message; it now + * throws the same "Missing required field in config: db.major_version" as Go and as L already + * did). + * + * ## Full eventual scope: every `Config.Validate` branch this module owns + * + * In Go's exact `Validate()` order (`config.go:989-1192`), first-failure-wins: + * + * | Go line(s) | Check | + * |--------------------------------|-------| + * | 990-991 | `project_id` required | + * | 1006-1027 | `api.port` / `api.tls.{cert,key}_path` presence (the actual file reads stay per-caller I/O) | + * | 1031-1062 | `db.port`, `db.major_version` (0 / 12 / 13-17 switch) | + * | 1064-1068, pattern @ 1549-1554 | `storage.buckets.*` names vs `LEGACY_BUCKET_NAME_PATTERN` | + * | 1070-1079 | `studio.port` / `studio.api_url` (L-only — D has no studio section) | + * | 1081-1085 | `local_smtp.port` (L-only) | + * | 1087-1153 | `auth.*` sub-sequence, in order: site_url (1088-1090); captcha enum + presence (1099-1109, enum itself decode-time per `auth.go:58-71`); signing_keys read (1110-1116, caller-side I/O); passkey/webauthn (1117-1134); hooks (1136-1138, checks @ 1453-1521, vs `LEGACY_HOOK_SECRET_PATTERN`); mfa (1139-1141, checks @ 1523-1534); email template/notification content-vs-content_path (1293-1323, caller-side I/O) + smtp (1325-1344); third_party (1151-1153, checks @ 1635-1683, vs `LEGACY_CLERK_DOMAIN_PATTERN`) | + * | 1159-1163, pattern @ 1539-1544 | `functions.*` slugs vs `LEGACY_FUNCTION_SLUG_PATTERN` | + * | 1164-1173 | `edge_runtime.deno_version` (0 / 1 / 2 switch) | + * | (decode-time enum) | `analytics.backend` must be `postgres`/`bigquery` | + * | 1175-1187 | `analytics.gcp_*` fields, gated on `backend === "bigquery"` | + * | 1846-1854 | `experimental.webhooks` / `experimental.pgdelta.format_options` | + * + * ## Explicitly OUT of scope forever (D-only, NEVER part of this module) + * + * - `remotes[*].project_id` pattern (`config.go:997-1001`, vs `LEGACY_PROJECT_REF_PATTERN`) — + * D's own remote-merge-phase check (`findInvalidRemoteProjectId`), never shared with L. + * - `auth.sms` (`config.go:1145-1147`/`1348-1417`) — stays 100% inline in D; L instead relies on + * `@supabase/config`'s `sms` schema enforcing the same provider-switch priority at decode time + * (`packages/config/src/auth/sms.ts`), since L decodes through that schema and D doesn't. + * - `auth.external` (`config.go:1148-1150`/`1419-1451`) — inline in BOTH D + * (`legacy-db-config.toml-read.ts`'s "B5: external providers") and L + * ({@link legacyResolveLocalConfigValues}'s `validateAuthExternalProviders`, called after this + * module's shared check, same ordering tradeoff as sms below) — never routed through this + * shared module, since it needs the RAW pre-decode document to see provider names + * `@supabase/config`'s schema doesn't model. + * - `auth.jwt_secret` length check (`apikeys.go:43-73`, `generateAPIKeys`) — each caller's own + * key-generation flow (D and L both already implement this separately), not part of + * `Config.Validate`'s pure-check set. + * + * `legacyExpandEnv` also stays in D (env-substitution machinery, not a validation leaf). + * + * ## Known ordering tradeoff (accepted — do not "fix") + * + * Go's real auth-block order is site_url → captcha → signing_keys[IO] → passkey → hooks → mfa → + * email[IO]+smtp → **sms → external** → third_party. Since sms/external are D-only and never + * part of this module, but third_party IS shared, D cannot call + * {@link legacyValidateResolvedConfig} in a way that preserves relative ordering across the + * sms/external ↔ third_party boundary without complex multi-phase calls. Decision (applies once + * D is wired up in a follow-up commit): D calls {@link legacyValidateResolvedConfig} ONCE with + * the full input (including third_party), positioned after D's own signing-keys and + * email-template I/O reads; D's inline sms/external checks then run AFTER that single call + * succeeds. This means: if third_party is broken, its error surfaces (matching Go); D's + * sms/external checks never run in that case. The only real behavior change from today: for the + * (untested, unrealistic) case where sms/external AND third_party are BOTH simultaneously + * broken in the same config.toml, Go/today's-D would report the sms/external error first, but + * the refactored D reports third_party's error first, since third_party is checked inside the + * single earlier shared call. This is an accepted, narrow, documented parity gap. + * + * The same category of tradeoff now also applies to L: `legacyResolveLocalConfigValues` calls + * {@link legacyValidateResolvedConfig} exactly ONCE, at the very end, after every value this + * module needs has been derived — including L's 3 I/O reads (signing keys, `api.tls` cert/key, + * email template/notification content), which stay at their original textual position (per-caller + * I/O, same as D's). Every pure check this module owns is therefore checked in Go's exact + * relative order against every OTHER pure check, but an I/O read that in L's source sits + * between two pure sections (e.g. the signing-keys read sits between the captcha check and the + * passkey/hooks/mfa/email/smtp/third_party checks) now effectively runs BEFORE any of those + * later pure checks, rather than interleaved at its original relative position — the same + * narrow, accepted, documented tradeoff, not something to "fix" by splitting this function into + * multiple calls. Every existing test constructs exactly one validation failure at a time, so + * this has zero effect on any real test. + */ + +// Go's project-ref pattern (`apps/cli-go/pkg/config/config.go:470`): exactly 20 lowercase +// ASCII letters. Exported from this module (was private in D before this relocation) as the +// canonical home; D's `findInvalidRemoteProjectId` is today the only consumer — the +// `remotes[*].project_id` check itself stays D-only forever, see the module header above. +export const LEGACY_PROJECT_REF_PATTERN = /^[a-z]{20}$/; + +// Go's storage bucket-name pattern (`apps/cli-go/pkg/config/config.go:1382`). +// `config.Validate` runs `ValidateBucketName` over every `[storage.buckets.*]` key +// during config load (`config.go:898-903`), aborting before any db command when a +// name does not match. The source string is reused verbatim in the error message via +// `.source` so it byte-matches Go's `bucketNamePattern.String()`. Used by both D +// (`legacy-db-config.toml-read.ts`) and L (`legacy-local-config-values.ts`), and internally by +// {@link legacyValidateResolvedConfig}'s storage-bucket-names step (`config.go:1064-1068`). +export const LEGACY_BUCKET_NAME_PATTERN = /^(\w|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/; + +// Go's function-slug pattern (`apps/cli-go/pkg/config/config.go:1372`). `config.Validate` +// runs `ValidateFunctionSlug` over every `[functions.*]` key during config load +// (`config.go:993-998`), rejecting the config before any db command. `.source` is reused +// in the message so it byte-matches Go's `funcSlugPattern.String()`. Used by both D and L +// (same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above), and internally by +// {@link legacyValidateResolvedConfig}'s function-slugs step (`config.go:1159-1163`). +export const LEGACY_FUNCTION_SLUG_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/; + +// Go's `hookSecretPattern` (`apps/cli-go/pkg/config/config.go:1436`). Used by both D and L +// (same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above), and internally by +// {@link legacyValidateResolvedConfig}'s hooks step (`config.go:1453-1521`). +export const LEGACY_HOOK_SECRET_PATTERN = /^v1,whsec_[A-Za-z0-9+/=]{32,88}$/u; + +// Go's `clerkDomainPattern` (`apps/cli-go/pkg/config/config.go:1553`). Used by both D and L +// (same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above), and internally by +// {@link legacyValidateResolvedConfig}'s third_party step (`config.go:1635-1683`). +export const LEGACY_CLERK_DOMAIN_PATTERN = + /^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$/u; + +// Go's `strconv.ParseBool` accepted forms (`go-viper/mapstructure` `decodeBool` under +// viper's forced `WeaklyTypedInput`): a string decodes to bool via ParseBool, an empty +// string is `false`, and any other value is a parse error. +const GO_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); +const GO_BOOL_FALSE = new Set(["0", "f", "F", "FALSE", "false", "False", ""]); + +/** + * Parse a config bool the way Go does (`strconv.ParseBool` via mapstructure's weakly + * typed decode). Returns the bool, or `undefined` for a malformed value (which Go + * surfaces as a `failed to parse config` error). + * + * Used by both D (`legacy-db-config.toml-read.ts`'s `resolveBool`/`resolveBoolOrFail`) and + * L (`legacy-local-config-values.ts`'s `legacyEnvOverrideBool`) for their `SUPABASE_*` + * bool-flavored env overrides and TOML bool decoding. + */ +export function legacyParseGoBool(value: string): boolean | undefined { + if (GO_BOOL_TRUE.has(value)) return true; + if (GO_BOOL_FALSE.has(value)) return false; + return undefined; +} + +/** + * Thrown by {@link legacyValidateResolvedConfig}. Deliberately does NOT override `.name` in a + * constructor — it stays the inherited `"Error"` — so `.toString()`/`.name`/`instanceof Error` + * checks are indistinguishable from a plain `new Error(message)`. Both D and L's existing + * callers/tests observe only `.message` (via `cause instanceof Error ? cause.message : ...` or + * `.toThrow("substring")`), so swapping their inline `throw new Error(...)` calls for this class + * is a byte-identical, purely internal refactor. + */ +export class LegacyConfigValidateError extends Error {} + +/** One `[api.tls]` section, post-env-override. See {@link LegacyConfigValidationInput}. */ +export interface LegacyApiInput { + readonly enabled: boolean; + readonly port: number; + readonly tls: { + readonly enabled: boolean; + readonly certPath: string | undefined; + readonly keyPath: string | undefined; + }; +} + +/** `[db]`, post-env-override. Required — Go validates `db.port`/`db.major_version` unconditionally. */ +export interface LegacyDbInput { + readonly port: number; + readonly majorVersion: number; +} + +/** `[studio]`, post-env-override. L-only — D has no studio section. */ +export interface LegacyStudioInput { + readonly enabled: boolean; + readonly port: number; + readonly apiUrl: string; +} + +/** `[local_smtp]` (Go's `Inbucket`), post-env-override. L-only. */ +export interface LegacyLocalSmtpInput { + readonly enabled: boolean; + readonly port: number; +} + +/** `[auth.captcha]`. `provider` is deliberately `string | undefined`, not a narrow union — see + * divergence #2 in the module's port plan: D passes a raw, untyped TOML string (the enum check + * is live for D); L's `@supabase/config`-decoded value is already schema-narrowed to + * `"hcaptcha" | "turnstile" | undefined` before this function ever sees it, making the branch + * dead-but-harmless for L specifically, while still needing the same widened type to keep this + * field honest and reusable across both callers. + */ +export interface LegacyCaptchaInput { + readonly enabled: boolean; + readonly provider: string | undefined; + readonly secret: string | undefined; +} + +/** `[auth.passkey]` + `[auth.webauthn]`. Present iff `passkey.enabled === true`. */ +export interface LegacyPasskeyInput { + readonly webauthnPresent: boolean; + readonly rpId: string | undefined; + readonly rpOrigins: ReadonlyArray | undefined; +} + +/** One enabled `[auth.hook.]` entry. Caller pre-filters to enabled-only and pre-orders + * per Go's fixed hook-type iteration order (`config.go:1453-1485`). */ +export interface LegacyHookInput { + readonly type: + | "mfa_verification_attempt" + | "password_verification_attempt" + | "custom_access_token" + | "send_sms" + | "send_email" + | "before_user_created"; + /** Post-env-expand; `""` = absent. */ + readonly uri: string; + /** Post-env-expand; `""` = absent. */ + readonly secrets: string; +} + +/** One `[auth.mfa.]` entry. Caller pre-orders totp, phone, web_authn. */ +export interface LegacyMfaFactorInput { + readonly label: "totp" | "phone" | "web_authn"; + readonly enrollEnabled: boolean; + readonly verifyEnabled: boolean; +} + +/** `[auth.email.smtp]`. Present iff the raw TOML table itself is present (Go's presence-based + * `enabled` default, `config.go:743-748` — NOT the decoded, always-defaulted value). */ +export interface LegacySmtpInput { + readonly enabled: boolean; + readonly host: string; + readonly port: number; + readonly user: string; + readonly pass: string; + readonly adminEmail: string; +} + +/** One enabled `[auth.third_party.]` entry. Caller pre-filters to enabled-only and + * pre-orders per Go's fixed provider order (firebase, auth0, cognito, clerk, workos). */ +export interface LegacyThirdPartyInput { + readonly provider: "firebase" | "auth0" | "cognito" | "clerk" | "workos"; + /** `project_id` / `tenant` / `user_pool_id` / `domain` / `issuer_url`, per provider. */ + readonly requiredField: string; + /** cognito's second required field only. */ + readonly cognitoUserPoolRegion?: string; +} + +/** `[auth]`. Present in {@link LegacyConfigValidationInput} iff auth is enabled — matches Go's + * `if c.Auth.Enabled` gate wrapping this entire sub-sequence (`config.go:1087-1153`). */ +export interface LegacyAuthInput { + readonly siteUrl: string; + readonly captcha?: LegacyCaptchaInput; + readonly passkey?: LegacyPasskeyInput; + readonly hooks: ReadonlyArray; + readonly mfa: ReadonlyArray; + readonly smtp?: LegacySmtpInput; + readonly thirdParty: ReadonlyArray; +} + +/** `[analytics]`, post-env-override. Unconditional entry — internally gated on `enabled` + + * `backend === "bigquery"`. `backend` is `string | undefined` for the same dead-but-harmless-for-L + * reason as {@link LegacyCaptchaInput.provider} — see divergence #2. */ +export interface LegacyAnalyticsInput { + readonly enabled: boolean; + readonly backend: string | undefined; + readonly gcpProjectId: string; + readonly gcpProjectNumber: string; + readonly gcpJwtPath: string; +} + +/** `[experimental]`. Unconditional entry — internally gated. `webhooksPresent`/`webhooksEnabled` + * hinge on TOML-section PRESENCE (not the decoded, always-defaulted `enabled` value) — see + * `config.go:1846-1854` and the callers' own doc comments for why. */ +export interface LegacyExperimentalInput { + readonly webhooksPresent?: boolean; + readonly webhooksEnabled?: boolean; + readonly pgdeltaFormatOptions: string; +} + +/** + * Normalized POST-env-override primitives mirroring Go's decoded config, for VALIDATED fields + * only. Every section is OPTIONAL — an absent section means "this caller doesn't run that Go + * branch, skip it" (e.g. D omits `studio`/`localSmtp` entirely; both D and L omit `auth` when + * auth is disabled). See the module header for the full ported-branch table and out-of-scope + * list. + */ +export interface LegacyConfigValidationInput { + /** L only — D's `project_id` isn't part of `Config.Validate`'s shared surface. */ + readonly projectId?: string; + /** L only — D has no `[api]` section. */ + readonly api?: LegacyApiInput; + /** Both, unconditional in Go. */ + readonly db: LegacyDbInput; + /** Both, unconditional (`[]` = none). */ + readonly storageBucketNames: ReadonlyArray; + /** L only. */ + readonly studio?: LegacyStudioInput; + /** L only. */ + readonly localSmtp?: LegacyLocalSmtpInput; + /** Both, present iff auth is enabled. */ + readonly auth?: LegacyAuthInput; + /** Both, unconditional (`[]` = none). */ + readonly functionSlugs: ReadonlyArray; + /** Both, unconditional. */ + readonly edgeRuntimeDenoVersion: number; + /** Both, unconditional entry (internally gated). */ + readonly analytics: LegacyAnalyticsInput; + /** Both, unconditional entry (internally gated). */ + readonly experimental: LegacyExperimentalInput; +} + +function messageOf(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +/** + * Runs every `Config.Validate` branch this module owns (see the module header's table), in + * Go's exact order, first-failure-wins. Pure — no I/O, no Effect. Callers own their own + * per-section I/O reads (signing keys, `api.tls` cert/key, email template/notification content) + * at the correct Go position themselves, using the pure helpers exported below. + */ +export function legacyValidateResolvedConfig(input: LegacyConfigValidationInput): void { + // config.go:990-991 — checked FIRST, before every other field. + if (input.projectId !== undefined && input.projectId.length === 0) { + throw new LegacyConfigValidateError("Missing required field in config: project_id"); + } + + // config.go:1006-1027 — api.port / api.tls.{cert,key}_path, gated on api.enabled. The actual + // cert/key file reads are caller-side I/O (see legacyResolveApiTlsPath below); this only + // checks the "exactly one of cert/key set" presence rule. + if (input.api?.enabled) { + if (input.api.port === 0) { + throw new LegacyConfigValidateError("Missing required field in config: api.port"); + } + if (input.api.tls.enabled) { + const hasCert = input.api.tls.certPath !== undefined && input.api.tls.certPath.length > 0; + const hasKey = input.api.tls.keyPath !== undefined && input.api.tls.keyPath.length > 0; + if (hasCert && !hasKey) { + throw new LegacyConfigValidateError("Missing required field in config: api.tls.key_path"); + } + if (hasKey && !hasCert) { + throw new LegacyConfigValidateError("Missing required field in config: api.tls.cert_path"); + } + } + } + + // config.go:1031-1033 — db.port, unconditional, no `enabled` gate. + if (input.db.port === 0) { + throw new LegacyConfigValidateError("Missing required field in config: db.port"); + } + // config.go:1034-1062 — db.major_version switch: 0 / 12 have dedicated messages, 13/14/15/17 + // are supported, anything else is the generic invalid-value message. + if (input.db.majorVersion === 0) { + throw new LegacyConfigValidateError("Missing required field in config: db.major_version"); + } + if (input.db.majorVersion === 12) { + throw new LegacyConfigValidateError( + "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.", + ); + } + if (![13, 14, 15, 17].includes(input.db.majorVersion)) { + throw new LegacyConfigValidateError( + `Failed reading config: Invalid db.major_version: ${input.db.majorVersion}.`, + ); + } + + // config.go:1064-1068, pattern @ 1549-1554 — every [storage.buckets.*] key, unconditional. + for (const name of input.storageBucketNames) { + if (!LEGACY_BUCKET_NAME_PATTERN.test(name)) { + throw new LegacyConfigValidateError( + `Invalid Bucket name: ${name}. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (${LEGACY_BUCKET_NAME_PATTERN.source})`, + ); + } + } + + // config.go:1070-1079 — studio.port / studio.api_url, gated on studio.enabled. L-only. + if (input.studio?.enabled) { + if (input.studio.port === 0) { + throw new LegacyConfigValidateError("Missing required field in config: studio.port"); + } + try { + legacyGoUrlParse(input.studio.apiUrl); + } catch (cause) { + throw new LegacyConfigValidateError(`Invalid config for studio.api_url: ${messageOf(cause)}`); + } + } + + // config.go:1081-1085 — local_smtp.port, gated on local_smtp.enabled. L-only. + if (input.localSmtp?.enabled && input.localSmtp.port === 0) { + throw new LegacyConfigValidateError("Missing required field in config: local_smtp.port"); + } + + // config.go:1087-1153 — the auth.* sub-sequence, all inside `if c.Auth.Enabled`. + if (input.auth !== undefined) { + const auth = input.auth; + + // config.go:1088-1090 — auth.site_url. + if (auth.siteUrl.length === 0) { + throw new LegacyConfigValidateError("Missing required field in config: auth.site_url"); + } + + // config.go:1099-1109 + auth.go:58-71 — auth.captcha. The provider enum check runs FIRST, + // regardless of `enabled` (it's actually a decode-time check in Go, reproduced here so both + // callers see it from one place); only then does the `enabled`-gated presence check run. + if (auth.captcha !== undefined) { + const provider = auth.captcha.provider; + if ( + provider !== undefined && + provider.length > 0 && + provider !== "hcaptcha" && + provider !== "turnstile" + ) { + throw new LegacyConfigValidateError( + "failed to parse config: decoding failed due to the following error(s):\n\n'auth.captcha.provider' must be one of [hcaptcha turnstile]", + ); + } + if (auth.captcha.enabled) { + if (auth.captcha.provider === undefined) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.captcha.provider", + ); + } + if (auth.captcha.secret === undefined || auth.captcha.secret.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.captcha.secret", + ); + } + } + } + + // config.go:1110-1116 — signing_keys read is caller-side I/O, not part of this function. + + // config.go:1117-1134 — auth.passkey / auth.webauthn. Caller only builds `passkey` when + // `[auth.passkey] enabled` is true. + if (auth.passkey !== undefined) { + if (!auth.passkey.webauthnPresent) { + throw new LegacyConfigValidateError( + "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", + ); + } + if (auth.passkey.rpId === undefined || auth.passkey.rpId.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.webauthn.rp_id", + ); + } + if (auth.passkey.rpOrigins === undefined || auth.passkey.rpOrigins.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.webauthn.rp_origins", + ); + } + } + + // config.go:1136-1138, checks @ 1453-1521 — auth.hook.*, caller pre-filtered to + // enabled-only and pre-ordered per Go's fixed hook-type iteration order. + for (const hook of auth.hooks) { + if (hook.uri.length === 0) { + throw new LegacyConfigValidateError( + `Missing required field in config: auth.hook.${hook.type}.uri`, + ); + } + // Go calls `url.Parse` before the scheme switch (`config.go:1497-1499`) and fails the + // whole load on a malformed URI (e.g. an unterminated IPv6 host like `http://[::1`) — + // a bare scheme-prefix regex would accept that. Reuse `legacyGoUrlParse` (the same + // `net/url.Parse` port already used for `studio.api_url` above) instead of re-deriving + // a scheme by hand. + let scheme: string; + try { + scheme = legacyGoUrlParse(hook.uri).scheme; + } catch (cause) { + throw new LegacyConfigValidateError(`failed to parse template url: ${messageOf(cause)}`); + } + if (scheme === "http" || scheme === "https") { + if (hook.secrets.length === 0) { + throw new LegacyConfigValidateError( + `Missing required field in config: auth.hook.${hook.type}.secrets`, + ); + } + for (const secret of hook.secrets.split("|")) { + if (!LEGACY_HOOK_SECRET_PATTERN.test(secret)) { + throw new LegacyConfigValidateError( + `Invalid hook config: auth.hook.${hook.type}.secrets must be formatted as "v1,whsec_" with a minimum length of 32 characters.`, + ); + } + } + } else if (scheme === "pg-functions") { + if (hook.secrets.length > 0) { + throw new LegacyConfigValidateError( + `Invalid hook config: auth.hook.${hook.type}.secrets is unsupported for pg-functions URI`, + ); + } + } else { + throw new LegacyConfigValidateError( + `Invalid hook config: auth.hook.${hook.type}.uri should be a HTTP, HTTPS, or pg-functions URI`, + ); + } + } + + // config.go:1139-1141, checks @ 1523-1534 — auth.mfa.*, caller pre-ordered totp/phone/web_authn. + for (const factor of auth.mfa) { + if (factor.enrollEnabled && !factor.verifyEnabled) { + throw new LegacyConfigValidateError( + `Invalid MFA config: auth.mfa.${factor.label}.enroll_enabled requires verify_enabled`, + ); + } + } + + // config.go:1293-1323 — email template/notification content read + exclusivity is + // caller-side, via legacyResolveEmailTemplateContentPath below. + + // config.go:1325-1344 — auth.email.smtp, gated on the raw table being present AND enabled. + if (auth.smtp !== undefined && auth.smtp.enabled) { + if (auth.smtp.host.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.host", + ); + } + if (auth.smtp.port === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.port", + ); + } + if (auth.smtp.user.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.user", + ); + } + if (auth.smtp.pass.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.pass", + ); + } + if (auth.smtp.adminEmail.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.admin_email", + ); + } + } + + // config.go:1151-1153, checks @ 1635-1683 — auth.third_party.*, caller pre-filtered to + // enabled-only and pre-ordered firebase, auth0, cognito, clerk, workos. Each provider's + // required field(s) are checked as encountered; the "more than one enabled" check runs only + // after every entry has individually validated. + for (const thirdParty of auth.thirdParty) { + switch (thirdParty.provider) { + case "firebase": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.firebase is enabled but without a project_id.", + ); + } + break; + } + case "auth0": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.auth0 is enabled but without a tenant.", + ); + } + break; + } + case "cognito": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.", + ); + } + if ( + thirdParty.cognitoUserPoolRegion === undefined || + thirdParty.cognitoUserPoolRegion.length === 0 + ) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.", + ); + } + break; + } + case "clerk": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.clerk is enabled but without a domain.", + ); + } + if (!LEGACY_CLERK_DOMAIN_PATTERN.test(thirdParty.requiredField)) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.clerk has invalid domain, it usually is like clerk.example.com or example.clerk.accounts.dev. Check https://clerk.com/setup/supabase on how to find the correct value.", + ); + } + break; + } + case "workos": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.workos is enabled but without a issuer_url.", + ); + } + break; + } + } + } + if (auth.thirdParty.length > 1) { + throw new LegacyConfigValidateError( + "Invalid config: Only one third_party provider allowed to be enabled at a time.", + ); + } + } + + // config.go:1159-1163, pattern @ 1539-1544 — every [functions.*] key, unconditional, not + // gated on auth.enabled. + for (const slug of input.functionSlugs) { + if (!LEGACY_FUNCTION_SLUG_PATTERN.test(slug)) { + throw new LegacyConfigValidateError( + `Invalid Function name: ${slug}. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (${LEGACY_FUNCTION_SLUG_PATTERN.source})`, + ); + } + } + + // config.go:1164-1173 — edge_runtime.deno_version switch, unconditional, not gated on + // edge_runtime.enabled. + if (input.edgeRuntimeDenoVersion === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: edge_runtime.deno_version", + ); + } + if (input.edgeRuntimeDenoVersion !== 1 && input.edgeRuntimeDenoVersion !== 2) { + throw new LegacyConfigValidateError( + `Failed reading config: Invalid edge_runtime.deno_version: ${input.edgeRuntimeDenoVersion}.`, + ); + } + + // Decode-time enum (`LogflareBackend.UnmarshalText`, config.go:60-65) — reproduced here so + // both callers' env-override paths (which bypass their own decode-time schema guard) see it. + const backend = input.analytics.backend; + if ( + backend !== undefined && + backend.length > 0 && + backend !== "postgres" && + backend !== "bigquery" + ) { + throw new LegacyConfigValidateError( + "failed to parse config: decoding failed due to the following error(s):\n\n'analytics.backend' must be one of [postgres bigquery]", + ); + } + // config.go:1175-1187 — analytics.gcp_*, gated on enabled && backend === "bigquery". + if (input.analytics.enabled && backend === "bigquery") { + if (input.analytics.gcpProjectId.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: analytics.gcp_project_id", + ); + } + if (input.analytics.gcpProjectNumber.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: analytics.gcp_project_number", + ); + } + if (input.analytics.gcpJwtPath.length === 0) { + throw new LegacyConfigValidateError( + "Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path", + ); + } + } + + // config.go:1847-1848 — experimental.webhooks, hinges on TOML-section PRESENCE, not the + // decoded (always-defaulted) `enabled` value. + if (input.experimental.webhooksPresent === true && input.experimental.webhooksEnabled !== true) { + throw new LegacyConfigValidateError( + "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", + ); + } + // config.go:1850-1851 — experimental.pgdelta.format_options, must be valid JSON when set. + if ( + input.experimental.pgdeltaFormatOptions !== "" && + !isValidJson(input.experimental.pgdeltaFormatOptions) + ) { + throw new LegacyConfigValidateError( + "Invalid config for experimental.pgdelta.format_options: must be valid JSON", + ); + } +} + +function isValidJson(value: string): boolean { + try { + JSON.parse(value); + return true; + } catch { + return false; + } +} + +// ── signing keys (config.go:1110-1116, path rule config.go:877-878 filepath.IsAbs guard) ── + +/** Absolute → verbatim; relative → join(workdir, "supabase", p). */ +export function legacyResolveSigningKeysPath(workdir: string, signingKeysPath: string): string { + return isAbsolute(signingKeysPath) ? signingKeysPath : join(workdir, "supabase", signingKeysPath); +} + +/** `failed to read signing keys: ${msg(cause)}` */ +export function legacySigningKeysReadErrorMessage(cause: unknown): string { + return `failed to read signing keys: ${messageOf(cause)}`; +} + +/** `failed to decode signing keys: ${msg(cause)}` */ +export function legacySigningKeysDecodeErrorMessage(cause: unknown): string { + return `failed to decode signing keys: ${messageOf(cause)}`; +} +// D only asserts Array.isArray(JSON.parse(text)); L further decodes into LegacyJwk[] to sign +// with the first key — that JWK-specific decode/signing logic stays in L, unrelated to parity. + +// ── email template / notification (config.go:1293-1323) ── + +/** + * Pure exclusivity decision + path to read for one template/notification entry. Throws + * {@link LegacyConfigValidateError} with the exclusivity message when `contentPath === ""` and + * `contentPresent`. Returns the absolute path to read, or `undefined` when there's nothing to + * read (both `contentPath` and `content` absent — skip, not an error). `contentPath` set (even + * when `content` is ALSO set) always wins — Go does not reject "both set", `content_path` + * silently wins/overwrites. + * + * `base` is caller-resolved: TEMPLATE section → workdir; NOTIFICATION section → + * join(workdir, "supabase") (this asymmetry is real, intentional Go behavior — config.go's own + * FIXME comment flags it, do not "fix" it). + */ +export function legacyResolveEmailTemplateContentPath(args: { + readonly section: "template" | "notification"; + readonly name: string; + /** Post-env-expand; `""` = absent. */ + readonly contentPath: string; + /** Raw `content` key present in the TOML document. */ + readonly contentPresent: boolean; + readonly base: string; +}): string | undefined { + if (args.contentPath.length === 0) { + if (args.contentPresent) { + throw new LegacyConfigValidateError( + `Invalid config for auth.email.${args.section}.${args.name}.content: please use content_path instead`, + ); + } + return undefined; + } + return isAbsolute(args.contentPath) ? args.contentPath : join(args.base, args.contentPath); +} + +/** `Invalid config for auth.email.${section}.${name}.content_path: ${msg(cause)}` */ +export function legacyEmailContentPathReadErrorMessage( + section: "template" | "notification", + name: string, + cause: unknown, +): string { + return `Invalid config for auth.email.${section}.${name}.content_path: ${messageOf(cause)}`; +} + +// ── api.tls cert/key (config.go:1016-1026, path rule ~961-965, NO isAbsolute guard) ── + +/** Unconditional join(workdir, "supabase", p) — Go's path.Join absorbs a leading "/" too. */ +export function legacyResolveApiTlsPath(workdir: string, p: string): string { + return join(workdir, "supabase", p); +} + +/** `failed to read TLS cert: ${msg(cause)}` */ +export function legacyApiTlsCertReadErrorMessage(cause: unknown): string { + return `failed to read TLS cert: ${messageOf(cause)}`; +} + +/** `failed to read TLS key: ${msg(cause)}` */ +export function legacyApiTlsKeyReadErrorMessage(cause: unknown): string { + return `failed to read TLS key: ${messageOf(cause)}`; +} diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.unit.test.ts b/apps/cli/src/legacy/shared/legacy-config-validate.unit.test.ts new file mode 100644 index 0000000000..c66d1db6c9 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-config-validate.unit.test.ts @@ -0,0 +1,1034 @@ +import { describe, expect, it } from "vitest"; + +import { + LEGACY_BUCKET_NAME_PATTERN, + LEGACY_CLERK_DOMAIN_PATTERN, + LEGACY_FUNCTION_SLUG_PATTERN, + LEGACY_HOOK_SECRET_PATTERN, + LEGACY_PROJECT_REF_PATTERN, + type LegacyAuthInput, + type LegacyConfigValidationInput, + legacyParseGoBool, + legacyValidateResolvedConfig, +} from "./legacy-config-validate.ts"; + +// Starter suite for the symbols relocated from `legacy-db-config.toml-read.ts` in an earlier +// commit (see the module header in `legacy-config-validate.ts`). The bulk of `Config.Validate` +// behavioral coverage — direct calls to `legacyValidateResolvedConfig`, covering every branch +// this module owns regardless of which caller (D or L) exercises it — lives further down this +// file; it was consolidated here from `legacy-local-config-values.unit.test.ts`, where it used +// to be exercised only indirectly through `legacyResolveLocalConfigValues`. + +describe("legacyParseGoBool", () => { + it("accepts Go's strconv.ParseBool true forms", () => { + for (const value of ["1", "t", "T", "TRUE", "true", "True"]) { + expect(legacyParseGoBool(value)).toBe(true); + } + }); + + it("accepts Go's strconv.ParseBool false forms, including the empty string", () => { + for (const value of ["0", "f", "F", "FALSE", "false", "False", ""]) { + expect(legacyParseGoBool(value)).toBe(false); + } + }); + + it("returns undefined for a value outside Go's strconv.ParseBool acceptance set", () => { + expect(legacyParseGoBool("yes")).toBeUndefined(); + expect(legacyParseGoBool("2")).toBeUndefined(); + }); +}); + +describe("LEGACY_PROJECT_REF_PATTERN", () => { + it("matches a valid 20-character lowercase project ref", () => { + expect(LEGACY_PROJECT_REF_PATTERN.test("abcdefghijklmnopqrst")).toBe(true); + }); + + it("rejects refs of the wrong length or case", () => { + expect(LEGACY_PROJECT_REF_PATTERN.test("short")).toBe(false); + expect(LEGACY_PROJECT_REF_PATTERN.test("ABCDEFGHIJKLMNOPQRST")).toBe(false); + }); +}); + +describe("LEGACY_BUCKET_NAME_PATTERN", () => { + it("matches Go-legal bucket name characters", () => { + expect(LEGACY_BUCKET_NAME_PATTERN.test("my-bucket.1")).toBe(true); + }); + + it("rejects characters outside Go's bucketNamePattern", () => { + expect(LEGACY_BUCKET_NAME_PATTERN.test("bad#name")).toBe(false); + expect(LEGACY_BUCKET_NAME_PATTERN.test("bad/name")).toBe(false); + }); +}); + +describe("LEGACY_FUNCTION_SLUG_PATTERN", () => { + it("matches a valid function slug (letters, digits, _ and -)", () => { + expect(LEGACY_FUNCTION_SLUG_PATTERN.test("my-function")).toBe(true); + expect(LEGACY_FUNCTION_SLUG_PATTERN.test("function_1")).toBe(true); + }); + + it("rejects a slug that doesn't start with a letter", () => { + expect(LEGACY_FUNCTION_SLUG_PATTERN.test("123")).toBe(false); + expect(LEGACY_FUNCTION_SLUG_PATTERN.test("1bad")).toBe(false); + }); +}); + +describe("LEGACY_HOOK_SECRET_PATTERN", () => { + it("matches a valid v1,whsec_ secret", () => { + expect(LEGACY_HOOK_SECRET_PATTERN.test(`v1,whsec_${"a".repeat(32)}`)).toBe(true); + }); + + it("rejects a secret that doesn't match Go's hookSecretPattern", () => { + expect(LEGACY_HOOK_SECRET_PATTERN.test("not-a-valid-secret")).toBe(false); + }); +}); + +describe("LEGACY_CLERK_DOMAIN_PATTERN", () => { + it("matches a valid clerk.example.com domain", () => { + expect(LEGACY_CLERK_DOMAIN_PATTERN.test("clerk.example.com")).toBe(true); + }); + + it("matches a valid .clerk.accounts.dev domain", () => { + expect(LEGACY_CLERK_DOMAIN_PATTERN.test("example.clerk.accounts.dev")).toBe(true); + }); + + it("rejects a domain that doesn't match Go's clerkDomainPattern", () => { + expect(LEGACY_CLERK_DOMAIN_PATTERN.test("not-a-clerk-domain")).toBe(false); + }); +}); + +/** + * A trivially-passing full input. Every test below spreads/overrides only the field(s) its + * check cares about, matching the fixture-building style of `legacy-local-config-values.unit + * .test.ts`'s own `baseConfig()` helper. + */ +function minimalInput( + overrides: Partial = {}, +): LegacyConfigValidationInput { + return { + db: { port: 5432, majorVersion: 17 }, + storageBucketNames: [], + functionSlugs: [], + edgeRuntimeDenoVersion: 2, + analytics: { + enabled: false, + backend: undefined, + gcpProjectId: "", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + experimental: { pgdeltaFormatOptions: "" }, + ...overrides, + }; +} + +/** A trivially-passing `[auth]` section — auth enabled, nothing else configured. */ +function minimalAuthInput(overrides: Partial = {}): LegacyAuthInput { + return { + siteUrl: "http://localhost:3000", + hooks: [], + mfa: [], + thirdParty: [], + ...overrides, + }; +} + +// Moved from `legacy-local-config-values.unit.test.ts`: these describe blocks exercise checks +// that now live entirely inside `legacyValidateResolvedConfig` and can be phrased as direct +// calls with a hand-built `LegacyConfigValidationInput` — no `ProjectConfig`/schema decode, no +// env-override machinery, no file I/O, no `document` threading. Everything that still needs +// one of those (value derivation, env-override mechanics, the 3 I/O checks' actual file reads) +// stays in `legacy-local-config-values.unit.test.ts`. +describe("legacyValidateResolvedConfig", () => { + // config.go:1034-1062 — db.major_version switch. The env-override + // (SUPABASE_DB_MAJOR_VERSION) variants stay in legacy-local-config-values.unit.test.ts. + describe("db.major_version", () => { + it("rejects a configured major_version of 0", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ db: { port: 5432, majorVersion: 0 } })), + ).toThrow("Missing required field in config: db.major_version"); + }); + + it("rejects the unsupported Postgres 12.x major_version with Go's dedicated message", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ db: { port: 5432, majorVersion: 12 } })), + ).toThrow("Postgres version 12.x is unsupported."); + }); + + it.each([13, 14, 15, 17])("accepts the supported major_version %d", (majorVersion) => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ db: { port: 5432, majorVersion } })), + ).not.toThrow(); + }); + + it("rejects an unsupported major_version with the generic invalid-value message", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ db: { port: 5432, majorVersion: 16 } })), + ).toThrow("Failed reading config: Invalid db.major_version: 16."); + }); + }); + + // config.go:1064-1068, pattern @ 1549-1554 — unconditional, no storage.enabled-style gate. + describe("storage.buckets", () => { + it("rejects a bucket name Go's ValidateBucketName refuses", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ storageBucketNames: ["bad/name"] })), + ).toThrow("Invalid Bucket name: bad/name."); + }); + + it("does not throw for a valid bucket name", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ storageBucketNames: ["avatars.public"] })), + ).not.toThrow(); + }); + + it("does not throw when no buckets are configured", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1164-1173 — edge_runtime.deno_version switch, unconditional, not gated on + // edge_runtime.enabled (there is no such field on LegacyConfigValidationInput at all). The + // env-override (SUPABASE_EDGE_RUNTIME_DENO_VERSION) variants stay in + // legacy-local-config-values.unit.test.ts. + describe("edge_runtime.deno_version", () => { + it("rejects a configured deno_version of 0", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ edgeRuntimeDenoVersion: 0 })), + ).toThrow("Missing required field in config: edge_runtime.deno_version"); + }); + + it.each([1, 2])("accepts the supported deno_version %d", (denoVersion) => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ edgeRuntimeDenoVersion: denoVersion })), + ).not.toThrow(); + }); + + it("rejects an unsupported deno_version with the generic invalid-value message", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ edgeRuntimeDenoVersion: 3 })), + ).toThrow("Failed reading config: Invalid edge_runtime.deno_version: 3."); + }); + + it("rejects an invalid deno_version even when edge_runtime is disabled", () => { + // There is no `edgeRuntime.enabled`-style gate on `LegacyConfigValidationInput` at + // all — this is identical to the "rejects a configured deno_version of 0" case above, + // which is itself the point: Go never gates this check on edge_runtime.enabled. + expect(() => + legacyValidateResolvedConfig(minimalInput({ edgeRuntimeDenoVersion: 0 })), + ).toThrow("Missing required field in config: edge_runtime.deno_version"); + }); + }); + + // config.go:1174-1187 — analytics.gcp_*, gated on enabled && backend === "bigquery". The + // env-override (SUPABASE_ANALYTICS_*) variants stay in legacy-local-config-values.unit.test.ts. + describe("analytics (BigQuery backend required fields)", () => { + it("rejects an enabled bigquery backend without gcp_project_id", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "bigquery", + gcpProjectId: "", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + }), + ), + ).toThrow("Missing required field in config: analytics.gcp_project_id"); + }); + + it("rejects an enabled bigquery backend without gcp_project_number", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "bigquery", + gcpProjectId: "proj", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + }), + ), + ).toThrow("Missing required field in config: analytics.gcp_project_number"); + }); + + it("rejects an enabled bigquery backend without gcp_jwt_path", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "bigquery", + gcpProjectId: "proj", + gcpProjectNumber: "123", + gcpJwtPath: "", + }, + }), + ), + ).toThrow( + "Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path", + ); + }); + + it("does not throw when an enabled bigquery backend has all three GCP fields", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "bigquery", + gcpProjectId: "proj", + gcpProjectNumber: "123", + gcpJwtPath: "gcp.json", + }, + }), + ), + ).not.toThrow(); + }); + + it("does not throw for the postgres backend, however incomplete the GCP fields are", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "postgres", + gcpProjectId: "", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + }), + ), + ).not.toThrow(); + }); + + it("does not throw when analytics is disabled, however incomplete the GCP fields are", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: false, + backend: "bigquery", + gcpProjectId: "", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + }), + ), + ).not.toThrow(); + }); + }); + + // config.go:1846-1854 — experimental.validate(), unconditional, internally gated. + describe("experimental.*", () => { + it("rejects a present [experimental.webhooks] section with enabled omitted", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ experimental: { webhooksPresent: true, pgdeltaFormatOptions: "" } }), + ), + ).toThrow( + "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", + ); + }); + + it("rejects a present [experimental.webhooks] section with enabled = false", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + experimental: { + webhooksPresent: true, + webhooksEnabled: false, + pgdeltaFormatOptions: "", + }, + }), + ), + ).toThrow( + "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", + ); + }); + + it("does not throw when [experimental.webhooks] enabled = true", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + experimental: { + webhooksPresent: true, + webhooksEnabled: true, + pgdeltaFormatOptions: "", + }, + }), + ), + ).not.toThrow(); + }); + + it("does not throw when [experimental.webhooks] is absent entirely", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + + it("rejects invalid JSON in experimental.pgdelta.format_options", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ experimental: { pgdeltaFormatOptions: "{not json" } }), + ), + ).toThrow("Invalid config for experimental.pgdelta.format_options: must be valid JSON"); + }); + + it("does not throw for valid JSON in experimental.pgdelta.format_options", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ experimental: { pgdeltaFormatOptions: '{"keywordCase":"upper"}' } }), + ), + ).not.toThrow(); + }); + + it("does not throw when experimental.pgdelta.format_options is unset", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ experimental: { pgdeltaFormatOptions: "" } })), + ).not.toThrow(); + }); + }); + + // config.go:1088-1090 — auth.site_url, checked first inside `if c.Auth.Enabled`. An absent + // `auth` section on `LegacyConfigValidationInput` IS "auth disabled" from this function's + // perspective. The SUPABASE_AUTH_ENABLED/SUPABASE_AUTH_SITE_URL env-override variants stay in + // legacy-local-config-values.unit.test.ts. + describe("auth.site_url", () => { + it("rejects an explicit empty site_url when auth is enabled", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput({ siteUrl: "" }) })), + ).toThrow("Missing required field in config: auth.site_url"); + }); + + it("does not throw when site_url is set and auth is enabled", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ auth: minimalAuthInput({ siteUrl: "http://localhost:3000" }) }), + ), + ).not.toThrow(); + }); + + it("does not throw an explicit empty site_url when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1099-1109 + auth.go:58-71 — auth.captcha, checked right after auth.site_url. + describe("auth.captcha", () => { + it("rejects an enabled captcha without a provider", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: true, provider: undefined, secret: undefined }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.captcha.provider"); + }); + + it("rejects an enabled captcha with a provider but no secret", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: true, provider: "hcaptcha", secret: undefined }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.captcha.secret"); + }); + + it("does not throw when an enabled captcha has both provider and secret", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: true, provider: "hcaptcha", secret: "shh" }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw when captcha is disabled, however incomplete", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: false, provider: undefined, secret: undefined }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw an enabled captcha without provider/secret when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1117-1134 — auth.passkey/auth.webauthn, right after the (caller-side) signing-keys + // read. + describe("auth.passkey / auth.webauthn", () => { + it("rejects passkey.enabled without an [auth.webauthn] section", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + passkey: { webauthnPresent: false, rpId: undefined, rpOrigins: undefined }, + }), + }), + ), + ).toThrow( + "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", + ); + }); + + it("rejects passkey.enabled with [auth.webauthn] missing rp_id", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + passkey: { + webauthnPresent: true, + rpId: undefined, + rpOrigins: ["http://localhost:3000"], + }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.webauthn.rp_id"); + }); + + it("rejects passkey.enabled with [auth.webauthn] missing rp_origins", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + passkey: { webauthnPresent: true, rpId: "localhost", rpOrigins: undefined }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.webauthn.rp_origins"); + }); + + it("does not throw when passkey.enabled has a complete [auth.webauthn] section", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + passkey: { + webauthnPresent: true, + rpId: "localhost", + rpOrigins: ["http://localhost:3000"], + }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw when passkey is absent from the input", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ auth: minimalAuthInput({ passkey: undefined }) }), + ), + ).not.toThrow(); + }); + + it("does not throw when auth carries no passkey data at all", () => { + // Distinct from the previous test only in the original (L-level) caller's derivation — + // "webauthn absent from the document" vs. "no document was threaded through at all". + // Both collapse to `passkey: undefined` at this shared, direct-call layer. + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput() })), + ).not.toThrow(); + }); + + it("does not throw an enabled passkey without webauthn when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1325-1344 — auth.email.smtp, gated on the raw table being present AND enabled. + describe("auth.email.smtp", () => { + it("rejects a present [auth.email.smtp] table with no fields", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + smtp: { enabled: true, host: "", port: 0, user: "", pass: "", adminEmail: "" }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.email.smtp.host"); + }); + + it("rejects a present [auth.email.smtp] table missing port/user/pass/admin_email", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + smtp: { + enabled: true, + host: "smtp.example.com", + port: 0, + user: "", + pass: "", + adminEmail: "", + }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.email.smtp.port"); + }); + + it("does not throw when [auth.email.smtp] explicitly sets enabled = false", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + smtp: { + enabled: false, + host: "smtp.example.com", + port: 0, + user: "", + pass: "", + adminEmail: "", + }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw when [auth.email.smtp] is a complete table", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + smtp: { + enabled: true, + host: "smtp.example.com", + port: 587, + user: "user", + pass: "pass", + adminEmail: "admin@example.com", + }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw when [auth.email.smtp] is absent from the input", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput({ smtp: undefined }) })), + ).not.toThrow(); + }); + + it("does not throw when auth carries no smtp data at all", () => { + // See the equivalent passkey note above — both collapse to `smtp: undefined` here. + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput() })), + ).not.toThrow(); + }); + + it("does not throw a present but incomplete [auth.email.smtp] table when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1136-1138, checks @ 1453-1521 — auth.hook.*, caller pre-filters to enabled-only. + describe("auth.hook.*", () => { + it("rejects an enabled hook without a uri", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [{ type: "custom_access_token", uri: "", secrets: "" }], + }), + }), + ), + ).toThrow("Missing required field in config: auth.hook.custom_access_token.uri"); + }); + + it("rejects an http(s) hook uri without secrets", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { type: "custom_access_token", uri: "https://example.test/hook", secrets: "" }, + ], + }), + }), + ), + ).toThrow("Missing required field in config: auth.hook.custom_access_token.secrets"); + }); + + it("rejects an http(s) hook secret that doesn't match Go's hookSecretPattern", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { + type: "custom_access_token", + uri: "https://example.test/hook", + secrets: "not-a-valid-secret", + }, + ], + }), + }), + ), + ).toThrow( + 'auth.hook.custom_access_token.secrets must be formatted as "v1,whsec_"', + ); + }); + + it("does not throw for a valid http(s) hook secret", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { + type: "custom_access_token", + uri: "https://example.test/hook", + secrets: `v1,whsec_${"a".repeat(32)}`, + }, + ], + }), + }), + ), + ).not.toThrow(); + }); + + it("rejects a pg-functions hook uri with secrets set", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { + type: "custom_access_token", + uri: "pg-functions://postgres/public/hook", + secrets: `v1,whsec_${"a".repeat(32)}`, + }, + ], + }), + }), + ), + ).toThrow("auth.hook.custom_access_token.secrets is unsupported for pg-functions URI"); + }); + + it("does not throw for a pg-functions hook uri without secrets", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { + type: "custom_access_token", + uri: "pg-functions://postgres/public/hook", + secrets: "", + }, + ], + }), + }), + ), + ).not.toThrow(); + }); + + it("rejects a hook uri with an unsupported scheme", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [{ type: "custom_access_token", uri: "ftp://example.test/hook", secrets: "" }], + }), + }), + ), + ).toThrow("auth.hook.custom_access_token.uri should be a HTTP, HTTPS, or pg-functions URI"); + }); + + // Go calls `url.Parse` before the scheme switch (config.go:1497-1499) and fails the whole + // load on a malformed URI, rather than treating any `http:`/`https:` prefix as valid. + it("rejects a hook uri that fails Go's url.Parse (malformed IPv6 host)", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [{ type: "custom_access_token", uri: "http://[::1", secrets: "" }], + }), + }), + ), + ).toThrow("failed to parse template url:"); + }); + + it("does not throw for a disabled hook, however incomplete", () => { + // The caller pre-filters to enabled-only hooks — a disabled hook is simply absent from + // `hooks`, matching an empty array here. + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput({ hooks: [] }) })), + ).not.toThrow(); + }); + + it("does not throw an enabled hook without a uri when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1139-1141, checks @ 1523-1534 — auth.mfa.*, fixed totp/phone/web_authn order. + describe("auth.mfa.*", () => { + it.each([ + ["totp", "auth.mfa.totp.enroll_enabled requires verify_enabled"], + ["phone", "auth.mfa.phone.enroll_enabled requires verify_enabled"], + ["web_authn", "auth.mfa.web_authn.enroll_enabled requires verify_enabled"], + ] as const)("rejects %s enroll_enabled without verify_enabled", (label, message) => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + mfa: [{ label, enrollEnabled: true, verifyEnabled: false }], + }), + }), + ), + ).toThrow(message); + }); + + it("does not throw when enroll_enabled and verify_enabled are both true", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + mfa: [{ label: "totp", enrollEnabled: true, verifyEnabled: true }], + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw an enroll_enabled MFA factor without verify_enabled when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1151-1153, checks @ 1635-1683 — auth.third_party.*, fixed provider order, caller + // pre-filters to enabled-only. + describe("auth.third_party.*", () => { + it("rejects firebase enabled without a project_id", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "firebase", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.firebase is enabled but without a project_id."); + }); + + it("rejects auth0 enabled without a tenant", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "auth0", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.auth0 is enabled but without a tenant."); + }); + + it("rejects aws_cognito enabled without a user_pool_id", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "cognito", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.cognito is enabled but without a user_pool_id."); + }); + + it("rejects aws_cognito enabled with a user_pool_id but no user_pool_region", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + thirdParty: [ + { provider: "cognito", requiredField: "pool-1", cognitoUserPoolRegion: undefined }, + ], + }), + }), + ), + ).toThrow( + "Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.", + ); + }); + + it("rejects clerk enabled without a domain", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "clerk", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.clerk is enabled but without a domain."); + }); + + it("rejects clerk enabled with a domain that doesn't match Go's clerkDomainPattern", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + thirdParty: [{ provider: "clerk", requiredField: "not-a-clerk-domain" }], + }), + }), + ), + ).toThrow("Invalid config: auth.third_party.clerk has invalid domain"); + }); + + it("does not throw for a valid clerk.example.com domain", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + thirdParty: [{ provider: "clerk", requiredField: "clerk.example.com" }], + }), + }), + ), + ).not.toThrow(); + }); + + it("rejects workos enabled without an issuer_url", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "workos", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.workos is enabled but without a issuer_url."); + }); + + it("rejects more than one third_party provider enabled at once", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + thirdParty: [ + { provider: "firebase", requiredField: "proj" }, + { provider: "auth0", requiredField: "tenant" }, + ], + }), + }), + ), + ).toThrow("Invalid config: Only one third_party provider allowed to be enabled at a time."); + }); + + it("does not throw when no third_party provider is enabled", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput() })), + ).not.toThrow(); + }); + + it("does not throw an enabled third_party provider missing its required field when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1159-1163, pattern @ 1539-1544 — every [functions.*] key, unconditional, not + // gated on auth.enabled. + describe("functions.*", () => { + it("rejects a function slug Go's ValidateFunctionSlug refuses", () => { + expect(() => legacyValidateResolvedConfig(minimalInput({ functionSlugs: ["1bad"] }))).toThrow( + "Invalid Function name: 1bad.", + ); + }); + + it("does not throw for a valid function slug", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ functionSlugs: ["hello-world_v2"] })), + ).not.toThrow(); + }); + + it("does not throw when no functions are configured", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + + it("rejects an invalid function slug even when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput({ functionSlugs: ["1bad"] }))).toThrow( + "Invalid Function name: 1bad.", + ); + }); + }); + + // config.go:1006-1027 — only the "exactly one of cert/key set" presence rule; the actual + // file reads and the disabled-skip/env-override tests stay in + // legacy-local-config-values.unit.test.ts. + describe("api.tls", () => { + it("rejects cert_path set without key_path", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + api: { + enabled: true, + port: 54321, + tls: { enabled: true, certPath: "cert.pem", keyPath: undefined }, + }, + }), + ), + ).toThrow("Missing required field in config: api.tls.key_path"); + }); + + it("rejects key_path set without cert_path", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + api: { + enabled: true, + port: 54321, + tls: { enabled: true, certPath: undefined, keyPath: "key.pem" }, + }, + }), + ), + ).toThrow("Missing required field in config: api.tls.cert_path"); + }); + }); + + // Direct-shared-level regression/divergence coverage (Part C) — behavior that's either new + // (the D fix) or only meaningfully testable at this exact layer (the captcha enum), not a + // move from either caller's own suite. + describe("Config.Validate divergence regression coverage", () => { + it("throws the Go-parity missing-required message for db.major_version = 0 (regression for the D fix in 0c62a914)", () => { + // D used to fall through to the generic "Invalid db.major_version: 0" message; both D + // and L now go through this exact branch, so pin it directly here too, not just via + // D's/L's own suites. + expect(() => + legacyValidateResolvedConfig({ ...minimalInput(), db: { port: 5432, majorVersion: 0 } }), + ).toThrow("Missing required field in config: db.major_version"); + }); + + it("throws Go's decode-time enum message for an invalid auth.captcha.provider, regardless of enabled", () => { + // This scenario is only meaningful at this direct shared-validator level: it's + // unreachable through L's real ProjectConfig-typed flow — `@supabase/config`'s schema + // (packages/config/src/auth/captcha.ts, stringEnum(["hcaptcha", "turnstile"])) already + // narrows `provider` to "hcaptcha" | "turnstile" | undefined before it ever reaches + // `legacyResolveLocalConfigValues`, so an invalid provider value would fail schema + // decoding first, on a completely different code path. D's real TOML flow CAN reach + // this branch (an untyped raw string) — D's own suite + // (`legacy-db-config.toml-read.unit.test.ts`) covers that separately. This test's job is + // only to pin the shared function's own behavior directly. + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: false, provider: "not-a-real-provider", secret: undefined }, + }), + }), + ), + ).toThrow( + "failed to parse config: decoding failed due to the following error(s):\n\n'auth.captcha.provider' must be one of [hcaptcha turnstile]", + ); + }); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index 3bf5f4244f..53bfb6e947 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Data, Effect, Stream } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; @@ -14,6 +14,36 @@ import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner type Spawner = ChildProcessSpawner["Service"]; +/** + * Raised when neither `docker` nor `podman` can be spawned at all (e.g. neither + * is installed or on `PATH`) — distinct from a spawned process exiting non-zero. + * Not exported: callers never need to match on this type directly, they fold it + * into their own tagged error via {@link legacyDescribeContainerCliFailure} so + * the "no runtime found" root cause survives instead of collapsing into a + * generic "failed to ..." message. + */ +class LegacyContainerRuntimeNotFoundError extends Data.TaggedError( + "LegacyContainerRuntimeNotFoundError", +)<{ + readonly message: string; +}> {} + +const RUNTIME_NOT_FOUND_MESSAGE = + "docker: command not found (podman also not found) — install Docker Desktop or Podman and ensure it is on PATH"; + +/** + * Renders a caller-facing suffix for a `spawnContainerCli`/`containerCliExitCode` + * failure: the clear "neither runtime found" message when that's the cause, + * otherwise the underlying cause's own message (falling back to `String(cause)` + * for non-`Error` causes) so callers never collapse a real failure reason into a + * bare, uninformative "failed to ..." string. + */ +export function legacyDescribeContainerCliFailure(cause: unknown): string { + if (cause instanceof LegacyContainerRuntimeNotFoundError) return cause.message; + if (cause instanceof Error) return cause.message; + return String(cause); +} + /** * Spawn a container-CLI command and return the process handle. Use when the * caller needs to read stdout/stderr or await the exit code itself. @@ -25,17 +55,115 @@ export const spawnContainerCli = ( ) => spawner .spawn(ChildProcess.make("docker", args, options)) - .pipe(Effect.catch(() => spawner.spawn(ChildProcess.make("podman", args, options)))); + .pipe( + Effect.catch(() => + spawner + .spawn(ChildProcess.make("podman", args, options)) + .pipe( + Effect.catch(() => + Effect.fail( + new LegacyContainerRuntimeNotFoundError({ message: RUNTIME_NOT_FOUND_MESSAGE }), + ), + ), + ), + ), + ); /** * Run a container-CLI command and resolve to its exit code, mirroring the * spawner's `exitCode` convenience for callers that only need the status. + * + * `podmanArgs` lets a caller pass different argv to the Podman fallback than to + * Docker, for the rare case where the two aren't drop-in compatible on a given + * subcommand (e.g. `volume prune --all` — Docker-only, see + * `stop.handler.ts`'s volume-prune call). Defaults to reusing `args` unchanged, + * which is correct for every other call site. */ export const containerCliExitCode = ( spawner: Spawner, args: ReadonlyArray, options?: ChildProcess.CommandOptions, + podmanArgs?: ReadonlyArray, ) => spawner .exitCode(ChildProcess.make("docker", args, options)) - .pipe(Effect.catch(() => spawner.exitCode(ChildProcess.make("podman", args, options)))); + .pipe( + Effect.catch(() => + spawner + .exitCode(ChildProcess.make("podman", podmanArgs ?? args, options)) + .pipe( + Effect.catch(() => + Effect.fail( + new LegacyContainerRuntimeNotFoundError({ message: RUNTIME_NOT_FOUND_MESSAGE }), + ), + ), + ), + ), + ); + +function collectDockerCliText(stream: Stream.Stream) { + const decoder = new TextDecoder(); + return Stream.runFold( + stream, + () => "", + (text, chunk) => text + decoder.decode(chunk, { stream: true }), + ).pipe(Effect.map((text) => text + decoder.decode())); +} + +/** + * Mirrors Go's `versions.GreaterThanOrEqualTo` (`docker/api/types/versions`, + * used by `apps/cli-go/internal/utils/docker.go:128`): splits each version on + * `.` and compares the parts numerically, component by component — not a + * naive string/float compare, which would misorder e.g. `"1.9"` vs `"1.10"`. + */ +function isDockerApiVersionAtLeast(version: string, minVersion: string): boolean { + const parts = version.split(".").map((part) => Number.parseInt(part, 10)); + const minParts = minVersion.split(".").map((part) => Number.parseInt(part, 10)); + for (let index = 0; index < Math.max(parts.length, minParts.length); index++) { + const part = parts[index] ?? 0; + const minPart = minParts[index] ?? 0; + if (part !== minPart) return part > minPart; + } + return true; +} + +/** + * Docker CLI's own `volume prune --all` flag is annotated `version: "1.42"` + * (vendored `docker/cli@v28.5.2` `cli/command/volume/prune.go:53`) and + * enforced by Cobra's `Args` validator *before* `RunE` runs + * (`cmd/docker/docker.go:659-660`): against a daemon with a lower negotiated + * API version, `docker volume prune --all ...` exits nonzero without pruning + * anything at all, rather than degrading gracefully. Go avoids ever hitting + * that by gating the equivalent `all=true` filter on + * `Docker.ClientVersion() >= "1.42"` (`docker.go:126-133`); there is no + * persistent Engine API client here to ask, so this asks the `docker` CLI + * itself via `docker version`. + * + * Deliberately does not fall back to Podman like {@link containerCliExitCode} + * does: Podman's `volume prune` never has an `--all` flag to gate in the + * first place (callers already omit it from their Podman argv + * unconditionally), so this check is meaningless on that path. Resolves to + * `false` (omit `--all`, matching how a pre-1.42 daemon's own `volume prune` + * already prunes every unused volume without it) whenever `docker` can't be + * spawned, its `version` call fails, or the reported version can't be read — + * the side that can never turn into a hard failure of the prune call itself. + */ +export const legacyDockerSupportsVolumePruneAllFlag = (spawner: Spawner) => + Effect.scoped( + Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make("docker", ["version", "--format", "{{.Server.APIVersion}}"], { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }), + ); + const [exitCode, stdout] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), collectDockerCliText(child.stdout)], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0) return false; + const version = stdout.trim(); + return version.length > 0 && isDockerApiVersionAtLeast(version, "1.42"); + }), + ).pipe(Effect.orElseSucceed(() => false)); diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts b/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts index 7c6f97aeca..e80dbf5ae8 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts @@ -2,9 +2,21 @@ import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, PlatformError, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { containerCliExitCode, spawnContainerCli } from "./legacy-container-cli.ts"; +import { + containerCliExitCode, + legacyDescribeContainerCliFailure, + legacyDockerSupportsVolumePruneAllFlag, + spawnContainerCli, +} from "./legacy-container-cli.ts"; -function mockSpawner(opts: { readonly dockerMissing?: boolean; readonly exitCode?: number } = {}) { +function mockSpawner( + opts: { + readonly dockerMissing?: boolean; + readonly bothMissing?: boolean; + readonly exitCode?: number; + readonly stdout?: string; + } = {}, +) { const spawned: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; const spawner = ChildProcessSpawner.make((command) => @@ -13,13 +25,13 @@ function mockSpawner(opts: { readonly dockerMissing?: boolean; readonly exitCode const args = command._tag === "StandardCommand" ? command.args : []; spawned.push({ command: cmd, args }); - if (opts.dockerMissing && cmd === "docker") { + if ((opts.dockerMissing && cmd === "docker") || opts.bothMissing === true) { return yield* Effect.fail( PlatformError.systemError({ _tag: "NotFound", module: "ChildProcess", method: "spawn", - description: "docker not found", + description: `${cmd} not found`, }), ); } @@ -29,7 +41,10 @@ function mockSpawner(opts: { readonly dockerMissing?: boolean; readonly exitCode return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - stdout: Stream.empty, + stdout: + opts.stdout !== undefined + ? Stream.fromIterable([new TextEncoder().encode(opts.stdout)]) + : Stream.empty, stderr: Stream.empty, all: Stream.empty, exitCode: Deferred.await(exitDeferred), @@ -98,4 +113,108 @@ describe("containerCliExitCode", () => { }), ); }); + + it.live("fails with a clear message when neither docker nor podman can be spawned", () => { + const mock = mockSpawner({ bothMissing: true }); + return containerCliExitCode(mock.spawner, ["image", "inspect", "img"]).pipe( + Effect.flip, + Effect.map((error) => { + expect(legacyDescribeContainerCliFailure(error)).toBe( + "docker: command not found (podman also not found) — install Docker Desktop or Podman and ensure it is on PATH", + ); + }), + ); + }); +}); + +describe("legacyDockerSupportsVolumePruneAllFlag", () => { + it.live("returns true when the daemon reports an API version at or above 1.42", () => { + const mock = mockSpawner({ stdout: "1.42" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(true); + expect(mock.spawned).toEqual([ + { command: "docker", args: ["version", "--format", "{{.Server.APIVersion}}"] }, + ]); + }), + ); + }); + + it.live("returns true for a version comfortably above 1.42", () => { + const mock = mockSpawner({ stdout: "1.51" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(true); + }), + ); + }); + + it.live("returns false when the daemon reports an API version below 1.42", () => { + const mock = mockSpawner({ stdout: "1.41" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + }), + ); + }); + + it.live("compares version components numerically, not lexicographically", () => { + // A naive string compare would misorder "1.9" as greater than "1.42" — this + // guards the numeric, component-by-component comparison instead. + const mock = mockSpawner({ stdout: "1.9" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + }), + ); + }); + + it.live("returns false when the version command exits non-zero", () => { + const mock = mockSpawner({ exitCode: 1, stdout: "1.51" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + }), + ); + }); + + it.live("returns false when the reported version is empty", () => { + const mock = mockSpawner({ stdout: "" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + }), + ); + }); + + it.live("returns false without falling back to podman when docker cannot be spawned", () => { + const mock = mockSpawner({ dockerMissing: true }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + expect(mock.spawned.map((entry) => entry.command)).toEqual(["docker"]); + }), + ); + }); +}); + +describe("legacyDescribeContainerCliFailure", () => { + it.live("describes a both-runtimes-missing failure with its clear message", () => { + const mock = mockSpawner({ bothMissing: true }); + return containerCliExitCode(mock.spawner, ["ps"]).pipe( + Effect.flip, + Effect.map((error) => { + expect(legacyDescribeContainerCliFailure(error)).toContain("docker: command not found"); + }), + ); + }); + + it("falls back to an Error instance's own message", () => { + expect(legacyDescribeContainerCliFailure(new Error("boom"))).toBe("boom"); + }); + + it("stringifies a non-Error cause", () => { + expect(legacyDescribeContainerCliFailure("boom")).toBe("boom"); + expect(legacyDescribeContainerCliFailure(42)).toBe("42"); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index 71b989aaeb..639c1169ca 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -1,5 +1,26 @@ import { Effect, type FileSystem, Option, type Path } from "effect"; import * as SmolToml from "smol-toml"; +import { + LEGACY_PROJECT_REF_PATTERN, + type LegacyAnalyticsInput, + type LegacyAuthInput, + type LegacyCaptchaInput, + type LegacyConfigValidationInput, + type LegacyDbInput, + legacyEmailContentPathReadErrorMessage, + type LegacyExperimentalInput, + type LegacyHookInput, + type LegacyMfaFactorInput, + legacyParseGoBool, + type LegacyPasskeyInput, + legacyResolveEmailTemplateContentPath, + legacyResolveSigningKeysPath, + legacySigningKeysDecodeErrorMessage, + legacySigningKeysReadErrorMessage, + type LegacySmtpInput, + type LegacyThirdPartyInput, + legacyValidateResolvedConfig, +} from "./legacy-config-validate.ts"; import { LegacyDbConfigLoadError } from "./legacy-db-config.errors.ts"; import { parseDotEnv } from "./legacy-dotenv.ts"; import { @@ -348,23 +369,6 @@ function findDuplicateRemoteProjectId( return undefined; } -// Go's project-ref pattern (`apps/cli-go/pkg/config/config.go:470`): exactly 20 -// lowercase ASCII letters. -const LEGACY_PROJECT_REF_PATTERN = /^[a-z]{20}$/; - -// Go's storage bucket-name pattern (`apps/cli-go/pkg/config/config.go:1382`). -// `config.Validate` runs `ValidateBucketName` over every `[storage.buckets.*]` key -// during config load (`config.go:898-903`), aborting before any db command when a -// name does not match. The source string is reused verbatim in the error message via -// `.source` so it byte-matches Go's `bucketNamePattern.String()`. -const LEGACY_BUCKET_NAME_PATTERN = /^(\w|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/; - -// Go's function-slug pattern (`apps/cli-go/pkg/config/config.go:1372`). `config.Validate` -// runs `ValidateFunctionSlug` over every `[functions.*]` key during config load -// (`config.go:993-998`), rejecting the config before any db command. `.source` is reused -// in the message so it byte-matches Go's `funcSlugPattern.String()`. -const LEGACY_FUNCTION_SLUG_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/; - /** * Go's `config.Validate` rejects any `[remotes.]` whose `project_id` is not a * valid project ref (`config.go:832-836`), on every config load — so a malformed or @@ -623,33 +627,6 @@ function nonEmptyString(value: unknown): Option.Option { return typeof value === "string" && value.length > 0 ? Option.some(value) : Option.none(); } -/** Go's `json.Valid` (`encoding/json`): reports whether the string is well-formed JSON. */ -function legacyIsValidJson(value: string): boolean { - try { - JSON.parse(value); - return true; - } catch { - return false; - } -} - -// Go's `strconv.ParseBool` accepted forms (`go-viper/mapstructure` `decodeBool` under -// viper's forced `WeaklyTypedInput`): a string decodes to bool via ParseBool, an empty -// string is `false`, and any other value is a parse error. -const GO_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); -const GO_BOOL_FALSE = new Set(["0", "f", "F", "FALSE", "false", "False", ""]); - -/** - * Parse a config bool the way Go does (`strconv.ParseBool` via mapstructure's weakly - * typed decode). Returns the bool, or `undefined` for a malformed value (which Go - * surfaces as a `failed to parse config` error). - */ -function legacyParseGoBool(value: string): boolean | undefined { - if (GO_BOOL_TRUE.has(value)) return true; - if (GO_BOOL_FALSE.has(value)) return false; - return undefined; -} - /** * Resolve a `[section] enabled` style bool. Go decodes a TOML bool natively and a * string (incl. an `env(VAR)` reference) via `strconv.ParseBool` — so `"1"`/`"t"`/etc. @@ -864,405 +841,6 @@ const legacyAssertDecryptableSecrets = ( // Go merges the template default before Validate (`templates/config.toml`), so an absent // `auth.site_url` is non-empty; only an explicit empty string fails A1 (`config.go:1037`). const DEFAULT_AUTH_SITE_URL = "http://127.0.0.1:3000"; -// Go's `hookSecretPattern` (`pkg/config/config.go:1436`). -const LEGACY_HOOK_SECRET_PATTERN = /^v1,whsec_[A-Za-z0-9+/=]{32,88}$/u; -// Go's `clerkDomainPattern` (`pkg/config/config.go:1553`). -const LEGACY_CLERK_DOMAIN_PATTERN = - /^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$/u; - -/** - * Ports the FATAL validations Go runs inside `if c.Auth.Enabled { … }` during - * `config.Validate` (`apps/cli-go/pkg/config/config.go:1036-1102` + the nested - * `.validate()` methods at 1242-1632), in Go's first-failure-wins order, so a db/migration - * command aborts on an invalid auth config exactly like the Go CLI (the reviewer's case: - * `migration down --local` must stop before any destructive work). Every check below mirrors - * a Go `return errors.New(...)` site with the byte-exact message. - * - * Deliberately NOT ported: the `assertEnvLoaded` WARN lines (Go's `config.go:1143-1148` only - * prints a stderr warning and always returns nil — never fatal), and the non-fatal mutations - * (the SMS "no provider → disable phone login" WARN, the linkedin/slack deprecation WARN); - * those affect neither the exit code nor any value this subset reader exposes. The - * linkedin/slack providers are still skipped (Go deletes them before validating). - */ -const legacyValidateAuthConfig = Effect.fnUntraced(function* ( - authRaw: RawDoc, - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - lookup: EnvLookup, -) { - const fail = (message: string) => Effect.fail(new LegacyDbConfigLoadError({ message })); - const supabaseDir = path.join(workdir, "supabase"); - // Env-expanded string of `rec[key]` ("" when absent/non-string). An unresolved `env(VAR)` - // stays literal (non-empty), matching Go's LoadEnvHook + the Secret decode hook. - const str = (rec: RawDoc | undefined, key: string): string => { - const value = rec?.[key]; - return typeof value === "string" ? legacyExpandEnv(value, lookup) : ""; - }; - // Weak-bool decode (Go mapstructure): boolean | nonzero number | strconv.ParseBool string. - // A malformed string ABORTS the load like Go's decode (it does NOT coerce to false), using - // the reader's `failed to parse config: invalid .` shape (the same simplification the - // db.* bools use; Go's verbose mapstructure string is not reproduced byte-for-byte there - // either). Absent / non-string → false (the default for every auth enable-flag). - const gate = (rec: RawDoc | undefined, key: string, field: string) => - Effect.gen(function* () { - const value = rec?.[key]; - if (typeof value === "boolean") return value; - if (typeof value === "number") return value !== 0; - if (typeof value !== "string") return false; - const parsed = legacyParseGoBool(legacyExpandEnv(value, lookup)); - if (parsed === undefined) return yield* fail(`failed to parse config: invalid ${field}.`); - return parsed; - }); - // Resolve a config file path: absolute → verbatim (Go opens it from the OS root after chdir); - // relative → joined under `base` (`filepath.IsAbs` guards, `config.go:854-878`). - const resolvePath = (p: string, base: string): string => - path.isAbsolute(p) ? p : path.join(base, p); - - // A1: site_url required (`config.go:1037-1039`). - const siteUrl = - authRaw["site_url"] === undefined ? DEFAULT_AUTH_SITE_URL : str(authRaw, "site_url"); - if (siteUrl.length === 0) return yield* fail("Missing required field in config: auth.site_url"); - - // A4: [auth.captcha]. The provider enum is a decode-time check (`CaptchaProvider.UnmarshalText`, - // `auth.go:58-71`) that fires whenever `provider` is set, regardless of `enabled`; the - // required-field checks run only when enabled (`config.go:1048-1058`). - const captcha = asRecord(authRaw["captcha"]); - if (captcha !== undefined) { - const provider = str(captcha, "provider"); - if (provider.length > 0 && provider !== "hcaptcha" && provider !== "turnstile") - return yield* fail( - "failed to parse config: decoding failed due to the following error(s):\n\n'auth.captcha.provider' must be one of [hcaptcha turnstile]", - ); - if (yield* gate(captcha, "enabled", "auth.captcha.enabled")) { - if (provider.length === 0) - return yield* fail("Missing required field in config: auth.captcha.provider"); - if (str(captcha, "secret").length === 0) - return yield* fail("Missing required field in config: auth.captcha.secret"); - } - } - - // A5: signing keys file load (`config.go:1059-1065`) — read + parse as a JSON array. A - // relative path resolves under the supabase dir (`config.go:877-878`); absolute is verbatim. - const signingKeysPath = str(authRaw, "signing_keys_path"); - if (signingKeysPath.length > 0) { - const keysJson = yield* fs.readFileString(resolvePath(signingKeysPath, supabaseDir)).pipe( - Effect.mapError( - (cause) => - new LegacyDbConfigLoadError({ - message: `failed to read signing keys: ${cause.message}`, - }), - ), - ); - yield* Effect.try({ - try: () => { - const parsed: unknown = JSON.parse(keysJson); - if (!Array.isArray(parsed)) throw new Error("signing keys must be a JSON array of JWKs"); - return parsed; - }, - catch: (cause) => - new LegacyDbConfigLoadError({ - message: `failed to decode signing keys: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - }); - } - - // A6: passkey/webauthn when passkey enabled (`config.go:1066-1084`). - const passkey = asRecord(authRaw["passkey"]); - if (passkey !== undefined && (yield* gate(passkey, "enabled", "auth.passkey.enabled"))) { - const webauthn = asRecord(authRaw["webauthn"]); - if (webauthn === undefined) - return yield* fail( - "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", - ); - if (str(webauthn, "rp_id").length === 0) - return yield* fail("Missing required field in config: auth.webauthn.rp_id"); - const rpOrigins = webauthn["rp_origins"]; - if (!Array.isArray(rpOrigins) || rpOrigins.length === 0) - return yield* fail("Missing required field in config: auth.webauthn.rp_origins"); - } - - // B1: hooks — each enabled hook (`config.go:1402-1470`). - const hook = asRecord(authRaw["hook"]); - if (hook !== undefined) { - const hookTypes = [ - "mfa_verification_attempt", - "password_verification_attempt", - "custom_access_token", - "send_sms", - "send_email", - "before_user_created", - ] as const; - for (const hookType of hookTypes) { - const h = asRecord(hook[hookType]); - if (h === undefined) continue; - if (!(yield* gate(h, "enabled", `auth.hook.${hookType}.enabled`))) continue; - const uri = str(h, "uri"); - if (uri.length === 0) - return yield* fail(`Missing required field in config: auth.hook.${hookType}.uri`); - // Go uses net/url.Parse, which (unlike `new URL`) does not throw on a missing scheme; - // extract the scheme the same lenient way so a no-scheme/other URI hits the default - // branch rather than erroring (the rare url.Parse error case is not separately ported). - const scheme = (/^([a-zA-Z][a-zA-Z0-9+.-]*):/u.exec(uri)?.[1] ?? "").toLowerCase(); - const secrets = str(h, "secrets"); - if (scheme === "http" || scheme === "https") { - if (secrets.length === 0) - return yield* fail(`Missing required field in config: auth.hook.${hookType}.secrets`); - for (const secret of secrets.split("|")) { - if (!LEGACY_HOOK_SECRET_PATTERN.test(secret)) - return yield* fail( - `Invalid hook config: auth.hook.${hookType}.secrets must be formatted as "v1,whsec_" with a minimum length of 32 characters.`, - ); - } - } else if (scheme === "pg-functions") { - if (secrets.length > 0) - return yield* fail( - `Invalid hook config: auth.hook.${hookType}.secrets is unsupported for pg-functions URI`, - ); - } else { - return yield* fail( - `Invalid hook config: auth.hook.${hookType}.uri should be a HTTP, HTTPS, or pg-functions URI`, - ); - } - } - } - - // B2: mfa — enroll requires verify (`config.go:1472-1483`). - const mfa = asRecord(authRaw["mfa"]); - if (mfa !== undefined) { - for (const [key, label] of [ - ["totp", "totp"], - ["phone", "phone"], - ["web_authn", "web_authn"], - ] as const) { - const factor = asRecord(mfa[key]); - if (factor === undefined) continue; - const enroll = yield* gate(factor, "enroll_enabled", `auth.mfa.${label}.enroll_enabled`); - const verify = yield* gate(factor, "verify_enabled", `auth.mfa.${label}.verify_enabled`); - if (enroll && !verify) - return yield* fail( - `Invalid MFA config: auth.mfa.${label}.enroll_enabled requires verify_enabled`, - ); - } - } - - // B3: email (`config.go:1242-1295`). - const email = asRecord(authRaw["email"]); - if (email !== undefined) { - // Go resolves a relative `content_path` differently per section: email TEMPLATE paths are - // relative to the PROJECT ROOT (`config.go:854-856`, the `// FIXME` there), while - // NOTIFICATION paths are relative to the supabase dir (`config.go:861-862`); absolute → as-is. - const validateTemplate = ( - section: "template" | "notification", - name: string, - tmpl: RawDoc, - base: string, - ) => - Effect.gen(function* () { - const contentPath = str(tmpl, "content_path"); - if (contentPath.length === 0) { - if (tmpl["content"] !== undefined) - return yield* fail( - `Invalid config for auth.email.${section}.${name}.content: please use content_path instead`, - ); - return; - } - yield* fs.readFileString(resolvePath(contentPath, base)).pipe( - Effect.mapError( - (cause) => - new LegacyDbConfigLoadError({ - message: `Invalid config for auth.email.${section}.${name}.content_path: ${cause.message}`, - }), - ), - ); - }); - const templates = asRecord(email["template"]); - if (templates !== undefined) { - for (const name of Object.keys(templates)) { - const tmpl = asRecord(templates[name]); - if (tmpl !== undefined) yield* validateTemplate("template", name, tmpl, workdir); - } - } - const notifications = asRecord(email["notification"]); - if (notifications !== undefined) { - for (const name of Object.keys(notifications)) { - const tmpl = asRecord(notifications[name]); - if ( - tmpl !== undefined && - (yield* gate(tmpl, "enabled", `auth.email.notification.${name}.enabled`)) - ) - yield* validateTemplate("notification", name, tmpl, supabaseDir); - } - } - // Go defaults `auth.email.smtp.enabled = true` when the `[auth.email.smtp]` table is present - // but omits `enabled` (`config.go:692-696`), so a present table validates unless explicitly - // disabled. - const smtp = asRecord(email["smtp"]); - const smtpEnabled = - smtp !== undefined && - (smtp["enabled"] === undefined - ? true - : yield* gate(smtp, "enabled", "auth.email.smtp.enabled")); - if (smtp !== undefined && smtpEnabled) { - if (str(smtp, "host").length === 0) - return yield* fail("Missing required field in config: auth.email.smtp.host"); - const portRaw = smtp["port"]; - const port = - typeof portRaw === "number" - ? portRaw - : typeof portRaw === "string" - ? Number(legacyExpandEnv(portRaw, lookup)) - : 0; - if (!port) return yield* fail("Missing required field in config: auth.email.smtp.port"); - if (str(smtp, "user").length === 0) - return yield* fail("Missing required field in config: auth.email.smtp.user"); - if (str(smtp, "pass").length === 0) - return yield* fail("Missing required field in config: auth.email.smtp.pass"); - if (str(smtp, "admin_email").length === 0) - return yield* fail("Missing required field in config: auth.email.smtp.admin_email"); - } - } - - // B4: sms — only the FIRST enabled provider is validated (Go's switch, `config.go:1297-1364`). - const sms = asRecord(authRaw["sms"]); - if (sms !== undefined) { - const twilio = asRecord(sms["twilio"]); - const twilioVerify = asRecord(sms["twilio_verify"]); - const messagebird = asRecord(sms["messagebird"]); - const textlocal = asRecord(sms["textlocal"]); - const vonage = asRecord(sms["vonage"]); - // Resolve every provider's enable-flag (a malformed bool aborts like Go's decode); Go's - // switch then validates only the FIRST enabled provider. - const twilioEnabled = yield* gate(twilio, "enabled", "auth.sms.twilio.enabled"); - const twilioVerifyEnabled = yield* gate( - twilioVerify, - "enabled", - "auth.sms.twilio_verify.enabled", - ); - const messagebirdEnabled = yield* gate(messagebird, "enabled", "auth.sms.messagebird.enabled"); - const textlocalEnabled = yield* gate(textlocal, "enabled", "auth.sms.textlocal.enabled"); - const vonageEnabled = yield* gate(vonage, "enabled", "auth.sms.vonage.enabled"); - if (twilioEnabled) { - if (str(twilio, "account_sid").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio.account_sid"); - if (str(twilio, "message_service_sid").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio.message_service_sid"); - if (str(twilio, "auth_token").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio.auth_token"); - } else if (twilioVerifyEnabled) { - if (str(twilioVerify, "account_sid").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio_verify.account_sid"); - if (str(twilioVerify, "message_service_sid").length === 0) - return yield* fail( - "Missing required field in config: auth.sms.twilio_verify.message_service_sid", - ); - if (str(twilioVerify, "auth_token").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio_verify.auth_token"); - } else if (messagebirdEnabled) { - if (str(messagebird, "originator").length === 0) - return yield* fail("Missing required field in config: auth.sms.messagebird.originator"); - if (str(messagebird, "access_key").length === 0) - return yield* fail("Missing required field in config: auth.sms.messagebird.access_key"); - } else if (textlocalEnabled) { - if (str(textlocal, "sender").length === 0) - return yield* fail("Missing required field in config: auth.sms.textlocal.sender"); - if (str(textlocal, "api_key").length === 0) - return yield* fail("Missing required field in config: auth.sms.textlocal.api_key"); - } else if (vonageEnabled) { - if (str(vonage, "from").length === 0) - return yield* fail("Missing required field in config: auth.sms.vonage.from"); - if (str(vonage, "api_key").length === 0) - return yield* fail("Missing required field in config: auth.sms.vonage.api_key"); - if (str(vonage, "api_secret").length === 0) - return yield* fail("Missing required field in config: auth.sms.vonage.api_secret"); - } - } - - // B5: external providers (`config.go:1368-1398`). linkedin/slack are deprecated and deleted - // before validation, so they are never validated here. - const external = asRecord(authRaw["external"]); - if (external !== undefined) { - for (const name of Object.keys(external)) { - if (name === "linkedin" || name === "slack") continue; - const provider = asRecord(external[name]); - if (provider === undefined) continue; - if (!(yield* gate(provider, "enabled", `auth.external.${name}.enabled`))) continue; - if (str(provider, "client_id").length === 0) - return yield* fail(`Missing required field in config: auth.external.${name}.client_id`); - if (name !== "apple" && name !== "google" && str(provider, "secret").length === 0) - return yield* fail(`Missing required field in config: auth.external.${name}.secret`); - } - } - - // B6: third_party — validate each enabled provider in order, then mutual exclusivity - // (`config.go:1584-1632`). Note `aws_cognito`'s messages say `cognito` (Go's wording). - const thirdParty = asRecord(authRaw["third_party"]); - if (thirdParty !== undefined) { - let enabledCount = 0; - const firebase = asRecord(thirdParty["firebase"]); - if ( - firebase !== undefined && - (yield* gate(firebase, "enabled", "auth.third_party.firebase.enabled")) - ) { - enabledCount += 1; - if (str(firebase, "project_id").length === 0) - return yield* fail( - "Invalid config: auth.third_party.firebase is enabled but without a project_id.", - ); - } - const auth0 = asRecord(thirdParty["auth0"]); - if (auth0 !== undefined && (yield* gate(auth0, "enabled", "auth.third_party.auth0.enabled"))) { - enabledCount += 1; - if (str(auth0, "tenant").length === 0) - return yield* fail( - "Invalid config: auth.third_party.auth0 is enabled but without a tenant.", - ); - } - const cognito = asRecord(thirdParty["aws_cognito"]); - if ( - cognito !== undefined && - (yield* gate(cognito, "enabled", "auth.third_party.aws_cognito.enabled")) - ) { - enabledCount += 1; - if (str(cognito, "user_pool_id").length === 0) - return yield* fail( - "Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.", - ); - if (str(cognito, "user_pool_region").length === 0) - return yield* fail( - "Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.", - ); - } - const clerk = asRecord(thirdParty["clerk"]); - if (clerk !== undefined && (yield* gate(clerk, "enabled", "auth.third_party.clerk.enabled"))) { - enabledCount += 1; - const domain = str(clerk, "domain"); - if (domain.length === 0) - return yield* fail( - "Invalid config: auth.third_party.clerk is enabled but without a domain.", - ); - if (!LEGACY_CLERK_DOMAIN_PATTERN.test(domain)) - return yield* fail( - "Invalid config: auth.third_party.clerk has invalid domain, it usually is like clerk.example.com or example.clerk.accounts.dev. Check https://clerk.com/setup/supabase on how to find the correct value.", - ); - } - const workos = asRecord(thirdParty["workos"]); - if ( - workos !== undefined && - (yield* gate(workos, "enabled", "auth.third_party.workos.enabled")) - ) { - enabledCount += 1; - if (str(workos, "issuer_url").length === 0) - return yield* fail( - "Invalid config: auth.third_party.workos is enabled but without a issuer_url.", - ); - } - if (enabledCount > 1) - return yield* fail( - "Invalid config: Only one third_party provider allowed to be enabled at a time.", - ); - } -}); /** * Reads `/supabase/config.toml` (db subtree + project id) and the linked @@ -1534,22 +1112,10 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }), ); } - // Reject unsupported major versions like Go's config.Validate ({13,14,15,17}; - // `apps/cli-go/pkg/config/config.go:869-897`) before any image/container runs. An - // absent value falls through to the default (Go's zero-then-default). - if ( - typeof majorVersionResolved === "number" && - ![13, 14, 15, 17].includes(majorVersionResolved) - ) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: - majorVersionResolved === 12 - ? "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects." - : `Failed reading config: Invalid db.major_version: ${majorVersionResolved}.`, - }), - ); - } + // Rejecting an unsupported major version ({13,14,15,17}; `apps/cli-go/pkg/config/config.go: + // 869-897`) is now `legacyValidateResolvedConfig`'s `db.major_version` switch (called once, + // below) — an absent value falls through to the default (Go's zero-then-default) and a present + // one (including `0`) flows into `input.db.majorVersion` for that switch to check. const majorVersion = typeof majorVersionResolved === "number" ? majorVersionResolved : DEFAULT_MAJOR_VERSION; @@ -1602,25 +1168,10 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }), ); } - // Go's config.Validate rejects a present-but-invalid deno_version before pg-delta - // runs (`config.go:999-1008`): 0 → missing-required, anything other than 1/2 → - // invalid. An absent key falls through to the default (Go merges deno_version=2). - if (typeof denoVersionResolved === "number") { - if (denoVersionResolved === 0) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: "Missing required field in config: edge_runtime.deno_version", - }), - ); - } - if (denoVersionResolved !== 1 && denoVersionResolved !== 2) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `Failed reading config: Invalid edge_runtime.deno_version: ${denoVersionResolved}.`, - }), - ); - } - } + // Rejecting a present-but-invalid deno_version (`config.go:999-1008`: 0 → missing-required, + // anything other than 1/2 → invalid) is now `legacyValidateResolvedConfig`'s + // `edgeRuntimeDenoVersion` switch (called once, below). An absent key falls through to the + // default (Go merges deno_version=2). const denoVersion = typeof denoVersionResolved === "number" ? denoVersionResolved : DEFAULT_DENO_VERSION; @@ -1704,60 +1255,19 @@ const readDbTomlCore = Effect.fnUntraced(function* ( (typeof formatOptionsRaw === "string" ? formatOptionsRaw : ""), lookup, ); - // Go's config.Validate aborts config load when a non-empty format_options is not - // valid JSON (`apps/cli-go/pkg/config/config.go:1685-1686`), before any shadow / - // catalog container runs. Fail here with Go's exact message so the user gets the - // actionable error up front rather than a later `JSON.parse` failure in the script. - if (formatOptionsExpanded.length > 0 && !legacyIsValidJson(formatOptionsExpanded)) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: "Invalid config for experimental.pgdelta.format_options: must be valid JSON", - }), - ); - } + // Rejecting a non-empty, non-JSON `format_options` (`apps/cli-go/pkg/config/config.go: + // 1685-1686`) is now `legacyValidateResolvedConfig`'s `experimental.pgdeltaFormatOptions` + // check (called once, below). const formatOptions = nonEmptyString(formatOptionsExpanded); - // Go's config.Validate runs `ValidateBucketName` over every `[storage.buckets.*]` - // key on load (`apps/cli-go/pkg/config/config.go:898-903`), rejecting the config - // before any db command when a bucket name does not match `bucketNamePattern`. - // The reader otherwise drops `storage.buckets`, so port the check here with Go's - // exact message (the trailing `(%s)` is the regex source, `config.go:1386`). + // Bucket-name/function-slug validation (`config.go:898-903`/`993-998`) now lives in + // `legacyValidateResolvedConfig` (called once, below); only the pure extraction stays here. const bucketsRaw = asRecord(storageRaw?.["buckets"]); - if (bucketsRaw !== undefined) { - for (const name of Object.keys(bucketsRaw)) { - if (!LEGACY_BUCKET_NAME_PATTERN.test(name)) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `Invalid Bucket name: ${name}. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (${LEGACY_BUCKET_NAME_PATTERN.source})`, - }), - ); - } - } - } - // Go's config.Validate runs `ValidateFunctionSlug` over every `[functions.*]` key on - // load (`apps/cli-go/pkg/config/config.go:993-998`, immediately after the bucket loop), - // rejecting the config before any db command when a slug does not match - // `funcSlugPattern`. The reader otherwise drops `functions`, so port the check here - // with Go's exact message (the trailing `(%s)` is the regex source, `config.go:1376`). - if (functionsRaw !== undefined) { - for (const name of Object.keys(functionsRaw)) { - if (!LEGACY_FUNCTION_SLUG_PATTERN.test(name)) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `Invalid Function name: ${name}. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (${LEGACY_FUNCTION_SLUG_PATTERN.source})`, - }), - ); - } - } - } - - // Go's config.Validate runs the full `if c.Auth.Enabled` block (`config.go:1036-1102`) - // after the bucket/function checks — port its fatal validations so db/migration commands - // abort on an invalid auth config exactly like Go (e.g. an enabled passkey without a valid - // [auth.webauthn], or two third_party providers). Gated on `auth.enabled` (default true). - // Go's viper AutomaticEnv binds `auth.enabled` to `SUPABASE_AUTH_ENABLED` before Validate - // (`config.go:529-535`), so the env override decides whether the auth block is validated. + // Go's config.Validate runs the full `if c.Auth.Enabled` block (`config.go:1036-1102`) after + // the bucket/function checks. Gated on `auth.enabled` (default true); Go's viper AutomaticEnv + // binds `auth.enabled` to `SUPABASE_AUTH_ENABLED` before Validate (`config.go:529-535`), so the + // env override decides whether the auth block is validated. const authEnabled = yield* resolveBoolOrFail( "auth.enabled", authRaw?.["enabled"], @@ -1765,41 +1275,300 @@ const readDbTomlCore = Effect.fnUntraced(function* ( lookup, envOverride("SUPABASE_AUTH_ENABLED"), ); + + // Local helpers mirroring the deleted `legacyValidateAuthConfig`'s closures — its Go-parity + // CHECKS now live in `legacyValidateResolvedConfig`; `str`/`gate`/`fail` are still needed here + // to build that call's `LegacyAuthInput`, and by the D-only sms/external checks below (never + // part of the shared validator — see `legacy-config-validate.ts`'s module header). + const fail = (message: string) => Effect.fail(new LegacyDbConfigLoadError({ message })); + // Env-expanded string of `rec[key]` ("" when absent/non-string). An unresolved `env(VAR)` + // stays literal (non-empty), matching Go's LoadEnvHook + the Secret decode hook. + const str = (rec: RawDoc | undefined, key: string): string => { + const value = rec?.[key]; + return typeof value === "string" ? legacyExpandEnv(value, lookup) : ""; + }; + // Weak-bool decode (Go mapstructure): boolean | nonzero number | strconv.ParseBool string. A + // malformed string ABORTS the load like Go's decode (it does NOT coerce to false). Absent / + // non-string → false (the default for every auth enable-flag). + const gate = (rec: RawDoc | undefined, key: string, field: string) => + Effect.gen(function* () { + const value = rec?.[key]; + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value !== "string") return false; + const parsed = legacyParseGoBool(legacyExpandEnv(value, lookup)); + if (parsed === undefined) return yield* fail(`failed to parse config: invalid ${field}.`); + return parsed; + }); + + const authRawResolved = authRaw ?? {}; + let authInput: LegacyAuthInput | undefined; if (authEnabled) { - yield* legacyValidateAuthConfig(authRaw ?? {}, fs, path, workdir, lookup); + // A1: site_url required (`config.go:1037-1039`). + const siteUrl = + authRawResolved["site_url"] === undefined + ? DEFAULT_AUTH_SITE_URL + : str(authRawResolved, "site_url"); + + // A4: [auth.captcha]. The provider enum is a decode-time check (`CaptchaProvider. + // UnmarshalText`, `auth.go:58-71`); the required-field checks are `enabled`-gated + // (`config.go:1048-1058`) — both now live in `legacyValidateResolvedConfig`. + const captchaRaw = asRecord(authRawResolved["captcha"]); + let captchaInput: LegacyCaptchaInput | undefined; + if (captchaRaw !== undefined) { + const provider = str(captchaRaw, "provider"); + const secret = str(captchaRaw, "secret"); + captchaInput = { + enabled: yield* gate(captchaRaw, "enabled", "auth.captcha.enabled"), + // `str()` returns `""` for an absent key, but the shared validator's + // `provider === undefined` check needs a real `undefined` to fire correctly for an + // enabled captcha with no provider set. + provider: provider.length > 0 ? provider : undefined, + secret: secret.length > 0 ? secret : undefined, + }; + } + + // A5: signing keys file load (`config.go:1059-1065`) — I/O, stays in D. A relative path + // resolves under the supabase dir; absolute is verbatim. + const signingKeysPath = str(authRawResolved, "signing_keys_path"); + if (signingKeysPath.length > 0) { + const keysJson = yield* fs + .readFileString(legacyResolveSigningKeysPath(workdir, signingKeysPath)) + .pipe( + Effect.mapError( + (cause) => + new LegacyDbConfigLoadError({ message: legacySigningKeysReadErrorMessage(cause) }), + ), + ); + yield* Effect.try({ + try: () => { + const parsed: unknown = JSON.parse(keysJson); + if (!Array.isArray(parsed)) { + throw new Error("signing keys must be a JSON array of JWKs"); + } + return parsed; + }, + catch: (cause) => + new LegacyDbConfigLoadError({ message: legacySigningKeysDecodeErrorMessage(cause) }), + }); + } + + // A6: passkey/webauthn when passkey enabled (`config.go:1066-1084`). + const passkeyRaw = asRecord(authRawResolved["passkey"]); + let passkeyInput: LegacyPasskeyInput | undefined; + if (passkeyRaw !== undefined && (yield* gate(passkeyRaw, "enabled", "auth.passkey.enabled"))) { + const webauthnRaw = asRecord(authRawResolved["webauthn"]); + const rpOrigins = webauthnRaw?.["rp_origins"]; + passkeyInput = { + webauthnPresent: webauthnRaw !== undefined, + rpId: str(webauthnRaw, "rp_id"), + rpOrigins: Array.isArray(rpOrigins) ? rpOrigins : undefined, + }; + } + + // B1: hooks — each enabled hook, Go's fixed iteration order (`config.go:1402-1470`). + const hookRaw = asRecord(authRawResolved["hook"]); + const hookTypes = [ + "mfa_verification_attempt", + "password_verification_attempt", + "custom_access_token", + "send_sms", + "send_email", + "before_user_created", + ] as const; + const hooks: Array = []; + for (const hookType of hookTypes) { + const h = asRecord(hookRaw?.[hookType]); + if (h !== undefined && (yield* gate(h, "enabled", `auth.hook.${hookType}.enabled`))) { + hooks.push({ type: hookType, uri: str(h, "uri"), secrets: str(h, "secrets") }); + } + } + + // B2: mfa — enroll requires verify (`config.go:1472-1483`), fixed totp/phone/web_authn order. + const mfaRaw = asRecord(authRawResolved["mfa"]); + const mfa: Array = []; + for (const label of ["totp", "phone", "web_authn"] as const) { + const factor = asRecord(mfaRaw?.[label]); + mfa.push({ + label, + enrollEnabled: yield* gate(factor, "enroll_enabled", `auth.mfa.${label}.enroll_enabled`), + verifyEnabled: yield* gate(factor, "verify_enabled", `auth.mfa.${label}.verify_enabled`), + }); + } + + // B3: email (`config.go:1242-1295`) — template/notification content is I/O, stays in D. Go + // resolves a relative `content_path` differently per section: TEMPLATE paths are relative to + // the PROJECT ROOT (`config.go:854-856`, the `// FIXME` there), NOTIFICATION paths are + // relative to the supabase dir (`config.go:861-862`); absolute → as-is. + const emailRaw = asRecord(authRawResolved["email"]); + const templatesRaw = asRecord(emailRaw?.["template"]); + if (templatesRaw !== undefined) { + for (const name of Object.keys(templatesRaw)) { + const tmpl = asRecord(templatesRaw[name]); + if (tmpl === undefined) continue; + const contentPath = yield* Effect.try({ + try: () => + legacyResolveEmailTemplateContentPath({ + section: "template", + name, + contentPath: str(tmpl, "content_path"), + contentPresent: tmpl["content"] !== undefined, + base: workdir, + }), + catch: (cause) => + new LegacyDbConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + if (contentPath === undefined) continue; + yield* fs.readFileString(contentPath).pipe( + Effect.mapError( + (cause) => + new LegacyDbConfigLoadError({ + message: legacyEmailContentPathReadErrorMessage("template", name, cause), + }), + ), + ); + } + } + const notificationsRaw = asRecord(emailRaw?.["notification"]); + if (notificationsRaw !== undefined) { + for (const name of Object.keys(notificationsRaw)) { + const tmpl = asRecord(notificationsRaw[name]); + if ( + tmpl === undefined || + !(yield* gate(tmpl, "enabled", `auth.email.notification.${name}.enabled`)) + ) { + continue; + } + const contentPath = yield* Effect.try({ + try: () => + legacyResolveEmailTemplateContentPath({ + section: "notification", + name, + contentPath: str(tmpl, "content_path"), + contentPresent: tmpl["content"] !== undefined, + base: supabaseDir, + }), + catch: (cause) => + new LegacyDbConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + if (contentPath === undefined) continue; + yield* fs.readFileString(contentPath).pipe( + Effect.mapError( + (cause) => + new LegacyDbConfigLoadError({ + message: legacyEmailContentPathReadErrorMessage("notification", name, cause), + }), + ), + ); + } + } + // Go defaults `auth.email.smtp.enabled = true` when the `[auth.email.smtp]` table is present + // but omits `enabled` (`config.go:692-696`), so a present table validates unless explicitly + // disabled. + const smtpRaw = asRecord(emailRaw?.["smtp"]); + let smtpInput: LegacySmtpInput | undefined; + if (smtpRaw !== undefined) { + const smtpPortRaw = smtpRaw["port"]; + // The shared validator's required-field check is `port === 0` (Go decodes `port` into a + // numeric type at the config-decode step, so it can never observe a non-numeric value here). + // D reads the raw TOML/env string directly, so a non-numeric `port` (or an unresolved + // `env(VAR)`) parses to `NaN` via `Number(...)` — normalize that to `0` so it still trips + // the "missing required field" check instead of silently passing config load. + const smtpPortNumeric = + typeof smtpPortRaw === "number" + ? smtpPortRaw + : typeof smtpPortRaw === "string" + ? Number(legacyExpandEnv(smtpPortRaw, lookup)) + : 0; + smtpInput = { + enabled: + smtpRaw["enabled"] === undefined + ? true + : yield* gate(smtpRaw, "enabled", "auth.email.smtp.enabled"), + host: str(smtpRaw, "host"), + port: Number.isNaN(smtpPortNumeric) ? 0 : smtpPortNumeric, + user: str(smtpRaw, "user"), + pass: str(smtpRaw, "pass"), + adminEmail: str(smtpRaw, "admin_email"), + }; + } + + // B6: third_party — each enabled provider, Go's fixed order (`config.go:1584-1632`). Note + // `aws_cognito`'s messages say `cognito` (Go's wording). + const thirdPartyRaw = asRecord(authRawResolved["third_party"]); + const thirdParty: Array = []; + const firebaseRaw = asRecord(thirdPartyRaw?.["firebase"]); + if ( + firebaseRaw !== undefined && + (yield* gate(firebaseRaw, "enabled", "auth.third_party.firebase.enabled")) + ) { + thirdParty.push({ provider: "firebase", requiredField: str(firebaseRaw, "project_id") }); + } + const auth0Raw = asRecord(thirdPartyRaw?.["auth0"]); + if ( + auth0Raw !== undefined && + (yield* gate(auth0Raw, "enabled", "auth.third_party.auth0.enabled")) + ) { + thirdParty.push({ provider: "auth0", requiredField: str(auth0Raw, "tenant") }); + } + const cognitoRaw = asRecord(thirdPartyRaw?.["aws_cognito"]); + if ( + cognitoRaw !== undefined && + (yield* gate(cognitoRaw, "enabled", "auth.third_party.aws_cognito.enabled")) + ) { + thirdParty.push({ + provider: "cognito", + requiredField: str(cognitoRaw, "user_pool_id"), + cognitoUserPoolRegion: str(cognitoRaw, "user_pool_region"), + }); + } + const clerkRaw = asRecord(thirdPartyRaw?.["clerk"]); + if ( + clerkRaw !== undefined && + (yield* gate(clerkRaw, "enabled", "auth.third_party.clerk.enabled")) + ) { + thirdParty.push({ provider: "clerk", requiredField: str(clerkRaw, "domain") }); + } + const workosRaw = asRecord(thirdPartyRaw?.["workos"]); + if ( + workosRaw !== undefined && + (yield* gate(workosRaw, "enabled", "auth.third_party.workos.enabled")) + ) { + thirdParty.push({ provider: "workos", requiredField: str(workosRaw, "issuer_url") }); + } + + authInput = { + siteUrl, + captcha: captchaInput, + passkey: passkeyInput, + hooks, + mfa, + smtp: smtpInput, + thirdParty, + }; } // Go's config.Validate validates `[analytics]` after the auth block (`config.go:1123-1135`). - // Two fatal checks run on the db/migration path: - // 1. `LogflareBackend.UnmarshalText` (`config.go:60-66`) is a decode-time enum that rejects - // any `backend` other than `postgres`/`bigquery` — regardless of `enabled` (it fires - // during UnmarshalExact, like the captcha provider enum), so it gates here too. - // 2. When analytics is enabled with the BigQuery backend, the three GCP fields are required - // (`config.go:1124-1134`), in order, with byte-exact messages. - // Go merges the template defaults `enabled = true`, `backend = "postgres"` before Validate - // (`templates/config.toml:388-392`), so an absent `[analytics]` section is enabled+postgres and - // passes (an empty backend never equals `bigquery`, so the GCP block is skipped). viper - // AutomaticEnv binds `SUPABASE_ANALYTICS_*`; a matched remote block makes those keys env-immune, - // same as every other `LEGACY_ENV_OVERRIDABLE_KEYS` field above. + // Computed here (after the auth block, before the single shared call) rather than pure-derived + // earlier: `analyticsEnabled` resolves through `resolveBoolOrFail`, which can itself FAIL on a + // malformed `SUPABASE_ANALYTICS_ENABLED`/`analytics.enabled` bool — positioning that failure + // point ahead of the auth block would report it before an auth-block error even for a config + // that's ALSO broken there, reversing Go's real order. Go merges the template defaults + // `enabled = true`, `backend = "postgres"` before Validate (`templates/config.toml:388-392`), so + // an absent `[analytics]` section is enabled+postgres and passes (an empty backend never equals + // `bigquery`, so the GCP block is skipped). viper AutomaticEnv binds `SUPABASE_ANALYTICS_*`; a + // matched remote block makes those keys env-immune, same as every other + // `LEGACY_ENV_OVERRIDABLE_KEYS` field above. const analyticsString = (key: string, envName: string): string => { const fromEnv = remoteOverrideKeys.has(`analytics.${key}`) ? undefined : envOverride(envName); const raw = fromEnv ?? analyticsRaw?.[key]; return typeof raw === "string" ? legacyExpandEnv(raw, lookup) : ""; }; const analyticsBackend = analyticsString("backend", "SUPABASE_ANALYTICS_BACKEND"); - if ( - analyticsBackend.length > 0 && - analyticsBackend !== "postgres" && - analyticsBackend !== "bigquery" - ) { - // Mirror the captcha enum's mapstructure envelope (`%v` of the allowed `[]LogflareBackend`). - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: - "failed to parse config: decoding failed due to the following error(s):\n\n'analytics.backend' must be one of [postgres bigquery]", - }), - ); - } const analyticsEnabled = yield* resolveBoolOrFail( "analytics.enabled", analyticsRaw?.["enabled"], @@ -1809,32 +1578,128 @@ const readDbTomlCore = Effect.fnUntraced(function* ( ? undefined : envOverride("SUPABASE_ANALYTICS_ENABLED"), ); - if (analyticsEnabled && analyticsBackend === "bigquery") { - // Each GCP value is env-expanded (Go's LoadEnvHook), so an unresolved `env(VAR)` stays - // non-empty and passes the `len(...) == 0` check, exactly like Go. - if (analyticsString("gcp_project_id", "SUPABASE_ANALYTICS_GCP_PROJECT_ID").length === 0) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: "Missing required field in config: analytics.gcp_project_id", - }), + // Each GCP value is env-expanded (Go's LoadEnvHook), so an unresolved `env(VAR)` stays + // non-empty and passes the shared validator's `length === 0` check, exactly like Go. + const gcpProjectId = analyticsString("gcp_project_id", "SUPABASE_ANALYTICS_GCP_PROJECT_ID"); + const gcpProjectNumber = analyticsString( + "gcp_project_number", + "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", + ); + const gcpJwtPath = analyticsString("gcp_jwt_path", "SUPABASE_ANALYTICS_GCP_JWT_PATH"); + + // Every PURE Config.Validate check this module/legacy-config-validate.ts jointly own (db.port + // is checked earlier, above, and stays there — see the comment at that check) is deferred to + // this single call, in Go's exact relative order. D's sms/external checks (D-only, never part + // of the shared validator) run AFTER this call succeeds, still gated on `authEnabled` — see + // `legacy-config-validate.ts`'s module header for the accepted ordering tradeoff this + // introduces against third_party. + const dbInput: LegacyDbInput = { port, majorVersion }; + const analyticsInput: LegacyAnalyticsInput = { + enabled: analyticsEnabled, + backend: analyticsBackend.length > 0 ? analyticsBackend : undefined, + gcpProjectId, + gcpProjectNumber, + gcpJwtPath, + }; + const experimentalInput: LegacyExperimentalInput = { + pgdeltaFormatOptions: formatOptionsExpanded, + }; + const validationInput: LegacyConfigValidationInput = { + db: dbInput, + storageBucketNames: bucketsRaw !== undefined ? Object.keys(bucketsRaw) : [], + functionSlugs: functionsRaw !== undefined ? Object.keys(functionsRaw) : [], + auth: authInput, + edgeRuntimeDenoVersion: denoVersion, + analytics: analyticsInput, + experimental: experimentalInput, + }; + yield* Effect.try({ + try: () => legacyValidateResolvedConfig(validationInput), + catch: (cause) => + new LegacyDbConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + + if (authEnabled) { + // B4: sms — D-only (`config.go:1145-1147`/`1348-1417`, never part of the shared validator, + // see `legacy-config-validate.ts`'s module header). Only the FIRST enabled provider is + // validated (Go's switch). + const sms = asRecord(authRawResolved["sms"]); + if (sms !== undefined) { + const twilio = asRecord(sms["twilio"]); + const twilioVerify = asRecord(sms["twilio_verify"]); + const messagebird = asRecord(sms["messagebird"]); + const textlocal = asRecord(sms["textlocal"]); + const vonage = asRecord(sms["vonage"]); + const twilioEnabled = yield* gate(twilio, "enabled", "auth.sms.twilio.enabled"); + const twilioVerifyEnabled = yield* gate( + twilioVerify, + "enabled", + "auth.sms.twilio_verify.enabled", ); - } - if ( - analyticsString("gcp_project_number", "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER").length === 0 - ) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: "Missing required field in config: analytics.gcp_project_number", - }), + const messagebirdEnabled = yield* gate( + messagebird, + "enabled", + "auth.sms.messagebird.enabled", ); + const textlocalEnabled = yield* gate(textlocal, "enabled", "auth.sms.textlocal.enabled"); + const vonageEnabled = yield* gate(vonage, "enabled", "auth.sms.vonage.enabled"); + if (twilioEnabled) { + if (str(twilio, "account_sid").length === 0) + return yield* fail("Missing required field in config: auth.sms.twilio.account_sid"); + if (str(twilio, "message_service_sid").length === 0) + return yield* fail( + "Missing required field in config: auth.sms.twilio.message_service_sid", + ); + if (str(twilio, "auth_token").length === 0) + return yield* fail("Missing required field in config: auth.sms.twilio.auth_token"); + } else if (twilioVerifyEnabled) { + if (str(twilioVerify, "account_sid").length === 0) + return yield* fail( + "Missing required field in config: auth.sms.twilio_verify.account_sid", + ); + if (str(twilioVerify, "message_service_sid").length === 0) + return yield* fail( + "Missing required field in config: auth.sms.twilio_verify.message_service_sid", + ); + if (str(twilioVerify, "auth_token").length === 0) + return yield* fail("Missing required field in config: auth.sms.twilio_verify.auth_token"); + } else if (messagebirdEnabled) { + if (str(messagebird, "originator").length === 0) + return yield* fail("Missing required field in config: auth.sms.messagebird.originator"); + if (str(messagebird, "access_key").length === 0) + return yield* fail("Missing required field in config: auth.sms.messagebird.access_key"); + } else if (textlocalEnabled) { + if (str(textlocal, "sender").length === 0) + return yield* fail("Missing required field in config: auth.sms.textlocal.sender"); + if (str(textlocal, "api_key").length === 0) + return yield* fail("Missing required field in config: auth.sms.textlocal.api_key"); + } else if (vonageEnabled) { + if (str(vonage, "from").length === 0) + return yield* fail("Missing required field in config: auth.sms.vonage.from"); + if (str(vonage, "api_key").length === 0) + return yield* fail("Missing required field in config: auth.sms.vonage.api_key"); + if (str(vonage, "api_secret").length === 0) + return yield* fail("Missing required field in config: auth.sms.vonage.api_secret"); + } } - if (analyticsString("gcp_jwt_path", "SUPABASE_ANALYTICS_GCP_JWT_PATH").length === 0) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: - "Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path", - }), - ); + + // B5: external providers — D-only (`config.go:1148-1150`/`1368-1398`, never part of the + // shared validator). linkedin/slack are deprecated and deleted before validation, so they are + // never validated here. + const external = asRecord(authRawResolved["external"]); + if (external !== undefined) { + for (const name of Object.keys(external)) { + if (name === "linkedin" || name === "slack") continue; + const provider = asRecord(external[name]); + if (provider === undefined) continue; + if (!(yield* gate(provider, "enabled", `auth.external.${name}.enabled`))) continue; + if (str(provider, "client_id").length === 0) + return yield* fail(`Missing required field in config: auth.external.${name}.client_id`); + if (name !== "apple" && name !== "google" && str(provider, "secret").length === 0) + return yield* fail(`Missing required field in config: auth.external.${name}.secret`); + } } } diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts index 3fd179eaf8..673fd52d41 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts @@ -1530,6 +1530,28 @@ describe("legacyReadDbToml", () => { ); }); + it.effect("rejects db.major_version = 0 with Go's missing-required message", () => { + // Divergence #1 fix: this used to fall through to the generic + // "Failed reading config: Invalid db.major_version: 0." message (the same branch that + // catches an unsupported value like 16) — `legacyValidateResolvedConfig`'s dedicated `0` case + // now matches Go's `Missing required field in config: db.major_version`. + const dir = withConfig(["[db]", "major_version = 0", ""].join("\n")); + return read(dir).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "Missing required field in config: db.major_version", + ); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + it.effect("rejects db.major_version = 12 with Go's 12.x message", () => { const dir = withConfig(["[db]", "major_version = 12", ""].join("\n")); return read(dir).pipe( diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.ts index 41a5e74b14..93ac55723b 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.ts @@ -32,15 +32,30 @@ function truncateText(text: string, maxLength: number) { return text.length > maxLength ? text.slice(0, maxLength) : text; } -/** Go's `GetId` sanitisation: replace invalid runs with `_`, strip leading - * `_.-`, and cap at 40 chars. */ -function sanitizeProjectId(src: string) { +/** + * Go's `GetId` sanitisation: replace invalid runs with `_`, strip leading + * `_.-`, and cap at 40 chars. + * + * Exported because it is not only a container-*naming* concern: Go's + * `Config.Validate` (`pkg/config/config.go:938-944`) rewrites `c.ProjectId` + * to this same sanitized form **in place, once, at config-load time** (every + * `flags.LoadConfig` call ends in `Load` -> `Validate`), and every later use + * of `Config.ProjectId` — including the Docker LABEL value written by `start` + * (`internal/utils/docker.go:375`: `config.Labels[CliProjectLabel] = + * Config.ProjectId`) — reads that already-sanitized singleton. `GetId` itself + * performs no sanitisation of its own; it just reads the pre-sanitized value. + * So on the config/env-derived (non-`--project-id`) path, callers building a + * Docker label FILTER must sanitize too, or a `project_id` like `"my app"` + * filters on the raw string while `start` labeled the sanitized one and never + * matches anything (see `legacyCliProjectFilterValue`'s doc comment). + */ +export function legacySanitizeProjectId(src: string) { const sanitized = src.replaceAll(INVALID_PROJECT_ID, "_").replace(/^[_.-]+/, ""); return truncateText(sanitized, MAX_PROJECT_ID_LENGTH); } function localDockerId(name: string, projectId: string) { - return `supabase_${name}_${sanitizeProjectId(projectId)}`; + return `supabase_${name}_${legacySanitizeProjectId(projectId)}`; } /** `utils.DbId` — the local Postgres container name. */ @@ -52,3 +67,57 @@ export function localDbContainerId(projectId: string) { export function localNetworkId(projectId: string) { return localDockerId("network", projectId); } + +/** Go's `utils.CliProjectLabel` (`apps/cli-go/internal/utils/docker.go:59`) — the + * Docker label every container/volume/network created by `supabase start` carries. */ +export const LEGACY_CLI_PROJECT_LABEL = "com.supabase.cli.project"; + +/** + * Go's `utils.GetDockerIds()` (`apps/cli-go/internal/utils/config.go:82-98`) — the + * 13 service container ids (excludes `db`, `network`, and the `differ` shadow + * container, which are not part of the "expected running services" set). Order and + * alias-name strings are taken verbatim from `config.go:36-49,61-79`. + */ +export function legacyServiceContainerIds(projectId: string): ReadonlyArray { + return [ + localDockerId("kong", projectId), + localDockerId("auth", projectId), + localDockerId("inbucket", projectId), + localDockerId("realtime", projectId), + localDockerId("rest", projectId), + localDockerId("storage", projectId), + localDockerId("imgproxy", projectId), + localDockerId("pg_meta", projectId), + localDockerId("studio", projectId), + localDockerId("edge_runtime", projectId), + localDockerId("analytics", projectId), + localDockerId("vector", projectId), + localDockerId("pooler", projectId), + ]; +} + +/** + * Go's `utils.CliProjectFilter` (`apps/cli-go/internal/utils/docker.go:148-156`) — + * the value that follows `--filter label=` on the `docker`/`podman` CLI. An empty + * `projectId` (Go's `--all` path) filters on the bare label across every project. + * + * This function itself does not sanitize — by design, it's a pure pass-through. + * The caller is responsible for sanitizing `projectId` with + * {@link legacySanitizeProjectId} on the config/env-derived (default) path + * BEFORE calling this, matching Go's `Config.Validate` sanitizing the + * `Config.ProjectId` singleton once at config-load time so every later + * reader — including the Docker LABEL `start` writes — sees the same + * sanitized string. An explicit `--project-id ` (where one exists, + * e.g. `stop`) is Go's one exception: it assigns straight to + * `Config.ProjectId` without going through `Validate` + * (`apps/cli-go/internal/stop/stop.go:19-20`), so that path must stay raw/ + * unsanitized to match. There is also no injection risk either way: this + * value is always passed as a single argv element to a spawned process + * (never through a shell), so a malformed value can only make Docker's own + * filter parsing reject it or match nothing — it cannot break out into + * another command. + */ +export function legacyCliProjectFilterValue(projectId: string): string { + if (projectId.length === 0) return LEGACY_CLI_PROJECT_LABEL; + return `${LEGACY_CLI_PROJECT_LABEL}=${projectId}`; +} diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts index ff967f18b8..9c07805652 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; -import { legacyResolveLocalProjectId, localDbContainerId } from "./legacy-docker-ids.ts"; +import { + LEGACY_CLI_PROJECT_LABEL, + legacyCliProjectFilterValue, + legacyResolveLocalProjectId, + legacySanitizeProjectId, + legacyServiceContainerIds, + localDbContainerId, +} from "./legacy-docker-ids.ts"; describe("legacyResolveLocalProjectId", () => { it("prefers SUPABASE_PROJECT_ID (env) over config.toml and the basename", () => { @@ -23,3 +30,72 @@ describe("legacyResolveLocalProjectId", () => { expect(localDbContainerId(id)).toBe("supabase_db_env-id"); }); }); + +describe("legacyServiceContainerIds", () => { + it("returns the 13 service container ids in Go's GetDockerIds() order", () => { + // apps/cli-go/internal/utils/config.go:82-98 — kong, auth, inbucket, realtime, + // rest, storage, imgproxy, pg_meta, studio, edge_runtime, analytics, vector, pooler. + expect(legacyServiceContainerIds("my-app")).toEqual([ + "supabase_kong_my-app", + "supabase_auth_my-app", + "supabase_inbucket_my-app", + "supabase_realtime_my-app", + "supabase_rest_my-app", + "supabase_storage_my-app", + "supabase_imgproxy_my-app", + "supabase_pg_meta_my-app", + "supabase_studio_my-app", + "supabase_edge_runtime_my-app", + "supabase_analytics_my-app", + "supabase_vector_my-app", + "supabase_pooler_my-app", + ]); + }); + + it("sanitizes the project id the same way as localDbContainerId", () => { + const ids = legacyServiceContainerIds("My App!!"); + expect(ids[0]).toBe("supabase_kong_My_App_"); + }); +}); + +describe("legacyCliProjectFilterValue", () => { + it("returns the bare label when the project id is empty (Go's --all path)", () => { + expect(legacyCliProjectFilterValue("")).toBe(LEGACY_CLI_PROJECT_LABEL); + }); + + it("returns label=projectId when a project id is given", () => { + expect(legacyCliProjectFilterValue("my-app")).toBe(`${LEGACY_CLI_PROJECT_LABEL}=my-app`); + }); + + it("must be sanitized by the caller for the label to match what start wrote", () => { + // This function is a pure pass-through by design (see its doc comment) — a + // dirty config/env-derived id must be sanitized by the caller BEFORE being + // passed here, matching Go's Config.Validate sanitizing Config.ProjectId + // once at config-load time so every reader (including the Docker label + // `start` writes) sees the same string. + const dirty = "My App!!"; + expect(legacyCliProjectFilterValue(dirty)).toBe(`${LEGACY_CLI_PROJECT_LABEL}=My App!!`); + expect(legacyCliProjectFilterValue(legacySanitizeProjectId(dirty))).toBe( + `${LEGACY_CLI_PROJECT_LABEL}=My_App_`, + ); + }); +}); + +describe("legacySanitizeProjectId", () => { + it("replaces invalid character runs with a single underscore", () => { + expect(legacySanitizeProjectId("My App!!")).toBe("My_App_"); + }); + + it("strips leading underscore/dot/dash runs", () => { + expect(legacySanitizeProjectId("...hidden-app")).toBe("hidden-app"); + }); + + it("caps the result at 40 characters", () => { + const long = "a".repeat(50); + expect(legacySanitizeProjectId(long)).toBe("a".repeat(40)); + }); + + it("leaves an already-clean id unchanged", () => { + expect(legacySanitizeProjectId("my-app_123")).toBe("my-app_123"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts new file mode 100644 index 0000000000..f8884a700a --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts @@ -0,0 +1,259 @@ +import { Data, Effect, Stream } from "effect"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { legacyDescribeContainerCliFailure, spawnContainerCli } from "./legacy-container-cli.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +/** + * Listing containers or volumes by Docker label failed. Wraps Go's + * `Docker.ContainerList`/`Docker.VolumeList` errors (`docker.go:99-104`, + * `docker.go:334-336` — see `checkServiceHealth`/`DockerRemoveAll`), which Go + * wraps as `"failed to list containers: %w"` / equivalent. + */ +export class LegacyDockerLifecycleListError extends Data.TaggedError( + "LegacyDockerLifecycleListError", +)<{ + readonly message: string; +}> {} + +/** Inspecting a single container's state failed for a reason other than "not found". */ +export class LegacyDockerLifecycleInspectError extends Data.TaggedError( + "LegacyDockerLifecycleInspectError", +)<{ + readonly message: string; +}> {} + +function collectByteStream(stream: Stream.Stream) { + const decoder = new TextDecoder(); + return Stream.runFold( + stream, + () => "", + (text, chunk) => text + decoder.decode(chunk, { stream: true }), + ).pipe(Effect.map((text) => text + decoder.decode())); +} + +function splitNonEmptyLines(text: string): ReadonlyArray { + return text + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +/** + * Go's `Docker.ContainerList(ctx, container.ListOptions{All, Filters})` + * (`docker.go:99-104`, `status.go:126-131`) via `docker ps --filter + * label=`. `all: false` mirrors `status`'s running-only list; + * `all: true` mirrors `stop`'s "every container regardless of state" list. + */ +export const legacyListContainersByLabel = ( + spawner: Spawner, + opts: { + readonly projectIdFilter: string; + readonly all: boolean; + readonly format: "id" | "names"; + }, +) => + Effect.scoped( + Effect.gen(function* () { + const formatArg = opts.format === "names" ? "{{.Names}}" : "{{.ID}}"; + const args = [ + "ps", + "--filter", + `label=${opts.projectIdFilter}`, + ...(opts.all ? ["--all"] : []), + "--format", + formatArg, + ]; + const child = yield* spawnContainerCli(spawner, args, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }).pipe( + Effect.mapError( + (cause) => + new LegacyDockerLifecycleListError({ + message: `failed to list containers: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + // Concurrency is required, not cosmetic: sequential `Effect.all` would + // await `exitCode` (resolved by Node's "exit" event) before subscribing + // to `stdout`/`stderr` at all. Node's "exit" can fire before a fast + // process's stdio pipes are drained, so a late subscriber sees an + // already-ended, empty stream instead of the buffered bytes. + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + collectByteStream(child.stdout), + collectByteStream(child.stderr), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + () => new LegacyDockerLifecycleListError({ message: "failed to list containers" }), + ), + ); + if (exitCode !== 0) { + const message = stderr.trim(); + return yield* Effect.fail( + new LegacyDockerLifecycleListError({ + message: + message.length > 0 + ? `failed to list containers: ${message}` + : "failed to list containers", + }), + ); + } + return splitNonEmptyLines(stdout); + }), + ); + +/** + * Go's `Docker.ContainerInspect(ctx, containerId)` (`docker.go:148`, + * `status.go:148-155`) via `docker container inspect --format + * {{json .State}}`. Go's `assertContainerHealthy` does not special-case a + * missing container — it wraps whatever error `ContainerInspect` returns + * (`status.go:148-149`), so every non-zero exit, including "no such + * container", propagates as `LegacyDockerLifecycleInspectError` carrying the + * real Docker stderr text. + */ +export const legacyInspectContainerState = (spawner: Spawner, containerId: string) => + Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli( + spawner, + ["container", "inspect", containerId, "--format", "{{json .State}}"], + { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }, + ).pipe( + Effect.mapError( + (cause) => + new LegacyDockerLifecycleInspectError({ + message: `failed to inspect container health: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + // Concurrency is required, not cosmetic — see the matching comment in + // `legacyListContainersByLabel` above. + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + collectByteStream(child.stdout), + collectByteStream(child.stderr), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + () => + new LegacyDockerLifecycleInspectError({ + message: "failed to inspect container health", + }), + ), + ); + if (exitCode !== 0) { + const message = stderr.trim(); + return yield* Effect.fail( + new LegacyDockerLifecycleInspectError({ + message: + message.length > 0 + ? `failed to inspect container health: ${message}` + : "failed to inspect container health", + }), + ); + } + return parseContainerState(stdout); + }), + ); + +function parseContainerState(stdout: string): { + readonly running: boolean; + readonly status: string; + readonly health?: string; +} { + const trimmed = stdout.trim(); + let parsed: unknown; + try { + parsed = trimmed.length > 0 ? JSON.parse(trimmed) : {}; + } catch { + parsed = {}; + } + const state = isJsonRecord(parsed) ? parsed : {}; + // Go's `assertContainerHealthy` (`internal/status/status.go:147-156`) gates + // on the boolean `resp.State.Running`, not the status string — Docker's + // inspect `State` struct exposes both independently, and a paused or + // restarting container reports `Running: true` alongside a non-"running" + // `Status` (`"paused"`/`"restarting"`). `status` is kept as-is for the + // "container is not running: " message text (`status.go:151`), + // which still reads the string, but the gate itself must read the boolean. + const status = typeof state["Status"] === "string" ? state["Status"] : ""; + const running = state["Running"] === true; + const health = state["Health"]; + const healthStatus = + isJsonRecord(health) && typeof health["Status"] === "string" ? health["Status"] : undefined; + return healthStatus !== undefined + ? { running, status, health: healthStatus } + : { running, status }; +} + +function isJsonRecord(value: unknown): value is { readonly [key: string]: unknown } { + return typeof value === "object" && value !== null; +} + +/** + * Go's `Docker.VolumeList(ctx, volume.ListOptions{Filters})` + * (`docker.go` — used by the `stop` post-run volume-suggestion check) via + * `docker volume ls --filter label=`. + */ +export const legacyListVolumesByLabel = (spawner: Spawner, projectIdFilter: string) => + Effect.scoped( + Effect.gen(function* () { + const args = [ + "volume", + "ls", + "--filter", + `label=${projectIdFilter}`, + "--format", + "{{.Name}}", + ]; + const child = yield* spawnContainerCli(spawner, args, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }).pipe( + Effect.mapError( + (cause) => + new LegacyDockerLifecycleListError({ + message: `failed to list volumes: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + // Concurrency is required, not cosmetic — see the matching comment in + // `legacyListContainersByLabel` above. + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + collectByteStream(child.stdout), + collectByteStream(child.stderr), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + () => new LegacyDockerLifecycleListError({ message: "failed to list volumes" }), + ), + ); + if (exitCode !== 0) { + const message = stderr.trim(); + return yield* Effect.fail( + new LegacyDockerLifecycleListError({ + message: + message.length > 0 ? `failed to list volumes: ${message}` : "failed to list volumes", + }), + ); + } + return splitNonEmptyLines(stdout); + }), + ); diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts new file mode 100644 index 0000000000..0ecc41b372 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts @@ -0,0 +1,344 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + LegacyDockerLifecycleInspectError, + LegacyDockerLifecycleListError, + legacyInspectContainerState, + legacyListContainersByLabel, + legacyListVolumesByLabel, +} from "./legacy-docker-lifecycle.ts"; + +function mockSpawner( + opts: { + readonly exitCode?: number; + readonly stdout?: string; + readonly stderr?: string; + } = {}, +) { + const encoder = new TextEncoder(); + const spawned: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const cmd = command._tag === "StandardCommand" ? command.command : ""; + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ command: cmd, args }); + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(opts.exitCode ?? 0)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.fromIterable(opts.stdout !== undefined ? [encoder.encode(opts.stdout)] : []), + stderr: Stream.fromIterable(opts.stderr !== undefined ? [encoder.encode(opts.stderr)] : []), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + return { + spawner, + get spawned() { + return spawned; + }, + }; +} + +describe("legacyListContainersByLabel", () => { + it.live("returns container ids for a successful listing", () => { + const mock = mockSpawner({ stdout: "abc123\ndef456\n" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project=my-app", + all: false, + format: "id", + }).pipe( + Effect.map((ids) => { + expect(ids).toEqual(["abc123", "def456"]); + expect(mock.spawned).toEqual([ + { + command: "docker", + args: [ + "ps", + "--filter", + "label=com.supabase.cli.project=my-app", + "--format", + "{{.ID}}", + ], + }, + ]); + }), + ); + }); + + it.live("passes --all and requests names when configured", () => { + const mock = mockSpawner({ stdout: "supabase_db_my-app\n" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: true, + format: "names", + }).pipe( + Effect.map((names) => { + expect(names).toEqual(["supabase_db_my-app"]); + expect(mock.spawned).toEqual([ + { + command: "docker", + args: [ + "ps", + "--filter", + "label=com.supabase.cli.project", + "--all", + "--format", + "{{.Names}}", + ], + }, + ]); + }), + ); + }); + + it.live("returns an empty array when no containers match", () => { + const mock = mockSpawner({ stdout: "" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: true, + format: "id", + }).pipe( + Effect.map((ids) => { + expect(ids).toEqual([]); + }), + ); + }); + + it.live("filters out blank lines from the trimmed output", () => { + const mock = mockSpawner({ stdout: "abc123\n\n \ndef456\n" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: false, + format: "id", + }).pipe( + Effect.map((ids) => { + expect(ids).toEqual(["abc123", "def456"]); + }), + ); + }); + + it.live("fails with LegacyDockerLifecycleListError on a non-zero exit", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "Cannot connect to the Docker daemon\n" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: false, + format: "id", + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleListError); + expect(error.message).toBe( + "failed to list containers: Cannot connect to the Docker daemon", + ); + }), + ); + }); + + it.live("fails with a generic message when stderr is empty", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: false, + format: "id", + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleListError); + expect(error.message).toBe("failed to list containers"); + }), + ); + }); +}); + +describe("legacyInspectContainerState", () => { + it.live("parses a running, healthy container's state", () => { + const mock = mockSpawner({ + stdout: JSON.stringify({ + Status: "running", + Running: true, + Health: { Status: "healthy" }, + }), + }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: true, status: "running", health: "healthy" }); + expect(mock.spawned).toEqual([ + { + command: "docker", + args: ["container", "inspect", "supabase_db_my-app", "--format", "{{json .State}}"], + }, + ]); + }), + ); + }); + + it.live("parses a running container with no health check configured", () => { + const mock = mockSpawner({ stdout: JSON.stringify({ Status: "running", Running: true }) }); + return legacyInspectContainerState(mock.spawner, "supabase_kong_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: true, status: "running" }); + }), + ); + }); + + it.live("parses a stopped/exited container", () => { + const mock = mockSpawner({ stdout: JSON.stringify({ Status: "exited", Running: false }) }); + return legacyInspectContainerState(mock.spawner, "supabase_kong_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: false, status: "exited" }); + }), + ); + }); + + it.live( + "treats a paused/restarting container as running, matching Go's boolean-based gate", + () => { + // Go's `assertContainerHealthy` (`status.go:150`) checks `resp.State.Running`, + // not `resp.State.Status` — a paused or restarting container reports + // `Running: true` alongside a non-"running" status string, and Go + // continues past the not-running branch in that case. + const mock = mockSpawner({ stdout: JSON.stringify({ Status: "paused", Running: true }) }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: true, status: "paused" }); + }), + ); + }, + ); + + it.live( + "fails with LegacyDockerLifecycleInspectError, preserving the real stderr, when the container does not exist", + () => { + // Go's `assertContainerHealthy` never special-cases "not found" — it + // wraps whatever `ContainerInspect` returns (`status.go:148-149`), so a + // missing container is just another non-zero exit here too. + const mock = mockSpawner({ + exitCode: 1, + stderr: "Error response from daemon: No such container: supabase_db_my-app\n", + }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleInspectError); + expect(error.message).toBe( + "failed to inspect container health: Error response from daemon: No such container: supabase_db_my-app", + ); + }), + ); + }, + ); + + it.live("fails with LegacyDockerLifecycleInspectError on any other inspect failure", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "Cannot connect to the Docker daemon\n" }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleInspectError); + expect(error.message).toBe( + "failed to inspect container health: Cannot connect to the Docker daemon", + ); + }), + ); + }); + + it.live( + "fails with LegacyDockerLifecycleInspectError with a generic message when stderr is empty", + () => { + const mock = mockSpawner({ exitCode: 1, stderr: "" }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleInspectError); + expect(error.message).toBe("failed to inspect container health"); + }), + ); + }, + ); + + it.live("treats empty inspect output as an unknown, not-running state", () => { + const mock = mockSpawner({ stdout: "" }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: false, status: "" }); + }), + ); + }); + + it.live("treats non-object inspect JSON as an unknown, not-running state", () => { + const mock = mockSpawner({ stdout: "null" }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: false, status: "" }); + }), + ); + }); +}); + +describe("legacyListVolumesByLabel", () => { + it.live("returns volume names for a successful listing", () => { + const mock = mockSpawner({ stdout: "supabase_db_my-app\n" }); + return legacyListVolumesByLabel(mock.spawner, "com.supabase.cli.project=my-app").pipe( + Effect.map((names) => { + expect(names).toEqual(["supabase_db_my-app"]); + expect(mock.spawned).toEqual([ + { + command: "docker", + args: [ + "volume", + "ls", + "--filter", + "label=com.supabase.cli.project=my-app", + "--format", + "{{.Name}}", + ], + }, + ]); + }), + ); + }); + + it.live("returns an empty array when no volumes remain", () => { + const mock = mockSpawner({ stdout: "" }); + return legacyListVolumesByLabel(mock.spawner, "com.supabase.cli.project").pipe( + Effect.map((names) => { + expect(names).toEqual([]); + }), + ); + }); + + it.live("fails with LegacyDockerLifecycleListError on a non-zero exit", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "boom\n" }); + return legacyListVolumesByLabel(mock.spawner, "com.supabase.cli.project").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleListError); + expect(error.message).toBe("failed to list volumes: boom"); + }), + ); + }); + + it.live("fails with a generic message when stderr is empty", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "" }); + return legacyListVolumesByLabel(mock.spawner, "com.supabase.cli.project").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleListError); + expect(error.message).toBe("failed to list volumes"); + }), + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.ts new file mode 100644 index 0000000000..a7b2768edf --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.ts @@ -0,0 +1,197 @@ +import { createHmac, createPrivateKey, createSign } from "node:crypto"; + +/** + * RFC 7517 JWK fields Go's `JWK` struct round-trips (`pkg/config/auth.go:88-108`, + * `toml`/`json` tags `kty`, `kid`, `alg`, `n`, `e`, `d`, `p`, `q`, `dp`, `dq`, + * `qi`, `crv`, `x`, `y`) — field names match exactly, so a signing-keys file can + * be parsed straight into this shape. A superset of Node's own + * `crypto.webcrypto.JsonWebKey` (which omits `kid`), so it's still assignable + * wherever that type is expected (e.g. `createPrivateKey`'s `format: "jwk"` input). + */ +export interface LegacyJwk { + readonly kty: string; + readonly kid?: string; + readonly alg?: string; + readonly n?: string; + readonly e?: string; + readonly d?: string; + readonly p?: string; + readonly q?: string; + readonly dp?: string; + readonly dq?: string; + readonly qi?: string; + readonly crv?: string; + readonly x?: string; + readonly y?: string; +} + +/** + * Go-byte-exact HS256 signer for the default local-dev `anon`/`service_role` + * keys, ported from `CustomClaims`/`generateJWT` (`apps/cli-go/pkg/config/apikeys.go:23-40,75-86`). + * {@link legacyGenerateAsymmetricGoJwt} below covers the RS256/ES256 branch of + * the same Go function, taken when `auth.signing_keys_path` is configured. + * + * This intentionally does NOT reuse `@supabase/stack`'s `generateJwt` + * (`packages/stack/src/JwtGenerator.ts`) — that helper uses `iss:"supabase"`, + * a dynamic `iat`/10-year `exp`, and a different claim order, none of which + * byte-match what Go prints for `supabase status`. Go's claims, in + * declaration order (the outer `CustomClaims.Issuer` field shadows the + * embedded `jwt.RegisteredClaims.Issuer`, so only one `iss` key is emitted): + * + * iss (fixed "supabase-demo"), ref (omitempty), role, is_anonymous (omitempty), + * then the remaining `jwt.RegisteredClaims` fields (sub, aud, exp, nbf, iat, jti), + * all `omitempty` except `exp`, which Go always sets to the fixed + * `defaultJwtExpiry = 1983812996` unix timestamp (never computed from "now"). + * + * `status` never sets `ref`/`is_anonymous`, so for this signer's two roles the + * payload always serializes to exactly `{"iss":...,"role":...,"exp":...}`. + */ + +const GO_JWT_ISSUER = "supabase-demo"; +const GO_JWT_FIXED_EXP = 1983812996; + +function base64UrlEncode(input: string): string { + return Buffer.from(input).toString("base64url"); +} + +export function legacyGenerateGoJwt(secret: string, role: "anon" | "service_role"): string { + const header = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const payload = base64UrlEncode( + JSON.stringify({ iss: GO_JWT_ISSUER, role, exp: GO_JWT_FIXED_EXP }), + ); + const data = `${header}.${payload}`; + const signature = createHmac("sha256", secret).update(data).digest("base64url"); + return `${data}.${signature}`; +} + +/** Go's asymmetric-JWT expiry: `time.Now().Add(time.Hour * 24 * 365 * 10)` (10 years). */ +const GO_JWT_ASYMMETRIC_EXPIRY_SECONDS = 60 * 60 * 24 * 365 * 10; + +function base64UrlToBigInt(value: string): bigint { + const hex = Buffer.from(value, "base64url").toString("hex"); + return hex.length === 0 ? 0n : BigInt(`0x${hex}`); +} + +function bigIntToBase64Url(value: bigint): string { + let hex = value.toString(16); + if (hex.length % 2 === 1) hex = `0${hex}`; + return Buffer.from(hex, "hex").toString("base64url"); +} + +/** Modular inverse of `a` mod `m` via the extended Euclidean algorithm (`a`/`m` coprime, as `q`/`p` always are for a valid RSA key). */ +function modInverse(a: bigint, m: bigint): bigint { + let [oldR, r] = [a, m]; + let [oldS, s] = [1n, 0n]; + while (r !== 0n) { + const quotient = oldR / r; + [oldR, r] = [r, oldR - quotient * r]; + [oldS, s] = [s, oldS - quotient * s]; + } + return ((oldS % m) + m) % m; +} + +/** + * Backfills the RSA CRT parameters (`dp`, `dq`, `qi`) Go's `jwkToRSAPrivateKey` + * (`apps/cli-go/pkg/config/apikeys.go:132-168`) never reads — it constructs + * `rsa.PrivateKey{N, E, D, Primes: [p, q]}` from `n`/`e`/`d`/`p`/`q` alone, and + * Go's stdlib `crypto/rsa` (`SignPKCS1v15` -> `precompute()`) lazily derives + * `Dp`/`Dq`/`Qinv` from `p`/`q`/`d` itself when they're absent, so a JWK + * missing them still signs successfully in Go. Node's + * `createPrivateKey({ format: "jwk" })` has no such fallback — it hard-rejects + * an RSA JWK without `dp`/`dq`/`qi` (`The "key.dp" property must be of type + * string`) — so this reproduces Go's derivation before handing the key to + * Node: `dp = d mod (p-1)`, `dq = d mod (q-1)`, `qi = q^-1 mod p` (RFC 7517 + * section 6.3.2 / RFC 3447 section 3.2). A key that already has all three (the common case + * for a Node/openssl-generated JWK) is returned unchanged; one missing + * `d`/`p`/`q` themselves is also returned unchanged — that's a genuinely + * invalid key in Go too, and `createPrivateKey` will raise its own error. + */ +function ensureRsaCrtParams(jwk: LegacyJwk): LegacyJwk { + if (jwk.dp !== undefined && jwk.dq !== undefined && jwk.qi !== undefined) { + return jwk; + } + if (jwk.d === undefined || jwk.p === undefined || jwk.q === undefined) { + return jwk; + } + const d = base64UrlToBigInt(jwk.d); + const p = base64UrlToBigInt(jwk.p); + const q = base64UrlToBigInt(jwk.q); + return { + ...jwk, + dp: bigIntToBase64Url(d % (p - 1n)), + dq: bigIntToBase64Url(d % (q - 1n)), + qi: bigIntToBase64Url(modInverse(q, p)), + }; +} + +/** + * Go's `GenerateAsymmetricJWT` (`pkg/config/apikeys.go:88-113`), reached from + * `generateJWT` only when `auth.signing_keys_path` resolves to a non-empty JWK + * array (`pkg/config/apikeys.go:76-80`) — the first key in the file signs both + * the anon and service_role tokens. Same claim shape as {@link legacyGenerateGoJwt} + * (`iss`/`role`/`exp`), except the expiry is 10 years from now rather than Go's + * fixed HMAC-path timestamp, since `generateJWT` sets `claims.ExpiresAt` + * explicitly before calling this function instead of falling through to + * `CustomClaims.NewToken()`'s fixed default. + * + * Only `RS256`/`ES256` are supported, matching Go's `jwkToPrivateKey` + * (RSA/EC key types) + this function's own switch on `jwk.alg`. `kty`/`alg` + * are cross-validated (RS256 requires `kty: "RSA"`, ES256 requires + * `kty: "EC"` and `crv: "P-256"`) — matching Go's `jwkToRSAPrivateKey` / + * `jwkToECDSAPrivateKey`, which reject any other combination rather than + * signing with a mismatched key or curve (Node's `createPrivateKey`/`createSign` + * do not themselves catch this: an EC key signed as RS256, or a non-P-256 + * curve signed as ES256, both "succeed" and produce a spec-invalid token that + * silently fails verification instead of raising an error). The header key + * order (`alg`, `kid`, `typ`) matches Go's `encoding/json` alphabetically + * sorting `map[string]interface{}` keys — `kid` is only present when set on + * the JWK, matching Go's `if len(jwk.KeyID) > 0` guard. + * + * `dsaEncoding: "ieee-p1363"` is required for ES256: Node's default ECDSA + * signature output is DER-encoded, which is not the raw (r‖s) format JWS + * requires — verified by round-tripping through `jose`'s `jwtVerify`. + */ +export function legacyGenerateAsymmetricGoJwt( + jwk: LegacyJwk, + role: "anon" | "service_role", +): string { + const algorithm = jwk.alg; + if (algorithm !== "RS256" && algorithm !== "ES256") { + throw new Error(`unsupported algorithm: ${algorithm ?? ""}`); + } + if (algorithm === "RS256" && jwk.kty !== "RSA") { + throw new Error(`unsupported key type: ${jwk.kty}`); + } + if (algorithm === "ES256") { + if (jwk.kty !== "EC") { + throw new Error(`unsupported key type: ${jwk.kty}`); + } + if (jwk.crv !== "P-256") { + throw new Error(`unsupported curve: ${jwk.crv ?? ""}`); + } + } + const header = + jwk.kid !== undefined && jwk.kid.length > 0 + ? { alg: algorithm, kid: jwk.kid, typ: "JWT" } + : { alg: algorithm, typ: "JWT" }; + const expiresAt = Math.floor(Date.now() / 1000) + GO_JWT_ASYMMETRIC_EXPIRY_SECONDS; + const headerEncoded = base64UrlEncode(JSON.stringify(header)); + const payloadEncoded = base64UrlEncode( + JSON.stringify({ iss: GO_JWT_ISSUER, role, exp: expiresAt }), + ); + const data = `${headerEncoded}.${payloadEncoded}`; + + const privateKey = createPrivateKey({ + key: algorithm === "RS256" ? ensureRsaCrtParams(jwk) : jwk, + format: "jwk", + }); + const signature = + algorithm === "RS256" + ? createSign("RSA-SHA256").update(data).end().sign(privateKey) + : createSign("sha256") + .update(data) + .end() + .sign({ key: privateKey, dsaEncoding: "ieee-p1363" }); + + return `${data}.${signature.toString("base64url")}`; +} diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts new file mode 100644 index 0000000000..5bfeabfe70 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts @@ -0,0 +1,179 @@ +import { createHmac, generateKeyPairSync } from "node:crypto"; +import { importJWK, jwtVerify } from "jose"; +import { describe, expect, it } from "vitest"; + +import { + legacyGenerateAsymmetricGoJwt, + legacyGenerateGoJwt, + type LegacyJwk, +} from "./legacy-go-jwt.ts"; + +const SECRET = "super-secret-jwt-token-with-at-least-32-characters-long"; + +function generateRsaJwk(kid?: string): LegacyJwk { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = privateKey.export({ format: "jwk" }); + return { ...jwk, kty: "RSA", alg: "RS256", kid }; +} + +function generateEcJwk(kid?: string): LegacyJwk { + const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const jwk = privateKey.export({ format: "jwk" }); + return { ...jwk, kty: "EC", alg: "ES256", kid }; +} + +function publicJwkOf(jwk: LegacyJwk): LegacyJwk { + const { d: _d, p: _p, q: _q, dp: _dp, dq: _dq, qi: _qi, ...publicJwk } = jwk; + return publicJwk; +} + +function decodeSegment(segment: string): string { + return Buffer.from(segment, "base64url").toString("utf8"); +} + +describe("legacyGenerateGoJwt", () => { + it("emits Go's exact JWT header (no extra fields, alg before typ)", () => { + const token = legacyGenerateGoJwt(SECRET, "anon"); + const [header] = token.split("."); + expect(header).toBeDefined(); + // Go's jwt.NewWithClaims builds Header as map[string]any{"typ":..,"alg":..}; + // encoding/json marshals map keys in sorted order, so "alg" sorts before "typ". + expect(decodeSegment(header ?? "")).toBe('{"alg":"HS256","typ":"JWT"}'); + }); + + it("emits the anon payload with Go's exact key order and fixed claims", () => { + const token = legacyGenerateGoJwt(SECRET, "anon"); + const [, payload] = token.split("."); + expect(payload).toBeDefined(); + const raw = decodeSegment(payload ?? ""); + // Byte-exact key order: iss, role, exp — ref/is_anonymous/iat are omitted + // entirely (Go's `omitempty`), matching status's no-ref, non-anonymous use. + expect(raw).toBe('{"iss":"supabase-demo","role":"anon","exp":1983812996}'); + + const parsed = JSON.parse(raw) as Record; + expect(parsed).toEqual({ iss: "supabase-demo", role: "anon", exp: 1983812996 }); + expect(Object.keys(parsed)).not.toContain("iat"); + expect(Object.keys(parsed)).not.toContain("ref"); + expect(Object.keys(parsed)).not.toContain("is_anonymous"); + }); + + it("emits the service_role payload with Go's exact key order and fixed claims", () => { + const token = legacyGenerateGoJwt(SECRET, "service_role"); + const [, payload] = token.split("."); + const raw = decodeSegment(payload ?? ""); + expect(raw).toBe('{"iss":"supabase-demo","role":"service_role","exp":1983812996}'); + }); + + it("signs with plain HMAC-SHA256 over the base64url header.payload, base64url-encoded", () => { + const token = legacyGenerateGoJwt(SECRET, "anon"); + const [header, payload, signature] = token.split("."); + const expectedSignature = createHmac("sha256", SECRET) + .update(`${header}.${payload}`) + .digest("base64url"); + expect(signature).toBe(expectedSignature); + }); + + it("is deterministic across calls (no timestamp derived from Date.now())", () => { + const first = legacyGenerateGoJwt(SECRET, "anon"); + const second = legacyGenerateGoJwt(SECRET, "anon"); + expect(first).toBe(second); + }); + + it("produces different tokens for different secrets", () => { + const a = legacyGenerateGoJwt(SECRET, "anon"); + const b = legacyGenerateGoJwt("a-different-secret-value-1234567", "anon"); + expect(a).not.toBe(b); + }); +}); + +describe("legacyGenerateAsymmetricGoJwt", () => { + it("signs and verifies an RS256 token from an RSA JWK", async () => { + const jwk = generateRsaJwk("rsa-kid"); + const token = legacyGenerateAsymmetricGoJwt(jwk, "anon"); + const publicKey = await importJWK(publicJwkOf(jwk), "RS256"); + const { payload, protectedHeader } = await jwtVerify(token, publicKey); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); + expect(protectedHeader).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); + }); + + it("signs an RS256 token from an RSA JWK missing CRT exponents (dp/dq/qi), matching Go", async () => { + // Go's `jwkToRSAPrivateKey` (`apps/cli-go/pkg/config/apikeys.go:132-168`) + // never reads `dp`/`dq`/`qi` — it builds the key from `n`/`e`/`d`/`p`/`q` + // alone, and Go's stdlib derives the CRT params itself when absent. A + // hand-authored signing-keys file that omits them (common — RFC 7517 marks + // them optional) must still sign successfully here. + const jwk = generateRsaJwk("rsa-kid"); + const { dp: _dp, dq: _dq, qi: _qi, ...jwkWithoutCrtParams } = jwk; + const token = legacyGenerateAsymmetricGoJwt(jwkWithoutCrtParams, "anon"); + const publicKey = await importJWK(publicJwkOf(jwk), "RS256"); + const { payload, protectedHeader } = await jwtVerify(token, publicKey); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); + expect(protectedHeader).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); + }); + + it("signs and verifies an ES256 token from an EC JWK", async () => { + const jwk = generateEcJwk("ec-kid"); + const token = legacyGenerateAsymmetricGoJwt(jwk, "service_role"); + const publicKey = await importJWK(publicJwkOf(jwk), "ES256"); + const { payload, protectedHeader } = await jwtVerify(token, publicKey); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "service_role" }); + expect(protectedHeader).toEqual({ alg: "ES256", kid: "ec-kid", typ: "JWT" }); + }); + + it("omits the kid header entirely when the JWK has no kid", () => { + const jwk = generateRsaJwk(); + const token = legacyGenerateAsymmetricGoJwt(jwk, "anon"); + const [header] = token.split("."); + const decoded = JSON.parse(Buffer.from(header ?? "", "base64url").toString()); + expect(decoded).toEqual({ alg: "RS256", typ: "JWT" }); + }); + + it("sets a ~10-year expiry computed from the current time, not a fixed timestamp", () => { + const jwk = generateRsaJwk(); + const before = Math.floor(Date.now() / 1000); + const token = legacyGenerateAsymmetricGoJwt(jwk, "anon"); + const [, payload] = token.split("."); + const decoded = JSON.parse(Buffer.from(payload ?? "", "base64url").toString()); + const tenYearsSeconds = 60 * 60 * 24 * 365 * 10; + expect(decoded.exp).toBeGreaterThanOrEqual(before + tenYearsSeconds); + expect(decoded.exp).toBeLessThan(before + tenYearsSeconds + 10); + }); + + it("rejects an unsupported algorithm", () => { + const jwk = { ...generateRsaJwk(), alg: "RS512" }; + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow( + "unsupported algorithm: RS512", + ); + }); + + it("rejects a JWK with no algorithm", () => { + const { alg: _alg, ...jwkWithoutAlg } = generateRsaJwk(); + expect(() => legacyGenerateAsymmetricGoJwt(jwkWithoutAlg, "anon")).toThrow( + "unsupported algorithm: ", + ); + }); + + it("rejects an EC key forged with alg: RS256 instead of signing garbage", () => { + const jwk = { ...generateEcJwk(), alg: "RS256" }; + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported key type: EC"); + }); + + it("rejects an RSA key forged with alg: ES256 instead of signing garbage", () => { + const jwk = { ...generateRsaJwk(), alg: "ES256" }; + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported key type: RSA"); + }); + + it("rejects an ES256 EC key whose curve is not P-256", () => { + const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-384" }); + const jwk = { ...privateKey.export({ format: "jwk" }), kty: "EC", alg: "ES256" }; + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported curve: P-384"); + }); + + it("rejects an ES256 EC key with no curve at all", () => { + const jwk = generateEcJwk(); + const { crv: _crv, ...jwkWithoutCurve } = jwk; + expect(() => legacyGenerateAsymmetricGoJwt(jwkWithoutCurve, "anon")).toThrow( + "unsupported curve: ", + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-hostname.ts b/apps/cli/src/legacy/shared/legacy-hostname.ts index 087363c20b..d2c6d719cd 100644 --- a/apps/cli/src/legacy/shared/legacy-hostname.ts +++ b/apps/cli/src/legacy/shared/legacy-hostname.ts @@ -1,17 +1,128 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + const LOCAL_HOST = "127.0.0.1"; +/** Docker CLI's reserved "no context store entry" name (`docker/cli` `cli/command/cli.go`'s `DefaultContextName`). */ +const DEFAULT_CONTEXT_NAME = "default"; + +/** + * Docker CLI's config directory: `$DOCKER_CONFIG` or `~/.docker` + * (`docker/cli` `cliconfig.Dir()`), read from here rather than + * `client.Client`'s own resolution since this module never spawns a real + * Docker client — it only needs the same two on-disk files that resolution + * reads. + */ +function dockerConfigDir(): string { + const override = process.env["DOCKER_CONFIG"]; + return override !== undefined && override.length > 0 ? override : join(homedir(), ".docker"); +} + +/** + * Go's `cli.CurrentContext()` name resolution (`docker/cli` + * `cli/command/cli.go`'s `resolveContextName`): `DOCKER_CONTEXT` env, else the + * config file's `currentContext`, else `"default"`. Only reached when + * `DOCKER_HOST` is unset — `resolveContextName` itself forces `"default"` + * when `DOCKER_HOST`/`--host` is set, which {@link legacyGetHostname} below + * already handles as its own, earlier branch. + */ +function currentDockerContextName(): string { + const fromEnv = process.env["DOCKER_CONTEXT"]; + if (fromEnv !== undefined && fromEnv.length > 0) { + return fromEnv; + } + try { + const config = JSON.parse(readFileSync(join(dockerConfigDir(), "config.json"), "utf8")) as { + currentContext?: unknown; + }; + if (typeof config.currentContext === "string" && config.currentContext.length > 0) { + return config.currentContext; + } + } catch { + // Missing/malformed config.json → the default context, same as Go's own + // silent fallback when it can't load the config file here. + } + return DEFAULT_CONTEXT_NAME; +} + +/** + * Reads a non-default context's daemon endpoint from Docker CLI's context + * store: `/contexts/meta//meta.json`'s + * `Endpoints.docker.Host` (`docker/cli` `cli/context/store/metadatastore.go`). + * The `"default"` context has no store entry (it's Go's synthetic + * always-available context, resolved without a client, see + * `cli.Initialize`), so it's never looked up here — matching the earlier + * `"default"` short-circuit in {@link currentDockerContextName}'s caller. + */ +function dockerContextEndpointHost(contextName: string): string | undefined { + if (contextName === DEFAULT_CONTEXT_NAME) { + return undefined; + } + try { + const contextId = createHash("sha256").update(contextName).digest("hex"); + const metaPath = join(dockerConfigDir(), "contexts", "meta", contextId, "meta.json"); + const meta = JSON.parse(readFileSync(metaPath, "utf8")) as { + readonly Endpoints?: { readonly docker?: { readonly Host?: unknown } }; + }; + const host = meta.Endpoints?.docker?.Host; + return typeof host === "string" && host.length > 0 ? host : undefined; + } catch { + // Missing/malformed context store entry → treat as unresolvable, same as + // Go silently falling back to the loopback default below. + return undefined; + } +} + +/** + * Extracts the bare host from a `tcp://host:port` daemon endpoint, mirroring + * Go's `client.ParseHostURL` + `net.SplitHostPort` (`misc.go:307`). Returns + * `undefined` for a non-`tcp://` endpoint (e.g. `unix://`, `npipe://`) or an + * unparseable one, in which case the caller falls back to the loopback + * default, matching Go's `net.SplitHostPort` failure/non-TCP handling. + */ +function hostFromTcpEndpoint(endpoint: string): string | undefined { + try { + const url = new URL(endpoint); + if (url.protocol !== "tcp:" || url.hostname.length === 0) { + return undefined; + } + // WHATWG `URL.hostname` returns an IPv6 host bracketed (`[::1]`), but Go's + // `net.SplitHostPort` (`misc.go:307`) returns the bare host (`::1`). Strip a + // single surrounding bracket pair so local-stack probes dial/compare the + // same host Go does; IPv4 and named hosts are returned unchanged. + const host = url.hostname; + return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; + } catch { + return undefined; + } +} + /** * Resolves the hostname used for local Supabase service connections, mirroring - * Go's `utils.GetHostname` (`apps/cli-go/internal/utils/misc.go:298`): + * Go's `utils.GetHostname` (`apps/cli-go/internal/utils/misc.go:298-311`): * * 1. `SUPABASE_SERVICES_HOSTNAME` env override — set in dev containers or when * the Docker daemon is not reachable on the container's own loopback. * 2. The Docker daemon host when `DOCKER_HOST` is a `tcp://host:port` endpoint * (Go's `Docker.DaemonHost()` + `client.ParseHostURL` + `net.SplitHostPort`). - * 3. `127.0.0.1` otherwise (the default unix-socket daemon). + * 3. Otherwise, the ACTIVE DOCKER CONTEXT's daemon endpoint, when it's a + * `tcp://` one — Go's `Docker.DaemonHost()` comes from a client built via + * `command.NewDockerCli()` + `cli.Initialize()` (`apps/cli-go/internal/ + * utils/docker.go:41-54`), whose endpoint resolution walks `DOCKER_HOST` -> + * `DOCKER_CONTEXT` -> the config file's `currentContext` -> the context + * store (`docker/cli` `cli/command/cli.go`'s `getDockerEndPoint`/ + * `resolveContextName`) — not just `DOCKER_HOST`. The `docker`/`podman` + * binary this module's callers shell out to for `ps`/`inspect` already + * resolves the same active context itself, so without this step `status` + * could correctly inspect a remote daemon while printing unusable + * `127.0.0.1` API/DB/Studio URLs for it. + * 4. `127.0.0.1` otherwise (the default unix-socket daemon, or an + * unresolvable/malformed context). * * Shared across legacy commands that connect to the local stack (`gen types`, - * `test db`, and later `db reset` / `db dump`). + * `test db`, `status`, `stop`, and later `db reset` / `db dump`). */ export function legacyGetHostname(): string { const override = process.env["SUPABASE_SERVICES_HOSTNAME"]; @@ -20,18 +131,13 @@ export function legacyGetHostname(): string { } const dockerHost = process.env["DOCKER_HOST"]; if (dockerHost !== undefined && dockerHost.length > 0) { - try { - const url = new URL(dockerHost); - if (url.protocol === "tcp:" && url.hostname.length > 0) { - // WHATWG `URL.hostname` returns an IPv6 host bracketed (`[::1]`), but Go's - // `net.SplitHostPort` (`misc.go:307`) returns the bare host (`::1`). Strip a - // single surrounding bracket pair so local-stack probes dial/compare the - // same host Go does; IPv4 and named hosts are returned unchanged. - const host = url.hostname; - return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; - } - } catch { - // Unparseable DOCKER_HOST → fall through to the loopback default. + return hostFromTcpEndpoint(dockerHost) ?? LOCAL_HOST; + } + const contextEndpoint = dockerContextEndpointHost(currentDockerContextName()); + if (contextEndpoint !== undefined) { + const host = hostFromTcpEndpoint(contextEndpoint); + if (host !== undefined) { + return host; } } return LOCAL_HOST; diff --git a/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts b/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts index 9706f5f002..0f1c351d0e 100644 --- a/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { legacyGetHostname } from "./legacy-hostname.ts"; @@ -19,6 +23,30 @@ function withEnv(entries: Record, run: () => T): } } +/** Writes a Docker CLI-shaped `$DOCKER_CONFIG` directory (`config.json` + a context store entry). */ +function writeDockerConfigDir(options: { + readonly currentContext?: string; + readonly contexts?: Readonly>; // context name -> docker.Host endpoint +}): string { + const dir = mkdtempSync(join(tmpdir(), "legacy-hostname-docker-config-")); + if (options.currentContext !== undefined) { + writeFileSync( + join(dir, "config.json"), + JSON.stringify({ currentContext: options.currentContext }), + ); + } + for (const [name, host] of Object.entries(options.contexts ?? {})) { + const contextId = createHash("sha256").update(name).digest("hex"); + const metaDir = join(dir, "contexts", "meta", contextId); + mkdirSync(metaDir, { recursive: true }); + writeFileSync( + join(metaDir, "meta.json"), + JSON.stringify({ Endpoints: { docker: { Host: host } } }), + ); + } + return dir; +} + describe("legacyGetHostname", () => { it("prefers SUPABASE_SERVICES_HOSTNAME over everything else", () => { expect( @@ -63,4 +91,98 @@ describe("legacyGetHostname", () => { withEnv({ SUPABASE_SERVICES_HOSTNAME: undefined, DOCKER_HOST: undefined }, legacyGetHostname), ).toBe("127.0.0.1"); }); + + describe("active Docker context resolution (Go's Docker.DaemonHost() parity)", () => { + let configDirs: Array = []; + + afterEach(() => { + for (const dir of configDirs) rmSync(dir, { recursive: true, force: true }); + configDirs = []; + }); + + function withDockerConfig( + options: Parameters[0], + env: Record, + run: () => T, + ): T { + const dir = writeDockerConfigDir(options); + configDirs.push(dir); + return withEnv( + { + SUPABASE_SERVICES_HOSTNAME: undefined, + DOCKER_HOST: undefined, + DOCKER_CONFIG: dir, + ...env, + }, + run, + ); + } + + it("resolves the host from the active context's tcp:// endpoint via config.json's currentContext", () => { + const result = withDockerConfig( + { currentContext: "remote", contexts: { remote: "tcp://remote-host:2375" } }, + {}, + legacyGetHostname, + ); + expect(result).toBe("remote-host"); + }); + + it("prefers DOCKER_CONTEXT over config.json's currentContext", () => { + const result = withDockerConfig( + { + currentContext: "other", + contexts: { envctx: "tcp://envctx-host:2375", other: "tcp://other-host:2375" }, + }, + { DOCKER_CONTEXT: "envctx" }, + legacyGetHostname, + ); + expect(result).toBe("envctx-host"); + }); + + it("strips brackets from an IPv6 context endpoint (net.SplitHostPort parity)", () => { + const result = withDockerConfig( + { currentContext: "remote", contexts: { remote: "tcp://[::1]:2375" } }, + {}, + legacyGetHostname, + ); + expect(result).toBe("::1"); + }); + + it("falls back to 127.0.0.1 when the active context's endpoint is not tcp://", () => { + const result = withDockerConfig( + { currentContext: "remote", contexts: { remote: "unix:///var/run/docker.sock" } }, + {}, + legacyGetHostname, + ); + expect(result).toBe("127.0.0.1"); + }); + + it("falls back to 127.0.0.1 when the context store entry is missing", () => { + const result = withDockerConfig({ currentContext: "ghost" }, {}, legacyGetHostname); + expect(result).toBe("127.0.0.1"); + }); + + it("falls back to 127.0.0.1 when config.json is missing entirely (default context)", () => { + const result = withDockerConfig({}, {}, legacyGetHostname); + expect(result).toBe("127.0.0.1"); + }); + + it("never consults the context store for the default context", () => { + const result = withDockerConfig( + { currentContext: "default", contexts: { default: "tcp://should-never-be-read:2375" } }, + {}, + legacyGetHostname, + ); + expect(result).toBe("127.0.0.1"); + }); + + it("DOCKER_HOST still takes precedence over an active non-default context", () => { + const result = withDockerConfig( + { currentContext: "remote", contexts: { remote: "tcp://context-host:2375" } }, + { DOCKER_HOST: "tcp://direct-host:2375" }, + legacyGetHostname, + ); + expect(result).toBe("direct-host"); + }); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts new file mode 100644 index 0000000000..a851d5a670 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -0,0 +1,1573 @@ +import { readFileSync } from "node:fs"; +import { basename, join } from "node:path"; + +import { ENV_CAPTURE_REGEX, type ProjectConfig } from "@supabase/config"; +import { defaultJwtSecret, defaultPublishableKey, defaultSecretKey } from "@supabase/stack/effect"; +import { Schema } from "effect"; + +import { legacyResolveApiExternalUrl } from "./legacy-api-url.ts"; +import { legacySanitizeProjectId } from "./legacy-docker-ids.ts"; +import { + legacyApiTlsCertReadErrorMessage, + legacyApiTlsKeyReadErrorMessage, + type LegacyAnalyticsInput, + type LegacyApiInput, + type LegacyAuthInput, + type LegacyCaptchaInput, + LegacyConfigValidateError, + type LegacyConfigValidationInput, + type LegacyDbInput, + legacyEmailContentPathReadErrorMessage, + type LegacyExperimentalInput, + type LegacyHookInput, + type LegacyLocalSmtpInput, + type LegacyMfaFactorInput, + legacyParseGoBool, + type LegacyPasskeyInput, + legacyResolveApiTlsPath, + legacyResolveEmailTemplateContentPath, + legacyResolveSigningKeysPath, + legacySigningKeysDecodeErrorMessage, + legacySigningKeysReadErrorMessage, + type LegacySmtpInput, + type LegacyStudioInput, + type LegacyThirdPartyInput, + legacyValidateResolvedConfig, +} from "./legacy-config-validate.ts"; +import { + legacyGenerateAsymmetricGoJwt, + legacyGenerateGoJwt, + type LegacyJwk, +} from "./legacy-go-jwt.ts"; +import { + legacyCollectDotenvPrivateKeys, + legacyDecryptSecret, + legacyIsEncryptedSecret, +} from "./legacy-vault-decrypt.ts"; + +/** + * Go-parity derived local-dev config values, ported from `utils.Config`'s + * post-load defaulting (`pkg/config/config.go:406-441,748-758`) and + * `utils.GetApiUrl`/status's `toValues()` (`internal/utils/config.go:255-268`, + * `internal/status/status.go:52-95`). `@supabase/config`'s schema has no field for + * a handful of Go constants (`db.password`, the S3 credential triple) — those are + * Go-hardcoded literals, reproduced here rather than added to the shared schema + * (`pkg/config/config.go:408,437-441`). + * + * Kept generic (no `status`-specific shaping) so a future native `start`/`restart` + * port can reuse it instead of re-deriving these values — see the plan's + * "Files to create" note. Do not fold this into `legacy-storage-credentials.ts`; + * that module resolves credentials through a different (HTTP/tenant-aware) path + * for the remote-project branch, which this pure resolver does not need (the + * shared `://:` derivation itself lives in + * `legacy-api-url.ts`, used by both). + */ + +/** Go's `Db.Password` default (`pkg/config/config.go:408`) — never present in config.toml. */ +const DEFAULT_DB_PASSWORD = "postgres"; + +/** Go's hardcoded local S3 credentials (`pkg/config/config.go:437-441`). */ +const DEFAULT_S3_ACCESS_KEY_ID = "625729a08b95bf1b7ff351a663f3a23c"; +const DEFAULT_S3_SECRET_ACCESS_KEY = + "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907"; +const DEFAULT_S3_REGION = "local"; + +export interface LegacyLocalConfigValues { + readonly apiUrl: string; + readonly restUrl: string; + readonly graphqlUrl: string; + readonly functionsUrl: string; + readonly mcpUrl: string; + readonly studioUrl: string; + readonly mailpitUrl: string; + readonly dbUrl: string; + readonly publishableKey: string; + readonly secretKey: string; + readonly jwtSecret: string; + readonly anonKey: string; + readonly serviceRoleKey: string; + readonly storageS3Url: string; + readonly storageS3AccessKeyId: string; + readonly storageS3SecretAccessKey: string; + readonly storageS3Region: string; +} + +/** + * Go's `utils.GetApiUrl(path)` (`internal/utils/config.go:255-268`): appends + * `path` to the resolved external URL. Go's own fallback branch (building a bare + * `http://host:port` when `Config.Api.ExternalUrl` is empty) is unreachable in + * practice because `config.Load` already defaults `ExternalUrl` before `status` + * runs — `resolveApiExternalUrl` reproduces that same default, so `apiExternalUrl` + * passed in here is never empty. + */ +function apiUrlWithPath(apiExternalUrl: string, path: string): string { + return `${apiExternalUrl}${path}`; +} + +/** + * Thrown by {@link legacyResolveLocalConfigValues} when `auth.jwt_secret` is + * configured but too short to sign with, mirroring Go's `Config.Validate` + * (`pkg/config/apikeys.go:45-47`) — that check runs at config-load time, before + * any command renders output, so no local dev stack can even start with a + * short secret. + */ +export class LegacyInvalidJwtSecretError extends Error { + constructor() { + super("Invalid config for auth.jwt_secret. Must be at least 16 characters"); + this.name = "LegacyInvalidJwtSecretError"; + } +} + +/** Go's minimum `auth.jwt_secret` length (`pkg/config/apikeys.go:46`). */ +const MIN_JWT_SECRET_LENGTH = 16; + +/** + * Thrown by {@link envOverridePort} when a `SUPABASE_*_PORT` env/dotenv + * override doesn't parse as a valid port, mirroring Go's `Config.Load` + * (`pkg/config/config.go:749-756`): `v.UnmarshalExact` decodes with + * `WeaklyTypedInput` on (viper's `defaultDecoderConfig`, never reset by our + * decoder options), so mapstructure's `decodeUint` runs `strconv.ParseUint` + * on the override string and hard-fails config loading on a bad value — + * there is no Go code path that reaches `status`/`stop` with a malformed + * port override. The message text isn't a byte-match for mapstructure's + * internal error (that's viper/mapstructure library text, not a Go-authored + * string), but the parity-relevant part — hard-fail, same field name — is. + */ +export class LegacyInvalidPortEnvOverrideError extends Error { + constructor(dottedFieldPath: string, value: string) { + super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a port`); + this.name = "LegacyInvalidPortEnvOverrideError"; + } +} + +/** Go's `uint16` port fields' valid range (`pkg/config/db.go:84`, `pkg/config/api.go:29`, etc). */ +const MAX_PORT = 65535; + +/** + * Port-flavored sibling of {@link envOverride}/{@link legacyEnvOverrideBool} + * for `SUPABASE_*_PORT` fields Go decodes as `uint16` rather than a plain + * string. Unlike the boolean sibling — which intentionally falls back to + * `configured` on a malformed override — a bad port override is a genuine + * Go-parity hard failure (see {@link LegacyInvalidPortEnvOverrideError}), not + * a leniency case: Go never proceeds with the pre-override value on a decode + * error, it fails config loading outright. + */ +function envOverridePort( + name: string, + configuredPort: number, + dottedFieldPath: string, + projectEnvValues: Readonly> | undefined, +): number { + const value = envOverride(name, undefined, projectEnvValues); + if (value === undefined) return configuredPort; + if (!/^\d+$/.test(value)) { + throw new LegacyInvalidPortEnvOverrideError(dottedFieldPath, value); + } + const port = Number(value); + if (port > MAX_PORT) { + throw new LegacyInvalidPortEnvOverrideError(dottedFieldPath, value); + } + return port; +} + +/** + * Go's `Config.Load` binds Viper with `SetEnvPrefix("SUPABASE")` + + * `AutomaticEnv()` + a `.`→`_` key replacer (`pkg/config/config.go:529-535`), + * so ANY config field can be overridden by a `SUPABASE_` env var, + * generically across the whole struct — not just auth fields + * (`config_test.go:351,1061` exercise this against `auth.site_url`, and + * `internal/status/status.go:52-95`'s `toValues()` reads `utils.Config.*` + * directly, so every already-overridden field is automatically reflected in + * `status`'s output). This resolves it for every field this module derives a + * URL/port from, at the same higher-than-config.toml precedence Viper gives + * env vars. An empty env var is treated as unset, matching Viper's default + * (`AllowEmptyEnv` is never enabled in `config.go`). + * + * Viper's `AutomaticEnv` binding runs AFTER `Config.Load`'s `loadNestedEnv` + * (`config.go:735-738`), which loads `supabase/.env`(.local) and project-root + * dotenv files into the process env before any `SUPABASE_*` var is read + * (`config.go:1169-1207`) — so a value that lives only in one of those files, + * not the ambient shell, must still be visible here. `projectEnvValues` is + * that already-resolved map (see `legacyResolveProjectEnvironmentValues`); + * falling back to `process.env` covers the "no `supabase/` project found" + * case, where `projectEnvValues` is `undefined`. + * + * The resolved override string itself can be a further `env(VAR)` indirection + * (e.g. `SUPABASE_API_ENABLED=env(API_ENABLED)`) — Go's `LoadEnvHook` + * (`decode_hooks.go:15-23`) is the first mapstructure decode hook composed + * into `v.UnmarshalExact` (`config.go:749-753,769-772`), so it resolves + * `env(...)` on every string mapstructure decodes into the struct, regardless + * of whether Viper sourced that string from `config.toml` or a `SUPABASE_*` + * `AutomaticEnv` override (`config.go:582-586`) — Viper's `Get()` just returns + * a string; the hook chain doesn't know or care where it came from. Resolved + * with the same `projectEnvValues ?? process.env` precedence and non-empty + * gate as the outer lookup (mirroring `decode_hooks.go:19-24`'s `len(env) > 0` + * check); an unresolved/empty indirection leaves the `env(VAR)` literal + * untouched, same as Go. + */ +function envOverride( + name: string, + configured: string | undefined, + projectEnvValues: Readonly> | undefined, +): string | undefined { + const value = projectEnvValues?.[name] ?? process.env[name]; + if (value === undefined || value.length === 0) return configured; + const indirection = ENV_CAPTURE_REGEX.exec(value)?.[1]; + if (indirection === undefined) return value; + const resolved = projectEnvValues?.[indirection] ?? process.env[indirection]; + return resolved !== undefined && resolved.length > 0 ? resolved : value; +} + +/** + * Thrown by {@link legacyEnvOverrideBool} when a `SUPABASE_*_ENABLED` (or other + * bool-typed) env/dotenv override doesn't parse as one of Go's accepted bool + * spellings, mirroring Go's `Config.Load` (`pkg/config/config.go:749-756`): + * `v.UnmarshalExact` decodes with `WeaklyTypedInput` on (viper's + * `defaultDecoderConfig`, never reset by our decoder options — same mechanism + * as {@link LegacyInvalidPortEnvOverrideError}), so mapstructure's `decodeBool` + * runs `strconv.ParseBool` on the override string and hard-fails config + * loading on a bad value — there is no Go code path that reaches `status`/ + * `stop` with a malformed bool override. + */ +export class LegacyInvalidBoolEnvOverrideError extends Error { + constructor(dottedFieldPath: string, value: string) { + super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a bool`); + this.name = "LegacyInvalidBoolEnvOverrideError"; + } +} + +/** + * Boolean-flavored sibling of {@link envOverride} for `SUPABASE_*` fields Go + * decodes as a native bool (`api.tls.enabled`, `auth.enabled`, and every other + * `
.enabled` gate `status`/`stop` read — see `status.values.ts`) + * rather than a string/number — those are bound by the same generic Viper + * mechanism (`ExperimentalBindStruct` + `SetEnvPrefix("SUPABASE")` + + * `AutomaticEnv()`, `pkg/config/config.go:582-586`), but the override string + * must be decoded with Go's own `strconv.ParseBool` acceptance set + * ({@link legacyParseGoBool}) instead of used verbatim. Unlike a plain string + * override — where an unparsed value has no Go-observable failure mode — a + * malformed bool override is a genuine Go-parity hard failure (see + * {@link LegacyInvalidBoolEnvOverrideError}), same as + * {@link LegacyInvalidPortEnvOverrideError} for ports: Go never proceeds with + * the pre-override value on a decode error, it fails config loading outright. + * + * Exported (not just used internally) because `status.values.ts`'s own + * `
.enabled` gates need this same override treatment — Go's + * `status.toValues()` reads `utils.Config.*.Enabled` post-Viper-override for + * every gated service, not only auth. + */ +export function legacyEnvOverrideBool( + name: string, + configured: boolean, + dottedFieldPath: string, + projectEnvValues: Readonly> | undefined, +): boolean { + const value = envOverride(name, undefined, projectEnvValues); + if (value === undefined) return configured; + const parsed = legacyParseGoBool(value); + if (parsed === undefined) { + throw new LegacyInvalidBoolEnvOverrideError(dottedFieldPath, value); + } + return parsed; +} + +/** + * Thrown by {@link envOverrideAnalyticsBackend} when `SUPABASE_ANALYTICS_BACKEND` + * doesn't match one of Go's `LogflareBackend` values. `Analytics.Backend` is + * typed `LogflareBackend` (`pkg/config/config.go:303`), and + * `LogflareBackend.UnmarshalText` (`config.go:60-65`) hard-rejects anything + * outside `{postgres, bigquery}` — that runs inside the same + * `v.UnmarshalExact` decode call (`config.go:749-756`) every other + * `SUPABASE_*` override goes through, so a malformed override fails config + * loading outright, same mechanism as {@link LegacyInvalidPortEnvOverrideError}/ + * {@link LegacyInvalidBoolEnvOverrideError}. + */ +export class LegacyInvalidAnalyticsBackendEnvOverrideError extends Error { + constructor(dottedFieldPath: string, value: string) { + super( + `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "postgres", "bigquery"`, + ); + this.name = "LegacyInvalidAnalyticsBackendEnvOverrideError"; + } +} + +/** + * `analytics.backend`-flavored sibling of {@link envOverridePort}/ + * {@link legacyEnvOverrideBool} for the one `SUPABASE_*` override this file + * decodes as a Go text-unmarshalled enum rather than a string/number/bool — + * see {@link LegacyInvalidAnalyticsBackendEnvOverrideError}. Validates the + * override-or-configured value with a SINGLE check (rather than only + * validating the override, trusting the schema for the configured value), + * matching Go more closely: viper merges the config.toml value and any env + * override into one string BEFORE `UnmarshalExact` calls `UnmarshalText` + * exactly once on the resolved value (`config.go:749-756`), not once per + * source. `@supabase/config`'s `stringEnum` (`packages/config/src/ + * analytics.ts:31-39`) already guards the `config.toml`-sourced value at + * decode time, so this is belt-and-suspenders for that source and the sole + * guard for the env-override one, which bypasses that schema entirely. + */ +function envOverrideAnalyticsBackend( + configured: string, + projectEnvValues: Readonly> | undefined, +): "postgres" | "bigquery" { + const value = + envOverride("SUPABASE_ANALYTICS_BACKEND", undefined, projectEnvValues) ?? configured; + if (value !== "postgres" && value !== "bigquery") { + throw new LegacyInvalidAnalyticsBackendEnvOverrideError("analytics.backend", value); + } + return value; +} + +/** + * Decrypts a resolved auth identity-key field (`jwt_secret`, `publishable_key`, + * `secret_key`, `anon_key`, `service_role_key`) when it's a dotenvx `encrypted:` + * value, mirroring Go's `DecryptSecretHookFunc` (`pkg/config/secret.go:30-73`), + * which Go runs unconditionally during `UnmarshalExact` for every + * `config.Secret`-typed field (`pkg/config/auth.go:181-185` types these five as + * `Secret`) — an undecryptable value aborts config loading with + * `failed to parse config: ` (`config.go:704`) before `status`/`stop` + * continue. `@supabase/config`'s schema only tags these fields for later + * `Redacted` wrapping (`packages/config/src/lib/env.ts`) and never decrypts, so + * without this step a valid `encrypted:` secret would be used as literal (wrong) + * key material and a malformed one would silently pass through instead of + * failing like Go does. + * + * Applied AFTER {@link envOverride}, matching Go: an env-sourced override lands + * on the same `config.Secret` field and goes through the same decode hook as a + * TOML-sourced value, so `SUPABASE_AUTH_JWT_SECRET=encrypted:...` is decrypted + * too, not just the config.toml value. + */ +function decryptAuthSecret( + value: string | undefined, + projectEnvValues: Readonly> | undefined, +): string | undefined { + if (value === undefined || !legacyIsEncryptedSecret(value)) return value; + const dotenvPrivateKeys = legacyCollectDotenvPrivateKeys({ ...projectEnvValues, ...process.env }); + const decrypted = legacyDecryptSecret(value, dotenvPrivateKeys); + if (!decrypted.ok) { + throw new LegacyConfigValidateError(`failed to parse config: ${decrypted.error}`); + } + return decrypted.value; +} + +/** Go's `(a *auth) generateAPIKeys` (`pkg/config/apikeys.go:43-73`). */ +function resolveJwtSecret(configured: string | undefined): string { + if (configured === undefined || configured.length === 0) return defaultJwtSecret; + if (configured.length < MIN_JWT_SECRET_LENGTH) { + throw new LegacyInvalidJwtSecretError(); + } + return configured; +} + +function resolveOpaqueKey(configured: string | undefined, fallback: string): string { + return configured !== undefined && configured.length > 0 ? configured : fallback; +} + +function resolveSignedKey( + configured: string | undefined, + jwtSecret: string, + signingKey: LegacyJwk | undefined, + role: "anon" | "service_role", +): string { + if (configured !== undefined && configured.length > 0) return configured; + return signingKey !== undefined + ? legacyGenerateAsymmetricGoJwt(signingKey, role) + : legacyGenerateGoJwt(jwtSecret, role); +} + +/** Matches Go's `JWK` struct fields (`pkg/config/auth.go:88-108`) — see `LegacyJwk`. */ +const LegacyJwkSchema = Schema.Struct({ + kty: Schema.String, + kid: Schema.optionalKey(Schema.String), + alg: Schema.optionalKey(Schema.String), + n: Schema.optionalKey(Schema.String), + e: Schema.optionalKey(Schema.String), + d: Schema.optionalKey(Schema.String), + p: Schema.optionalKey(Schema.String), + q: Schema.optionalKey(Schema.String), + dp: Schema.optionalKey(Schema.String), + dq: Schema.optionalKey(Schema.String), + qi: Schema.optionalKey(Schema.String), + crv: Schema.optionalKey(Schema.String), + x: Schema.optionalKey(Schema.String), + y: Schema.optionalKey(Schema.String), +}); +const decodeLegacyJwks = Schema.decodeUnknownSync(Schema.Array(LegacyJwkSchema)); + +/** + * Go's `Config.Validate` (`pkg/config/config.go:877-878,1059-1062`): a relative + * `signing_keys_path` resolves against `/supabase`, then the file is + * read and JSON-decoded into `[]JWK`. Only the first key is ever used + * ({@link resolveSignedKey}), matching `generateJWT`'s `a.SigningKeys[0]`. + * + * Uses `node:fs` directly (not the `FileSystem` Effect service other Go-parity + * resolvers in `legacy/` use for file reads) so this function — and its large + * existing test surface — can stay a plain synchronous resolver; this is an + * optional, rarely-configured field, not worth threading Effect dependencies + * through `legacyStatusValues`/`status.handler.ts` for. + * + * Error wording matches Go's two `Validate` failure branches exactly + * (`"failed to read signing keys: %w"` for an open failure, `"failed to decode + * signing keys: %w"` for a parse failure) rather than letting `readFileSync`/ + * `JSON.parse`'s raw Node error text through unwrapped. + * + * Callers must only invoke this when auth is enabled (the `SUPABASE_AUTH_ENABLED`- + * overridden value, not necessarily raw `config.auth.enabled` — see + * {@link legacyEnvOverrideBool}) — Go's `Validate` nests the entire signing-keys read + * inside `if c.Auth.Enabled` (`pkg/config/config.go:1036,1059-1065`), reading + * that same post-override value, so a disabled auth section never touches + * `signing_keys_path`, however stale or missing that file is. + */ +function loadFirstSigningKey(workdir: string, signingKeysPath: string): LegacyJwk | undefined { + const absolutePath = legacyResolveSigningKeysPath(workdir, signingKeysPath); + + let contents: string; + try { + contents = readFileSync(absolutePath, "utf8"); + } catch (cause) { + throw new LegacyConfigValidateError(legacySigningKeysReadErrorMessage(cause)); + } + + let jwks: ReadonlyArray; + try { + jwks = decodeLegacyJwks(JSON.parse(contents)); + } catch (cause) { + throw new LegacyConfigValidateError(legacySigningKeysDecodeErrorMessage(cause)); + } + return jwks[0]; +} + +/** + * Go's `Config.Validate` TLS branch (`pkg/config/config.go:1006-1027`) file reads: gated on + * `api.enabled && api.tls.enabled` same as the caller, each configured path is read to confirm + * it's actually reachable, matching Go's `fs.ReadFile` calls (Go caches the bytes for `start` to + * serve as `CertContent`/`KeyContent` — `status`/`stop` have no use for the bytes, only the same + * validation outcome, so they're discarded here). The "exactly one of cert/key set" presence + * check now lives in `legacyValidateResolvedConfig`'s `api.tls` step + * (`legacy-config-validate.ts`) — this function only runs the reads, and only when BOTH paths + * are actually present: neither path set, or only one, never reaches a `fs.ReadFile` call here, + * since the presence check (run later, as part of the single consolidated validation call) owns + * rejecting the one-but-not-the-other case. + * + * Go joins both paths unconditionally with the `supabase/` dir — no `filepath.IsAbs` guard + * (`config.go:961-965` uses `path.Join`, which absorbs a leading `/`) — unlike + * {@link loadFirstSigningKey}'s `signing_keys_path`, which Go does guard with `filepath.IsAbs` + * (`config.go:928-929`). See `legacyResolveApiTlsPath`. Matches the identical Kong-side + * validation already ported for `seed buckets`/`storage` in + * `legacy-storage-credentials.ts`'s `validateLocalKongTls`. + * + * Uses `node:fs` directly for the same reason as {@link loadFirstSigningKey}: this stays a plain + * synchronous resolver rather than threading the Effect `FileSystem` service through + * `legacyStatusValues`/`status.handler.ts`. + */ +function readApiTlsFiles( + workdir: string, + certPath: string | undefined, + keyPath: string | undefined, +): void { + if (certPath === undefined || certPath.length === 0) return; + if (keyPath === undefined || keyPath.length === 0) return; + + try { + readFileSync(legacyResolveApiTlsPath(workdir, certPath), "utf8"); + } catch (cause) { + throw new LegacyConfigValidateError(legacyApiTlsCertReadErrorMessage(cause)); + } + try { + readFileSync(legacyResolveApiTlsPath(workdir, keyPath), "utf8"); + } catch (cause) { + throw new LegacyConfigValidateError(legacyApiTlsKeyReadErrorMessage(cause)); + } +} + +/** + * Go's `(e *email) validate(fsys)` template/notification content read (`pkg/config/ + * config.go:1293-1313`), called from `Config.Validate` right after `Auth.MFA.validate()`, still + * inside `if c.Auth.Enabled` (`config.go:1142`). Every template is checked unconditionally; a + * notification only when that notification is itself enabled (`config.go:1308`). Uses the same + * `readFileSync`-based pattern as {@link loadFirstSigningKey}/`readApiTlsFiles` in this file, + * not an Effect `FileSystem` service. + * + * The `content`-vs-`content_path` exclusivity decision and path resolution (including the + * TEMPLATE-vs-`workdir`/NOTIFICATION-vs-`/supabase` base asymmetry, per Go's `(c + * *baseConfig) resolve` (`config.go:900-916`) — this asymmetry is real, intentional Go behavior + * to match, not a bug to fix) now live in `legacyResolveEmailTemplateContentPath` + * (`legacy-config-validate.ts`); this function only feeds it `contentPresent` (computed from the + * raw `document`, since `@supabase/config`'s `template`/`notification` schema + * (`packages/config/src/auth/email.ts`) has no `content` field to see) and performs the read + * when a path comes back. + * + * `auth.email.template..*`/`auth.email.notification..*` are Viper-bound like every + * other nested field once `[auth.email.template.]`/`[auth.email.notification.]` are + * present in config.toml (`ExperimentalBindStruct`/`AutomaticEnv`, `config.go:581-586`), so + * `SUPABASE_AUTH_EMAIL_TEMPLATE__CONTENT_PATH`/`SUPABASE_AUTH_EMAIL_NOTIFICATION__ + * ENABLED`/`_CONTENT_PATH` overrides apply before this read runs. Unlike the hook/passkey/smtp + * presence gates elsewhere in this file, no extra raw-document presence check is needed here to + * decide WHETHER to apply an override: `email.template`/`email.notification` are `Schema.Record`s + * (`packages/config/src/auth/email.ts`), which — unlike a fixed-shape struct with + * `withDecodingDefaultKey` — only ever contain a key when the TOML section was actually present, + * so `Object.entries` already reflects presence. + */ +function readAuthEmailTemplateContent( + email: ProjectConfig["auth"]["email"], + workdir: string, + authDocument: Record | undefined, + projectEnvValues: Readonly> | undefined, +): void { + const emailDoc = asRecord(authDocument?.["email"]); + const templatesDoc = asRecord(emailDoc?.["template"]); + const notificationsDoc = asRecord(emailDoc?.["notification"]); + + for (const [name, tmpl] of Object.entries(email.template)) { + const contentPath = + envOverride( + `SUPABASE_AUTH_EMAIL_TEMPLATE_${name.toUpperCase()}_CONTENT_PATH`, + tmpl.content_path, + projectEnvValues, + ) ?? tmpl.content_path; + const path = legacyResolveEmailTemplateContentPath({ + section: "template", + name, + contentPath, + contentPresent: asRecord(templatesDoc?.[name])?.["content"] !== undefined, + base: workdir, + }); + if (path === undefined) continue; + try { + readFileSync(path, "utf8"); + } catch (cause) { + throw new LegacyConfigValidateError( + legacyEmailContentPathReadErrorMessage("template", name, cause), + ); + } + } + for (const [name, tmpl] of Object.entries(email.notification)) { + const envPrefix = `SUPABASE_AUTH_EMAIL_NOTIFICATION_${name.toUpperCase()}`; + const enabled = legacyEnvOverrideBool( + `${envPrefix}_ENABLED`, + tmpl.enabled, + `auth.email.notification.${name}.enabled`, + projectEnvValues, + ); + if (!enabled) continue; + const contentPath = + envOverride(`${envPrefix}_CONTENT_PATH`, tmpl.content_path, projectEnvValues) ?? + tmpl.content_path; + const path = legacyResolveEmailTemplateContentPath({ + section: "notification", + name, + contentPath, + contentPresent: asRecord(notificationsDoc?.[name])?.["content"] !== undefined, + base: join(workdir, "supabase"), + }); + if (path === undefined) continue; + try { + readFileSync(path, "utf8"); + } catch (cause) { + throw new LegacyConfigValidateError( + legacyEmailContentPathReadErrorMessage("notification", name, cause), + ); + } + } +} + +/** + * `SUPABASE_DB_MAJOR_VERSION` sibling of {@link envOverridePort} for the one + * numeric field Go decodes as `uint` rather than `uint16` (`pkg/config/db.go:87`) + * — same generic Viper `AutomaticEnv` binding (`config.go:576-586`), same + * mapstructure hard-fail-on-bad-value semantics as the port/bool overrides, but + * with no upper-bound cap. A non-digit override folds into the same generic + * "Invalid db.major_version" message `legacyValidateResolvedConfig` produces for + * an out-of-set numeric value, since Go's own decode failure and `Validate` + * failure for this field aren't independently distinguishable from the CLI's + * output the way ports/bools are. + */ +function envOverrideMajorVersion( + configured: number, + projectEnvValues: Readonly> | undefined, +): number { + const value = envOverride("SUPABASE_DB_MAJOR_VERSION", undefined, projectEnvValues); + if (value === undefined) return configured; + if (!/^\d+$/.test(value)) { + throw new Error(`Failed reading config: Invalid db.major_version: ${value}.`); + } + return Number(value); +} + +/** + * `SUPABASE_EDGE_RUNTIME_DENO_VERSION` sibling of {@link envOverrideMajorVersion} + * — same generic Viper `AutomaticEnv` binding, same mapstructure + * hard-fail-on-bad-value semantics, no upper-bound cap. A non-digit override + * folds into the same generic "Invalid edge_runtime.deno_version" message + * `legacyValidateResolvedConfig` produces for an out-of-set numeric value. + */ +function envOverrideDenoVersion( + configured: number, + projectEnvValues: Readonly> | undefined, +): number { + const value = envOverride("SUPABASE_EDGE_RUNTIME_DENO_VERSION", undefined, projectEnvValues); + if (value === undefined) return configured; + if (!/^\d+$/.test(value)) { + throw new Error(`Failed reading config: Invalid edge_runtime.deno_version: ${value}.`); + } + return Number(value); +} + +/** Narrows an unknown value to a plain object, mirroring `legacy-db-config.toml-read.ts`'s `asRecord`. */ +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** Go's `hook.validate()` hook-type iteration order (`pkg/config/config.go:1453-1485`), used + * only to build {@link legacyResolveLocalConfigValues}'s `hooks` input in the right order — + * the actual per-hook validation now lives in `legacyValidateResolvedConfig`. */ +const LEGACY_HOOK_TYPE_ORDER = [ + "mfa_verification_attempt", + "password_verification_attempt", + "custom_access_token", + "send_sms", + "send_email", + "before_user_created", +] as const; + +/** Go's `(s *sms) validate()` fixed provider priority (`pkg/config/config.go:1348-1410`) — a + * `switch` that validates ONLY the first enabled provider in this order. */ +const LEGACY_SMS_PROVIDER_ORDER = [ + "twilio", + "twilio_verify", + "messagebird", + "textlocal", + "vonage", +] as const; + +/** Required fields per SMS provider, in the order Go checks them (`config.go:1349-1403`). */ +const LEGACY_SMS_REQUIRED_FIELDS: Record< + (typeof LEGACY_SMS_PROVIDER_ORDER)[number], + ReadonlyArray +> = { + twilio: ["account_sid", "message_service_sid", "auth_token"], + twilio_verify: ["account_sid", "message_service_sid", "auth_token"], + messagebird: ["originator", "access_key"], + textlocal: ["sender", "api_key"], + vonage: ["from", "api_key", "api_secret"], +}; + +/** + * Go's `(s *sms) validate()` (`pkg/config/config.go:1348-1410`): a boolean `switch` that inspects + * providers in the FIXED priority order above and validates ONLY the first one whose `enabled` is + * true — a later enabled-but-incomplete provider is never even looked at. `@supabase/config`'s + * `sms` schema (`packages/config/src/auth/sms.ts`) already implements this exact switch for the + * schema-decoded (pre-env-override) TOML value, which is Go-parity-correct for a config with no + * relevant `SUPABASE_AUTH_SMS_*` env override — but `@supabase/config`'s decode pipeline never + * resolves `SUPABASE_*` overrides at all (only this legacy-shell layer does, post-decode), so a + * `SUPABASE_AUTH_SMS__ENABLED` override that flips a section's enabled state after + * decode is invisible to it. This re-runs the same switch against the RAW `authDocument` with + * env overrides applied first — same document-based, post-override pattern as + * {@link validateAuthExternalProviders} below, and the same "duplicate D's/the schema's check for + * the env-override-aware L path" tradeoff already accepted for that function. + * + * `auth.sms..*` is Viper-bound like every other nested field once + * `[auth.sms.]` is present in config.toml (`ExperimentalBindStruct`/`AutomaticEnv`, + * `config.go:581-586`), so `SUPABASE_AUTH_SMS__ENABLED`/`_` overrides apply + * before this validation runs, gated on the raw provider section already being present, matching + * `AutomaticEnv` (which only intercepts keys already present in the merged config). + */ +function validateAuthSmsProviders( + authDocument: Record | undefined, + projectEnvValues: Readonly> | undefined, +): void { + const smsDoc = asRecord(authDocument?.["sms"]); + if (smsDoc === undefined) return; + for (const providerName of LEGACY_SMS_PROVIDER_ORDER) { + const providerDoc = asRecord(smsDoc[providerName]); + if (providerDoc === undefined) continue; + const envPrefix = `SUPABASE_AUTH_SMS_${providerName.toUpperCase()}`; + const enabled = legacyEnvOverrideBool( + `${envPrefix}_ENABLED`, + providerDoc["enabled"] === true, + `auth.sms.${providerName}.enabled`, + projectEnvValues, + ); + if (!enabled) continue; + for (const field of LEGACY_SMS_REQUIRED_FIELDS[providerName]) { + const value = envOverride( + `${envPrefix}_${field.toUpperCase()}`, + typeof providerDoc[field] === "string" ? providerDoc[field] : undefined, + projectEnvValues, + ); + if (value === undefined || value.length === 0) { + throw new LegacyConfigValidateError( + `Missing required field in config: auth.sms.${providerName}.${field}`, + ); + } + } + // Go's switch stops at the first enabled provider — later providers are never inspected. + return; + } +} + +/** Go's `external.validate()` deprecated-provider skip (`config.go:1419-1423`) — `linkedin`/ + * `slack` are deleted (and warned on, if enabled) before the required-field loop runs, so they + * are never validated here. Mirrors `legacy-db-config.toml-read.ts`'s identical "B5: external + * providers" skip list. */ +const DEPRECATED_EXTERNAL_PROVIDERS = new Set(["linkedin", "slack"]); + +/** + * Go's `(e external) validate()` (`pkg/config/config.go:1419-1451`) — D-only per + * `legacy-config-validate.ts`'s module header ("`auth.external` ... stays 100% inline in D"), so + * this ports the identical inline block D already has (`legacy-db-config.toml-read.ts`'s "B5: + * external providers") to close the same gap for L. `auth.external` is a genuine Go + * `map[string]provider` (`apps/cli-go/pkg/config/auth.go:190`), so an arbitrary/unmodeled + * provider name (e.g. `[auth.external.custom]`) is a legitimate config shape — Go validates + * every enabled entry regardless of name. `@supabase/config`'s `external` schema only models the + * ~20 known provider ids and silently drops anything else at decode time + * (`packages/config/src/auth/providers.ts`), so an unmodeled provider's required-field check + * must run against the RAW `authDocument` instead of the decoded `ProjectConfig` — same + * document-based approach as {@link readAuthEmailTemplateContent}/the passkey/smtp checks above. + * Known providers are already covered by the schema's own `requiredWhenEnabled` check at decode + * time, so in practice this only ever fires for a name the schema doesn't model, but it runs + * over every raw key unconditionally, matching Go's own map iteration rather than special-casing + * "unknown" a different way. `authDocument`'s values are already post-`env()`-interpolation (see + * `LoadedProjectConfig.document`), so no `legacyExpandEnv`-style resolution is needed here, + * unlike D's raw pre-interpolation document. + * + * `auth.external..*` is Viper-bound like every other nested field once + * `[auth.external.]` is present in config.toml (`ExperimentalBindStruct`/`AutomaticEnv`, + * `config.go:581-586`), so `SUPABASE_AUTH_EXTERNAL__ENABLED`/`_CLIENT_ID`/`_SECRET` + * overrides apply before this validation runs — same gap this schema's own `requiredWhenEnabled` + * check has for KNOWN providers too (that check only sees the decoded, pre-override TOML value), + * so this now covers both known and unmodeled provider names uniformly, matching Go not + * distinguishing between them either. + */ +function validateAuthExternalProviders( + authDocument: Record | undefined, + projectEnvValues: Readonly> | undefined, +): void { + const external = asRecord(authDocument?.["external"]); + if (external === undefined) return; + for (const name of Object.keys(external)) { + if (DEPRECATED_EXTERNAL_PROVIDERS.has(name)) continue; + const provider = asRecord(external[name]); + if (provider === undefined) continue; + const envPrefix = `SUPABASE_AUTH_EXTERNAL_${name.toUpperCase()}`; + const enabled = legacyEnvOverrideBool( + `${envPrefix}_ENABLED`, + provider["enabled"] === true, + `auth.external.${name}.enabled`, + projectEnvValues, + ); + if (!enabled) continue; + const clientId = envOverride( + `${envPrefix}_CLIENT_ID`, + typeof provider["client_id"] === "string" ? provider["client_id"] : undefined, + projectEnvValues, + ); + if (clientId === undefined || clientId.length === 0) { + throw new LegacyConfigValidateError( + `Missing required field in config: auth.external.${name}.client_id`, + ); + } + const secret = envOverride( + `${envPrefix}_SECRET`, + typeof provider["secret"] === "string" ? provider["secret"] : undefined, + projectEnvValues, + ); + if (name !== "apple" && name !== "google" && (secret === undefined || secret.length === 0)) { + throw new LegacyConfigValidateError( + `Missing required field in config: auth.external.${name}.secret`, + ); + } + } +} + +/** + * @throws when `project_id` (post-override, post-workdir-basename-fallback) is + * an explicit empty string. Go's `Config.Validate` checks this FIRST, before + * every other field (`pkg/config/config.go:990-991`): `mergeDefaultValues` + * merges `sanitizeProjectId(filepath.Base(cwd))` in as a viper DEFAULT value + * BEFORE `config.toml` is merged (`config.go:690-699`, via `Eject`, + * `config.go:561-570`) — so `c.ProjectId` is NEVER Go's zero value by the time + * `Validate` runs; it's always at least this sanitized basename. A workdir + * whose basename sanitizes to the empty string (e.g. `!!!`) therefore fails + * config loading in Go even with NO `project_id` key in the file at all. An + * explicit `project_id = ""` IN the file overwrites that default with the + * literal empty string the same way (rather than being treated as absent) — + * Go fails outright rather than falling back to the basename either way. + * `legacySanitizeProjectId` is only applied to the BASENAME fallback here, + * matching `Eject`'s pre-sanitized default — an explicit non-empty + * `config.project_id`/`SUPABASE_PROJECT_ID` value is intentionally NOT + * re-sanitized at this point, matching Go's `Validate` "auto-fix" branch + * (`config.go:992-996`) being a WARN-only rewrite with no throwing + * equivalent, same precedent as this module's other WARN-only omissions + * (`auth.captcha.secret`/`assertEnvLoaded`, SMS's `EnableSignup` case). + * @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. + * @throws {LegacyInvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv + * override doesn't parse as a valid port. + * @throws {LegacyInvalidBoolEnvOverrideError} when a `SUPABASE_*_ENABLED` env/dotenv + * override doesn't parse as a valid bool. + * @throws when a configured `api.tls` cert/key file can't be read — see + * {@link readApiTlsFiles}. The "exactly one of cert/key set" presence check + * runs later, as part of {@link legacyValidateResolvedConfig}. + * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, + * or its first key uses an unsupported algorithm — see {@link loadFirstSigningKey} + * and {@link legacyGenerateAsymmetricGoJwt}. + * @throws when an email template's `content` is present without `content_path`, or a + * configured `content_path` file can't be read — see {@link readAuthEmailTemplateContent}. + * @throws {LegacyInvalidAnalyticsBackendEnvOverrideError} when `SUPABASE_ANALYTICS_BACKEND` + * doesn't parse as one of Go's `LogflareBackend` values. + * @throws {LegacyConfigValidateError} for every other `Config.Validate` branch this module + * and `legacy-config-validate.ts` jointly own — project_id emptiness aside (checked above, + * inline, since the value is also needed for the throw's own message-free early-exit shape), + * every REMAINING pure check (api.port/tls presence, db.port/major_version, storage bucket + * names, studio, local_smtp, auth.site_url/captcha/passkey/hooks/mfa/smtp/third_party, + * function slugs, edge_runtime.deno_version, analytics.gcp_*, experimental.*) is deferred to a + * SINGLE call to {@link legacyValidateResolvedConfig} at the end of this function, in Go's exact + * relative order — see that module's header for the full table and the accepted ordering + * tradeoff this introduces against the I/O checks listed above (which keep running at their + * original position, per-caller, rather than being folded into that single call). + */ +export function legacyResolveLocalConfigValues( + config: ProjectConfig, + hostname: string, + workdir: string, + projectEnvValues: Readonly> | undefined = undefined, + /** + * `LoadedProjectConfig.document` (`packages/config/src/io.ts`) — the raw, + * pre-schema-default TOML document `config` was decoded from. Lets checks + * that hinge on TOML-section PRESENCE (not the decoded, always-defaulted + * value) inspect the file directly — see `legacyValidateResolvedConfig`'s + * `experimental.webhooks`/`auth.passkey`/`auth.email.smtp` steps. + * `undefined` for callers that haven't threaded it through yet (e.g. most + * existing unit tests); those checks are then simply skipped rather than + * guessed at. + */ + document: Readonly> | undefined = undefined, +): LegacyLocalConfigValues { + // Go's `Config.Validate` checks `ProjectId` FIRST, before every other field + // (`pkg/config/config.go:990-991`) — see this function's `@throws` doc above + // for why a workdir basename that sanitizes to `""` fails here even when + // `project_id` is absent from the file entirely. `config.project_id` is + // `undefined` only when the key is genuinely absent (`optionalKey`, see + // `packages/config/src/base.ts`) — that's the ONE case where Go's own + // sanitized-basename viper default shows through instead of a file value, + // so the fallback belongs here, not as a third branch after `envOverride`. + // `SUPABASE_PROJECT_ID` is checked via the same `envOverride` precedence + // every other field here uses, since Viper's `AutomaticEnv` binds it too + // (`config.go:529-535`) and it can turn an explicit-empty file value (or an + // unsanitizable basename fallback) back into a valid override. + const resolvedProjectId = envOverride( + "SUPABASE_PROJECT_ID", + config.project_id ?? legacySanitizeProjectId(basename(workdir)), + projectEnvValues, + ); + + // Go's `status` reads `utils.Config.Api.Port`/`ExternalUrl`/`Tls.Enabled` + // after Viper's AutomaticEnv has already applied any `SUPABASE_API_PORT`/ + // `SUPABASE_API_EXTERNAL_URL`/`SUPABASE_API_TLS_ENABLED` override + // (`config.go:529-535,799-809`), so the values fed into + // `legacyResolveApiExternalUrl`'s own `external_url`-wins-else- + // `scheme://host:port` derivation (which picks `https` vs `http` from + // `tls.enabled`) must be the overridden ones too. + const apiTlsEnabled = legacyEnvOverrideBool( + "SUPABASE_API_TLS_ENABLED", + config.api.tls.enabled, + "api.tls.enabled", + projectEnvValues, + ); + // Go's TLS cert/key validation nests entirely inside `if c.Api.Enabled` + // (`config.go:1006,1010`) — mirroring `authEnabled` below, gate on the + // POST-`SUPABASE_API_ENABLED`-override value, not raw `config.api.enabled`. + const apiEnabled = legacyEnvOverrideBool( + "SUPABASE_API_ENABLED", + config.api.enabled, + "api.enabled", + projectEnvValues, + ); + const apiTlsCertPath = envOverride( + "SUPABASE_API_TLS_CERT_PATH", + config.api.tls.cert_path, + projectEnvValues, + ); + const apiTlsKeyPath = envOverride( + "SUPABASE_API_TLS_KEY_PATH", + config.api.tls.key_path, + projectEnvValues, + ); + if (apiEnabled && apiTlsEnabled) { + readApiTlsFiles(workdir, apiTlsCertPath, apiTlsKeyPath); + } + // Go's `Config.Validate` rejects `api.port === 0`/`SUPABASE_API_PORT=0` ONLY + // when `api.enabled` (`pkg/config/config.go:1006-1008`) — unlike `db.port` + // below, which has no `enabled` gate. Resolved once into a named const so the + // check and the URL derivation below share the same overridden value instead + // of calling `envOverridePort` twice. + const apiPort = envOverridePort( + "SUPABASE_API_PORT", + config.api.port, + "api.port", + projectEnvValues, + ); + const apiExternalUrl = legacyResolveApiExternalUrl( + { + external_url: envOverride( + "SUPABASE_API_EXTERNAL_URL", + config.api.external_url, + projectEnvValues, + ), + port: apiPort, + tls: { enabled: apiTlsEnabled }, + }, + hostname, + ); + // Unlike `api.port`/`studio.port`/`local_smtp.port` below, `db.port` has no + // `enabled` gate in Go's `Config.Validate` — it's unconditionally required, + // and a decoded `0` (e.g. `SUPABASE_DB_PORT=0`) fails validation with this + // exact message (`pkg/config/config.go:1031-1032`) before `status`/`stop` + // render anything, same wording already used for the `db query`/`test db` + // path (`legacy-db-config.toml-read.ts:1380`). + const dbPort = envOverridePort("SUPABASE_DB_PORT", config.db.port, "db.port", projectEnvValues); + // Go's `Config.Validate` checks `db.major_version` right after `db.port` + // (`pkg/config/config.go:1034-1061`), unconditionally (no `enabled` gate). + const majorVersion = envOverrideMajorVersion(config.db.major_version, projectEnvValues); + // Go's `Config.Validate` runs `ValidateBucketName` over every `[storage.buckets.*]` + // key right after `db.major_version`, unconditionally. + const storageBucketNames = + config.storage.buckets !== undefined ? Object.keys(config.storage.buckets) : []; + // Go's `Config.Validate` rejects `studio.port === 0`/`SUPABASE_STUDIO_PORT=0` + // ONLY when `studio.enabled` (`pkg/config/config.go:1070-1073`) — same + // enabled-gated pattern as `api.port` above. + const studioEnabled = legacyEnvOverrideBool( + "SUPABASE_STUDIO_ENABLED", + config.studio.enabled, + "studio.enabled", + projectEnvValues, + ); + const studioPort = envOverridePort( + "SUPABASE_STUDIO_PORT", + config.studio.port, + "studio.port", + projectEnvValues, + ); + // Go's `Config.Validate` parses `studio.api_url` with `net/url.Parse` right + // after the port check, still inside `if c.Studio.Enabled` + // (`pkg/config/config.go:1074-1078`). `config.studio.api_url` is a required + // (defaulted) field, so `envOverride` can only return `undefined` here if + // that default itself were somehow undefined — the `??` fallback just + // satisfies that generic signature. + const studioApiUrl = + envOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? + config.studio.api_url; + // Go's `Config.Validate` rejects `local_smtp.port === 0`/ + // `SUPABASE_LOCAL_SMTP_PORT=0` ONLY when `local_smtp.enabled` — Go's struct + // field is still named `Inbucket` for the `[local_smtp]` TOML section + // (`pkg/config/config.go:235,1081-1083`), so `local_smtp.enabled` and the + // deprecated `inbucket.enabled` alias are the same underlying flag, not two + // independent ones. + const mailpitEnabled = legacyEnvOverrideBool( + "SUPABASE_LOCAL_SMTP_ENABLED", + config.local_smtp.enabled, + "local_smtp.enabled", + projectEnvValues, + ); + const mailpitPort = envOverridePort( + "SUPABASE_LOCAL_SMTP_PORT", + config.local_smtp.port, + "local_smtp.port", + projectEnvValues, + ); + const jwtSecret = resolveJwtSecret( + decryptAuthSecret( + envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, projectEnvValues), + projectEnvValues, + ), + ); + const signingKeysPath = envOverride( + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + config.auth.signing_keys_path, + projectEnvValues, + ); + // Gated on `auth.enabled` to match Go's `Validate` (`pkg/config/config.go:1036,1059-1065`): + // the signing-keys file read lives entirely inside `if c.Auth.Enabled`, so a + // disabled auth section never opens/parses `signing_keys_path`, even a stale + // or missing one. JWT-secret validation and anon/service_role key generation + // (`generateAPIKeys`, `apikeys.go:43-73`) run unconditionally either way, so + // only this file read is gated. `c.Auth.Enabled` is itself Viper-bound like + // any other field (`config.go:582-586`), so `Validate`'s gate reads the + // POST-`SUPABASE_AUTH_ENABLED`-override value, not the raw TOML one — hence + // `legacyEnvOverrideBool` here instead of `config.auth.enabled` directly. + const authEnabled = legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ); + // Go's `Config.Validate` checks `auth.site_url` first inside `if c.Auth.Enabled` + // (`pkg/config/config.go:1086-1090`), before the signing-keys read below — + // `@supabase/config`'s schema only defaults `site_url` when the key is ABSENT + // (`Schema.withDecodingDefaultKey`), so an explicit `site_url = ""` decodes as + // `""` with no schema-level error, same gap as `db.port === 0` above. + const siteUrl = envOverride("SUPABASE_AUTH_SITE_URL", config.auth.site_url, projectEnvValues); + // `LoadedProjectConfig.document` (the raw, pre-schema-default TOML `config` was decoded from) — + // hoisted here (rather than inside the `authEnabled` block below, where it used to live) because + // the captcha presence check right below needs it too. `undefined` for callers that haven't + // threaded `document` through yet, in which case presence-based checks are simply skipped. + const authDocument = asRecord(document?.["auth"]); + // Go's `Config.Validate` checks `auth.captcha` right after `auth.site_url`, + // still inside `if c.Auth.Enabled` (`pkg/config/config.go:1099-1109`): an + // enabled CAPTCHA section requires both `provider` and `secret`. `auth.captcha.*` + // is Viper-bound like every other nested field once `[auth.captcha]` is present + // in config.toml (`ExperimentalBindStruct`/`AutomaticEnv`, `config.go:581-586`), + // so `SUPABASE_AUTH_CAPTCHA_ENABLED`/`_PROVIDER`/`_SECRET` overrides apply before + // this validation runs. Unlike the flat `auth.site_url` field, `config.auth.captcha` + // does NOT decode to `undefined` when `[auth.captcha]` is absent from config.toml — + // `captcha.ts`'s own `withDecodingDefaultKey` fills in `{ enabled: false }` even + // through the outer `Schema.optionalKey` wrapper (`packages/config/src/auth/index.ts`), + // confirmed empirically; there is no schema-level presence signal here, unlike + // `auth.passkey`/`auth.webauthn` below. So, like those, presence is read from the raw + // `authDocument` instead — matching Go's `AutomaticEnv` (which only intercepts keys + // already present in the merged config), an absent `[auth.captcha]` section never + // picks up an env override alone. + const captchaDoc = asRecord(authDocument?.["captcha"]); + const captchaInput: LegacyCaptchaInput | undefined = config.auth.captcha + ? { + enabled: + captchaDoc !== undefined + ? legacyEnvOverrideBool( + "SUPABASE_AUTH_CAPTCHA_ENABLED", + config.auth.captcha.enabled ?? false, + "auth.captcha.enabled", + projectEnvValues, + ) + : (config.auth.captcha.enabled ?? false), + provider: + captchaDoc !== undefined + ? envOverride( + "SUPABASE_AUTH_CAPTCHA_PROVIDER", + config.auth.captcha.provider, + projectEnvValues, + ) + : config.auth.captcha.provider, + secret: + captchaDoc !== undefined + ? envOverride( + "SUPABASE_AUTH_CAPTCHA_SECRET", + config.auth.captcha.secret, + projectEnvValues, + ) + : config.auth.captcha.secret, + } + : undefined; + const signingKey = + authEnabled && signingKeysPath !== undefined && signingKeysPath.length > 0 + ? loadFirstSigningKey(workdir, signingKeysPath) + : undefined; + // Go's `Config.Validate` runs passkey/webauthn validation, then + // `Auth.Hook.validate()`, then `Auth.MFA.validate()`, then + // `Auth.Email.validate()`, then `Auth.Sms.validate()`/`Auth.ThirdParty.validate()` (skipping + // the D-only `external` step, ported separately below), all right after the signing-keys read + // and still inside `if c.Auth.Enabled` (`pkg/config/config.go:1117-1153`). Sms + // (`config.go:1145-1147`/`1348-1417`) is enforced at decode time by `@supabase/config`'s `sms` + // schema (`packages/config/src/auth/sms.ts`'s provider-switch check) for the TOML-only case, + // AND re-checked here post-env-override by {@link validateAuthSmsProviders} (called alongside + // {@link validateAuthExternalProviders}, after the single `legacyValidateResolvedConfig` call + // below) — see that function's doc comment for why both are needed. External + // (`config.go:1148-1150`/`1419-1451`) is D-only per `legacy-config-validate.ts`'s module + // header; {@link validateAuthExternalProviders} ports D's identical inline check. This block + // only ACCUMULATES the inputs those checks need — the checks themselves run once, later, as + // part of the single `legacyValidateResolvedConfig` call below. + let authInput: LegacyAuthInput | undefined; + if (authEnabled) { + // `@supabase/config`'s auth schema has no `passkey`/`webauthn` fields at all (see + // `config-sync/auth.sync.ts`'s "not in `@supabase/config` schema" note), so passkey/webauthn + // are read from the RAW, post-`env()`-interpolation TOML document (`authDocument`, hoisted + // above) instead of the decoded `ProjectConfig` — same document-based approach already used + // on the `db`/migration config-load path (`legacy-db-config.toml-read.ts`'s + // `legacyValidateAuthConfig`, section A6). `authDocument` is `undefined` when a caller hasn't + // threaded `document` through yet, in which case passkey/smtp presence-based checks are + // simply skipped rather than guessed at. + const passkeyDoc = asRecord(authDocument?.["passkey"]); + const webauthnDoc = asRecord(authDocument?.["webauthn"]); + // `auth.passkey.enabled`/`auth.webauthn.*` are Viper-bound like every other nested field once + // `[auth.passkey]`/`[auth.webauthn]` are present in config.toml (`ExperimentalBindStruct`/ + // `AutomaticEnv`, `config.go:581-586`), so `SUPABASE_AUTH_PASSKEY_ENABLED` and + // `SUPABASE_AUTH_WEBAUTHN_RP_ID`/`_RP_ORIGINS` overrides apply before `Auth.Passkey`/ + // `Auth.Webauthn` validation runs (`config.go:1117-1134`). Gated on the raw section already + // being present (`passkeyDoc`/`webauthnDoc !== undefined`), matching Go's `AutomaticEnv` + // (which only intercepts keys already present in the merged config) — an absent + // `[auth.passkey]`/`[auth.webauthn]` section is never synthesized from an env override alone. + const passkeyEnabled = + passkeyDoc !== undefined + ? legacyEnvOverrideBool( + "SUPABASE_AUTH_PASSKEY_ENABLED", + passkeyDoc["enabled"] === true, + "auth.passkey.enabled", + projectEnvValues, + ) + : false; + const rpId = + webauthnDoc !== undefined + ? envOverride( + "SUPABASE_AUTH_WEBAUTHN_RP_ID", + typeof webauthnDoc["rp_id"] === "string" ? webauthnDoc["rp_id"] : undefined, + projectEnvValues, + ) + : undefined; + // Go decodes `rp_origins` (a `[]string`) through the same `StringToSliceHookFunc(",")` + // mapstructure hook as every other Go string-slice field (`config.go:775-784`), so a + // `SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS` override is comma-split the same way. + const rpOriginsOverride = + webauthnDoc !== undefined + ? envOverride("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", undefined, projectEnvValues) + : undefined; + const rpOrigins = + rpOriginsOverride !== undefined + ? rpOriginsOverride.split(",") + : Array.isArray(webauthnDoc?.["rp_origins"]) + ? webauthnDoc["rp_origins"] + : undefined; + const passkey: LegacyPasskeyInput | undefined = passkeyEnabled + ? { webauthnPresent: webauthnDoc !== undefined, rpId, rpOrigins } + : undefined; + + // Go's `hook.validate()` fixed iteration order (`pkg/config/config.go:1453-1485`) — only + // enabled hooks are forwarded, in that order. `auth.hook..*` is Viper-bound like every + // other nested field (`ExperimentalBindStruct`/`AutomaticEnv`, `config.go:581-586`), so + // `SUPABASE_AUTH_HOOK__ENABLED`/`_URI`/`_SECRETS` overrides apply before this + // validation runs. `@supabase/config`'s hook schema always decodes a `{ enabled: false }` + // default per type regardless of file presence (`packages/config/src/auth/hooks.ts`'s + // `withDecodingDefaultKey`), which erases the presence signal Go's `AutomaticEnv` needs (it + // only intercepts keys already present in the merged config) — so, like the passkey/webauthn + // overrides above, this reads the raw `[auth.hook.]` document instead to gate the + // override on the section actually being present. + const hookDocument = asRecord(authDocument?.["hook"]); + const hooks: Array = []; + for (const hookType of LEGACY_HOOK_TYPE_ORDER) { + const hook = config.auth.hook[hookType]; + const hookSectionPresent = asRecord(hookDocument?.[hookType]) !== undefined; + const envPrefix = `SUPABASE_AUTH_HOOK_${hookType.toUpperCase()}`; + const hookEnabled = hookSectionPresent + ? legacyEnvOverrideBool( + `${envPrefix}_ENABLED`, + hook.enabled, + `auth.hook.${hookType}.enabled`, + projectEnvValues, + ) + : hook.enabled; + if (hookEnabled) { + hooks.push({ + type: hookType, + uri: + (hookSectionPresent + ? envOverride(`${envPrefix}_URI`, hook.uri, projectEnvValues) + : hook.uri) ?? "", + secrets: + (hookSectionPresent + ? envOverride(`${envPrefix}_SECRETS`, hook.secrets, projectEnvValues) + : hook.secrets) ?? "", + }); + } + } + + // Go's `Auth.MFA` factor fields (`TOTP`/`Phone`/`WebAuthn`) are value-typed structs + // (`pkg/config/auth.go:317-320`), never `nil` — unlike `Auth.Hook`'s pointer-typed fields + // above, `ExperimentalBindStruct` always recurses into them (vendored Viper's + // `decodeStructKeys`/`flattenAndMergeMap`) regardless of whether `[auth.mfa.]` is + // present in config.toml (the default template even leaves `[auth.mfa.web_authn]` commented + // out and it's still overridable), so `SUPABASE_AUTH_MFA__{ENROLL,VERIFY}_ENABLED` + // overrides always apply before `Auth.MFA.validate()` runs (`config.go:1523-1534`) — no + // raw-document presence gate needed here, unlike hooks/smtp above. + const mfa: ReadonlyArray = [ + { + label: "totp", + enrollEnabled: legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", + config.auth.mfa.totp.enroll_enabled, + "auth.mfa.totp.enroll_enabled", + projectEnvValues, + ), + verifyEnabled: legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED", + config.auth.mfa.totp.verify_enabled, + "auth.mfa.totp.verify_enabled", + projectEnvValues, + ), + }, + { + label: "phone", + enrollEnabled: legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_PHONE_ENROLL_ENABLED", + config.auth.mfa.phone.enroll_enabled, + "auth.mfa.phone.enroll_enabled", + projectEnvValues, + ), + verifyEnabled: legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_PHONE_VERIFY_ENABLED", + config.auth.mfa.phone.verify_enabled, + "auth.mfa.phone.verify_enabled", + projectEnvValues, + ), + }, + { + label: "web_authn", + enrollEnabled: legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_WEB_AUTHN_ENROLL_ENABLED", + config.auth.mfa.web_authn.enroll_enabled, + "auth.mfa.web_authn.enroll_enabled", + projectEnvValues, + ), + verifyEnabled: legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_WEB_AUTHN_VERIFY_ENABLED", + config.auth.mfa.web_authn.verify_enabled, + "auth.mfa.web_authn.verify_enabled", + projectEnvValues, + ), + }, + ]; + + // Go's `Config.Validate` runs the email template/notification content read right after + // `Auth.MFA.validate()`, still inside `if c.Auth.Enabled` (`config.go:1142`) — this I/O read + // stays at this exact textual position (see this function's `@throws` doc for why). + readAuthEmailTemplateContent(config.auth.email, workdir, authDocument, projectEnvValues); + + // Go's `[auth.email.smtp]` presence-based `enabled` default (`pkg/config/config.go:743-748`): + // when the TOML table is present but omits `enabled`, Go treats it as `true` — a genuinely + // presence-based default `@supabase/config`'s schema can't see (it always decodes + // `smtp.enabled` to `false` when the key is absent), so this reads the raw `document` too. + // `auth.email.smtp.*` is Viper-bound like every other nested field once `[auth.email.smtp]` + // is present in config.toml (`ExperimentalBindStruct`/`AutomaticEnv`, `config.go:581-586`), + // so `SUPABASE_AUTH_EMAIL_SMTP_ENABLED`/`_HOST`/`_PORT`/`_USER`/`_PASS`/`_ADMIN_EMAIL` + // overrides apply before `Auth.Email.validate` runs (`config.go:1325-1344`) — layered on top + // of the presence-aware raw-document read above, same `envOverride`/`envOverridePort` + // precedent as every other field in this file. + const smtpDoc = asRecord(asRecord(authDocument?.["email"])?.["smtp"]); + const smtp: LegacySmtpInput | undefined = + smtpDoc !== undefined + ? { + enabled: legacyEnvOverrideBool( + "SUPABASE_AUTH_EMAIL_SMTP_ENABLED", + smtpDoc["enabled"] === undefined ? true : smtpDoc["enabled"] === true, + "auth.email.smtp.enabled", + projectEnvValues, + ), + host: + envOverride( + "SUPABASE_AUTH_EMAIL_SMTP_HOST", + typeof smtpDoc["host"] === "string" ? smtpDoc["host"] : "", + projectEnvValues, + ) ?? "", + port: envOverridePort( + "SUPABASE_AUTH_EMAIL_SMTP_PORT", + typeof smtpDoc["port"] === "number" ? smtpDoc["port"] : 0, + "auth.email.smtp.port", + projectEnvValues, + ), + user: + envOverride( + "SUPABASE_AUTH_EMAIL_SMTP_USER", + typeof smtpDoc["user"] === "string" ? smtpDoc["user"] : "", + projectEnvValues, + ) ?? "", + pass: + envOverride( + "SUPABASE_AUTH_EMAIL_SMTP_PASS", + typeof smtpDoc["pass"] === "string" ? smtpDoc["pass"] : "", + projectEnvValues, + ) ?? "", + adminEmail: + envOverride( + "SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", + typeof smtpDoc["admin_email"] === "string" ? smtpDoc["admin_email"] : "", + projectEnvValues, + ) ?? "", + } + : undefined; + + // Go's `(tpa *thirdParty) validate()` fixed provider order (`pkg/config/config.go:1635-1683`) + // — only enabled providers are forwarded, in that order. Like `Auth.MFA` above, each provider + // struct (`tpaFirebase`/`tpaAuth0`/`tpaCognito`/`tpaClerk`/`tpaWorkOs`, `auth.go:191-198`) is + // value-typed, so `SUPABASE_AUTH_THIRD_PARTY__*` overrides always apply — including + // `workos`, whose default template omits `[auth.third_party.workos]` entirely — before + // `Auth.ThirdParty.validate()` runs; no raw-document presence gate needed. + const thirdParty: Array = []; + if ( + legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", + config.auth.third_party.firebase.enabled, + "auth.third_party.firebase.enabled", + projectEnvValues, + ) + ) { + thirdParty.push({ + provider: "firebase", + requiredField: + envOverride( + "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", + config.auth.third_party.firebase.project_id, + projectEnvValues, + ) ?? "", + }); + } + if ( + legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", + config.auth.third_party.auth0.enabled, + "auth.third_party.auth0.enabled", + projectEnvValues, + ) + ) { + thirdParty.push({ + provider: "auth0", + requiredField: + envOverride( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", + config.auth.third_party.auth0.tenant, + projectEnvValues, + ) ?? "", + }); + } + if ( + legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", + config.auth.third_party.aws_cognito.enabled, + "auth.third_party.aws_cognito.enabled", + projectEnvValues, + ) + ) { + thirdParty.push({ + provider: "cognito", + requiredField: + envOverride( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", + config.auth.third_party.aws_cognito.user_pool_id, + projectEnvValues, + ) ?? "", + cognitoUserPoolRegion: envOverride( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", + config.auth.third_party.aws_cognito.user_pool_region, + projectEnvValues, + ), + }); + } + if ( + legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", + config.auth.third_party.clerk.enabled, + "auth.third_party.clerk.enabled", + projectEnvValues, + ) + ) { + thirdParty.push({ + provider: "clerk", + requiredField: + envOverride( + "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", + config.auth.third_party.clerk.domain, + projectEnvValues, + ) ?? "", + }); + } + if ( + legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", + config.auth.third_party.workos.enabled, + "auth.third_party.workos.enabled", + projectEnvValues, + ) + ) { + thirdParty.push({ + provider: "workos", + requiredField: + envOverride( + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", + config.auth.third_party.workos.issuer_url, + projectEnvValues, + ) ?? "", + }); + } + + authInput = { + siteUrl: siteUrl ?? "", + captcha: captchaInput, + passkey, + hooks, + mfa, + smtp, + thirdParty, + }; + } + // Go's `Config.Validate` runs `ValidateFunctionSlug` over every `[functions.*]` + // key right after the auth block/`generateAPIKeys`, unconditionally. + const functionSlugs = Object.keys(config.functions); + // Go's `Config.Validate` checks `edge_runtime.deno_version` after the auth + // block and the functions loop (`pkg/config/config.go:1158-1173`), and — + // unlike `studio.port`/`local_smtp.port` above — unconditionally, with no + // `edge_runtime.enabled` gate. + const denoVersion = envOverrideDenoVersion(config.edge_runtime.deno_version, projectEnvValues); + + // Go's `Config.Validate` validates `[analytics]` right after + // `edge_runtime.deno_version` (`pkg/config/config.go:1174-1187`): when + // `analytics.enabled` and `analytics.backend == "bigquery"`, all three GCP + // fields are required, checked in that order, each with its own message. + // Backend-enum validation (rejecting a non-postgres/bigquery value) is + // covered at decode time for the `config.toml`-sourced value by + // `@supabase/config`'s `stringEnum` (`packages/config/src/analytics.ts:17-41`), + // but that schema doesn't see the `SUPABASE_ANALYTICS_BACKEND` env-override + // path — see {@link envOverrideAnalyticsBackend} for that case. + const analyticsEnabled = legacyEnvOverrideBool( + "SUPABASE_ANALYTICS_ENABLED", + config.analytics.enabled, + "analytics.enabled", + projectEnvValues, + ); + const analyticsBackend = envOverrideAnalyticsBackend(config.analytics.backend, projectEnvValues); + const gcpProjectId = envOverride( + "SUPABASE_ANALYTICS_GCP_PROJECT_ID", + config.analytics.gcp_project_id, + projectEnvValues, + ); + const gcpProjectNumber = envOverride( + "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", + config.analytics.gcp_project_number, + projectEnvValues, + ); + const gcpJwtPath = envOverride( + "SUPABASE_ANALYTICS_GCP_JWT_PATH", + config.analytics.gcp_jwt_path, + projectEnvValues, + ); + + // Go's `Config.Validate` calls `c.Experimental.validate()` right after the + // analytics/bigquery block and right before returning. The webhooks check is NOT "the user + // disabled a feature" — Go's bool zero-value is `false`, so `e.Webhooks != nil && + // !e.Webhooks.Enabled` rejects ANY present `[experimental.webhooks]` section whose `enabled` + // isn't explicitly `true`, including one where the key is simply omitted; the section exists + // only so it can be turned on, never explicitly off. This hinges on PRESENCE of the TOML + // section, not the decoded `enabled` value — `@supabase/config`'s decode-time default + // (`packages/config/src/experimental.ts`'s `withDecodingDefaultKey(Effect.succeed({}))`) fills + // in `experimental.webhooks = { enabled: false }` on the DECODED `ProjectConfig` even when the + // TOML section is entirely absent — verified empirically, this default-fill erases exactly the + // presence signal this check needs. So this reads `LoadedProjectConfig.document` (the raw, + // pre-default TOML) instead, same as the passkey/smtp checks above. + const experimentalDocument = asRecord(document?.["experimental"]); + const webhooksPresent = asRecord(experimentalDocument?.["webhooks"]) !== undefined; + const webhooksEnabled = config.experimental.webhooks?.enabled === true; + const pgdeltaFormatOptions = config.experimental.pgdelta?.format_options ?? ""; + + // Every PURE Config.Validate check this module/legacy-config-validate.ts jointly own is + // deferred to this single call, positioned here (where the last of those checks ran until + // this commit), in Go's exact relative order against every OTHER pure check. This means a + // config broken in TWO OR MORE independent pure-section ways reports whichever Go considers + // first among the ones broken — unchanged from before. The only real reordering risk is + // between a pure check and one of this function's 3 I/O reads (signing keys, api.tls + // cert/key, email template/notification content) that in THIS function's source sits between + // two pure sections (e.g. the signing-keys read sits between the captcha check above and the + // passkey/hooks/mfa/email/smtp/third_party checks folded into `authInput` above) — that I/O + // read now effectively runs BEFORE those later pure checks rather than interleaved at its + // original relative position. This is the same narrow, accepted, documented tradeoff recorded + // in `legacy-config-validate.ts`'s module header; every existing test constructs exactly one + // validation failure at a time, so it has zero effect on any real test. + const apiInput: LegacyApiInput = { + enabled: apiEnabled, + port: apiPort, + tls: { enabled: apiTlsEnabled, certPath: apiTlsCertPath, keyPath: apiTlsKeyPath }, + }; + const dbInput: LegacyDbInput = { port: dbPort, majorVersion }; + const studioInput: LegacyStudioInput = { + enabled: studioEnabled, + port: studioPort, + apiUrl: studioApiUrl, + }; + const localSmtpInput: LegacyLocalSmtpInput = { enabled: mailpitEnabled, port: mailpitPort }; + const analyticsInput: LegacyAnalyticsInput = { + enabled: analyticsEnabled, + backend: analyticsBackend, + gcpProjectId: gcpProjectId ?? "", + gcpProjectNumber: gcpProjectNumber ?? "", + gcpJwtPath: gcpJwtPath ?? "", + }; + const experimentalInput: LegacyExperimentalInput = { + webhooksPresent, + webhooksEnabled, + pgdeltaFormatOptions, + }; + + const input: LegacyConfigValidationInput = { + projectId: resolvedProjectId, + api: apiInput, + db: dbInput, + storageBucketNames, + studio: studioInput, + localSmtp: localSmtpInput, + auth: authInput, + functionSlugs, + edgeRuntimeDenoVersion: denoVersion, + analytics: analyticsInput, + experimental: experimentalInput, + }; + legacyValidateResolvedConfig(input); + // Both run after the single shared `legacyValidateResolvedConfig` call per the module's + // documented sms/external-vs-third_party ordering tradeoff (third_party is checked inside that + // call; sms/external run after it here) — in Go's own relative sms-then-external order + // (`config.go:1145-1150`). `validateAuthSmsProviders` re-runs `@supabase/config`'s schema-level + // switch with env overrides applied (see its doc comment); `validateAuthExternalProviders` is + // D-only per `legacy-config-validate.ts`'s module header ("auth.external ... stays 100% inline + // in D") — this is L's port of D's identical inline block. + if (authEnabled) { + validateAuthSmsProviders(authDocument, projectEnvValues); + validateAuthExternalProviders(authDocument, projectEnvValues); + } + + return { + apiUrl: apiExternalUrl, + restUrl: apiUrlWithPath(apiExternalUrl, "/rest/v1"), + graphqlUrl: apiUrlWithPath(apiExternalUrl, "/graphql/v1"), + functionsUrl: apiUrlWithPath(apiExternalUrl, "/functions/v1"), + mcpUrl: apiUrlWithPath(apiExternalUrl, "/mcp"), + studioUrl: `http://${hostname}:${studioPort}`, + mailpitUrl: `http://${hostname}:${mailpitPort}`, + dbUrl: `postgresql://postgres:${DEFAULT_DB_PASSWORD}@${hostname}:${dbPort}/postgres`, + publishableKey: resolveOpaqueKey( + decryptAuthSecret( + envOverride("SUPABASE_AUTH_PUBLISHABLE_KEY", config.auth.publishable_key, projectEnvValues), + projectEnvValues, + ), + defaultPublishableKey, + ), + secretKey: resolveOpaqueKey( + decryptAuthSecret( + envOverride("SUPABASE_AUTH_SECRET_KEY", config.auth.secret_key, projectEnvValues), + projectEnvValues, + ), + defaultSecretKey, + ), + jwtSecret, + anonKey: resolveSignedKey( + decryptAuthSecret( + envOverride("SUPABASE_AUTH_ANON_KEY", config.auth.anon_key, projectEnvValues), + projectEnvValues, + ), + jwtSecret, + signingKey, + "anon", + ), + serviceRoleKey: resolveSignedKey( + decryptAuthSecret( + envOverride( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + config.auth.service_role_key, + projectEnvValues, + ), + projectEnvValues, + ), + jwtSecret, + signingKey, + "service_role", + ), + storageS3Url: apiUrlWithPath(apiExternalUrl, "/storage/v1/s3"), + storageS3AccessKeyId: DEFAULT_S3_ACCESS_KEY_ID, + storageS3SecretAccessKey: DEFAULT_S3_SECRET_ACCESS_KEY, + storageS3Region: DEFAULT_S3_REGION, + }; +} diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts new file mode 100644 index 0000000000..d3ca495d7c --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -0,0 +1,1716 @@ +import { generateKeyPairSync } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; +import { Schema } from "effect"; +import { importJWK, jwtVerify } from "jose"; +import { afterEach, describe, expect, it } from "vitest"; + +import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; +import { + LegacyInvalidAnalyticsBackendEnvOverrideError, + LegacyInvalidBoolEnvOverrideError, + LegacyInvalidJwtSecretError, + LegacyInvalidPortEnvOverrideError, + legacyResolveLocalConfigValues, +} from "./legacy-local-config-values.ts"; + +const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); +const WORKDIR = "/tmp/legacy-local-config-values-test"; + +function baseConfig(overrides: Record = {}): ProjectConfig { + return decodeConfig({ project_id: "test", ...overrides }); +} + +/** RSA JWK matching Go's `JWK` struct field names (kty/n/e/d/p/q/dp/dq/qi). */ +function generateRsaJwk(): Record { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = privateKey.export({ format: "jwk" }); + return { ...jwk, alg: "RS256", kid: "test-rsa-kid" }; +} + +function writeSigningKeys(workdir: string, jwks: ReadonlyArray>) { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "signing_keys.json"), JSON.stringify(jwks)); +} + +describe("legacyResolveLocalConfigValues", () => { + it("derives every URL from api.external_url when unset", () => { + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + + expect(values.apiUrl).toBe("http://127.0.0.1:54321"); + expect(values.restUrl).toBe("http://127.0.0.1:54321/rest/v1"); + expect(values.graphqlUrl).toBe("http://127.0.0.1:54321/graphql/v1"); + expect(values.functionsUrl).toBe("http://127.0.0.1:54321/functions/v1"); + expect(values.mcpUrl).toBe("http://127.0.0.1:54321/mcp"); + expect(values.storageS3Url).toBe("http://127.0.0.1:54321/storage/v1/s3"); + expect(values.studioUrl).toBe("http://127.0.0.1:54323"); + expect(values.mailpitUrl).toBe("http://127.0.0.1:54324"); + }); + + it("uses https and the configured port when api.tls.enabled", () => { + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://127.0.0.1:54321"); + }); + + it("uses api.external_url verbatim when configured", () => { + const config = baseConfig({ api: { external_url: "https://example.test" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://example.test"); + expect(values.restUrl).toBe("https://example.test/rest/v1"); + }); + + it("brackets an IPv6 hostname when building host:port", () => { + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "::1", WORKDIR); + expect(values.apiUrl).toBe("http://[::1]:54321"); + }); + + it("builds the db URL with the hardcoded postgres password", () => { + const config = baseConfig({ db: { port: 54322 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); + }); + + it("falls back to the default JWT secret and opaque keys when unset", () => { + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.jwtSecret).toBe("super-secret-jwt-token-with-at-least-32-characters-long"); + expect(values.publishableKey).toBe("sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH"); + expect(values.secretKey).toBe("sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz"); + }); + + it("uses configured opaque keys verbatim when set", () => { + const config = baseConfig({ + auth: { publishable_key: "sb_publishable_custom", secret_key: "sb_secret_custom" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.publishableKey).toBe("sb_publishable_custom"); + expect(values.secretKey).toBe("sb_secret_custom"); + }); + + it("signs the default anon/service_role JWTs from the resolved secret", () => { + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + // Byte-exact Go-parity shape is covered by legacy-go-jwt.unit.test.ts; here we + // only assert the resolver wires the default secret through to both roles. + const [, anonPayload] = values.anonKey.split("."); + const [, serviceRolePayload] = values.serviceRoleKey.split("."); + expect(JSON.parse(Buffer.from(anonPayload ?? "", "base64url").toString())).toMatchObject({ + role: "anon", + }); + expect(JSON.parse(Buffer.from(serviceRolePayload ?? "", "base64url").toString())).toMatchObject( + { role: "service_role" }, + ); + }); + + it("uses configured anon/service_role keys verbatim when set", () => { + const config = baseConfig({ + auth: { anon_key: "configured-anon", service_role_key: "configured-service-role" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.anonKey).toBe("configured-anon"); + expect(values.serviceRoleKey).toBe("configured-service-role"); + }); + + it("signs anon/service_role JWTs from a configured jwt_secret", () => { + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.jwtSecret).toBe("a".repeat(32)); + expect(values.anonKey).not.toBe(""); + }); + + it("rejects a configured jwt_secret shorter than 16 characters", () => { + // Go's Config.Validate fails this at config-load time, before any command + // can render output (pkg/config/apikeys.go:45-47) — reproduced as a thrown + // error here rather than silently signing with the too-short secret. + const config = baseConfig({ auth: { jwt_secret: "a".repeat(15) } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidJwtSecretError, + ); + }); + + describe("encrypted auth secrets", () => { + // Go's test vector (`apps/cli-go/pkg/config/secret_test.go`): this ciphertext + // decrypts to "value" under the keypair below. + const VAULT_PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; + const VAULT_ENCRYPTED = + "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; + + afterEach(() => { + delete process.env["DOTENV_PRIVATE_KEY"]; + }); + + it("decrypts an encrypted: jwt_secret when DOTENV_PRIVATE_KEY is set", () => { + // "value" is only 5 characters, shorter than Go's minimum JWT secret length, + // so pad it out the way a real deployment's decrypted secret would be sized. + process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; + const config = baseConfig({ auth: { jwt_secret: VAULT_ENCRYPTED } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidJwtSecretError, + ); + }); + + it("decrypts an encrypted: publishable_key when DOTENV_PRIVATE_KEY is set", () => { + process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; + const config = baseConfig({ auth: { publishable_key: VAULT_ENCRYPTED } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.publishableKey).toBe("value"); + }); + + it("fails config loading for an encrypted: secret with no private key, matching Go", () => { + // Go aborts the whole command with `failed to parse config: ` rather + // than silently using the ciphertext as literal key material + // (`secret.go:30-73`, `config.go:704`). + const config = baseConfig({ auth: { publishable_key: VAULT_ENCRYPTED } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "failed to parse config: missing private key", + ); + }); + + it("decrypts an encrypted: SUPABASE_AUTH_* env override, not just the config.toml value", () => { + // Go's decrypt hook runs on whatever value reaches the config.Secret field, + // whether it was sourced from config.toml or a Viper env override. + process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, { + SUPABASE_AUTH_SECRET_KEY: VAULT_ENCRYPTED, + }); + expect(values.secretKey).toBe("value"); + delete process.env["DOTENV_PRIVATE_KEY"]; + }); + }); + + it("rejects an explicit empty project_id, matching Go's Config.Validate", () => { + // Go's Config.Validate checks ProjectId first, before any other field + // (pkg/config/config.go:990-991). The workdir-basename default is merged + // in as a viper default BEFORE config.toml is merged, so an explicit + // `project_id = ""` in the file overwrites that default with the literal + // empty string rather than being treated as absent — Go fails outright. + const config = baseConfig({ project_id: "" }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: project_id", + ); + }); + + it("does not reject an absent project_id when the workdir basename sanitizes to a non-empty value", () => { + const config = Schema.decodeUnknownSync(ProjectConfigSchema)({}); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects an absent project_id when the workdir basename sanitizes to empty, matching Go", () => { + // Go's `mergeDefaultValues` merges `sanitizeProjectId(filepath.Base(cwd))` in as a viper + // DEFAULT before config.toml is merged (config.go:690-699, via Eject at config.go:561-570) — + // so `c.ProjectId` is never Go's zero value by the time `Validate` runs. A workdir whose + // basename sanitizes to `""` (every character invalid, e.g. `!!!`) therefore still fails + // config loading in Go even with no `project_id` key in the file at all. + const config = Schema.decodeUnknownSync(ProjectConfigSchema)({}); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", "/tmp/!!!")).toThrow( + "Missing required field in config: project_id", + ); + }); + + it("lets SUPABASE_PROJECT_ID override an absent project_id whose basename sanitizes to empty", () => { + const config = Schema.decodeUnknownSync(ProjectConfigSchema)({}); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", "/tmp/!!!", { + SUPABASE_PROJECT_ID: "env-project", + }), + ).not.toThrow(); + }); + + it("lets SUPABASE_PROJECT_ID override an explicit empty project_id", () => { + // Viper's AutomaticEnv binds SUPABASE_PROJECT_ID with higher precedence + // than config.toml (config.go:529-535), so a non-empty env override must + // win even when the file's project_id is explicitly empty. + const config = baseConfig({ project_id: "" }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, { + SUPABASE_PROJECT_ID: "env-project", + }), + ).not.toThrow(); + }); + + it("hardcodes the Go-parity local S3 credentials", () => { + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.storageS3AccessKeyId).toBe("625729a08b95bf1b7ff351a663f3a23c"); + expect(values.storageS3SecretAccessKey).toBe( + "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + ); + expect(values.storageS3Region).toBe("local"); + }); + + describe("SUPABASE_AUTH_* env overrides", () => { + const tempRoot = useLegacyTempWorkdir("supabase-signing-keys-env-override-test-"); + + // Go's Config.Load binds Viper with SetEnvPrefix("SUPABASE") + AutomaticEnv() + // (pkg/config/config.go:529-535) — env vars take precedence over config.toml. + const ENV_KEYS = [ + "SUPABASE_AUTH_JWT_SECRET", + "SUPABASE_AUTH_PUBLISHABLE_KEY", + "SUPABASE_AUTH_SECRET_KEY", + "SUPABASE_AUTH_ANON_KEY", + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + ] as const; + + afterEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + }); + + it("overrides jwt_secret even when config.toml sets one", () => { + process.env["SUPABASE_AUTH_JWT_SECRET"] = "b".repeat(32); + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.jwtSecret).toBe("b".repeat(32)); + }); + + it("overrides publishable_key/secret_key", () => { + process.env["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "env-publishable"; + process.env["SUPABASE_AUTH_SECRET_KEY"] = "env-secret"; + const config = baseConfig({ + auth: { publishable_key: "config-publishable", secret_key: "config-secret" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.publishableKey).toBe("env-publishable"); + expect(values.secretKey).toBe("env-secret"); + }); + + it("overrides anon_key/service_role_key", () => { + process.env["SUPABASE_AUTH_ANON_KEY"] = "env-anon"; + process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = "env-service-role"; + const config = baseConfig({ + auth: { anon_key: "config-anon", service_role_key: "config-service-role" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.anonKey).toBe("env-anon"); + expect(values.serviceRoleKey).toBe("env-service-role"); + }); + + it("treats an empty env var as unset, matching Viper's default", () => { + process.env["SUPABASE_AUTH_JWT_SECRET"] = ""; + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.jwtSecret).toBe("a".repeat(32)); + }); + + it("still applies the short-secret validation to an env-provided jwt_secret", () => { + process.env["SUPABASE_AUTH_JWT_SECRET"] = "too-short"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidJwtSecretError, + ); + }); + + it("overrides signing_keys_path even when config.toml doesn't set one", async () => { + const jwk = generateRsaJwk(); + writeSigningKeys(tempRoot.current, [jwk]); + process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "signing_keys.json"; + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + + const publicJwk = { ...jwk, d: undefined, p: undefined, q: undefined, dp: undefined }; + const publicKey = await importJWK(publicJwk, "RS256"); + const { protectedHeader } = await jwtVerify(values.anonKey, publicKey); + expect(protectedHeader).toMatchObject({ alg: "RS256", kid: "test-rsa-kid" }); + }); + + it("prefers an env-provided signing_keys_path over config.toml's", () => { + const envJwk = { ...generateRsaJwk(), kid: "env-kid" }; + const configJwk = { ...generateRsaJwk(), kid: "config-kid" }; + writeSigningKeys(tempRoot.current, [envJwk]); + const supabaseDir = join(tempRoot.current, "supabase"); + writeFileSync(join(supabaseDir, "other_keys.json"), JSON.stringify([configJwk])); + process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "signing_keys.json"; + const config = baseConfig({ auth: { signing_keys_path: "other_keys.json" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + const [header] = values.anonKey.split("."); + expect(JSON.parse(Buffer.from(header ?? "", "base64url").toString())).toMatchObject({ + kid: "env-kid", + }); + }); + }); + + describe("SUPABASE_* env(VAR) indirection (Go's LoadEnvHook)", () => { + // Go's `LoadEnvHook` (`apps/cli-go/pkg/config/decode_hooks.go:15-23`) is + // the first mapstructure decode hook composed into `v.UnmarshalExact` + // (`config.go:749-753,769-772`), so it resolves a nested `env(VAR)` + // reference on ANY string mapstructure decodes into the struct — including + // a `SUPABASE_*` env-override value itself, not just a `config.toml` + // literal. `envOverride`'s callers (string/port/bool fields) must all see + // that same resolution. + const ENV_KEYS = ["SUPABASE_AUTH_JWT_SECRET", "SUPABASE_DB_PORT", "SUPABASE_API_ENABLED"]; + + afterEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + delete process.env["INDIRECT_JWT_SECRET"]; + delete process.env["INDIRECT_DB_PORT"]; + delete process.env["INDIRECT_API_ENABLED"]; + }); + + it("resolves a string override's env(VAR) indirection", () => { + process.env["SUPABASE_AUTH_JWT_SECRET"] = "env(INDIRECT_JWT_SECRET)"; + process.env["INDIRECT_JWT_SECRET"] = "c".repeat(32); + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.jwtSecret).toBe("c".repeat(32)); + }); + + it("resolves a port override's env(VAR) indirection", () => { + process.env["SUPABASE_DB_PORT"] = "env(INDIRECT_DB_PORT)"; + process.env["INDIRECT_DB_PORT"] = "54329"; + const config = baseConfig({ db: { port: 54322 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54329/postgres"); + }); + + it("resolves a bool override's env(VAR) indirection", () => { + process.env["SUPABASE_API_ENABLED"] = "env(INDIRECT_API_ENABLED)"; + process.env["INDIRECT_API_ENABLED"] = "false"; + const config = baseConfig({ + api: { enabled: true, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + }); + // If the bool override weren't resolved through the indirection, the + // literal "env(INDIRECT_API_ENABLED)" string would fail Go's + // strconv.ParseBool acceptance set and throw LegacyInvalidBoolEnvOverrideError; + // resolving it to "false" disables api.enabled and skips the TLS check + // that would otherwise throw on the missing cert file. + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("preserves the env(VAR) literal when the indirected var is unset, matching Go", () => { + process.env["SUPABASE_AUTH_JWT_SECRET"] = "env(INDIRECT_JWT_SECRET)"; + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + // Go's LoadEnvHook only substitutes when the target var is non-empty + // (`decode_hooks.go:19-24`) — an unset indirection leaves the literal + // `env(VAR)` string, same as an unresolved config.toml-level reference. + expect(values.jwtSecret).toBe("env(INDIRECT_JWT_SECRET)"); + }); + }); + + describe("non-auth SUPABASE_* env overrides", () => { + // Go's Config.Load binds Viper with SetEnvPrefix("SUPABASE") + AutomaticEnv() + // generically across the whole config struct (pkg/config/config.go:529-535), + // not just auth fields — config_test.go:351,1061 exercise this against + // auth.site_url, and status.go's toValues() reads the already-overridden + // utils.Config.* directly, so every port/URL status derives must honor the + // same override. + const ENV_KEYS = [ + "SUPABASE_DB_PORT", + "SUPABASE_STUDIO_PORT", + "SUPABASE_LOCAL_SMTP_PORT", + "SUPABASE_API_PORT", + "SUPABASE_API_EXTERNAL_URL", + "SUPABASE_STUDIO_API_URL", + ] as const; + + afterEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + }); + + it("overrides db.port for the derived DB URL", () => { + process.env["SUPABASE_DB_PORT"] = "54329"; + const config = baseConfig({ db: { port: 54322 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54329/postgres"); + }); + + it("overrides studio.port for the derived Studio URL", () => { + process.env["SUPABASE_STUDIO_PORT"] = "54330"; + const config = baseConfig({ studio: { port: 54323 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.studioUrl).toBe("http://127.0.0.1:54330"); + }); + + it("overrides local_smtp.port for the derived Mailpit URL", () => { + process.env["SUPABASE_LOCAL_SMTP_PORT"] = "54331"; + const config = baseConfig({ local_smtp: { port: 54324 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.mailpitUrl).toBe("http://127.0.0.1:54331"); + }); + + it("overrides api.port for every API-derived URL", () => { + process.env["SUPABASE_API_PORT"] = "54332"; + const config = baseConfig({ api: { port: 54321 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("http://127.0.0.1:54332"); + expect(values.restUrl).toBe("http://127.0.0.1:54332/rest/v1"); + }); + + it("overrides api.external_url even when config.toml sets one", () => { + process.env["SUPABASE_API_EXTERNAL_URL"] = "https://env-override.example"; + const config = baseConfig({ api: { external_url: "https://config.example" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://env-override.example"); + }); + + it("treats an empty non-auth env var as unset, matching Viper's default", () => { + process.env["SUPABASE_DB_PORT"] = ""; + const config = baseConfig({ db: { port: 54322 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); + }); + + // Go's Config.Load decodes `SUPABASE_*_PORT` overrides as `uint16` via + // Viper's UnmarshalExact (pkg/config/config.go:749-756, WeaklyTypedInput + // decodes the override string with strconv.ParseUint and hard-fails on a + // malformed value) rather than silently producing a `NaN`-laced URL. + it.each([ + "SUPABASE_DB_PORT", + "SUPABASE_STUDIO_PORT", + "SUPABASE_LOCAL_SMTP_PORT", + "SUPABASE_API_PORT", + ] as const)("rejects a malformed %s override instead of producing NaN", (envKey) => { + process.env[envKey] = "abc"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidPortEnvOverrideError, + ); + }); + + it("rejects a SUPABASE_DB_PORT override above the uint16 range", () => { + process.env["SUPABASE_DB_PORT"] = "99999"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidPortEnvOverrideError, + ); + }); + + // Unlike the malformed/out-of-range cases above (a decode-time hard-fail, + // uniform across all four SUPABASE_*_PORT fields), db.port=0 is a + // Config.Validate-time hard-fail specific to db.port: it has no `enabled` + // gate in Go, unlike api.port/studio.port/local_smtp.port + // (pkg/config/config.go:1006-1009,1031-1032,1070-1073,1081-1084). + it("rejects a zero SUPABASE_DB_PORT override, matching Go's required-field check", () => { + process.env["SUPABASE_DB_PORT"] = "0"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: db.port", + ); + }); + + // Unlike db.port, Go gates the api.port===0 rejection on api.enabled + // (pkg/config/config.go:1006-1008) — api.enabled defaults to true, so a + // configured or env-overridden zero port is rejected by default. + it("rejects a configured api.port of 0 when api is enabled", () => { + const config = baseConfig({ api: { port: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: api.port", + ); + }); + + it("rejects a zero SUPABASE_API_PORT override when api is enabled", () => { + process.env["SUPABASE_API_PORT"] = "0"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: api.port", + ); + }); + + it("does not reject a zero api.port when api is disabled", () => { + const config = baseConfig({ api: { enabled: false, port: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + // Go gates the studio.port===0 rejection on studio.enabled + // (pkg/config/config.go:1070-1073), same pattern as api.port above. + // studio.enabled defaults to true, so a configured or env-overridden zero + // port is rejected by default. + it("rejects a configured studio.port of 0 when studio is enabled", () => { + const config = baseConfig({ studio: { port: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: studio.port", + ); + }); + + it("rejects a zero SUPABASE_STUDIO_PORT override when studio is enabled", () => { + process.env["SUPABASE_STUDIO_PORT"] = "0"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: studio.port", + ); + }); + + it("does not reject a zero studio.port when studio is disabled", () => { + const config = baseConfig({ studio: { enabled: false, port: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + // Go's Config.Validate parses studio.api_url with net/url.Parse right + // after the port check, still inside `if c.Studio.Enabled` + // (pkg/config/config.go:1074-1078). + it("rejects a malformed studio.api_url (unterminated IPv6 literal) when studio is enabled", () => { + const config = baseConfig({ studio: { api_url: "http://[::1" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + `Invalid config for studio.api_url: parse "http://[::1": missing ']' in host`, + ); + }); + + it("does not reject a malformed studio.api_url when studio is disabled", () => { + const config = baseConfig({ studio: { enabled: false, api_url: "http://[::1" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("does not throw for the default studio.api_url", () => { + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects a malformed SUPABASE_STUDIO_API_URL override", () => { + process.env["SUPABASE_STUDIO_API_URL"] = "http://[::1"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + `Invalid config for studio.api_url: parse "http://[::1": missing ']' in host`, + ); + }); + + // Go gates the local_smtp.port===0 rejection on local_smtp.enabled (Go's + // struct field is still named `Inbucket` for the `[local_smtp]` TOML + // section, pkg/config/config.go:235,1081-1083), same pattern as api.port/ + // studio.port above. local_smtp.enabled defaults to true, so a configured + // or env-overridden zero port is rejected by default. + it("rejects a configured local_smtp.port of 0 when local_smtp is enabled", () => { + const config = baseConfig({ local_smtp: { port: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: local_smtp.port", + ); + }); + + it("rejects a zero SUPABASE_LOCAL_SMTP_PORT override when local_smtp is enabled", () => { + process.env["SUPABASE_LOCAL_SMTP_PORT"] = "0"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: local_smtp.port", + ); + }); + + it("does not reject a zero local_smtp.port when local_smtp is disabled", () => { + const config = baseConfig({ local_smtp: { enabled: false, port: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("db.major_version (required field in config)", () => { + // The pure 0/12/13-17/generic-invalid assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // only the SUPABASE_DB_MAJOR_VERSION env-override mechanics stay here. + afterEach(() => { + delete process.env["SUPABASE_DB_MAJOR_VERSION"]; + }); + + it("overrides a valid configured major_version via SUPABASE_DB_MAJOR_VERSION", () => { + process.env["SUPABASE_DB_MAJOR_VERSION"] = "15"; + const config = baseConfig({ db: { major_version: 17 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects an unsupported SUPABASE_DB_MAJOR_VERSION override", () => { + process.env["SUPABASE_DB_MAJOR_VERSION"] = "16"; + const config = baseConfig({ db: { major_version: 17 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid db.major_version: 16.", + ); + }); + + it("rejects a non-numeric SUPABASE_DB_MAJOR_VERSION override", () => { + process.env["SUPABASE_DB_MAJOR_VERSION"] = "abc"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid db.major_version: abc.", + ); + }); + + it("treats an empty SUPABASE_DB_MAJOR_VERSION override as unset, matching Viper's default", () => { + process.env["SUPABASE_DB_MAJOR_VERSION"] = ""; + const config = baseConfig({ db: { major_version: 17 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + // Go's Config.Validate runs ValidateBucketName over every [storage.buckets.*] + // key right after db.major_version, unconditionally — there is no + // storage.enabled-style gate (pkg/config/config.go:1063-1068). + // + // Moved to `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` + // calls) — this section has no L-specific derivation or env-override mechanics of its own. + + // Go's Config.Validate rejects an invalid edge_runtime.deno_version + // unconditionally — NOT gated on edge_runtime.enabled + // (pkg/config/config.go:1164-1173). + describe("edge_runtime.deno_version (required field in config)", () => { + // The pure 0/1/2/generic-invalid/disabled assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // only the SUPABASE_EDGE_RUNTIME_DENO_VERSION env-override mechanics stay here. + afterEach(() => { + delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + }); + + it("rejects a zero SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "0"; + const config = baseConfig({ edge_runtime: { deno_version: 2 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: edge_runtime.deno_version", + ); + }); + + it("rejects an unsupported SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "3"; + const config = baseConfig({ edge_runtime: { deno_version: 2 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid edge_runtime.deno_version: 3.", + ); + }); + + it("rejects a non-numeric SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid edge_runtime.deno_version: abc.", + ); + }); + + it("treats an empty SUPABASE_EDGE_RUNTIME_DENO_VERSION override as unset, matching Viper's default", () => { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = ""; + const config = baseConfig({ edge_runtime: { deno_version: 2 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("analytics (BigQuery backend required fields)", () => { + // Go's `Config.Validate` validates `[analytics]` right after + // `edge_runtime.deno_version` (`pkg/config/config.go:1174-1187`): when + // `analytics.enabled` and `analytics.backend == "bigquery"`, all three GCP + // fields are required, checked in that order. + // + // The pure required-field/complete/disabled assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // only the SUPABASE_ANALYTICS_* env-override mechanics stay here. + afterEach(() => { + delete process.env["SUPABASE_ANALYTICS_ENABLED"]; + delete process.env["SUPABASE_ANALYTICS_BACKEND"]; + delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; + delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; + delete process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; + }); + + it("rejects a bigquery backend enabled only via SUPABASE_ANALYTICS_ENABLED", () => { + process.env["SUPABASE_ANALYTICS_ENABLED"] = "true"; + const config = baseConfig({ analytics: { enabled: false, backend: "bigquery" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: analytics.gcp_project_id", + ); + }); + + it("rejects a bigquery backend selected only via SUPABASE_ANALYTICS_BACKEND", () => { + process.env["SUPABASE_ANALYTICS_BACKEND"] = "bigquery"; + const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: analytics.gcp_project_id", + ); + }); + + it("accepts env-provided GCP fields overriding empty config.toml values", () => { + process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "proj"; + process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "123"; + process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "gcp.json"; + const config = baseConfig({ analytics: { enabled: true, backend: "bigquery" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + // Go's `LogflareBackend.UnmarshalText` (`config.go:60-65`) hard-rejects any + // `analytics.backend` value outside `postgres`/`bigquery` during the same + // `UnmarshalExact` decode every `SUPABASE_*` override goes through + // (`config.go:749-756`) — a malformed `SUPABASE_ANALYTICS_BACKEND` fails + // config loading outright, same mechanism as the port/bool overrides below. + it("rejects an invalid SUPABASE_ANALYTICS_BACKEND override", () => { + process.env["SUPABASE_ANALYTICS_BACKEND"] = "mysql"; + const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidAnalyticsBackendEnvOverrideError, + ); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for analytics.backend: cannot parse "mysql" as one of "postgres", "bigquery"', + ); + }); + }); + + describe("experimental.* (experimental.validate())", () => { + // Go's `(e *experimental) validate()` (`pkg/config/config.go:1846-1854`), + // called right after the analytics/bigquery block and right before + // `Config.Validate` returns — unconditionally, no `enabled` gate of its own. + // + // Every webhooks-presence/enabled combination and the pgdelta format_options JSON checks + // moved to `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` + // calls, setting `experimental.webhooksPresent`/`webhooksEnabled` directly instead of + // deriving them from a raw `document`) — only this document-THREADING-specific case stays + // here, since it exercises this function's own "no document provided" fallback rather than + // a check `legacyValidateResolvedConfig` itself owns. + it("does not throw a present [experimental.webhooks] section without enabled when no document is provided", () => { + // No `document` (5th param) at all — e.g. a caller that hasn't threaded + // `LoadedProjectConfig.document` through yet. The presence-only check + // can't run without it, so it's skipped rather than guessed at; this + // also covers every pre-existing call site/test in this file that + // doesn't pass a 5th argument. + const config = baseConfig({ experimental: { webhooks: {} } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("SUPABASE_API_TLS_ENABLED env override", () => { + // Go applies the Viper-bound `api.tls.enabled` override (config.go:582-586) + // BEFORE deriving the default `api.external_url` scheme (config.go:799-809), + // so an ambient/dotenv override flips http/https even when config.toml says + // otherwise. + afterEach(() => { + delete process.env["SUPABASE_API_TLS_ENABLED"]; + }); + + it("overrides api.tls.enabled from false to true", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = "true"; + const config = baseConfig({ api: { tls: { enabled: false }, port: 54321 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://127.0.0.1:54321"); + }); + + it("overrides api.tls.enabled from true to false", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = "false"; + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("http://127.0.0.1:54321"); + }); + + it("does not override api.tls.enabled once api.external_url is set", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = "true"; + const config = baseConfig({ api: { external_url: "http://config.example" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("http://config.example"); + }); + + it("rejects a malformed override instead of falling back to the configured value", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = "not-a-bool"; + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidBoolEnvOverrideError, + ); + }); + + it("treats an empty override as unset, matching Viper's default", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = ""; + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://127.0.0.1:54321"); + }); + }); + + describe("auth.signing_keys_path (asymmetric JWT signing)", () => { + const tempRoot = useLegacyTempWorkdir("supabase-signing-keys-test-"); + + it("signs anon/service_role with the first RS256 key in the file", async () => { + const jwk = generateRsaJwk(); + writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + + const publicJwk = { ...jwk, d: undefined, p: undefined, q: undefined, dp: undefined }; + const publicKey = await importJWK(publicJwk, "RS256"); + const { payload, protectedHeader } = await jwtVerify(values.anonKey, publicKey); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); + expect(protectedHeader).toMatchObject({ alg: "RS256", kid: "test-rsa-kid" }); + + const serviceRole = await jwtVerify(values.serviceRoleKey, publicKey); + expect(serviceRole.payload).toMatchObject({ role: "service_role" }); + }); + + it("resolves a relative signing_keys_path against /supabase", async () => { + const jwk = generateRsaJwk(); + writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ auth: { signing_keys_path: "./signing_keys.json" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + expect(values.anonKey.split(".")).toHaveLength(3); + }); + + it("uses an absolute signing_keys_path as-is, without joining the workdir", async () => { + const jwk = generateRsaJwk(); + writeSigningKeys(tempRoot.current, [jwk]); + const absolutePath = join(tempRoot.current, "supabase", "signing_keys.json"); + const config = baseConfig({ auth: { signing_keys_path: absolutePath } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", "/some/unrelated/workdir"); + expect(values.anonKey.split(".")).toHaveLength(3); + }); + + it("still prefers an explicit anon_key/service_role_key over signing keys", () => { + writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + const config = baseConfig({ + auth: { + signing_keys_path: "signing_keys.json", + anon_key: "configured-anon", + service_role_key: "configured-service-role", + }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + expect(values.anonKey).toBe("configured-anon"); + expect(values.serviceRoleKey).toBe("configured-service-role"); + }); + + it("falls back to HMAC signing when signing_keys_path resolves to an empty array", () => { + writeSigningKeys(tempRoot.current, []); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + const [, payload] = values.anonKey.split("."); + expect(JSON.parse(Buffer.from(payload ?? "", "base64url").toString())).toMatchObject({ + iss: "supabase-demo", + }); + }); + + it("throws a Go-worded error when the signing keys file does not exist", () => { + const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read signing keys: ", + ); + }); + + it("throws a Go-worded error when the signing keys file is malformed JSON", () => { + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "signing_keys.json"), "not valid json"); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to decode signing keys: ", + ); + }); + + it("throws when the first key uses an unsupported algorithm", () => { + writeSigningKeys(tempRoot.current, [{ ...generateRsaJwk(), alg: "RS512" }]); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "unsupported algorithm: RS512", + ); + }); + + // Go's `Validate` only opens/parses `signing_keys_path` inside + // `if c.Auth.Enabled` (`pkg/config/config.go:1036,1059-1065`) — a disabled + // auth section never touches the file, however stale or missing it is. + it("skips reading a missing signing_keys_path when auth is disabled", () => { + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "missing.json" }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("skips reading a malformed signing_keys_path when auth is disabled", () => { + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "signing_keys.json"), "not valid json"); + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "signing_keys.json" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + // Falls back to HMAC signing, matching an absent signing key. + const [, payload] = values.anonKey.split("."); + expect(JSON.parse(Buffer.from(payload ?? "", "base64url").toString())).toMatchObject({ + iss: "supabase-demo", + }); + }); + + describe("SUPABASE_AUTH_ENABLED env override", () => { + // `c.Auth.Enabled` is Viper-bound like any other field + // (config.go:582-586), so `Validate`'s `if c.Auth.Enabled` gate + // (config.go:1036,1059-1065) reads the POST-override value, not raw + // TOML — a stale/missing signing_keys_path must be skipped when auth is + // disabled only via env/dotenv, and read when auth is enabled only via + // env/dotenv despite TOML saying otherwise. + afterEach(() => { + delete process.env["SUPABASE_AUTH_ENABLED"]; + }); + + it("skips reading a missing signing_keys_path when auth is disabled only via env", () => { + process.env["SUPABASE_AUTH_ENABLED"] = "false"; + const config = baseConfig({ + auth: { enabled: true, signing_keys_path: "missing.json" }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("reads signing_keys_path when auth is enabled only via env despite TOML saying disabled", async () => { + process.env["SUPABASE_AUTH_ENABLED"] = "true"; + const jwk = generateRsaJwk(); + writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "signing_keys.json" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + expect(values.anonKey.split(".")).toHaveLength(3); + }); + + it("rejects a malformed override instead of falling back to the configured value", () => { + process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "missing.json" }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + LegacyInvalidBoolEnvOverrideError, + ); + }); + }); + }); + + describe("auth.site_url (required field in config)", () => { + // The pure empty/set/disabled assertions moved to `legacy-config-validate.unit.test.ts` + // (direct `legacyValidateResolvedConfig` calls) — only the SUPABASE_AUTH_ENABLED / + // SUPABASE_AUTH_SITE_URL env-override mechanics stay here. + describe("SUPABASE_AUTH_ENABLED / SUPABASE_AUTH_SITE_URL env overrides", () => { + afterEach(() => { + delete process.env["SUPABASE_AUTH_ENABLED"]; + delete process.env["SUPABASE_AUTH_SITE_URL"]; + }); + + it("rejects an empty site_url when auth is enabled only via env", () => { + process.env["SUPABASE_AUTH_ENABLED"] = "true"; + const config = baseConfig({ auth: { enabled: false, site_url: "" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: auth.site_url", + ); + }); + + it("does not throw when auth is disabled only via env, however empty site_url is", () => { + process.env["SUPABASE_AUTH_ENABLED"] = "false"; + const config = baseConfig({ auth: { enabled: true, site_url: "" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("accepts an env-provided site_url overriding an empty config.toml value", () => { + process.env["SUPABASE_AUTH_SITE_URL"] = "http://localhost:4000"; + const config = baseConfig({ auth: { enabled: true, site_url: "" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + }); + + // auth.captcha/passkey/webauthn/hook/smtp REQUIRED-FIELD checks (the actual `enabled` ⇒ + // provider/secret/uri/host/etc. logic) live entirely in `legacy-config-validate.unit.test.ts` + // (direct `legacyValidateResolvedConfig` calls). Only the SUPABASE_*-env-override MECHANICS + // this resolver owns — layering an env/dotenv value on top of the TOML-decoded or + // raw-document-derived value before that validation ever runs — are tested here, same split as + // `auth.site_url` above. + + describe("auth.captcha env overrides", () => { + // `auth.captcha.*` is Viper-bound like any other nested field once `[auth.captcha]` is + // present in config.toml (`ExperimentalBindStruct`/`AutomaticEnv`, `config.go:581-586`). + afterEach(() => { + delete process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"]; + delete process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"]; + delete process.env["SUPABASE_AUTH_CAPTCHA_SECRET"]; + }); + + it("rejects a captcha section enabled only via env with no provider", () => { + process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; + const config = baseConfig({ auth: { captcha: { enabled: false } } }); + const document = { auth: { captcha: { enabled: false } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("Missing required field in config: auth.captcha.provider"); + }); + + it("does not throw when an incomplete enabled captcha section is disabled only via env", () => { + process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "false"; + const config = baseConfig({ auth: { captcha: { enabled: true } } }); + const document = { auth: { captcha: { enabled: true } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("accepts env-provided provider/secret overriding an enabled captcha section", () => { + process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "hcaptcha"; + process.env["SUPABASE_AUTH_CAPTCHA_SECRET"] = "shh"; + const config = baseConfig({ auth: { captcha: { enabled: true } } }); + const document = { auth: { captcha: { enabled: true } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("does not synthesize a captcha section purely from an env override when [auth.captcha] is absent", () => { + process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("auth.passkey / auth.webauthn env overrides", () => { + // `auth.passkey.enabled`/`auth.webauthn.*` are Viper-bound like any other nested field once + // `[auth.passkey]`/`[auth.webauthn]` are present in config.toml. Both are read from the raw + // `document` (5th param), same as the presence-based defaulting above, so these tests thread + // a `document` object through explicitly instead of relying on `baseConfig`'s decoded schema + // (which has no `passkey`/`webauthn` fields at all). + afterEach(() => { + delete process.env["SUPABASE_AUTH_PASSKEY_ENABLED"]; + delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"]; + delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"]; + }); + + it("rejects a passkey section enabled only via env with no [auth.webauthn] section", () => { + process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; + const config = baseConfig(); + const document = { auth: { passkey: { enabled: false } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow( + "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", + ); + }); + + it("accepts env-provided rp_id/rp_origins overriding an incomplete [auth.webauthn] section", () => { + process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; + process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "localhost"; + process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = + "http://localhost:3000,http://localhost:3001"; + const config = baseConfig(); + const document = { auth: { passkey: { enabled: false }, webauthn: {} } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("does not synthesize a passkey section purely from an env override when [auth.passkey] is absent from the document", () => { + process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("auth.hook.* env overrides", () => { + // `auth.hook..*` is Viper-bound like any other nested field once `[auth.hook.]` + // is present in config.toml. `@supabase/config`'s hook schema always decodes a default + // `{ enabled: false }` regardless of file presence, so — like passkey/webauthn above — the + // presence gate is read from the raw `document`, not the decoded `config`. + afterEach(() => { + delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"]; + delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_URI"]; + delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_SECRETS"]; + }); + + it("rejects a hook section enabled only via env with no uri", () => { + process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"] = "true"; + const config = baseConfig(); + const document = { auth: { hook: { send_email: { enabled: false } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("Missing required field in config: auth.hook.send_email.uri"); + }); + + it("accepts an env-provided uri overriding a TOML-enabled hook missing its uri", () => { + process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_URI"] = "pg-functions://postgres/auth/hook"; + const config = baseConfig({ auth: { hook: { send_email: { enabled: true } } } }); + const document = { auth: { hook: { send_email: { enabled: true } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("does not synthesize a hook enablement purely from an env override when the section is absent from the document", () => { + process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"] = "true"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("auth.email.smtp env overrides", () => { + // `auth.email.smtp.*` is Viper-bound like any other nested field once `[auth.email.smtp]` + // is present in config.toml — layered on top of the presence-aware raw-document read that + // already exists here for Go's presence-based `enabled` default. + afterEach(() => { + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"]; + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_PORT"]; + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"]; + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"]; + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"]; + }); + + it("rejects an smtp section enabled only via env with no host", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "true"; + const config = baseConfig(); + const document = { auth: { email: { smtp: { enabled: false } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("Missing required field in config: auth.email.smtp.host"); + }); + + it("accepts env-provided host/port/user/pass/admin_email overriding an enabled-but-incomplete smtp section", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.example.com"; + process.env["SUPABASE_AUTH_EMAIL_SMTP_PORT"] = "587"; + process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "user"; + process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "pass"; + process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "admin@example.com"; + const config = baseConfig(); + const document = { auth: { email: { smtp: { enabled: true } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("rejects an invalid SUPABASE_AUTH_EMAIL_SMTP_PORT override", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.example.com"; + process.env["SUPABASE_AUTH_EMAIL_SMTP_PORT"] = "not-a-port"; + process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "user"; + process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "pass"; + process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "admin@example.com"; + const config = baseConfig(); + const document = { auth: { email: { smtp: { enabled: true } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow(LegacyInvalidPortEnvOverrideError); + }); + + it("does not synthesize an smtp section purely from an env override when [auth.email.smtp] is absent from the document", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "true"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("auth.mfa env overrides", () => { + // `auth.mfa..*` is Viper-bound unconditionally (value-typed struct fields, never + // `nil`) — unlike hooks/smtp above, no raw-document presence gate is needed; see the block + // comment above the `mfa` array in legacy-local-config-values.ts. + afterEach(() => { + delete process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"]; + delete process.env["SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED"]; + }); + + it("rejects an env-enabled enroll factor left at its TOML-decoded verify default", () => { + process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "true"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid MFA config: auth.mfa.totp.enroll_enabled requires verify_enabled", + ); + }); + + it("accepts an env-enabled enroll factor when verify is also env-enabled", () => { + process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "true"; + process.env["SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED"] = "true"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED override", () => { + process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidBoolEnvOverrideError, + ); + }); + }); + + describe("auth.third_party env overrides", () => { + // Same value-typed-struct reasoning as auth.mfa above — including `workos`, whose default + // template omits `[auth.third_party.workos]` entirely yet is still unconditionally overridable. + afterEach(() => { + delete process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"]; + delete process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"]; + }); + + it("rejects a third-party provider enabled only via env with no required field configured", () => { + process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "true"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid config: auth.third_party.firebase is enabled but without a project_id.", + ); + }); + + it("accepts an env-provided project_id overriding a TOML-enabled firebase provider", () => { + process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"] = "my-project"; + const config = baseConfig({ auth: { third_party: { firebase: { enabled: true } } } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("does not enable a third-party provider purely from a required-field env override", () => { + process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"] = "my-project"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("auth.email.template/notification (content_path validation)", () => { + // Go's `(e *email) validate(fsys)` (`pkg/config/config.go:1293-1313`), + // called right after `Auth.MFA.validate()`, still inside `if c.Auth.Enabled`. + const tempRoot = useLegacyTempWorkdir("supabase-email-templates-test-"); + + it("rejects a template content_path pointing at a missing file", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "missing-invite.html" } } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Invalid config for auth.email.template.invite.content_path: ", + ); + }); + + it("resolves a relative template content_path against the workdir itself, not /supabase", () => { + writeFileSync(join(tempRoot.current, "invite.html"), ""); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "invite.html" } } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("does not throw a template with no content_path configured", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: {} } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("rejects an enabled notification content_path pointing at a missing file", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { password_changed: { enabled: true, content_path: "missing.html" } }, + }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Invalid config for auth.email.notification.password_changed.content_path: ", + ); + }); + + it("resolves a relative notification content_path against /supabase", () => { + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "pw-changed.html"), ""); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { enabled: true, content_path: "pw-changed.html" }, + }, + }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("does not throw a disabled notification's missing content_path", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { enabled: false, content_path: "missing.html" }, + }, + }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("does not throw a missing template content_path when auth is disabled", () => { + const config = baseConfig({ + auth: { enabled: false, email: { template: { invite: { content_path: "missing.html" } } } }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + // Divergence #2 (see `legacy-config-validate.ts`'s port-plan notes): Go's asymmetric + // content-vs-content_path exclusivity (`config.go:1293-1313`) — a raw `content` key present + // with no `content_path` is an error, not a silent no-op. `@supabase/config`'s schema has no + // `content` field to see, so this only fires when the raw `document` (5th param) carries it. + it("rejects a template content key present without content_path", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: {} } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current, undefined, { + auth: { email: { template: { invite: { content: "Hi" } } } }, + }), + ).toThrow( + "Invalid config for auth.email.template.invite.content: please use content_path instead", + ); + }); + }); + + describe("auth.email.template/notification env overrides", () => { + // `auth.email.template..*`/`auth.email.notification..*` are Viper-bound like any + // other nested field once the section is present in config.toml. Unlike hook/passkey, no + // extra raw-document presence gate is needed: `email.template`/`email.notification` are + // `Schema.Record`s, so `Object.entries` on the decoded config already reflects presence. + const tempRoot = useLegacyTempWorkdir("supabase-email-template-env-test-"); + + afterEach(() => { + delete process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"]; + delete process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"]; + delete process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"]; + }); + + it("lets an env-provided template content_path override a missing TOML content_path", () => { + writeFileSync(join(tempRoot.current, "invite.html"), ""); + process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "invite.html"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: {} } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("rejects a notification enabled only via env with a missing content_path file", () => { + // Go applies SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED before + // Auth.Email.validate() decides whether to read content_path — a notification disabled + // in TOML but enabled by env must still be checked. + process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "true"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { password_changed: { enabled: false, content_path: "missing.html" } }, + }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Invalid config for auth.email.notification.password_changed.content_path: ", + ); + }); + + it("does not validate a notification disabled only via env despite a TOML-enabled section", () => { + process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "false"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { password_changed: { enabled: true, content_path: "missing.html" } }, + }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("lets an env-provided notification content_path override a missing TOML content_path", () => { + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "pw-changed.html"), ""); + process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"] = + "pw-changed.html"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { notification: { password_changed: { enabled: true } } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + }); + + // auth.third_party.* (thirdParty.validate()) and functions.* (function-slug validation) + // moved entirely to `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` + // calls) — L pre-filters to enabled-only third_party providers and derives function slugs + // directly off `config.functions` with no env-override mechanics of its own for these checks. + + describe("auth.external (external.validate(), D-only, ported to L)", () => { + // `auth.external` is a genuine Go `map[string]provider`, so an unmodeled/arbitrary provider + // name is a legitimate config shape `@supabase/config`'s schema silently drops at decode — + // this check reads the raw `document` (5th param) instead, same as passkey/hook above. + it("rejects an enabled unmodeled external provider missing client_id", () => { + const config = baseConfig(); + const document = { auth: { external: { custom: { enabled: true } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("Missing required field in config: auth.external.custom.client_id"); + }); + + it("rejects an enabled unmodeled external provider missing secret", () => { + const config = baseConfig(); + const document = { + auth: { external: { custom: { enabled: true, client_id: "abc" } } }, + }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("Missing required field in config: auth.external.custom.secret"); + }); + + it("does not require a secret for apple/google providers", () => { + const config = baseConfig(); + const document = { + auth: { external: { apple: { enabled: true, client_id: "abc" } } }, + }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("skips deprecated linkedin/slack providers", () => { + const config = baseConfig(); + const document = { auth: { external: { slack: { enabled: true } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("does not validate a disabled unmodeled external provider", () => { + const config = baseConfig(); + const document = { auth: { external: { custom: { enabled: false } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("skips the check entirely when no document is threaded through", () => { + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("auth.external env overrides", () => { + // `auth.external..*` is Viper-bound like any other nested field once + // `[auth.external.]` is present in config.toml — same gap the schema's own + // `requiredWhenEnabled` check has for KNOWN providers too. + afterEach(() => { + delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"]; + delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_CLIENT_ID"]; + delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_SECRET"]; + }); + + it("rejects a provider enabled only via env with no client_id", () => { + process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"] = "true"; + const config = baseConfig(); + const document = { auth: { external: { custom: { enabled: false } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("Missing required field in config: auth.external.custom.client_id"); + }); + + it("accepts env-provided client_id/secret overriding a TOML-enabled provider missing both", () => { + process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_CLIENT_ID"] = "abc"; + process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_SECRET"] = "shh"; + const config = baseConfig(); + const document = { auth: { external: { custom: { enabled: true } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("does not synthesize a provider purely from an env override when the section is absent from the document", () => { + process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"] = "true"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("auth.sms env overrides (provider switch)", () => { + // Go's `(s *sms) validate()` (`pkg/config/config.go:1348-1410`) is a `switch` that validates + // ONLY the first enabled provider in a fixed priority order (twilio, twilio_verify, + // messagebird, textlocal, vonage). `@supabase/config`'s schema already implements this switch + // for the schema-decoded (pre-env-override) TOML value; this re-runs it against the raw + // document with `SUPABASE_AUTH_SMS_*` overrides applied, since the schema never sees them. + afterEach(() => { + delete process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"]; + delete process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; + delete process.env["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"]; + delete process.env["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"]; + delete process.env["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"]; + }); + + it("rejects a provider enabled only via env with missing required fields", () => { + process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; + const config = baseConfig(); + const document = { auth: { sms: { twilio: { enabled: false } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("Missing required field in config: auth.sms.twilio.account_sid"); + }); + + it("accepts env-provided credentials overriding a TOML-enabled provider missing them", () => { + process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "AC123"; + process.env["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"] = "MG123"; + process.env["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"] = "tok"; + const config = baseConfig(); + const document = { auth: { sms: { twilio: { enabled: true } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("only validates the first enabled provider in Go's fixed priority order", () => { + // twilio is disabled via env; messagebird becomes the switch winner and is missing its + // required fields — twilio's own (still-missing) fields must never be inspected. + process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "false"; + process.env["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"] = "true"; + const config = baseConfig(); + const document = { + auth: { sms: { twilio: { enabled: true }, messagebird: { enabled: false } } }, + }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("Missing required field in config: auth.sms.messagebird.originator"); + }); + + it("does not synthesize a provider purely from an env override when the section is absent from the document", () => { + process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("api.tls (cert/key validation)", () => { + const tempRoot = useLegacyTempWorkdir("supabase-api-tls-test-"); + + function writeTlsFile(workdir: string, name: string, contents = "dummy") { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, name), contents); + } + + it("does not throw when tls.enabled with neither cert_path nor key_path set", () => { + // Go's Validate only rejects the "exactly one set" case (config.go:1010-1027); + // tls.enabled with nothing configured still loads. + const config = baseConfig({ api: { tls: { enabled: true } } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + // The "exactly one of cert/key set" presence-only assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // the actual file reads below stay here, since I/O is per-caller. + + it("throws a Go-worded error when the configured cert file does not exist", () => { + writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "missing-cert.pem", key_path: "key.pem" } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read TLS cert: ", + ); + }); + + it("throws a Go-worded error when the configured key file does not exist", () => { + writeTlsFile(tempRoot.current, "cert.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "missing-key.pem" } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read TLS key: ", + ); + }); + + it("succeeds when both cert_path and key_path are readable", () => { + writeTlsFile(tempRoot.current, "cert.pem"); + writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "key.pem" } }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("resolves cert_path/key_path against /supabase unconditionally, no isAbsolute guard", () => { + // Go's `path.Join` (config.go:961-965) absorbs a leading "/" — unlike + // signing_keys_path, which Go DOES guard with filepath.IsAbs. + writeTlsFile(tempRoot.current, "cert.pem"); + writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { + tls: { + enabled: true, + cert_path: "/cert.pem", + key_path: "/key.pem", + }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + // Go's `Validate` nests the whole TLS branch inside `if c.Api.Enabled` + // (config.go:1006,1010) — a disabled api section never validates cert/key, + // however invalid the pairing. + it("skips TLS validation entirely when api is disabled", () => { + const config = baseConfig({ + api: { enabled: false, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + describe("SUPABASE_API_ENABLED / SUPABASE_API_TLS_ENABLED env overrides", () => { + afterEach(() => { + delete process.env["SUPABASE_API_ENABLED"]; + delete process.env["SUPABASE_API_TLS_ENABLED"]; + }); + + it("skips TLS validation when api is disabled only via env", () => { + process.env["SUPABASE_API_ENABLED"] = "false"; + const config = baseConfig({ + api: { enabled: true, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("validates TLS when enabled only via env despite TOML saying tls.enabled = false", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = "true"; + const config = baseConfig({ + api: { tls: { enabled: false, cert_path: "missing-cert.pem" } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Missing required field in config: api.tls.key_path", + ); + }); + }); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.ts b/apps/cli/src/legacy/shared/legacy-project-environment.ts new file mode 100644 index 0000000000..fe1ad08291 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-project-environment.ts @@ -0,0 +1,141 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { ProjectEnvironment } from "@supabase/config"; + +import { parseDotEnv } from "./legacy-dotenv.ts"; + +/** + * Fills the gap between `@supabase/config`'s `loadProjectEnvironment` and Go's + * `loadNestedEnv` (`apps/cli-go/pkg/config/config.go:1169-1190`). Go's version + * walks not just `supabase/` but one directory further, up to the project + * root/workdir (the loop stops once `cwd == filepath.Dir(repoDir)`, i.e. after + * exactly two directories: `supabase/`, then its parent), and at each + * directory calls `loadDefaultEnv` (`config.go:1192-1207`), which loads dotenv + * files chosen by `SUPABASE_ENV` (empty/unset defaults to `"development"`, + * `config.go:1193-1195`): `.env..local`, `.env.local` (skipped when + * `env === "test"`), `.env.`, `.env` — via `godotenv.Load`, which only + * sets a key if it isn't already present in the process environment + * (`godotenv@v1.5.1/godotenv.go:184-204`, `overload: false`). Because + * `godotenv.Load` writes straight into the process env as it goes, the net + * precedence (highest first) is: ambient shell env > `supabase/`-dir dotenv + * files (`.local` variant before non-local, env-specific before bare `.env`) + * > project-root dotenv files (same internal order). + * + * `loadProjectEnvironment` only implements the `supabase/`-dir, plain + * `.env`/`.env.local` half of this (no project-root pass, no `SUPABASE_ENV` + * filename selection) — and it's shared infrastructure used well beyond + * `legacy/` (the `next/` command tree, `secrets set`), so extending its + * file-resolution semantics is out of scope for a `stop`/`status` port. + * Instead, this fills in the missing project-root + `SUPABASE_ENV`-selected + * files locally: `loadProjectEnvironment`'s already-resolved `values` (its + * ambient-wins-over-`supabase/.env`(.local) result) always takes precedence + * over anything discovered here, since it's already correct for the keys it + * knows about. + */ +function candidateDotenvFilenames(env: string): ReadonlyArray { + return [`.env.${env}.local`, ...(env === "test" ? [] : [".env.local"]), `.env.${env}`, ".env"]; +} + +/** + * Minimal dotenv reader for the project-root and `SUPABASE_ENV`-selected extra + * files this module resolves, intentionally not reusing `@supabase/config`'s + * Effect-based `FileSystem` parser: this module stays a plain synchronous + * helper (like `legacy-local-config-values.ts`'s `loadFirstSigningKey`) since + * it only needs a handful of extra files read once per `stop`/`status` + * invocation. Delegates to {@link parseDotEnv} — the same `godotenv`-faithful, + * cursor-based parser `bootstrap`/`legacyReadDbToml` already use — rather than + * a hand-rolled line-by-line scan, so a quoted value spanning physical lines + * (a PEM/private key) parses correctly instead of aborting on what looks like + * a malformed continuation line. + * + * @throws on a line that isn't blank, a comment, or a `KEY=VALUE`/`KEY: VALUE` + * assignment — matching Go's `loadEnvIfExists` (`pkg/config/config.go:1209-1234`), + * which propagates `godotenv.Load`'s parse error up through `loadNestedEnv` and + * fails `Config.Load` before `stop`/`status` touch Docker, rather than silently + * skipping the bad line. + */ +function readDotEnvFile(path: string): Record | undefined { + if (!existsSync(path)) return undefined; + + const contents = readFileSync(path, "utf8"); + try { + return parseDotEnv(contents); + } catch (cause) { + throw new Error( + `failed to parse environment file: ${path} (${cause instanceof Error ? cause.message : String(cause)})`, + ); + } +} + +/** + * Returns the merged env-var map `stop`/`status` should read `SUPABASE_*` + * overrides (project id, auth fields) from — the project-root and + * `SUPABASE_ENV`-selected files `loadProjectEnvironment` doesn't cover, layered + * under only the truly ambient-sourced entries of `projectEnv.values`. + * + * Only `projectEnv`'s AMBIENT entries outrank `merged`: `projectEnv.values` + * also carries plain `supabase/.env`/`.env.local` values it read itself, and + * those are not necessarily higher Go precedence than an env-specific file + * (`.env..local`/`.env.`) `merged` resolved — `loadProjectEnvironment` + * has no notion of `SUPABASE_ENV`-selected filenames, so it can't tell the two + * apart itself. `merged`'s own walk below already re-derives the full file + * precedence, including `supabase/.env`(.local), so only ambient needs to be + * layered back on top (`projectEnv.sources[key] === "ambient"` marks exactly + * those entries — see `loadProjectEnvironment`'s `ProjectEnvironment` shape). + * + * `projectEnv` is `null` whenever `@supabase/config` found no + * `supabase/config.toml`/`config.json` (searching ancestors, or at exactly + * `workdir` when the caller passed `search: false`) — but Go's dotenv loading + * doesn't share that precondition: `Config.Load` calls + * `loadNestedEnv(builder.SupabaseDirPath)` BEFORE it ever opens `config.toml` + * (`pkg/config/config.go:786-793`), and `SupabaseDirPath` is a pure string + * join with no existence check (`NewPathBuilder`, `pkg/config/utils.go:43-48`). + * So a missing/absent config file must not skip dotenv loading — fall back to + * deriving the same two directories directly from `workdir` + * (`/supabase` and `workdir` itself) and read `process.env` itself as + * the ambient layer, since there's no `loadProjectEnvironment` result to + * consult for it in this branch. + */ +export function legacyResolveProjectEnvironmentValues( + projectEnv: ProjectEnvironment | null, + workdir: string, +): Record { + const env = process.env["SUPABASE_ENV"] || "development"; + const filenames = candidateDotenvFilenames(env); + const merged: Record = {}; + + const supabaseDir = projectEnv?.paths.supabaseDir ?? join(workdir, "supabase"); + const projectRoot = projectEnv?.paths.projectRoot ?? workdir; + + // supabase/ dir first, then its parent (the project root) — matching Go's + // directory walk order. Within a directory, `godotenv.Load`'s "never + // override an already-set var" means first-processed-wins, so the plain + // merge below (skip keys already present) reproduces both orderings at once. + for (const dir of [supabaseDir, projectRoot]) { + for (const filename of filenames) { + const parsed = readDotEnvFile(join(dir, filename)); + if (parsed === undefined) continue; + for (const [key, value] of Object.entries(parsed)) { + if (!(key in merged)) merged[key] = value; + } + } + } + + const ambientOverrides: Record = {}; + if (projectEnv !== null) { + for (const [key, value] of Object.entries(projectEnv.values)) { + if (projectEnv.sources[key] === "ambient") { + ambientOverrides[key] = value; + } + } + } else { + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + ambientOverrides[key] = value; + } + } + } + + return { ...merged, ...ambientOverrides }; +} diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts new file mode 100644 index 0000000000..8e7e71b826 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts @@ -0,0 +1,300 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { ProjectEnvironment } from "@supabase/config"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { legacyResolveProjectEnvironmentValues } from "./legacy-project-environment.ts"; + +let root: string; +let supabaseDir: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "supabase-legacy-project-env-")); + supabaseDir = join(root, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); + delete process.env["SUPABASE_ENV"]; + delete process.env["SUPABASE_PROJECT_ID"]; +}); + +function fakeProjectEnv( + values: Record = {}, + sources: Record = {}, +): ProjectEnvironment { + return { + paths: { + projectRoot: root, + supabaseDir, + configPath: join(supabaseDir, "config.toml"), + envPath: join(supabaseDir, ".env"), + envLocalPath: join(supabaseDir, ".env.local"), + }, + values, + loadedPaths: [], + // Default every given value to "ambient" unless the caller says otherwise — + // matches how most tests use this helper (representing an already-resolved, + // highest-precedence value) without forcing every call site to spell it out. + sources: Object.fromEntries(Object.keys(values).map((key) => [key, sources[key] ?? "ambient"])), + }; +} + +describe("legacyResolveProjectEnvironmentValues", () => { + it("returns just the already-loaded values when no extra dotenv files exist", () => { + const projectEnv = fakeProjectEnv({ SUPABASE_PROJECT_ID: "from-loader" }); + expect(legacyResolveProjectEnvironmentValues(projectEnv, root)).toEqual({ + SUPABASE_PROJECT_ID: "from-loader", + }); + }); + + it("fills in a value from a project-root .env file Go's loadNestedEnv would load", () => { + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-env-project"); + }); + + it("prefers a supabase/-dir dotenv file over the same key in a project-root file", () => { + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=supabase-dir-project\n"); + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-dir-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); + }); + + it("lets already-resolved projectEnv.values win over anything discovered locally", () => { + // `projectEnv.values` already reflects loadProjectEnvironment's correct + // ambient-wins-over-supabase/.env(.local) result; a redundant root .env + // entry for the same key must never override it. + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); + const projectEnv = fakeProjectEnv({ SUPABASE_PROJECT_ID: "ambient-project" }); + const merged = legacyResolveProjectEnvironmentValues(projectEnv, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); + }); + + it("defaults SUPABASE_ENV to development when unset", () => { + writeFileSync(join(root, ".env.development"), "SUPABASE_PROJECT_ID=dev-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("dev-project"); + }); + + it("selects the SUPABASE_ENV-named file over the bare .env file", () => { + process.env["SUPABASE_ENV"] = "production"; + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=bare-env-project\n"); + writeFileSync(join(root, ".env.production"), "SUPABASE_PROJECT_ID=prod-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-project"); + }); + + it("prefers the .local variant of the SUPABASE_ENV file over the non-local one", () => { + process.env["SUPABASE_ENV"] = "production"; + writeFileSync(join(root, ".env.production"), "SUPABASE_PROJECT_ID=prod-project\n"); + writeFileSync(join(root, ".env.production.local"), "SUPABASE_PROJECT_ID=prod-local-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-local-project"); + }); + + it("skips .env.local when SUPABASE_ENV=test, matching Go's loadDefaultEnv", () => { + process.env["SUPABASE_ENV"] = "test"; + writeFileSync(join(root, ".env.local"), "SUPABASE_PROJECT_ID=local-project\n"); + writeFileSync(join(root, ".env.test"), "SUPABASE_PROJECT_ID=test-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("test-project"); + }); + + it("strips quotes the same way the shared dotenv parser does", () => { + writeFileSync(join(root, ".env"), 'SUPABASE_AUTH_JWT_SECRET="a quoted value"\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("a quoted value"); + }); + + it("ignores blank lines and comments", () => { + writeFileSync(root + "/.env", "\n# a comment\nSUPABASE_PROJECT_ID=commented-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("commented-project"); + }); + + it("preserves a literal # in an unquoted value with no leading whitespace, matching godotenv", () => { + // godotenv only starts an inline comment at a `#` preceded by whitespace + // (`godotenv@v1.5.1/parser.go:144-153`); `foo#bar` keeps the `#` verbatim. + writeFileSync(root + "/.env", "SUPABASE_AUTH_JWT_SECRET=long#secret\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("long#secret"); + }); + + it("still truncates an unquoted value at a whitespace-preceded inline comment", () => { + writeFileSync(root + "/.env", "SUPABASE_PROJECT_ID=54323 # local\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("54323"); + }); + + it("strips a trailing comment after a quoted value, matching godotenv", () => { + // godotenv's `extractVarValue` locates the quoted span by scanning forward for the + // closing quote (`godotenv@v1.5.1/parser.go:160-180`) and discards anything after + // it as a comment — the value is `demo`, not the literal `"demo"` a check that + // requires the whole trimmed remainder to end with a quote would produce. + writeFileSync(root + "/.env", 'SUPABASE_PROJECT_ID="demo" # local\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); + }); + + it("accepts a colon-separated assignment, matching godotenv's YAML-style key/value form", () => { + // godotenv's `locateKeyName` treats `=` and `:` as interchangeable separators + // (`godotenv@v1.5.1/parser.go:90-95`), and the repo's other dotenv parser + // (`packages/config/src/project.ts`'s `parseDotEnv`) already accepts both. + writeFileSync(root + "/.env", "SUPABASE_PROJECT_ID: colon-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("colon-project"); + }); + + it("prefers an env-specific file over a same-key value projectEnv.values sourced from a bare .env file", () => { + // `projectEnv.values` has no notion of SUPABASE_ENV-selected filenames, so + // a key it resolved from a plain supabase/.env file is NOT necessarily + // higher Go precedence than a same-named key from `.env..local` — + // only an "ambient" source outranks the file precedence computed locally. + process.env["SUPABASE_ENV"] = "development"; + writeFileSync( + join(supabaseDir, ".env.development.local"), + "SUPABASE_PROJECT_ID=env-specific-project\n", + ); + const projectEnv = fakeProjectEnv( + { SUPABASE_PROJECT_ID: "bare-dotenv-project" }, + { SUPABASE_PROJECT_ID: ".env" }, + ); + const merged = legacyResolveProjectEnvironmentValues(projectEnv, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("env-specific-project"); + }); + + it("still lets a truly ambient-sourced value win over any file", () => { + process.env["SUPABASE_ENV"] = "development"; + writeFileSync( + join(supabaseDir, ".env.development.local"), + "SUPABASE_PROJECT_ID=env-specific-project\n", + ); + const projectEnv = fakeProjectEnv( + { SUPABASE_PROJECT_ID: "ambient-project" }, + { SUPABASE_PROJECT_ID: "ambient" }, + ); + const merged = legacyResolveProjectEnvironmentValues(projectEnv, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); + }); + + it("throws on a malformed line, matching Go's loadEnvIfExists propagating godotenv's parse error", () => { + writeFileSync(join(root, ".env"), "not a valid line\n"); + expect(() => legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root)).toThrow( + /failed to parse environment file/, + ); + }); + + it("expands an unquoted $VAR reference to an earlier value in the same file", () => { + // godotenv expands unquoted/double-quoted references while loading + // (`godotenv@v1.5.1/parser.go:157`), so a later key can reuse an earlier one. + writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID=$BASE\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); + }); + + it("expands a braced ${VAR} reference in a double-quoted value", () => { + writeFileSync(join(root, ".env"), 'SECRET=shh\nSUPABASE_AUTH_JWT_SECRET="${SECRET}"\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("shh"); + }); + + it("does not expand variable references inside single-quoted values", () => { + // godotenv never calls expandVariables for single-quoted values + // (`parser.go:172-173`) — they stay byte-literal. + writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID='$BASE'\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("$BASE"); + }); + + it("expands an unresolved bare reference to an empty string, matching Go's map zero-value", () => { + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=$NOPE\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe(""); + }); + + it("expands an unresolved braced reference to an empty string, matching Go's map zero-value", () => { + writeFileSync(join(root, ".env"), 'SUPABASE_AUTH_JWT_SECRET="${NOPE}"\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe(""); + }); + + it("preserves a backslash-escaped $VAR reference as a literal, matching godotenv's escape rule", () => { + // godotenv's expandVarRegex captures a leading backslash and strips ONLY + // that backslash, returning the rest of the match verbatim instead of + // doing a lookup (`godotenv@v1.5.1/parser.go:253,264-265`) — even when + // BASE is defined, `demo\$BASE` must stay `demo$BASE`, not become + // `demodemo`. + writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID=demo\\$BASE\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$BASE"); + }); + + it("preserves a backslash-escaped ${VAR} reference in a double-quoted value", () => { + writeFileSync(join(root, ".env"), 'BASE=demo\nSUPABASE_PROJECT_ID="demo\\${BASE}"\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo${BASE}"); + }); + + it("treats a bare trailing $ with no variable name as a literal", () => { + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=demo$\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$"); + }); + + it("preserves a multiline quoted value alongside an unrelated SUPABASE_* key (godotenv parity)", () => { + // godotenv's parser scans the whole buffer with a cursor, not line-by-line + // (`godotenv@v1.5.1/parser.go:20-45`), so a quoted value spanning physical + // lines — e.g. a pasted PEM private key — doesn't break parsing of the rest + // of the file. A naive line-by-line reader would see the continuation line + // as malformed and abort before SUPABASE_PROJECT_ID is ever read. + const pem = "-----BEGIN PRIVATE KEY-----\nMIIBogIBAAJ\n-----END PRIVATE KEY-----"; + writeFileSync( + join(root, ".env"), + `PRIVATE_KEY="${pem}"\nSUPABASE_PROJECT_ID=multiline-safe-project\n`, + ); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("multiline-safe-project"); + }); + + describe("when no project was found (projectEnv is null)", () => { + // Go's `loadNestedEnv` runs unconditionally before `config.toml` is ever + // opened (`pkg/config/config.go:786-793`), so a missing config file must + // not skip dotenv loading — these cover the local fallback that derives + // `/supabase`/`workdir` directly instead of giving up. + + it("still reads a supabase/-dir dotenv file directly under workdir", () => { + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=fallback-project\n"); + const merged = legacyResolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("fallback-project"); + }); + + it("still reads a project-root dotenv file directly under workdir", () => { + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-fallback-project\n"); + const merged = legacyResolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-fallback-project"); + }); + + it("prefers the supabase/-dir file over the project-root file, same as the non-null case", () => { + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=supabase-dir-project\n"); + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-dir-project\n"); + const merged = legacyResolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); + }); + + it("lets an ambient shell var win over a dotenv value, using process.env directly", () => { + process.env["SUPABASE_PROJECT_ID"] = "ambient-fallback-project"; + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=dotenv-fallback-project\n"); + const merged = legacyResolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-fallback-project"); + }); + + it("returns an empty object when workdir has no dotenv files and no ambient value", () => { + const merged = legacyResolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBeUndefined(); + }); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-seed-buckets.ts b/apps/cli/src/legacy/shared/legacy-seed-buckets.ts index 382d16e6b1..0ec17002b5 100644 --- a/apps/cli/src/legacy/shared/legacy-seed-buckets.ts +++ b/apps/cli/src/legacy/shared/legacy-seed-buckets.ts @@ -144,8 +144,8 @@ export const legacySeedBucketsRun = Effect.fnUntraced(function* (opts: { // Load config.toml, passing projectRef so `[remotes.*]` overrides are merged for // --linked. A parse failure aborts before any network call. - const loadOptions: LoadProjectConfigOptions | undefined = - projectRef !== "" ? { projectRef } : undefined; + const loadOptions: LoadProjectConfigOptions = + projectRef !== "" ? { projectRef, goViperCompat: true } : { goViperCompat: true }; const loaded = yield* loadProjectConfig(cliConfig.workdir, loadOptions).pipe( Effect.catchTag( "ProjectConfigParseError", diff --git a/apps/cli/src/legacy/shared/legacy-storage-credentials.ts b/apps/cli/src/legacy/shared/legacy-storage-credentials.ts index 0826a7de8f..21983e8d15 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-credentials.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-credentials.ts @@ -4,6 +4,7 @@ import { Effect, FileSystem, Path } from "effect"; import { LegacyPlatformApiFactory } from "../auth/legacy-platform-api-factory.service.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { legacyResolveApiExternalUrl } from "./legacy-api-url.ts"; import { legacyMapTenantApiKeysError } from "./legacy-get-tenant-api-keys.ts"; import { legacyGetHostname } from "./legacy-hostname.ts"; import { legacyExtractServiceKeys } from "./legacy-tenant-keys.ts"; @@ -122,23 +123,11 @@ export const legacyResolveStorageCredentials = Effect.fnUntraced(function* (opts }); /** - * Local API URL, mirroring Go's `config.go:634-644` + `misc.go:298`: an explicit - * `api.external_url` wins, otherwise `://:` where the scheme - * follows `api.tls.enabled`, the host is `legacyGetHostname` (Go's - * `utils.GetHostname`), and the port is `api.port`. + * Local API URL: `legacyResolveApiExternalUrl` with `legacyGetHostname` (Go's + * `utils.GetHostname`) supplying the host when `api.external_url` is unset. */ function resolveLocalBaseUrl(config: LegacyStorageConfigView): string { - if (config.api.external_url !== undefined && config.api.external_url.length > 0) { - return config.api.external_url; - } - const host = legacyGetHostname(); - const scheme = config.api.tls.enabled ? "https" : "http"; - // Go builds host:port with net.JoinHostPort (config.go:636-638), bracketing an - // IPv6 host. legacyGetHostname returns the unbracketed host, so bracket here. - const hostPort = host.includes(":") - ? `[${host}]:${config.api.port}` - : `${host}:${config.api.port}`; - return `${scheme}://${hostPort}`; + return legacyResolveApiExternalUrl(config.api, legacyGetHostname()); } /** diff --git a/apps/cli/src/legacy/shared/legacy-storage-url.ts b/apps/cli/src/legacy/shared/legacy-storage-url.ts index 4401c56387..0810c61872 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-url.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-url.ts @@ -155,6 +155,62 @@ function hostFromAuthority(authority: string): string { return at === -1 ? authority : authority.slice(at + 1); } +/** Go `net/url.validOptionalPort` (`url.go:761-774`): `""`, or `:` followed by only digits. */ +function isValidOptionalPort(port: string): boolean { + if (port.length === 0) return true; + if (port.charCodeAt(0) !== 0x3a /* : */) return false; + for (let i = 1; i < port.length; i++) { + if (!isDigit(port.charCodeAt(i))) return false; + } + return true; +} + +/** + * Go `net/url.parseHost`, restricted to the branches this module's callers + * can actually hit (`net/url/url.go:544-608` in the Go stdlib `apps/cli-go` + * builds against): IP-literal (`[...]`) bracket/port validation, and the bare + * `host[:port]` port-digit check. Deliberately does NOT implement IPv6 + * zone-identifier percent-decoding or `netip.ParseAddr` address validation + * (Go additionally requires a bracketed literal to parse as a valid, + * non-IPv4 IP address) — callers only need to know whether validation + * THROWS, and a config author writing a bogus-but-bracket-balanced IPv6 + * literal is exotic enough that the narrower `netip.ParseAddr` check isn't + * worth the added surface; the common bracket-mismatch/invalid-port mistakes + * Go actually raises for realistic input (e.g. `http://[::1`, a truncated + * IPv6 literal) are still reproduced byte-for-byte. Also skips Go's + * `GODEBUG=urlstrictcolons=0` legacy multi-colon opt-out (`url.go:596-604`) — + * an explicit, non-default env var this codebase's bundled Go binary doesn't + * set, so the http/https "first colon is the port separator" behavior always + * applies for those schemes; other schemes use the last colon, matching Go's + * unconditional `else` branch for non-http(s) schemes. + */ +function validateGoUrlHost(scheme: string, host: string): void { + if (host.length === 0) return; + const openBracketIdx = host.indexOf("["); + if (openBracketIdx > 0) { + throw new Error("invalid IP-literal"); + } + if (openBracketIdx === 0) { + const closeBracketIdx = host.lastIndexOf("]"); + if (closeBracketIdx < 0) { + throw new Error("missing ']' in host"); + } + const colonPort = host.slice(closeBracketIdx + 1); + if (!isValidOptionalPort(colonPort)) { + throw new Error(`invalid port ${JSON.stringify(colonPort)} after host`); + } + return; + } + const colonIdx = + scheme === "http" || scheme === "https" ? host.indexOf(":") : host.lastIndexOf(":"); + if (colonIdx !== -1) { + const colonPort = host.slice(colonIdx); + if (!isValidOptionalPort(colonPort)) { + throw new Error(`invalid port ${JSON.stringify(colonPort)} after host`); + } + } +} + /** * Port of Go's `url.Parse` restricted to `Scheme`/`Host`/`Path`. Throws * `LegacyGoUrlParseError` (Go's `*url.Error`) on the failures the storage @@ -208,6 +264,11 @@ export function legacyGoUrlParse(rawURL: string): LegacyGoUrl { const authority = slash === -1 ? afterSlashes : afterSlashes.slice(0, slash); rest = slash === -1 ? "" : afterSlashes.slice(slash); host = hostFromAuthority(authority); + try { + validateGoUrlHost(scheme, host); + } catch (cause) { + throw new LegacyGoUrlParseError(u, cause instanceof Error ? cause.message : String(cause)); + } } let path: string; diff --git a/apps/cli/src/legacy/shared/legacy-storage-url.unit.test.ts b/apps/cli/src/legacy/shared/legacy-storage-url.unit.test.ts index 5e9643f48e..6100802123 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-url.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-url.unit.test.ts @@ -4,6 +4,7 @@ import { LegacyGoUrlParseError, LegacyStorageUrlPatternError, legacyDetectScheme, + legacyGoUrlParse, legacyParseStorageUrl, legacySplitBucketPrefix, legacyStorageIsDir, @@ -85,6 +86,52 @@ describe("legacyDetectScheme", () => { }); }); +// Oracle: `go run` against Go 1.25's `net/url.Parse` directly (net/url/url.go's +// `parseHost`) — used by `studio.api_url` validation, which needs the Host, not +// just Scheme/Path, so the failures below matter beyond the storage commands. +describe("legacyGoUrlParse (host validation)", () => { + it("rejects an unterminated IPv6 literal", () => { + expect(() => legacyGoUrlParse("http://[::1")).toThrow(LegacyGoUrlParseError); + expect(() => legacyGoUrlParse("http://[::1")).toThrow( + `parse "http://[::1": missing ']' in host`, + ); + }); + + it("accepts a bracketed IPv6 literal with no port", () => { + expect(legacyGoUrlParse("http://[::1]").host).toBe("[::1]"); + }); + + it("accepts a bracketed IPv6 literal with a valid port", () => { + expect(legacyGoUrlParse("http://[::1]:8080").host).toBe("[::1]:8080"); + }); + + it("rejects a bracketed IPv6 literal with a non-numeric port", () => { + expect(() => legacyGoUrlParse("http://[::1]:abc")).toThrow( + `parse "http://[::1]:abc": invalid port ":abc" after host`, + ); + }); + + it("rejects a bracket that isn't the first character of the host", () => { + expect(() => legacyGoUrlParse("http://host[::1]")).toThrow( + `parse "http://host[::1]": invalid IP-literal`, + ); + }); + + it("accepts a plain hostname with a numeric port", () => { + expect(legacyGoUrlParse("http://example.com:99999").host).toBe("example.com:99999"); + }); + + it("rejects more than one colon in an http(s) host (strict-colon default)", () => { + expect(() => legacyGoUrlParse("http://host:1:2")).toThrow( + `parse "http://host:1:2": invalid port ":1:2" after host`, + ); + }); + + it("does not validate a host for a scheme with no authority (ss:///bucket)", () => { + expect(() => legacyGoUrlParse("ss:///bucket/x")).not.toThrow(); + }); +}); + describe("legacyStorageIsDir", () => { it.each([ ["", true], diff --git a/apps/cli/src/legacy/shared/legacy-workdir-validation.ts b/apps/cli/src/legacy/shared/legacy-workdir-validation.ts new file mode 100644 index 0000000000..40e8a5976f --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-workdir-validation.ts @@ -0,0 +1,52 @@ +import { Data, Effect, FileSystem } from "effect"; + +/** + * Raised by {@link legacyValidateWorkdirIsDirectory} when the target path + * doesn't exist or isn't a directory. Callers map this into their own + * command-specific error type. + */ +export class LegacyWorkdirValidationError extends Data.TaggedError("LegacyWorkdirValidationError")<{ + readonly message: string; +}> {} + +/** + * Validates that `workdir` exists and is a directory, the way Go's + * `ChangeWorkDir` implicitly does via `os.Chdir` (`apps/cli-go/internal/utils/ + * misc.go:231-250`, called from `PersistentPreRunE`, `apps/cli-go/cmd/root.go: + * 93-105`, before any command runs): a missing path or a path that isn't a + * directory fails immediately, before config load or any Docker/API access. + * + * Callers that resolve `workdir` via `LegacyCliConfig` only need this check + * when `--workdir`/`SUPABASE_WORKDIR` was set explicitly — `legacy-cli-config. + * layer.ts`'s default walk-up-for-`supabase/config.toml` resolution always + * returns a real, already-existing directory (either one containing + * `supabase/config.toml`, or the process's own `cwd`), so it can never fail + * this check; calling it unconditionally is therefore safe and simpler than + * threading "was this explicit?" through every caller. + */ +export function legacyValidateWorkdirIsDirectory( + workdir: string, + fs: FileSystem.FileSystem, +): Effect.Effect { + return fs.stat(workdir).pipe( + Effect.matchEffect({ + onFailure: (error) => { + const reason = + error.reason._tag === "NotFound" ? "no such file or directory" : error.message; + return Effect.fail( + new LegacyWorkdirValidationError({ + message: `failed to change workdir: chdir ${workdir}: ${reason}`, + }), + ); + }, + onSuccess: (info) => + info.type === "Directory" + ? Effect.void + : Effect.fail( + new LegacyWorkdirValidationError({ + message: `failed to change workdir: chdir ${workdir}: not a directory`, + }), + ), + }), + ); +} diff --git a/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts index 1b61948324..acd17b7a1b 100644 --- a/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts @@ -34,6 +34,7 @@ export const functionsDeploy = Effect.fn("functions.deploy")(function* ( projectRoot: projectHome.projectRoot, supabaseDir: projectHome.supabaseDir, dashboardUrl: cliConfig.dashboardUrl, + goViperCompat: false, yes: flags.yes, rawArgs, edgeRuntimeVersion, diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts index b31bdd35b9..983a7a6a5e 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts @@ -4,7 +4,7 @@ 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 { Effect, Exit, Layer, Option } from "effect"; import { ProjectHome } from "../../../config/project-home.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { @@ -147,4 +147,79 @@ literal = "literal-secret" Effect.provide(projectLayer(cwd)), ); }); + + it.live("does not resolve a lowercase-named env() reference in edge runtime secrets", () => { + const cwd = makeTempProject(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(cwd, "supabase"), { recursive: true })); + yield* Effect.tryPromise(() => + writeFile(join(cwd, "supabase", ".env"), "lowercase_env_var=should-not-resolve\n"), + ); + yield* Effect.tryPromise(() => + writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "test" + +[edge_runtime] +policy = "oneshot" +inspector_port = 8123 + +[edge_runtime.secrets] +lowercase_secret = "env(lowercase_env_var)" +`, + ), + ); + + const result = yield* resolveFunctionsDevEdgeRuntimeConfig(); + + expect(result.config).toEqual({ + enabled: true, + inspectorPort: 8123, + policy: "oneshot", + env: { + LOWERCASE_SECRET: "env(lowercase_env_var)", + }, + }); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), + Effect.provide(projectLayer(cwd)), + ); + }); + + it.live("does not split a comma-separated string literal for an array field", () => { + const cwd = makeTempProject(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(cwd, "supabase"), { recursive: true })); + yield* Effect.tryPromise(() => + writeFile(join(cwd, "supabase", ".env"), "EDGE_API_KEY=edge-secret\n"), + ); + yield* Effect.tryPromise(() => + writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "test" + +[auth] +additional_redirect_urls = "http://a,http://b" + +[edge_runtime] +policy = "oneshot" +inspector_port = 8123 + +[edge_runtime.secrets] +api_key = "env(EDGE_API_KEY)" +literal = "literal-secret" +`, + ), + ); + + const exit = yield* resolveFunctionsDevEdgeRuntimeConfig().pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), + Effect.provide(projectLayer(cwd)), + ); + }); }); diff --git a/apps/cli/src/next/commands/start/start.integration.test.ts b/apps/cli/src/next/commands/start/start.integration.test.ts index 7179a25000..be7b06c555 100644 --- a/apps/cli/src/next/commands/start/start.integration.test.ts +++ b/apps/cli/src/next/commands/start/start.integration.test.ts @@ -1,7 +1,12 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; import { Deferred, Effect, Exit, Fiber, Layer } from "effect"; import type { StackServiceStatus } from "@supabase/stack"; import { DEFAULT_VERSIONS, stackMetadata, type StackInfo } from "@supabase/stack/effect"; +import { loadProjectConfig } from "@supabase/config"; import { start } from "./start.handler.ts"; import { StartVersionState } from "./start.command.ts"; import { startForegroundWithStopSignal } from "./flows/foreground.flow.ts"; @@ -532,3 +537,68 @@ describe("start", () => { }).pipe(Effect.provide(layer)); }); }); + +describe("next start config-load path (goViperCompat default-off regression)", () => { + // start.command.ts's `Command.provide` factory calls + // `loadProjectConfig(projectHome.projectRoot)` with no options (line ~183) — + // deeply inside `Layer.unwrap`/`StateManager`/daemon-layer construction that + // this file's `start()`-from-handler harness (StartVersionState provided + // directly) never reaches and can't be extended to reach without + // reproducing that machinery. This test instead exercises the exact + // zero-options `loadProjectConfig` call directly against a real temp + // project, pinning that `next start` still loads successfully when a + // config.toml has a duplicate or malformed `[remotes.*]` block — the + // regression that motivated gating these Go-viper checks behind + // `goViperCompat` (packages/config/src/io.unit.test.ts has the + // authoritative, broader coverage of this default-off behavior). + it("loads successfully despite a duplicate [remotes.*] project_id", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "supabase-next-start-config-")); + try { + await mkdir(join(tempDir, "supabase"), { recursive: true }); + await writeFile( + join(tempDir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.a] +project_id = "dupref" + +[remotes.b] +project_id = "dupref" +`, + ); + + const result = await Effect.runPromise( + loadProjectConfig(tempDir).pipe(Effect.provide(BunServices.layer)), + ); + + expect(result).not.toBeNull(); + expect(result?.config.project_id).toBe("baseref"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("loads successfully despite a malformed [remotes.*] project_id", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "supabase-next-start-config-")); + try { + await mkdir(join(tempDir, "supabase"), { recursive: true }); + await writeFile( + join(tempDir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.bad] +project_id = "not-a-ref" +`, + ); + + const result = await Effect.runPromise( + loadProjectConfig(tempDir).pipe(Effect.provide(BunServices.layer)), + ); + + expect(result).not.toBeNull(); + expect(result?.config.project_id).toBe("baseref"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); 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 8dc2efe194..3020fe3c1d 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -123,10 +123,15 @@ describe("native hidden flags", () => { Effect.scoped( Effect.gen(function* () { yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })(["start", "--preview"]); - yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + // `stop` is natively ported (no longer a `LegacyGoProxy` forward), so it can fail for + // Docker-related reasons in this proxy-only test layer — the point here is only to + // prove the hidden `--backup` flag still parses by exact name, not that the command + // succeeds, matching the `functions deploy`/`serve` assertions below. + const stopExit = yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ "stop", "--backup=false", - ]); + ]).pipe(Effect.exit); + expect(JSON.stringify(stopExit)).not.toContain("UnrecognizedFlag"); yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ "functions", "download", @@ -162,7 +167,6 @@ describe("native hidden flags", () => { expect(proxy.calls).toEqual([ ["start", "--preview"], - ["stop", "--backup=false"], ["functions", "download", "hello", "--project-ref", "abcdefghijklmnopqrst", "--use-docker"], ]); }); diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index b5d7be72a0..51f0d9a9a8 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -62,6 +62,7 @@ interface DeployFunctionsDependencies { readonly projectRoot: string; readonly supabaseDir: string; readonly dashboardUrl: string; + readonly goViperCompat: boolean; readonly yes?: boolean; readonly rawArgs: ReadonlyArray; readonly edgeRuntimeVersion: string; @@ -2150,7 +2151,10 @@ export function deployFunctions( // `@supabase/config` merges the matching `[remotes.*]` block over the base // config (Go's `loadFromFile` with `Config.ProjectId` set), so the resolved // config already reflects any remote function/edge_runtime overrides. - const loadedConfig = yield* loadProjectConfig(dependencies.projectRoot, { projectRef }); + const loadedConfig = yield* loadProjectConfig(dependencies.projectRoot, { + projectRef, + goViperCompat: dependencies.goViperCompat, + }); const deployConfig = loadedConfig?.config; const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( deployConfig?.edge_runtime.deno_version, diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index e11a1c4736..710afdd972 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -127,6 +127,7 @@ export interface FunctionsServeDependencies { readonly debug: boolean; readonly networkId: Option.Option; readonly projectIdOverride: Option.Option; + readonly goViperCompat: boolean; } interface PlainServeAuthConfig { @@ -616,6 +617,7 @@ const resolveAuthArtifacts = Effect.fnUntraced(function* ( const resolveServeConfig = Effect.fnUntraced(function* ( projectRoot: string, projectIdOverride: Option.Option, + goViperCompat: boolean, ) { const projectEnv = yield* loadServeProjectEnvironment(projectRoot); const projectRef = Option.match(projectIdOverride, { @@ -632,28 +634,35 @@ const resolveServeConfig = Effect.fnUntraced(function* ( const loadedConfig = yield* loadProjectConfig(projectRoot, { ...(projectRef === undefined ? {} : { projectRef }), ...(projectEnv === null ? {} : { projectEnv }), + goViperCompat, }); const baseConfig = loadedConfig?.config ?? defaultProjectConfig; const auth = projectEnv === null ? toPlainAuthConfig(baseConfig.auth) - : toPlainAuthConfig(yield* resolveProjectSubtree(baseConfig.auth, projectEnv, "auth")); + : toPlainAuthConfig( + yield* resolveProjectSubtree(baseConfig.auth, projectEnv, "auth", { goViperCompat }), + ); const edgeRuntime = projectEnv === null ? toPlainEdgeRuntimeConfig(baseConfig.edge_runtime) : toPlainEdgeRuntimeConfig( - yield* resolveProjectSubtree(baseConfig.edge_runtime, projectEnv, "edge_runtime"), + yield* resolveProjectSubtree(baseConfig.edge_runtime, projectEnv, "edge_runtime", { + goViperCompat, + }), ); const apiPort = projectEnv === null ? baseConfig.api.port - : (yield* resolveProjectSubtree(baseConfig.api, projectEnv, "api")).port; + : (yield* resolveProjectSubtree(baseConfig.api, projectEnv, "api", { goViperCompat })).port; const configDeclaredFunctions = projectEnv === null ? toPlainFunctionRecord(baseConfig.functions) : toPlainFunctionRecord( - yield* resolveProjectSubtree(baseConfig.functions, projectEnv, "functions"), + yield* resolveProjectSubtree(baseConfig.functions, projectEnv, "functions", { + goViperCompat, + }), ); const configForManifest: ProjectConfig = { ...baseConfig, @@ -667,7 +676,9 @@ const resolveServeConfig = Effect.fnUntraced(function* ( projectEnv === null ? (baseConfig.project_id ?? "") : (reveal( - yield* resolveProjectValue(baseConfig.project_id ?? "", projectEnv, "project_id"), + yield* resolveProjectValue(baseConfig.project_id ?? "", projectEnv, "project_id", { + goViperCompat, + }), ) ?? ""); const rawProjectId = Option.getOrElse(projectIdOverride, () => configProjectId).trim(); const fallbackProjectId = basename(resolve(projectRoot)); @@ -1296,6 +1307,7 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { const resolved = yield* resolveServeConfig( input.dependencies.projectRoot, input.dependencies.projectIdOverride, + input.dependencies.goViperCompat, ); const projectId = resolved.projectId; const containerId = localDockerId("edge_runtime", projectId); diff --git a/apps/docs/public/cli/config.schema.json b/apps/docs/public/cli/config.schema.json index 0503269e77..c3f73abf09 100644 --- a/apps/docs/public/cli/config.schema.json +++ b/apps/docs/public/cli/config.schema.json @@ -41,19 +41,10 @@ "default": 54327 }, "backend": { - "anyOf": [ - { - "type": "string", - "enum": [ - "postgres" - ] - }, - { - "type": "string", - "enum": [ - "bigquery" - ] - } + "type": "string", + "enum": [ + "postgres", + "bigquery" ], "description": "Configure one of the supported backends:\n\n- `postgres`\n- `bigquery`", "default": "postgres" @@ -347,31 +338,12 @@ "default": 6 }, "password_requirements": { - "anyOf": [ - { - "type": "string", - "enum": [ - "" - ] - }, - { - "type": "string", - "enum": [ - "letters_digits" - ] - }, - { - "type": "string", - "enum": [ - "lower_upper_letters_digits" - ] - }, - { - "type": "string", - "enum": [ - "lower_upper_letters_digits_symbols" - ] - } + "type": "string", + "enum": [ + "", + "letters_digits", + "lower_upper_letters_digits", + "lower_upper_letters_digits_symbols" ], "description": "Password character requirements.", "default": "" @@ -600,19 +572,10 @@ "default": false }, "provider": { - "anyOf": [ - { - "type": "string", - "enum": [ - "hcaptcha" - ] - }, - { - "type": "string", - "enum": [ - "turnstile" - ] - } + "type": "string", + "enum": [ + "hcaptcha", + "turnstile" ], "description": "CAPTCHA provider to use." }, @@ -1019,30 +982,28 @@ "anyOf": [ { "type": "object", - "patternProperties": { - "^(invite|confirmation|recovery|magic_link|email_change|reauthentication)$": { - "anyOf": [ - { - "type": "object", - "properties": { - "subject": { - "type": "string", - "description": "Subject line for the email template.", - "default": "" - }, - "content_path": { - "type": "string", - "description": "Path to the HTML template.", - "default": "" - } + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "subject": { + "type": "string", + "description": "Subject line for the email template.", + "default": "" }, - "additionalProperties": false + "content_path": { + "type": "string", + "description": "Path to the HTML template.", + "default": "" + } }, - { - "type": "null" - } - ] - } + "additionalProperties": false + }, + { + "type": "null" + } + ] }, "description": "Custom email template configuration.", "default": {} @@ -1056,35 +1017,33 @@ "anyOf": [ { "type": "object", - "patternProperties": { - "^(password_changed|email_changed|phone_changed|identity_linked|identity_unlinked|mfa_factor_enrolled|mfa_factor_unenrolled)$": { - "anyOf": [ - { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable the notification email.", - "default": false - }, - "subject": { - "type": "string", - "description": "Subject line for the notification email.", - "default": "" - }, - "content_path": { - "type": "string", - "description": "Path to the HTML notification template.", - "default": "" - } + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the notification email.", + "default": false }, - "additionalProperties": false + "subject": { + "type": "string", + "description": "Subject line for the notification email.", + "default": "" + }, + "content_path": { + "type": "string", + "description": "Path to the HTML notification template.", + "default": "" + } }, - { - "type": "null" - } - ] - } + "additionalProperties": false + }, + { + "type": "null" + } + ] }, "description": "Notification email configuration.", "default": {} @@ -1893,7 +1852,7 @@ }, "additionalProperties": false }, - "slack": { + "slack_oidc": { "type": "object", "properties": { "enabled": { @@ -1910,7 +1869,7 @@ "type": "string", "description": "Client secret for the Slack OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_SLACK_SECRET)" + "env(SUPABASE_AUTH_EXTERNAL_SLACK_OIDC_SECRET)" ] }, "url": { @@ -2334,19 +2293,10 @@ "default": 54329 }, "pool_mode": { - "anyOf": [ - { - "type": "string", - "enum": [ - "transaction" - ] - }, - { - "type": "string", - "enum": [ - "session" - ] - } + "type": "string", + "enum": [ + "transaction", + "session" ], "description": "Specifies when a server connection can be reused by other clients.", "default": "transaction" @@ -2675,25 +2625,11 @@ ] }, "session_replication_role": { - "anyOf": [ - { - "type": "string", - "enum": [ - "origin" - ] - }, - { - "type": "string", - "enum": [ - "replica" - ] - }, - { - "type": "string", - "enum": [ - "local" - ] - } + "type": "string", + "enum": [ + "origin", + "replica", + "local" ], "description": "Session replication role." }, @@ -2783,19 +2719,10 @@ "default": true }, "policy": { - "anyOf": [ - { - "type": "string", - "enum": [ - "oneshot" - ] - }, - { - "type": "string", - "enum": [ - "per_worker" - ] - } + "type": "string", + "enum": [ + "oneshot", + "per_worker" ], "description": "Configure the supported request policy.", "default": "per_worker" @@ -2903,6 +2830,22 @@ }, "description": "Static files to bundle with the function.", "default": [] + }, + "env": { + "type": "object", + "patternProperties": { + "^[A-Z_][A-Z0-9_]*$": { + "type": "string", + "allOf": [ + { + "pattern": "^env\\((.*)\\)$", + "description": "Reference to a project environment variable available to the Function." + } + ] + } + }, + "description": "Environment variables from the project environment that this Function can access.", + "default": {} } }, "additionalProperties": false @@ -3028,19 +2971,10 @@ "default": true }, "ip_version": { - "anyOf": [ - { - "type": "string", - "enum": [ - "IPv4" - ] - }, - { - "type": "string", - "enum": [ - "IPv6" - ] - } + "type": "string", + "enum": [ + "IPv4", + "IPv6" ], "description": "Bind realtime via either IPv4 or IPv6.", "default": "IPv4" @@ -3084,12 +3018,35 @@ "default": true }, "file_size_limit": { - "type": "string", - "description": "The maximum file size allowed.", - "default": "50MiB", - "examples": [ - "5MB", - "500KB" + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + } ] }, "image_transformation": { @@ -3116,12 +3073,35 @@ "default": false }, "file_size_limit": { - "type": "string", - "description": "The maximum file size allowed for the bucket.", - "default": "50MiB", - "examples": [ - "5MB", - "500KB" + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + } ] }, "allowed_mime_types": { @@ -3286,7 +3266,7 @@ "enabled": { "type": "boolean", "description": "Enable vector buckets.", - "default": false + "default": true }, "max_buckets": { "anyOf": [ @@ -3586,19 +3566,10 @@ "default": 54327 }, "backend": { - "anyOf": [ - { - "type": "string", - "enum": [ - "postgres" - ] - }, - { - "type": "string", - "enum": [ - "bigquery" - ] - } + "type": "string", + "enum": [ + "postgres", + "bigquery" ], "description": "Configure one of the supported backends:\n\n- `postgres`\n- `bigquery`", "default": "postgres" @@ -3892,31 +3863,12 @@ "default": 6 }, "password_requirements": { - "anyOf": [ - { - "type": "string", - "enum": [ - "" - ] - }, - { - "type": "string", - "enum": [ - "letters_digits" - ] - }, - { - "type": "string", - "enum": [ - "lower_upper_letters_digits" - ] - }, - { - "type": "string", - "enum": [ - "lower_upper_letters_digits_symbols" - ] - } + "type": "string", + "enum": [ + "", + "letters_digits", + "lower_upper_letters_digits", + "lower_upper_letters_digits_symbols" ], "description": "Password character requirements.", "default": "" @@ -4145,19 +4097,10 @@ "default": false }, "provider": { - "anyOf": [ - { - "type": "string", - "enum": [ - "hcaptcha" - ] - }, - { - "type": "string", - "enum": [ - "turnstile" - ] - } + "type": "string", + "enum": [ + "hcaptcha", + "turnstile" ], "description": "CAPTCHA provider to use." }, @@ -4564,30 +4507,28 @@ "anyOf": [ { "type": "object", - "patternProperties": { - "^(invite|confirmation|recovery|magic_link|email_change|reauthentication)$": { - "anyOf": [ - { - "type": "object", - "properties": { - "subject": { - "type": "string", - "description": "Subject line for the email template.", - "default": "" - }, - "content_path": { - "type": "string", - "description": "Path to the HTML template.", - "default": "" - } + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "subject": { + "type": "string", + "description": "Subject line for the email template.", + "default": "" }, - "additionalProperties": false + "content_path": { + "type": "string", + "description": "Path to the HTML template.", + "default": "" + } }, - { - "type": "null" - } - ] - } + "additionalProperties": false + }, + { + "type": "null" + } + ] }, "description": "Custom email template configuration.", "default": {} @@ -4601,35 +4542,33 @@ "anyOf": [ { "type": "object", - "patternProperties": { - "^(password_changed|email_changed|phone_changed|identity_linked|identity_unlinked|mfa_factor_enrolled|mfa_factor_unenrolled)$": { - "anyOf": [ - { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable the notification email.", - "default": false - }, - "subject": { - "type": "string", - "description": "Subject line for the notification email.", - "default": "" - }, - "content_path": { - "type": "string", - "description": "Path to the HTML notification template.", - "default": "" - } + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the notification email.", + "default": false }, - "additionalProperties": false + "subject": { + "type": "string", + "description": "Subject line for the notification email.", + "default": "" + }, + "content_path": { + "type": "string", + "description": "Path to the HTML notification template.", + "default": "" + } }, - { - "type": "null" - } - ] - } + "additionalProperties": false + }, + { + "type": "null" + } + ] }, "description": "Notification email configuration.", "default": {} @@ -5438,7 +5377,7 @@ }, "additionalProperties": false }, - "slack": { + "slack_oidc": { "type": "object", "properties": { "enabled": { @@ -5455,7 +5394,7 @@ "type": "string", "description": "Client secret for the Slack OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_SLACK_SECRET)" + "env(SUPABASE_AUTH_EXTERNAL_SLACK_OIDC_SECRET)" ] }, "url": { @@ -5879,19 +5818,10 @@ "default": 54329 }, "pool_mode": { - "anyOf": [ - { - "type": "string", - "enum": [ - "transaction" - ] - }, - { - "type": "string", - "enum": [ - "session" - ] - } + "type": "string", + "enum": [ + "transaction", + "session" ], "description": "Specifies when a server connection can be reused by other clients.", "default": "transaction" @@ -6220,25 +6150,11 @@ ] }, "session_replication_role": { - "anyOf": [ - { - "type": "string", - "enum": [ - "origin" - ] - }, - { - "type": "string", - "enum": [ - "replica" - ] - }, - { - "type": "string", - "enum": [ - "local" - ] - } + "type": "string", + "enum": [ + "origin", + "replica", + "local" ], "description": "Session replication role." }, @@ -6328,19 +6244,10 @@ "default": true }, "policy": { - "anyOf": [ - { - "type": "string", - "enum": [ - "oneshot" - ] - }, - { - "type": "string", - "enum": [ - "per_worker" - ] - } + "type": "string", + "enum": [ + "oneshot", + "per_worker" ], "description": "Configure the supported request policy.", "default": "per_worker" @@ -6448,6 +6355,22 @@ }, "description": "Static files to bundle with the function.", "default": [] + }, + "env": { + "type": "object", + "patternProperties": { + "^[A-Z_][A-Z0-9_]*$": { + "type": "string", + "allOf": [ + { + "pattern": "^env\\((.*)\\)$", + "description": "Reference to a project environment variable available to the Function." + } + ] + } + }, + "description": "Environment variables from the project environment that this Function can access.", + "default": {} } }, "additionalProperties": false @@ -6573,19 +6496,10 @@ "default": true }, "ip_version": { - "anyOf": [ - { - "type": "string", - "enum": [ - "IPv4" - ] - }, - { - "type": "string", - "enum": [ - "IPv6" - ] - } + "type": "string", + "enum": [ + "IPv4", + "IPv6" ], "description": "Bind realtime via either IPv4 or IPv6.", "default": "IPv4" @@ -6629,12 +6543,35 @@ "default": true }, "file_size_limit": { - "type": "string", - "description": "The maximum file size allowed.", - "default": "50MiB", - "examples": [ - "5MB", - "500KB" + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + } ] }, "image_transformation": { @@ -6661,12 +6598,35 @@ "default": false }, "file_size_limit": { - "type": "string", - "description": "The maximum file size allowed for the bucket.", - "default": "50MiB", - "examples": [ - "5MB", - "500KB" + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + } ] }, "allowed_mime_types": { @@ -6831,7 +6791,7 @@ "enabled": { "type": "boolean", "description": "Enable vector buckets.", - "default": false + "default": true }, "max_buckets": { "anyOf": [ diff --git a/packages/cli-test-helpers/src/parity.ts b/packages/cli-test-helpers/src/parity.ts index 9b0aae97c1..a52401f85f 100644 --- a/packages/cli-test-helpers/src/parity.ts +++ b/packages/cli-test-helpers/src/parity.ts @@ -214,6 +214,24 @@ function snapshotChangedFiles(dir: string): FileRecord[] { // RunResult collection // --------------------------------------------------------------------------- +/** + * Docker Engine API calls carry two client-negotiation artifacts that reflect + * nothing about CLI behavior: an `HEAD /_ping` handshake the real `docker` CLI + * issues before every subprocess invocation (Go's SDK pings once per command + * via a persistent client; ts-legacy shells out to `docker` per operation, so + * it pings once per operation), and a negotiated API version segment in the + * URL path (`/v1.51/...` vs `/v1.53/...`) that reflects the installed docker + * CLI/daemon version, not anything the command controls. Strip both so parity + * comparisons reflect actual Docker operations (list, stop, prune, inspect, + * with their filters) rather than client plumbing. Mirrors the equivalent + * normalization in `apps/cli-e2e/src/server/placeholder.ts`'s + * `normalizeUrlPath`, applied here to the request-log comparison instead of + * fixture matching. + */ +function normalizeDockerRequestPath(pathname: string): string { + return pathname.replace(/\/v1\.\d+(\/|$)/g, "/__DOCKER_VERSION__$1"); +} + async function fetchRequestLog(apiUrl: string): Promise { const res = await fetch(`${apiUrl}/_ctrl/requests`); const raw = (await res.json()) as Array<{ @@ -222,12 +240,14 @@ async function fetchRequestLog(apiUrl: string): Promise { query: Record; body: unknown; }>; - return raw.map(({ method, pathname, query, body }) => ({ - method, - pathname, - query, - body, - })); + return raw + .filter(({ method, pathname }) => !(method === "HEAD" && pathname === "/_ping")) + .map(({ method, pathname, query, body }) => ({ + method, + pathname: normalizeDockerRequestPath(pathname), + query, + body, + })); } async function collectRunResult( diff --git a/packages/config/src/auth/email.ts b/packages/config/src/auth/email.ts index 536282874b..4b17fb0a6f 100644 --- a/packages/config/src/auth/email.ts +++ b/packages/config/src/auth/email.ts @@ -25,16 +25,19 @@ const defaultNotificationEnabled = false; const defaultSubject = ""; const defaultContentPath = ""; -const templateNamePattern = new RegExp( - "^(invite|confirmation|recovery|magic_link|email_change|reauthentication)$", -); - -const notificationNamePattern = new RegExp( - "^(password_changed|email_changed|phone_changed|identity_linked|identity_unlinked|mfa_factor_enrolled|mfa_factor_unenrolled)$", -); - -const templateName = Schema.String.check(Schema.isPattern(templateNamePattern)); -const notificationName = Schema.String.check(Schema.isPattern(notificationNamePattern)); +/** + * Go's `Auth.Email.Template`/`Notification` are genuine `map[string]emailTemplate`/ + * `map[string]notification` (`apps/cli-go/pkg/config/auth.go:247-248`) — open maps with no key + * restriction at all; `(e *email) validate(fsys)` (`pkg/config/config.go:1293-1313`) iterates + * every entry regardless of name. The known template/notification names below are the ones the + * Studio/docs UI and `config push`'s Management API sync recognise, but an unrecognized key like + * `[auth.email.template.custom]` is still a legitimate config shape Go accepts — it's simply + * never synced anywhere, same as an unmodeled `auth.external` provider. Record keys therefore + * accept any string, matching Go; only decode failures on a KNOWN/matching name would be a + * Go-parity bug. + */ +const templateName = Schema.String; +const notificationName = Schema.String; function requiredWhenEnabled< T extends Record & { enabled: boolean }, diff --git a/packages/config/src/auth/providers.ts b/packages/config/src/auth/providers.ts index fcb059220d..4e7691d117 100644 --- a/packages/config/src/auth/providers.ts +++ b/packages/config/src/auth/providers.ts @@ -118,6 +118,15 @@ const provider = (providerConfig: { const defaultExternal = {}; +/** + * Go's deprecated `linkedin`/`slack` provider ids (`pkg/config/config.go:1418- + * 1423`) are intentionally NOT modeled here — only their `_oidc` replacements + * (`linkedin_oidc`, `slack_oidc`) are, matching Go's `(e external) validate()`, + * which unconditionally deletes the deprecated keys before anything decodes + * them. `io.ts`'s `normalizeDeprecatedExternalProviders` strips a config's + * `linkedin`/`slack` table (warning on stderr when it was `enabled`, same as + * Go) before this schema ever sees it. + */ export const external = Schema.Struct({ apple: provider({ id: "apple", @@ -185,8 +194,8 @@ export const external = Schema.Struct({ id: "x", name: "X", }), - slack: provider({ - id: "slack", + slack_oidc: provider({ + id: "slack_oidc", name: "Slack", }), spotify: provider({ diff --git a/packages/config/src/auth/sms.ts b/packages/config/src/auth/sms.ts index 3f7b781f80..05b1570139 100644 --- a/packages/config/src/auth/sms.ts +++ b/packages/config/src/auth/sms.ts @@ -32,19 +32,114 @@ const defaultTextlocalEnabled = false; const defaultVonage = {}; const defaultVonageEnabled = false; -function requiredWhenEnabled< - T extends Record & { enabled: boolean }, ->(path: string, predicate: (value: T) => boolean, message: string) { - return Schema.makeFilter((value: T) => { - if (!value.enabled || predicate(value)) { - return undefined; - } +interface SmsProviderSwitchInput { + readonly twilio: { + readonly enabled: boolean; + readonly account_sid: string; + readonly message_service_sid: string; + readonly auth_token?: string; + }; + readonly twilio_verify: { + readonly enabled: boolean; + readonly account_sid?: string; + readonly message_service_sid?: string; + readonly auth_token?: string; + }; + readonly messagebird: { + readonly enabled: boolean; + readonly originator?: string; + readonly access_key?: string; + }; + readonly textlocal: { + readonly enabled: boolean; + readonly sender?: string; + readonly api_key?: string; + }; + readonly vonage: { + readonly enabled: boolean; + readonly from?: string; + readonly api_key?: string; + readonly api_secret?: string; + }; +} + +function missing(provider: string, field: string) { + return { + path: [provider, field], + issue: `Missing required field in config: auth.sms.${provider}.${field}`, + }; +} - return { - path: [path], - issue: message, - }; - }); +/** + * Go's `(s *sms) validate()` (`apps/cli-go/pkg/config/config.go:1348-1410`): a boolean `switch` + * that inspects providers in a FIXED priority order — twilio, twilio_verify, messagebird, + * textlocal, vonage — and validates ONLY the first one whose `enabled` is true, matching Go's + * `switch` short-circuit semantics. A later enabled-but-incomplete provider is never even looked + * at. This replaces five independent per-provider `requiredWhenEnabled` checks (one per provider + * sub-struct) that used to validate EVERY enabled provider table regardless of priority — a real + * Go-parity gap, since a stale secondary `[auth.sms.*]` block Go silently ignores could make this + * schema reject a config Go accepts. `s.EnableSignup`'s own switch case (`config.go:1408-1410`, a + * WARN-only "no SMS provider enabled" notice with no throwing equivalent) isn't reproduced here, + * matching this package's established precedent of not porting WARN-only branches (e.g. the + * `auth.captcha.secret`/`assertEnvLoaded` case). + */ +function validateSmsProviderSwitch(value: SmsProviderSwitchInput) { + if (value.twilio.enabled) { + if (value.twilio.account_sid === "") return missing("twilio", "account_sid"); + if (value.twilio.message_service_sid === "") { + return missing("twilio", "message_service_sid"); + } + if (value.twilio.auth_token === undefined || value.twilio.auth_token === "") { + return missing("twilio", "auth_token"); + } + return undefined; + } + if (value.twilio_verify.enabled) { + if (value.twilio_verify.account_sid === undefined || value.twilio_verify.account_sid === "") { + return missing("twilio_verify", "account_sid"); + } + if ( + value.twilio_verify.message_service_sid === undefined || + value.twilio_verify.message_service_sid === "" + ) { + return missing("twilio_verify", "message_service_sid"); + } + if (value.twilio_verify.auth_token === undefined || value.twilio_verify.auth_token === "") { + return missing("twilio_verify", "auth_token"); + } + return undefined; + } + if (value.messagebird.enabled) { + if (value.messagebird.originator === undefined || value.messagebird.originator === "") { + return missing("messagebird", "originator"); + } + if (value.messagebird.access_key === undefined || value.messagebird.access_key === "") { + return missing("messagebird", "access_key"); + } + return undefined; + } + if (value.textlocal.enabled) { + if (value.textlocal.sender === undefined || value.textlocal.sender === "") { + return missing("textlocal", "sender"); + } + if (value.textlocal.api_key === undefined || value.textlocal.api_key === "") { + return missing("textlocal", "api_key"); + } + return undefined; + } + if (value.vonage.enabled) { + if (value.vonage.from === undefined || value.vonage.from === "") { + return missing("vonage", "from"); + } + if (value.vonage.api_key === undefined || value.vonage.api_key === "") { + return missing("vonage", "api_key"); + } + if (value.vonage.api_secret === undefined || value.vonage.api_secret === "") { + return missing("vonage", "api_secret"); + } + return undefined; + } + return undefined; } export const sms = Schema.Struct({ @@ -100,25 +195,7 @@ export const sms = Schema.Struct({ links: [links.phoneLogin("Twilio")], }), ), - }) - .check( - requiredWhenEnabled( - "account_sid", - (value) => value.account_sid !== "", - "Missing required field in config: auth.sms.twilio.account_sid", - ), - requiredWhenEnabled( - "message_service_sid", - (value) => value.message_service_sid !== "", - "Missing required field in config: auth.sms.twilio.message_service_sid", - ), - requiredWhenEnabled( - "auth_token", - (value) => value.auth_token !== undefined && value.auth_token !== "", - "Missing required field in config: auth.sms.twilio.auth_token", - ), - ) - .pipe(Schema.withDecodingDefaultKey(Effect.succeed({ ...defaultTwilio }))), + }).pipe(Schema.withDecodingDefaultKey(Effect.succeed({ ...defaultTwilio }))), twilio_verify: Schema.Struct({ enabled: Schema.Boolean.annotate({ default: defaultTwilioVerifyEnabled, @@ -147,25 +224,7 @@ export const sms = Schema.Struct({ links: [links.phoneLogin("Twilio")], }), ), - }) - .check( - requiredWhenEnabled( - "account_sid", - (value) => value.account_sid !== undefined && value.account_sid !== "", - "Missing required field in config: auth.sms.twilio_verify.account_sid", - ), - requiredWhenEnabled( - "message_service_sid", - (value) => value.message_service_sid !== undefined && value.message_service_sid !== "", - "Missing required field in config: auth.sms.twilio_verify.message_service_sid", - ), - requiredWhenEnabled( - "auth_token", - (value) => value.auth_token !== undefined && value.auth_token !== "", - "Missing required field in config: auth.sms.twilio_verify.auth_token", - ), - ) - .pipe(Schema.withDecodingDefaultKey(Effect.succeed({ ...defaultTwilioVerify }))), + }).pipe(Schema.withDecodingDefaultKey(Effect.succeed({ ...defaultTwilioVerify }))), messagebird: Schema.Struct({ enabled: Schema.Boolean.annotate({ default: defaultMessagebirdEnabled, @@ -187,20 +246,7 @@ export const sms = Schema.Struct({ links: [links.phoneLogin("MessageBird")], }), ), - }) - .check( - requiredWhenEnabled( - "originator", - (value) => value.originator !== undefined && value.originator !== "", - "Missing required field in config: auth.sms.messagebird.originator", - ), - requiredWhenEnabled( - "access_key", - (value) => value.access_key !== undefined && value.access_key !== "", - "Missing required field in config: auth.sms.messagebird.access_key", - ), - ) - .pipe(Schema.withDecodingDefaultKey(Effect.succeed({ ...defaultMessagebird }))), + }).pipe(Schema.withDecodingDefaultKey(Effect.succeed({ ...defaultMessagebird }))), textlocal: Schema.Struct({ enabled: Schema.Boolean.annotate({ default: defaultTextlocalEnabled, @@ -222,20 +268,7 @@ export const sms = Schema.Struct({ links: [links.phoneLogin("Textlocal%2520(Community%2520Supported)")], }), ), - }) - .check( - requiredWhenEnabled( - "sender", - (value) => value.sender !== undefined && value.sender !== "", - "Missing required field in config: auth.sms.textlocal.sender", - ), - requiredWhenEnabled( - "api_key", - (value) => value.api_key !== undefined && value.api_key !== "", - "Missing required field in config: auth.sms.textlocal.api_key", - ), - ) - .pipe(Schema.withDecodingDefaultKey(Effect.succeed({ ...defaultTextlocal }))), + }).pipe(Schema.withDecodingDefaultKey(Effect.succeed({ ...defaultTextlocal }))), vonage: Schema.Struct({ enabled: Schema.Boolean.annotate({ default: defaultVonageEnabled, @@ -264,25 +297,7 @@ export const sms = Schema.Struct({ links: [links.phoneLogin("Vonage")], }), ), - }) - .check( - requiredWhenEnabled( - "from", - (value) => value.from !== undefined && value.from !== "", - "Missing required field in config: auth.sms.vonage.from", - ), - requiredWhenEnabled( - "api_key", - (value) => value.api_key !== undefined && value.api_key !== "", - "Missing required field in config: auth.sms.vonage.api_key", - ), - requiredWhenEnabled( - "api_secret", - (value) => value.api_secret !== undefined && value.api_secret !== "", - "Missing required field in config: auth.sms.vonage.api_secret", - ), - ) - .pipe(Schema.withDecodingDefaultKey(Effect.succeed({ ...defaultVonage }))), + }).pipe(Schema.withDecodingDefaultKey(Effect.succeed({ ...defaultVonage }))), test_otp: Schema.optionalKey( Schema.Record(Schema.String, Schema.String).annotate({ description: "Use pre-defined map of phone number to OTP for testing.", @@ -290,4 +305,6 @@ export const sms = Schema.Struct({ links: [links.auth], }), ), -}).pipe(Schema.withDecodingDefaultKey(Effect.succeed({ ...defaultSms }))); +}) + .check(Schema.makeFilter(validateSmsProviderSwitch)) + .pipe(Schema.withDecodingDefaultKey(Effect.succeed({ ...defaultSms }))); diff --git a/packages/config/src/base.ts b/packages/config/src/base.ts index 26e14810e0..35c727fc3b 100644 --- a/packages/config/src/base.ts +++ b/packages/config/src/base.ts @@ -55,15 +55,30 @@ const remoteProjectConfig = Schema.Struct({ experimental, }).pipe(Schema.withDecodingDefault(Effect.succeed({}))); +/** + * Exported separately (not inlined into {@link ProjectConfigSchema}) so + * `packages/config/src/io.ts` can decode it on its own with + * `disableChecks: true`. Go's `Config.Validate` only ever checks + * `remotes.*.project_id` format for every remote block + * (`apps/cli-go/pkg/config/config.go:996-1001`, "Since remote config is merged + * to base, we only need to validate the project_id field") — every other + * business-rule check (`Auth.External.validate()`, `Auth.Sms.validate()`, + * etc.) runs exactly once, against the merged effective config + * (`config.go:1136-1152`), never iterated over `c.Remotes[*]`. Decoding this + * schema normally (checks enabled) would apply those same business-rule + * `.check()`s — embedded in `auth`/`db`/etc. — to every remote regardless of + * selection, rejecting configs Go accepts (e.g. an unselected + * `[remotes.prod.auth.external.github] enabled = true` stub with no secret). + */ +export const RemotesSchema = Schema.Record(Schema.String, remoteProjectConfig).annotate({ + default: {}, + description: "Remote branch-specific project configuration.", + tags: ["general"], +}); + export const ProjectConfigSchema = Schema.Struct({ ...baseProjectConfigFields, - remotes: Schema.Record(Schema.String, remoteProjectConfig) - .annotate({ - default: {}, - description: "Remote branch-specific project configuration.", - tags: ["general"], - }) - .pipe(Schema.withDecodingDefault(Effect.succeed({}))), + remotes: RemotesSchema.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }); export type ProjectConfig = typeof ProjectConfigSchema.Type; diff --git a/packages/config/src/errors.ts b/packages/config/src/errors.ts index 0644df83ce..1a3c17bc42 100644 --- a/packages/config/src/errors.ts +++ b/packages/config/src/errors.ts @@ -70,3 +70,15 @@ export class DuplicateRemoteProjectIdError extends Data.TaggedError( )<{ readonly message: string; }> {} + +/** + * A `[remotes.]` block's `project_id` is not a valid 20-lowercase-letter + * project ref. Mirrors Go's `Config.Validate` (`apps/cli-go/pkg/config/config.go: + * 558,996-1001`), which checks every remote's `project_id` against `refPattern` + * on every config load — regardless of whether that remote ends up selected — + * so this fails before Docker/API access, same as Go. `message` matches the Go + * string verbatim so callers can surface it without rewrapping. + */ +export class InvalidRemoteProjectIdError extends Data.TaggedError("InvalidRemoteProjectIdError")<{ + readonly message: string; +}> {} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index e9a8eed763..77af2064ae 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,6 +1,7 @@ export { ProjectConfigSchema, type ProjectConfig, type ProjectConfigJson } from "./base.ts"; export { DuplicateRemoteProjectIdError, + InvalidRemoteProjectIdError, MissingProjectConfigValueError, ProjectConfigParseError, ProjectEnvParseError, @@ -30,6 +31,7 @@ export { type LoadProjectEnvironmentOptions, type ProjectEnvironment, type ResolvedProjectValue, + type ResolveProjectOptions, loadProjectEnvironment, resolveProjectSubtree, resolveProjectValue, @@ -39,3 +41,4 @@ export { projectConfigStoreLayer } from "./project-config.layer.ts"; export { ProjectConfigStore } from "./project-config.service.ts"; export { PROJECT_CONFIG_SCHEMA_URL } from "./schema-metadata.ts"; export { KONG_LOCAL_CA_CERT } from "./tls.ts"; +export { ENV_CAPTURE_REGEX } from "./lib/env.ts"; diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index 599833dced..151ce2a373 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -1,7 +1,11 @@ import { Console, Effect, FileSystem, Path, Redacted, Schema } from "effect"; import * as SmolToml from "smol-toml"; -import { ProjectConfigSchema, type ProjectConfig } from "./base.ts"; -import { DuplicateRemoteProjectIdError, ProjectConfigParseError } from "./errors.ts"; +import { ProjectConfigSchema, RemotesSchema, type ProjectConfig } from "./base.ts"; +import { + DuplicateRemoteProjectIdError, + InvalidRemoteProjectIdError, + ProjectConfigParseError, +} from "./errors.ts"; import { interpolateEnvReferencesAgainstSchema } from "./lib/env.ts"; import { findProjectPaths } from "./paths.ts"; import { loadProjectEnvironment, type ProjectEnvironment } from "./project.ts"; @@ -34,11 +38,18 @@ export interface LoadedProjectConfig { } /** - * When `projectRef` is set, the matching `[remotes.]` block (the one whose - * `project_id` equals it) is merged over the base config before decode, mirroring - * Go's `config.Load` with `Config.ProjectId` set - * (`apps/cli-go/pkg/config/config.go:503-562`). Omitting it loads the base config - * verbatim, so existing callers are unaffected. + * When `projectRef` is set, the matching `[remotes.]` block (the one + * whose `project_id` equals it) is merged over the base config before decode, + * mirroring Go's `config.Load` with `Config.ProjectId` set + * (`apps/cli-go/pkg/config/config.go:503-562`). Omitting it loads the base + * config verbatim (no merge), so existing callers are unaffected. Go's + * duplicate-`project_id`/project-ref-format checks across every + * `[remotes.*]` block (`config.go:594-602,996-1001`) run unconditionally on + * every config load in Go, not only when a caller ends up selecting a + * remote — but here they only run when {@link LoadProjectConfigOptions.goViperCompat} + * is `true`, regardless of whether `projectRef` is set, so non-Go-parity + * callers that never select a remote (and never opt into Go parity) aren't + * broken by an unrelated duplicate/malformed `[remotes.*]` block. */ export interface LoadProjectConfigOptions { readonly projectRef?: string; @@ -51,6 +62,37 @@ export interface LoadProjectConfigOptions { * so loading does not re-read those files or depend on `process.env` mutation. */ readonly projectEnv?: ProjectEnvironment; + /** See {@link FindProjectPathsOptions.search}. */ + readonly search?: boolean; + /** + * Skip the `config.json`-over-`config.toml` preference below and only ever + * load `config.toml`. Go's `Config.Load`/`NewPathBuilder` + * (`apps/cli-go/pkg/config/utils.go:43-48`) has no concept of a JSON project + * config file — it always resolves `supabase/config.toml` and treats a + * missing file as defaults — so Go-parity callers (the legacy `status`/`stop` + * ports) must set this to avoid picking up a stray `config.json` that Go + * would never see. + */ + readonly tomlOnly?: boolean; + /** + * Opt into the Go/viper-parity decode+validation semantics this loader + * otherwise omits, so only the Go-parity legacy shell (and shared modules + * invoked exclusively by it) pays for them. Defaults to `false` = pre-PR-#5765 + * behavior, which `next/`, `packages/stack`, and the functions manifest rely + * on. When `true`, mirrors Go's `config.Load` exactly: + * - runs the unconditional duplicate-`project_id` and project-ref-format + * checks across every `[remotes.*]` block (`config.go:594-602,996-1001`), + * even when no `projectRef` is requested; + * - warns on stderr for deprecated `auth.external.{linkedin,slack}` blocks + * (`config.go:1418-1423`) — the block is stripped from the decoded config + * either way, since the schema ignores excess properties; + * - matches `env(...)` references case-agnostically (`^env\((.*)\)$`) + * rather than the strict SCREAMING_SNAKE_CASE form; + * - splits a comma-separated string into a `[]string`-typed field (Go's + * `mapstructure.StringToSliceHookFunc(",")`, `config.go:775-784`), not + * just an `env()`-substituted one. + */ + readonly goViperCompat?: boolean; } export interface SaveProjectConfigOptions { @@ -61,6 +103,18 @@ export interface SaveProjectConfigOptions { } const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); +/** + * Decodes the `remotes` map with `disableChecks: true` — full type/shape + * decoding, defaults, and transformations (e.g. secret redaction) still run, + * but the `.check()`-based business-rule refinements embedded in `auth`/`db`/ + * etc. (e.g. "external provider requires a secret when enabled") are skipped. + * See {@link RemotesSchema}'s doc comment for why: Go only ever applies those + * business rules to the merged effective config, never to a `[remotes.*]` + * block that wasn't selected. + */ +const decodeRemotesWithoutChecks = Schema.decodeUnknownSync(RemotesSchema, { + disableChecks: true, +}); const encodeProjectConfig = Schema.encodeSync(ProjectConfigSchema); const defaultEncodedProjectConfig = encodeProjectConfig(decodeProjectConfig({})); const defaultEncodedFunctionConfig = { @@ -124,28 +178,20 @@ function withDbSeedDisabled(document: Record): Record]` override whose `project_id` matches `projectRef` - * to `document`, mirroring Go's `loadFromFile` remote resolution - * (`config.go:503-518`). Returns the merged document (with `remotes` stripped) and - * the matched remote name. - * - * Like Go, duplicate `project_id`s are detected across *all* `[remotes.*]` blocks — - * not just the ones matching `projectRef` — before the matching override is applied. - * A missing `project_id` reads as `""` (Go's `viper.GetString`), so two remotes that + * Builds a `project_id -> "[remotes.]"` map across every `[remotes.*]` + * block, failing on the first duplicate. Mirrors Go's `loadFromFile` + * (`config.go:594-602`): that loop runs unconditionally on every config load, + * regardless of whether any remote's `project_id` ends up matching + * `Config.ProjectId`. Here, {@link applyRemoteOverride} only invokes this when + * `goViperCompat` is set, so it still runs even for callers that don't + * request a specific `projectRef` — but only under Go-parity mode. A missing + * `project_id` reads as `""` (Go's `viper.GetString`), so two remotes that * both omit it collide on the empty key and fail just as in Go. */ -const applyRemoteOverride = Effect.fnUntraced(function* ( - document: Record, - projectRef: string, +const checkDuplicateRemoteProjectIds = Effect.fnUntraced(function* ( + remotes: Record, ) { - const remotes = document["remotes"]; - if (!isObject(remotes)) { - return { document, appliedRemote: undefined as string | undefined }; - } - // Build a project_id -> "[remotes.]" map over every remote, failing on the - // first duplicate, then resolve the single block matching projectRef. const idToName = new Map(); - let name: string | undefined; for (const [remoteName, remote] of Object.entries(remotes)) { const projectId = isObject(remote) && typeof remote["project_id"] === "string" ? remote["project_id"] : ""; @@ -156,17 +202,91 @@ const applyRemoteOverride = Effect.fnUntraced(function* ( }); } idToName.set(projectId, `[remotes.${remoteName}]`); - if (projectId === projectRef) { - name = remoteName; + } +}); + +/** Go's project-ref pattern (`apps/cli-go/pkg/config/config.go:558`): exactly 20 + * lowercase ASCII letters. */ +const REMOTE_PROJECT_ID_PATTERN = /^[a-z]{20}$/; + +/** + * Rejects the first `[remotes.*]` block whose `project_id` is not a valid + * project ref, mirroring Go's `Config.Validate` (`config.go:996-1001`) — that + * loop runs unconditionally over every remote on every config load, not only + * the one that ends up selected/merged. Here, {@link applyRemoteOverride} only + * invokes this when `goViperCompat` is set. + * + * Unlike {@link checkDuplicateRemoteProjectIds}/the match below (which read + * viper's raw, pre-`LoadEnvHook` values — see {@link applyRemoteOverride}'s + * doc comment), `Config.Validate` runs entirely AFTER the struct decode + * (`config.go:882`), by which point `LoadEnvHook` has already resolved every + * `env(...)` reference (`config.go:749-753`). So this check must see the + * already-interpolated `project_id`, not the literal `env(REF)` form — an + * `[remotes.x] project_id = "env(REF)"` that resolves to a valid 20-letter ref + * passes here even though the raw string doesn't match the pattern itself. + */ +const checkRemoteProjectIdFormat = Effect.fnUntraced(function* (remotes: Record) { + for (const [remoteName, remote] of Object.entries(remotes)) { + const projectId = + isObject(remote) && typeof remote["project_id"] === "string" ? remote["project_id"] : ""; + if (!REMOTE_PROJECT_ID_PATTERN.test(projectId)) { + return yield* new InvalidRemoteProjectIdError({ + message: `Invalid config for remotes.${remoteName}.project_id. Must be like: abcdefghijklmnopqrst`, + }); } } +}); + +/** + * Applies the `[remotes.]` override whose `project_id` matches `projectRef` + * to `rawDocument`, mirroring Go's `loadFromFile` remote resolution + * (`config.go:503-518`). Returns the merged document (with `remotes` stripped, + * still pre-`env()`-interpolation — the caller re-interpolates the result) and + * the matched remote name. `projectRef` of `undefined` never matches any remote + * (including one that itself omits `project_id`, which reads as `""`) — callers + * that don't request a specific remote get the duplicate/format checks below + * without the merge, so the base document loads verbatim as before. + * + * `rawDocument`'s `remotes` block is the PRE-interpolation document: Go's + * duplicate-check/selection loop in `loadFromFile` reads directly off viper's + * raw config values (`v.GetString(fmt.Sprintf("remotes.%s.project_id", name))`, + * `config.go:596-610`) and only calls `c.load(v)` — which resolves `env(...)` + * via `LoadEnvHook` during the struct decode (`config.go:749-753`, + * `decode_hooks.go:13-26`) — afterward (`config.go:611`). So a + * `[remotes.prod] project_id = "env(REF)"` is matched/deduped against the + * LITERAL `env(REF)` string in Go, never against `REF`'s resolved value; this + * mirrors that exactly rather than matching post-interpolation, which would + * merge a remote Go itself would never select. `interpolatedRemotes` (Go's + * post-decode `c.Remotes`, mirrored here as the already-interpolated + * `remotes` subtree) is used only for {@link checkRemoteProjectIdFormat} — see + * its doc comment for why that check needs the resolved value instead. + */ +const applyRemoteOverride = Effect.fnUntraced(function* ( + rawDocument: Record, + interpolatedRemotes: Record | undefined, + projectRef: string | undefined, + goViperCompat: boolean, +) { + const remotes = rawDocument["remotes"]; + if (!isObject(remotes)) { + return { document: rawDocument, appliedRemote: undefined as string | undefined }; + } + if (goViperCompat) { + yield* checkDuplicateRemoteProjectIds(remotes); + yield* checkRemoteProjectIdFormat(interpolatedRemotes ?? remotes); + } + const name = Object.entries(remotes).find(([, remote]) => { + const projectId = + isObject(remote) && typeof remote["project_id"] === "string" ? remote["project_id"] : ""; + return projectRef !== undefined && projectId === projectRef; + })?.[0]; if (name === undefined) { - return { document, appliedRemote: undefined as string | undefined }; + return { document: rawDocument, appliedRemote: undefined as string | undefined }; } const remoteSubtree = remotes[name]; let merged = isObject(remoteSubtree) - ? mergeRemoteSubtree(document, remoteSubtree) - : { ...document }; + ? mergeRemoteSubtree(rawDocument, remoteSubtree) + : { ...rawDocument }; if (!(isObject(remoteSubtree) && remoteSetsDbSeedEnabled(remoteSubtree))) { merged = withDbSeedDisabled(merged); } @@ -317,6 +437,78 @@ function normalizeDeprecatedSMTPSections(document: unknown): NormalizedSMTPDocum return { document: normalized, deprecatedSections }; } +interface NormalizedExternalProvidersDocument { + readonly document: unknown; + /** Provider ids (`"linkedin"` | `"slack"`) whose deprecated top-level block was `enabled` — drives the WARN. */ + readonly deprecatedProviders: ReadonlyArray; +} + +const DEPRECATED_EXTERNAL_PROVIDERS = ["linkedin", "slack"] as const; + +/** + * Go's `(e external) validate()` deprecated-provider handling + * (`apps/cli-go/pkg/config/config.go:1418-1423`): `linkedin`/`slack` are + * unconditionally deleted from `auth.external` before the required-field loop + * runs, so a bare `[auth.external.slack] enabled = true` with no + * `client_id`/`secret` loads fine in Go — a warning prints to stderr only + * when the deleted provider was `enabled`, never a hard failure. + * + * Unlike {@link normalizeDeprecatedSMTPSections}'s `[inbucket]` rename — which + * Go's own `normalizeDeprecatedSMTPConfig` runs BEFORE remote selection, over + * every `[remotes.*]` entry unconditionally (`config.go:594,614-640`) — Go's + * `external.validate()` runs from `Config.Validate()`, exactly ONCE on the + * final post-remote-merge struct (`config.go:882,1148`). A non-selected + * remote's own `auth.external.slack` block is never even looked at by Go. So + * this must run on the POST-merge document (`documentForDecode`, after + * `applyRemoteOverride`), not the pre-merge one: + * - the top-level `auth.external.{linkedin,slack}` is always stripped, and + * reported (for the caller to warn on) only when it was `enabled`, + * matching Go's single `external.validate()` call. + * - any `remotes.*.auth.external.{linkedin,slack}` still present (only + * possible when no remote matched `projectRef`, so `applyRemoteOverride` + * left `remotes` in place) is also stripped, but never reported — purely + * so `remoteProjectConfig`'s eager, whole-map schema decode + * (`packages/config/src/base.ts`) doesn't reject an unselected remote's + * deprecated block over a field Go itself never struct-decodes at all for + * a remote that isn't in effect. + */ +function normalizeDeprecatedExternalProviders( + document: unknown, +): NormalizedExternalProvidersDocument { + if (!isObject(document)) { + return { document, deprecatedProviders: [] }; + } + const normalized = { ...document }; + const deprecatedProviders: Array = []; + if (isObject(normalized.auth) && isObject(normalized.auth.external)) { + const external = { ...normalized.auth.external }; + for (const ext of DEPRECATED_EXTERNAL_PROVIDERS) { + const provider = external[ext]; + if (provider === undefined) continue; + if (isObject(provider) && provider.enabled === true) { + deprecatedProviders.push(ext); + } + delete external[ext]; + } + normalized.auth = { ...normalized.auth, external }; + } + if (isObject(normalized.remotes)) { + normalized.remotes = Object.fromEntries( + Object.entries(normalized.remotes).map(([name, remote]) => { + if (!isObject(remote) || !isObject(remote.auth) || !isObject(remote.auth.external)) { + return [name, remote]; + } + const external = { ...remote.auth.external }; + for (const ext of DEPRECATED_EXTERNAL_PROVIDERS) { + delete external[ext]; + } + return [name, { ...remote, auth: { ...remote.auth, external } }]; + }), + ); + } + return { document: normalized, deprecatedProviders }; +} + /** * Wraps every `edge_runtime.secrets` value in `Redacted` before it's attached * to `ProjectConfigParseError.document`. By this point `secrets` values are @@ -382,7 +574,23 @@ function parseProjectConfig( appliedRemote: string | undefined, ): Effect.Effect { return Effect.try({ - try: () => decodeProjectConfig(document), + try: () => { + // Decode `remotes` separately, with business-rule checks disabled — see + // `decodeRemotesWithoutChecks`/`RemotesSchema`'s doc comments. Non-selected + // `[remotes.*]` blocks reach here still attached to `document` (only a + // SELECTED remote gets merged in and stripped from `remotes` by + // `applyRemoteOverride`), so decoding them through the normal, + // checks-enabled `decodeProjectConfig` below would apply Go's + // merged-config-only business rules to every remote regardless of + // selection. Structural decoding (types, defaults, transformations) + // still runs either way, matching Go's unconditional `UnmarshalExact` + // struct decode of every remote. + const rawRemotes = isObject(document) ? document.remotes : undefined; + const config = decodeProjectConfig( + isObject(document) ? { ...document, remotes: {} } : document, + ); + return { ...config, remotes: decodeRemotesWithoutChecks(rawRemotes ?? {}) }; + }, // `document` always parsed successfully by this point (raw parse failures // are caught earlier, in `loadProjectConfigFile`), so any error here is a // schema-decode failure — attach it so callers can attempt a narrower, @@ -479,25 +687,78 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( (yield* loadProjectEnvironment({ cwd: projectRoot, baseEnv: process.env, + search: options?.search, })); - const interpolated = interpolateEnvReferencesAgainstSchema( - normalized, - projectEnv?.values ?? {}, - ProjectConfigSchema, - ); - - // Merge the matching `[remotes.*]` override over the base document before - // decode (Go's `loadFromFile` with `Config.ProjectId` set). Only requested - // when a `projectRef` is supplied, so other callers load the base verbatim. - let documentForDecode: unknown = interpolated; + const goViperCompat = options?.goViperCompat ?? false; + const interpolateDocument = (document: unknown): unknown => + interpolateEnvReferencesAgainstSchema(document, projectEnv?.values ?? {}, ProjectConfigSchema, { + goViperCompat, + }); + + // Interpolated once here purely to give `applyRemoteOverride`'s FORMAT check + // (not its match/merge — see that function's doc comment) the resolved + // `remotes.*.project_id`, matching Go's post-decode `Config.Validate`. + const interpolatedForValidation = interpolateDocument(normalized); + const interpolatedRemotes = + isObject(interpolatedForValidation) && isObject(interpolatedForValidation["remotes"]) + ? interpolatedForValidation["remotes"] + : undefined; + + // Merge the matching `[remotes.*]` override over the RAW (pre-`env()`- + // interpolation) document — Go's `loadFromFile` duplicate-check/selection + // loop runs on viper's raw string values, before `LoadEnvHook` ever resolves + // `env(...)` (`config.go:594-611`, `decode_hooks.go:13-26`); see + // `applyRemoteOverride`'s doc comment. The match/merge itself always runs + // (callers that don't request a `projectRef` just never match a remote, so + // the base document loads verbatim), but the duplicate-`project_id`/format + // checks only run when `goViperCompat` is set — see `applyRemoteOverride`. + let documentForDecode: unknown = normalized; let appliedRemote: string | undefined; - if (options?.projectRef !== undefined && isObject(interpolated)) { - const resolved = yield* applyRemoteOverride(interpolated, options.projectRef); + if (isObject(normalized)) { + const resolved = yield* applyRemoteOverride( + normalized, + interpolatedRemotes, + options?.projectRef, + goViperCompat, + ); documentForDecode = resolved.document; appliedRemote = resolved.appliedRemote; } - const config = yield* parseProjectConfig(documentForDecode, format, filePath, appliedRemote); + // The merge above ran on the raw document, so any `env(...)` reference in + // the winning remote's subtree (or elsewhere in the base) still needs + // resolving before decode — mirrors Go's `LoadEnvHook` running on the + // post-merge viper store inside `c.load(v)`. When no remote matched, this + // recomputes the same substitutions `interpolatedForValidation` already + // made (documentForDecode is just `normalized` again) — a redundant walk on + // that path, but correctness on the match+`env()` path matters more than + // avoiding it. + documentForDecode = isObject(documentForDecode) + ? interpolateDocument(documentForDecode) + : documentForDecode; + + // Strip Go's deprecated `auth.external.{linkedin,slack}` provider ids from + // the POST-remote-merge document, matching `external.validate()` running + // once on the final effective config (see `normalizeDeprecatedExternalProviders`). + const { document: normalizedForDecode, deprecatedProviders } = + normalizeDeprecatedExternalProviders(documentForDecode); + // Warn on stderr, matching Go's `external.validate()` (`config.go:1418-1423`). + // Go's own format string is a raw string literal ending in a literal + // backslash-n (raw string literals never process escapes, and `Fprintf` + // doesn't append a newline the way `Fprintln` does), so Go's actual stderr + // bytes have no real line break after this message — a library-internal + // artifact, not the parity-relevant part, same call already made for + // `LegacyInvalidPortEnvOverrideError` in the legacy shell. Not reproduced + // byte-for-byte; `Console.error` supplies a normal trailing newline instead. + if (goViperCompat) { + for (const ext of deprecatedProviders) { + yield* Console.error( + `WARN: disabling deprecated "${ext}" provider. Please use [auth.external.${ext}_oidc] instead`, + ); + } + } + + const config = yield* parseProjectConfig(normalizedForDecode, format, filePath, appliedRemote); return { path: filePath, @@ -505,7 +766,7 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( config, schemaRef: getSchemaRef(document), ignoredPaths: [], - document: isObject(documentForDecode) ? documentForDecode : undefined, + document: isObject(normalizedForDecode) ? normalizedForDecode : undefined, appliedRemote, } satisfies LoadedProjectConfig; }); @@ -515,7 +776,7 @@ export const loadProjectConfig = Effect.fnUntraced(function* ( options?: LoadProjectConfigOptions, ) { const fs = yield* FileSystem.FileSystem; - const project = yield* findProjectPaths(cwd); + const project = yield* findProjectPaths(cwd, { search: options?.search }); if (project === null) { return null; @@ -528,7 +789,7 @@ export const loadProjectConfig = Effect.fnUntraced(function* ( ? project.configPath : project.configPath.replace(/config\.json$/, "config.toml"); - if (yield* fs.exists(jsonPath)) { + if (!options?.tomlOnly && (yield* fs.exists(jsonPath))) { const json = yield* loadProjectConfigFile(jsonPath, options); return { diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 6d70f191e6..a09b321502 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -16,6 +16,7 @@ import { loadProjectConfig, loadProjectConfigFile, saveProjectConfig, + type LoadProjectConfigOptions, } from "./io.ts"; import { loadProjectConfig as loadProjectConfigFromNode } from "./node.ts"; import { projectConfigStoreLayer } from "./project-config.layer.ts"; @@ -173,6 +174,45 @@ describe("config io", () => { ).toThrow(); }); + test("only validates the highest-priority enabled sms provider during decode (Go switch parity)", () => { + // Go's `(s *sms) validate()` (`apps/cli-go/pkg/config/config.go:1348-1410`) is a boolean + // `switch` that inspects providers in a fixed priority order (twilio, twilio_verify, + // messagebird, textlocal, vonage) and validates ONLY the first enabled one — a later + // enabled-but-incomplete provider is never even looked at. A complete, higher-priority + // `twilio` block plus an incomplete, lower-priority `messagebird` block must decode fine. + const config = decodeProjectConfig({ + auth: { + sms: { + twilio: { + enabled: true, + account_sid: "AC123", + message_service_sid: "MG123", + auth_token: "secret", + }, + messagebird: { + enabled: true, + }, + }, + }, + }); + expect(config.auth.sms.twilio.enabled).toBe(true); + expect(config.auth.sms.messagebird.enabled).toBe(true); + }); + + test("rejects an incomplete sms provider when no higher-priority provider is enabled", () => { + expect(() => + decodeProjectConfig({ + auth: { + sms: { + messagebird: { + enabled: true, + }, + }, + }, + }), + ).toThrow(/auth\.sms\.messagebird\.originator/); + }); + test("requires enabled smtp fields during decode", () => { expect(() => decodeProjectConfig({ @@ -187,6 +227,24 @@ describe("config io", () => { ).toThrow(); }); + test("decodes an unmodeled email template/notification name (Go map[string] parity)", () => { + // Go's `Auth.Email.Template`/`Notification` are genuine `map[string]emailTemplate`/ + // `map[string]notification` (`apps/cli-go/pkg/config/auth.go:247-248`) — open maps with no + // key restriction; `(e *email) validate(fsys)` (`pkg/config/config.go:1293-1313`) iterates + // every entry regardless of name. An unrecognized key like `[auth.email.template.custom]` + // is a legitimate config shape Go accepts, not a decode error. + const config = decodeProjectConfig({ + auth: { + email: { + template: { custom: { subject: "Hi" } }, + notification: { custom_notice: { enabled: true, content_path: "custom.html" } }, + }, + }, + }); + expect(config.auth.email.template["custom"]?.subject).toBe("Hi"); + expect(config.auth.email.notification["custom_notice"]?.enabled).toBe(true); + }); + test("requires enabled external provider credentials during decode", () => { expect(() => decodeProjectConfig({ @@ -323,6 +381,50 @@ major_version = 16 } }); + // Go's `NewPathBuilder`/`Config.Load` (`apps/cli-go/pkg/config/utils.go: + // 43-48`) only ever resolves `supabase/config.toml` — it has no concept of a + // JSON project config file. Go-parity callers (legacy `status`/`stop`) pass + // `tomlOnly: true` so a stray `config.json` never wins over `config.toml`. + test("loads TOML instead of JSON when tomlOnly is set, even if JSON exists", async () => { + const cwd = makeTempProject(); + const jsonPath = await runConfigEffect(configJsonPath(cwd)); + const tomlPath = await runConfigEffect(configTomlPath(cwd)); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(jsonPath, encodeProjectConfigToJson(sampleConfig)); + await writeFile( + tomlPath, + `project_id = "toml-ref" + +[db] +major_version = 16 +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd, { tomlOnly: true })); + expect(loaded?.format).toBe("toml"); + expect(loaded?.config.project_id).toBe("toml-ref"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("returns null when tomlOnly is set and only JSON exists", async () => { + const cwd = makeTempProject(); + const jsonPath = await runConfigEffect(configJsonPath(cwd)); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(jsonPath, encodeProjectConfigToJson(sampleConfig)); + + const loaded = await runConfigEffect(loadProjectConfig(cwd, { tomlOnly: true })); + expect(loaded).toBeNull(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("loads TOML when JSON is absent", async () => { const cwd = makeTempProject(); const tomlPath = await runConfigEffect(configTomlPath(cwd)); @@ -889,6 +991,128 @@ enabled = "env(SUPABASE_ANALYTICS_ENABLED)" } }); + test.each([ + ["1", true], + ["TRUE", true], + ["T", true], + ["True", true], + ["0", false], + ["f", false], + ["FALSE", false], + ] as const)( + "resolves env() on boolean fields using Go's strconv.ParseBool acceptance set (%s -> %s)", + async (envValue, expected) => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[analytics] +enabled = "env(SUPABASE_ANALYTICS_ENABLED)" +`, + ); + await writeFile(join(cwd, "supabase", ".env"), `SUPABASE_ANALYTICS_ENABLED=${envValue}\n`); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.config.analytics.enabled).toBe(expected); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, + ); + + test("splits a comma-separated string literal into a slice (Go's StringToSliceHookFunc)", async () => { + // Go's `newDecodeHook` (`apps/cli-go/pkg/config/config.go:775-784`) wires + // `mapstructure.StringToSliceHookFunc(",")` unconditionally, so a plain + // string value for a `[]string` field like `additional_redirect_urls` + // decodes fine in Go — not just via `env(...)`. + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +additional_redirect_urls = "http://a,http://b" +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); + expect(loaded!.config.auth.additional_redirect_urls).toEqual(["http://a", "http://b"]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("splits an env()-substituted comma-separated string into a slice", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +additional_redirect_urls = "env(SUPABASE_REDIRECT_URLS)" +`, + ); + await writeFile(join(cwd, "supabase", ".env"), "SUPABASE_REDIRECT_URLS=http://a,http://b\n"); + + const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); + expect(loaded!.config.auth.additional_redirect_urls).toEqual(["http://a", "http://b"]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("an empty string literal for a slice field decodes to an empty array", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +additional_redirect_urls = "" +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); + expect(loaded!.config.auth.additional_redirect_urls).toEqual([]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("an actual array value for a slice field is left untouched", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +additional_redirect_urls = ["http://a", "http://b"] +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.config.auth.additional_redirect_urls).toEqual(["http://a", "http://b"]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("preserves env() literals on string fields when the var is unset (Go parity)", async () => { const cwd = makeTempProject(); @@ -910,6 +1134,28 @@ jwt_secret = "env(MISSING_SECRET)" } }); + test("preserves env() literals on string fields when the var is set but empty (Go parity)", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +jwt_secret = "env(MISSING_SECRET)" +`, + ); + await writeFile(join(cwd, "supabase", ".env"), "MISSING_SECRET=\n"); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.config.auth.jwt_secret).toBe("env(MISSING_SECRET)"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("fails to decode a numeric field when env var is unset", async () => { const cwd = makeTempProject(); @@ -968,6 +1214,163 @@ port = "env(SUPABASE_DB_PORT_TEST)" await rm(cwd, { recursive: true, force: true }); } }); + + // Regression coverage for the default-off (`goViperCompat` omitted) path — + // these pin pre-PR-#5765 behavior so `next/`, `packages/stack`, and the + // functions manifest (none of which pass `goViperCompat`) don't inherit the + // Go-parity legacy shell's stricter/wider semantics. + test("loads successfully with a duplicate [remotes.*] project_id when goViperCompat is omitted", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.a] +project_id = "dupref" + +[remotes.b] +project_id = "dupref" +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded).not.toBeNull(); + expect(loaded!.config.project_id).toBe("baseref"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("loads successfully with an invalid [remotes.*] project_id format when goViperCompat is omitted", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.bad] +project_id = "not-a-ref" +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded).not.toBeNull(); + expect(loaded!.config.project_id).toBe("baseref"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("does not split a comma-separated string literal for an array field when goViperCompat is omitted", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +additional_redirect_urls = "http://a,http://b" +`, + ); + + const exit = await Effect.runPromiseExit( + loadProjectConfig(cwd).pipe(Effect.provide(BunServices.layer)), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect((error.value as { _tag: string })._tag).toBe("ProjectConfigParseError"); + } + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("does not warn on a deprecated provider (but still strips it) when goViperCompat is omitted", async () => { + const cwd = makeTempProject(); + const warnings: Array = []; + const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { + warnings.push(args.map((a) => String(a)).join(" ")); + }); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "abc123" + +[auth.external.slack] +enabled = true +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect("slack" in loaded!.config.auth.external).toBe(false); + expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); + } finally { + errorSpy.mockRestore(); + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("does not resolve a lowercase-named env() reference when goViperCompat is omitted", async () => { + const previous = process.env.lowercase_ref_default_off_test; + process.env.lowercase_ref_default_off_test = "lowercase-ref-value"; + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "env(lowercase_ref_default_off_test)"\n`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.config.project_id).toBe("env(lowercase_ref_default_off_test)"); + } finally { + if (previous === undefined) { + delete process.env.lowercase_ref_default_off_test; + } else { + process.env.lowercase_ref_default_off_test = previous; + } + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("resolves a lowercase-named env() reference when goViperCompat is true", async () => { + const previous = process.env.lowercase_ref_default_on_test; + process.env.lowercase_ref_default_on_test = "lowercase-ref-value"; + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "env(lowercase_ref_default_on_test)"\n`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); + expect(loaded!.config.project_id).toBe("lowercase-ref-value"); + } finally { + if (previous === undefined) { + delete process.env.lowercase_ref_default_on_test; + } else { + process.env.lowercase_ref_default_on_test = previous; + } + await rm(cwd, { recursive: true, force: true }); + } + }); }); describe("config io [remotes.*] merge", () => { @@ -978,6 +1381,14 @@ describe("config io [remotes.*] merge", () => { return cwd; } + // Remote `project_id`s below are valid 20-lowercase-letter refs (Go's + // `refPattern`, `config.go:558`) — `Config.Validate` rejects every + // `[remotes.*].project_id` against that pattern unconditionally on every + // config load (`config.go:996-1001`), so test fixtures must satisfy it too, + // even for scenarios that don't care about the ref's specific value. + const PREVIEW_REF = "previewrefaaaaaaaaaa"; + const STAGING_REF = "stagingrefaaaaaaaaaa"; + const BASE_WITH_REMOTES = `project_id = "baseref" [api] @@ -989,13 +1400,13 @@ max_rows = 123 major_version = 15 [remotes.preview] -project_id = "previewref" +project_id = "${PREVIEW_REF}" [remotes.preview.api] schemas = ["remote_only"] max_rows = 999 [remotes.staging] -project_id = "stagingref" +project_id = "${STAGING_REF}" [remotes.staging.api] enabled = false `; @@ -1003,10 +1414,10 @@ enabled = false test("merges the matching remote subtree over the base before decode", async () => { const cwd = await writeTomlProject(BASE_WITH_REMOTES); try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: "previewref" })); + const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); expect(loaded!.appliedRemote).toBe("preview"); // remote block's project_id overrides the base - expect(loaded!.config.project_id).toBe("previewref"); + expect(loaded!.config.project_id).toBe(PREVIEW_REF); // remote scalar wins expect(loaded!.config.api.max_rows).toBe(999); // array replaced wholesale (not element-merged) @@ -1037,9 +1448,7 @@ major_version = "not-a-number" ); try { const exit = await Effect.runPromiseExit( - loadProjectConfig(cwd, { projectRef: "previewref" }).pipe( - Effect.provide(BunServices.layer), - ), + loadProjectConfig(cwd, { projectRef: PREVIEW_REF }).pipe(Effect.provide(BunServices.layer)), ); expect(Exit.isFailure(exit)).toBe(true); if (!Exit.isFailure(exit)) { @@ -1069,7 +1478,10 @@ major_version = "not-a-number" } }); - test("does not merge remotes when no projectRef is requested", async () => { + test("does not merge remotes when no projectRef is requested and none has an empty project_id", async () => { + // `projectRef` defaults to "" (Go's own `Config.ProjectId` default for + // commands with no `--project-ref` flag), so this only stays unmerged + // because neither remote's `project_id` is empty. const cwd = await writeTomlProject(BASE_WITH_REMOTES); try { const loaded = await runConfigEffect(loadProjectConfig(cwd)); @@ -1081,6 +1493,42 @@ major_version = "not-a-number" } }); + test("rejects duplicate project_id across remotes even when no projectRef is requested", async () => { + // Go's duplicate-project_id check (config.go:594-602) runs unconditionally + // on every config load, inside the same loop that resolves the [remotes.*] + // override — it is not gated on a caller actually selecting a remote. + // status/stop (internal/utils/flags/config_path.go:11) never bind a + // `--project-ref` flag, so they hit this check with `Config.ProjectId == ""`, + // and it must still fail on a config-wide duplicate. + const cwd = await writeTomlProject(`project_id = "baseref" + +[remotes.a] +project_id = "dupref" + +[remotes.b] +project_id = "dupref" +`); + try { + const message = await Effect.runPromise( + loadProjectConfig(cwd, { goViperCompat: true }).pipe( + Effect.catchTag("DuplicateRemoteProjectIdError", (error) => + Effect.succeed(error.message), + ), + Effect.provide(BunServices.layer), + ), + ); + expect(message).toBe("duplicate project_id for [remotes.b] and [remotes.a]"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + // `goViperCompat` is required even though a `projectRef` is passed: the + // duplicate/format checks in `applyRemoteOverride` are gated solely on + // `goViperCompat`, not on whether a remote is being selected — the remote + // match/merge itself stays unconditional, but pre-PR-#5765 callers that + // pass a `projectRef` without opting into Go parity no longer get these + // checks for free. test("rejects duplicate project_id across remotes with Go's message", async () => { const cwd = await writeTomlProject(`project_id = "baseref" @@ -1092,7 +1540,7 @@ project_id = "dupref" `); try { const message = await Effect.runPromise( - loadProjectConfig(cwd, { projectRef: "dupref" }).pipe( + loadProjectConfig(cwd, { projectRef: "dupref", goViperCompat: true }).pipe( Effect.catchTag("DuplicateRemoteProjectIdError", (error) => Effect.succeed(error.message), ), @@ -1122,7 +1570,7 @@ project_id = "dupref" `); try { const message = await Effect.runPromise( - loadProjectConfig(cwd, { projectRef: "previewref" }).pipe( + loadProjectConfig(cwd, { projectRef: "previewref", goViperCompat: true }).pipe( Effect.catchTag("DuplicateRemoteProjectIdError", (error) => Effect.succeed(error.message), ), @@ -1150,7 +1598,7 @@ max_rows = 2 `); try { const message = await Effect.runPromise( - loadProjectConfig(cwd, { projectRef: "previewref" }).pipe( + loadProjectConfig(cwd, { projectRef: "previewref", goViperCompat: true }).pipe( Effect.catchTag("DuplicateRemoteProjectIdError", (error) => Effect.succeed(error.message), ), @@ -1163,16 +1611,41 @@ max_rows = 2 } }); + test("rejects a remote project_id that is not a valid 20-letter ref, even with no projectRef requested", async () => { + // Go's Config.Validate (config.go:996-1001) checks every [remotes.*].project_id + // against refPattern unconditionally on every config load — not only the one + // that ends up selected — so this must fail closed before status/stop reach + // Docker, exactly like Go, even when the caller never selects a remote. + const cwd = await writeTomlProject(`project_id = "baseref" + +[remotes.bad] +project_id = "not-a-ref" +`); + try { + const message = await Effect.runPromise( + loadProjectConfig(cwd, { goViperCompat: true }).pipe( + Effect.catchTag("InvalidRemoteProjectIdError", (error) => Effect.succeed(error.message)), + Effect.provide(BunServices.layer), + ), + ); + expect(message).toBe( + "Invalid config for remotes.bad.project_id. Must be like: abcdefghijklmnopqrst", + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("the merged document carries pointer sections introduced by the remote", async () => { const cwd = await writeTomlProject(`project_id = "baseref" [remotes.preview] -project_id = "previewref" +project_id = "${PREVIEW_REF}" [remotes.preview.db.ssl_enforcement] enabled = true `); try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: "previewref" })); + const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); // `legacyPresenceIn` reads `document` to detect optional pointer sections; // a remote-introduced `db.ssl_enforcement` must be present there. const db = loaded!.document?.db; @@ -1189,12 +1662,12 @@ enabled = true enabled = true [remotes.preview] -project_id = "previewref" +project_id = "${PREVIEW_REF}" [remotes.preview.api] max_rows = 5 `); try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: "previewref" })); + const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); expect(loaded!.config.db.seed.enabled).toBe(false); } finally { await rm(cwd, { recursive: true, force: true }); @@ -1205,18 +1678,103 @@ max_rows = 5 const cwd = await writeTomlProject(`project_id = "baseref" [remotes.preview] -project_id = "previewref" +project_id = "${PREVIEW_REF}" [remotes.preview.db.seed] enabled = true `); try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: "previewref" })); + const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); expect(loaded!.config.db.seed.enabled).toBe(true); } finally { await rm(cwd, { recursive: true, force: true }); } }); + test("resolves env() on a lowercase-named variable, matching Go's case-agnostic matcher", async () => { + // Go's `LoadEnvHook` (`apps/cli-go/pkg/config/decode_hooks.go:11`) is + // `^env\((.*)\)$` — it doesn't restrict the captured name's case, so + // `project_id = "env(project_id)"` resolves against a same-case env var + // in the Go CLI. This isn't specific to `project_id`; any string field + // goes through the same pre-decode walk. This case-agnostic matching is + // itself one of the four Go-viper-parity behaviors gated by + // `goViperCompat` — without it, the strict SCREAMING_SNAKE_CASE matcher + // wouldn't match this lowercase name at all. + const previous = process.env.project_id; + process.env.project_id = "lowercase-ref"; + const cwd = await writeTomlProject(`project_id = "env(project_id)"\n`); + try { + const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); + expect(loaded!.config.project_id).toBe("lowercase-ref"); + } finally { + if (previous === undefined) { + delete process.env.project_id; + } else { + process.env.project_id = previous; + } + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("does not match a remote whose project_id is env(REF) against the resolved ref (Go parity)", async () => { + // Go's `loadFromFile` duplicate-check/selection loop reads viper's RAW + // string values (`config.go:596-610`) and only calls `c.load(v)` — which + // resolves `env(...)` via `LoadEnvHook` — afterward (`config.go:611`, + // `decode_hooks.go:13-26`). So a `[remotes.x] project_id = "env(REF)"` + // never matches a caller-supplied, already-resolved `REF`: Go compares the + // literal `env(REF)` string, not what it resolves to. + const previous = process.env.SUPABASE_REMOTE_ENV_REF_TEST; + process.env.SUPABASE_REMOTE_ENV_REF_TEST = PREVIEW_REF; + const cwd = await writeTomlProject(`project_id = "baseref" + +[api] +max_rows = 1 + +[remotes.preview] +project_id = "env(SUPABASE_REMOTE_ENV_REF_TEST)" +[remotes.preview.api] +max_rows = 999 +`); + try { + const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); + expect(loaded!.appliedRemote).toBeUndefined(); + expect(loaded!.config.api.max_rows).toBe(1); + } finally { + if (previous === undefined) { + delete process.env.SUPABASE_REMOTE_ENV_REF_TEST; + } else { + process.env.SUPABASE_REMOTE_ENV_REF_TEST = previous; + } + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("validates a remote's env(REF) project_id format against its resolved value, not the literal", async () => { + // Go's `Config.Validate` (`config.go:989-1001`) runs entirely after the + // struct decode, by which point `LoadEnvHook` has already resolved + // `env(...)` — so it validates the RESOLVED project_id against the + // 20-lowercase-letter pattern, not the literal `env(REF)` string (which + // would never match the pattern itself). + const previous = process.env.SUPABASE_REMOTE_ENV_REF_FORMAT_TEST; + process.env.SUPABASE_REMOTE_ENV_REF_FORMAT_TEST = PREVIEW_REF; + const cwd = await writeTomlProject(`project_id = "baseref" + +[remotes.preview] +project_id = "env(SUPABASE_REMOTE_ENV_REF_FORMAT_TEST)" +`); + try { + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.appliedRemote).toBeUndefined(); + expect(loaded!.config.project_id).toBe("baseref"); + } finally { + if (previous === undefined) { + delete process.env.SUPABASE_REMOTE_ENV_REF_FORMAT_TEST; + } else { + process.env.SUPABASE_REMOTE_ENV_REF_FORMAT_TEST = previous; + } + await rm(cwd, { recursive: true, force: true }); + } + }); + test("resolves env() references inside the matching remote before merge", async () => { const previous = process.env.SUPABASE_REMOTE_MAX_ROWS_TEST; process.env.SUPABASE_REMOTE_MAX_ROWS_TEST = "777"; @@ -1226,12 +1784,12 @@ enabled = true max_rows = 1 [remotes.preview] -project_id = "previewref" +project_id = "${PREVIEW_REF}" [remotes.preview.api] max_rows = "env(SUPABASE_REMOTE_MAX_ROWS_TEST)" `); try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: "previewref" })); + const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); expect(loaded!.config.api.max_rows).toBe(777); } finally { if (previous === undefined) { @@ -1242,6 +1800,81 @@ max_rows = "env(SUPABASE_REMOTE_MAX_ROWS_TEST)" await rm(cwd, { recursive: true, force: true }); } }); + + // Go's `Config.Validate` only checks `remotes.*.project_id` format for + // every remote (`config.go:996-1001`, "Since remote config is merged to + // base, we only need to validate the project_id field") — every other + // business-rule check (`Auth.External.validate()`, etc.) runs exactly once, + // against the merged effective config (`config.go:1136-1152`), never + // iterated over `c.Remotes[*]`. A non-selected `[remotes.*]` block's own + // business-rule violations must not fail the whole config load. + test("loads an unselected remote whose external provider is enabled without a secret", async () => { + const cwd = await writeTomlProject( + `project_id = "baseref" + +[remotes.staging] +project_id = "${STAGING_REF}" + +[remotes.staging.auth.external.github] +enabled = true +`, + ); + try { + // No projectRef requested, so [remotes.staging] is never selected/merged — + // Go would never business-rule-validate it, even though it decodes fine + // structurally. + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.appliedRemote).toBeUndefined(); + expect(loaded!.config.remotes.staging?.auth.external.github.enabled).toBe(true); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("still validates the same remote's external provider once it is selected", async () => { + const cwd = await writeTomlProject( + `project_id = "baseref" + +[remotes.staging] +project_id = "${STAGING_REF}" + +[remotes.staging.auth.external.github] +enabled = true +`, + ); + try { + // Selecting [remotes.staging] merges it into the effective config, which + // Go DOES business-rule-validate (config.go:1136-1152) — a required + // `client_id`/`secret` is missing, so this must still fail. + const exit = await Effect.runPromiseExit( + loadProjectConfig(cwd, { projectRef: STAGING_REF }).pipe(Effect.provide(BunServices.layer)), + ); + expect(Exit.isFailure(exit)).toBe(true); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("still fails on a structurally malformed value inside an unselected remote", async () => { + // Go's `UnmarshalExact` always structurally decodes every remote + // (`config.go:246,749-756`) regardless of selection — only the + // merged-config-only business rules are skipped for a non-selected + // remote, not type/shape decoding. + const cwd = await writeTomlProject( + `${BASE_WITH_REMOTES} +[remotes.staging.db] +major_version = "not-a-number" +`, + ); + try { + const exit = await Effect.runPromiseExit( + loadProjectConfig(cwd).pipe(Effect.provide(BunServices.layer)), + ); + expect(Exit.isFailure(exit)).toBe(true); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); }); describe("config io deprecated [inbucket] back-compat", () => { @@ -1341,7 +1974,7 @@ port = 22222 `project_id = "abc123" [remotes.staging] -project_id = "stagingref" +project_id = "stagingrefaaaaaaaaaa" [remotes.staging.inbucket] enabled = true @@ -1376,3 +2009,124 @@ port = 54324 expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); }); }); + +describe("config io deprecated [auth.external.{linkedin,slack}] back-compat", () => { + let warnings: Array = []; + let errorSpy: ReturnType | undefined; + + function captureWarnings() { + warnings = []; + errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { + warnings.push(args.map((a) => String(a)).join(" ")); + }); + } + + afterEach(() => { + errorSpy?.mockRestore(); + errorSpy = undefined; + }); + + async function loadToml(contents: string, options?: LoadProjectConfigOptions) { + const cwd = makeTempProject(); + const path = await runConfigEffect(configTomlPath(cwd)); + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(path, contents); + try { + return await runConfigEffect(loadProjectConfigFile(path, options)); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + } + + test("loads a bare [auth.external.slack] block without required fields", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[auth.external.slack] +enabled = true +`, + { goViperCompat: true }, + ); + + expect("slack" in loaded.config.auth.external).toBe(false); + expect(loaded.document).not.toHaveProperty("auth.external.slack"); + expect( + warnings.some((m) => + m.includes( + 'WARN: disabling deprecated "slack" provider. Please use [auth.external.slack_oidc] instead', + ), + ), + ).toBe(true); + }); + + test("loads a bare [auth.external.linkedin] block without required fields", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[auth.external.linkedin] +enabled = true +`, + { goViperCompat: true }, + ); + + expect("linkedin" in loaded.config.auth.external).toBe(false); + expect( + warnings.some((m) => + m.includes( + 'WARN: disabling deprecated "linkedin" provider. Please use [auth.external.linkedin_oidc] instead', + ), + ), + ).toBe(true); + }); + + test("does not warn when the deprecated section is present but disabled", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[auth.external.slack] +enabled = false +`, + ); + + expect("slack" in loaded.config.auth.external).toBe(false); + expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); + }); + + test("does not warn when only [auth.external.slack_oidc] is used", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[auth.external.slack_oidc] +enabled = true +client_id = "abc" +secret = "shh" +`, + ); + + expect(loaded.config.auth.external.slack_oidc.enabled).toBe(true); + expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); + }); + + test("strips a deprecated [remotes.*.auth.external.slack] block without warning for an unselected remote", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[remotes.staging] +project_id = "stagingrefaaaaaaaaaa" + +[remotes.staging.auth.external.slack] +enabled = true +`, + ); + + // Not requesting `projectRef` means no remote is selected, so `remotes` survives + // decode verbatim (minus the deprecated key) rather than being merged/dropped. + expect(loaded.config.remotes.staging?.auth.external).not.toHaveProperty("slack"); + expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); + }); +}); diff --git a/packages/config/src/lib/env.ts b/packages/config/src/lib/env.ts index bd16819293..23a0c30056 100644 --- a/packages/config/src/lib/env.ts +++ b/packages/config/src/lib/env.ts @@ -1,11 +1,22 @@ import { Schema, SchemaAST } from "effect"; -export const ENV_PATTERN = "^env\\([A-Z_][A-Z0-9_]*\\)$"; -export const ENV_CAPTURE_REGEX = /^env\(([A-Z_][A-Z0-9_]*)\)$/; +// Go's `LoadEnvHook` matcher (`apps/cli-go/pkg/config/decode_hooks.go:11`) is +// `^env\((.*)\)$` — permissive on the captured name's case/content, and +// reused verbatim for secrets (`secret.go:99`) and the unset-var warning +// (`config.go:1195`). Matching that exactly (not an uppercase-only +// restriction) so e.g. `project_id = "env(project_id)"` substitutes the same +// way it does in the Go CLI. +export const ENV_PATTERN = "^env\\((.*)\\)$"; +export const ENV_CAPTURE_REGEX = /^env\((.*)\)$/; +// Pre-PR-#5765 strict matcher: SCREAMING_SNAKE_CASE names only. Selected when +// `goViperCompat` is off so non-Go-parity surfaces (next/, packages/stack, the +// functions manifest) keep the narrower matching they had before PR #5765 +// widened env() resolution to Go's case-agnostic `^env\((.*)\)$`. +export const ENV_CAPTURE_REGEX_STRICT = /^env\(([A-Z_][A-Z0-9_]*)\)$/; const envRegex = new RegExp(ENV_PATTERN); -export function isEnvReference(value: string): boolean { - return envRegex.test(value); +export function isEnvReference(value: string, goViperCompat: boolean): boolean { + return (goViperCompat ? ENV_CAPTURE_REGEX : ENV_CAPTURE_REGEX_STRICT).test(value); } interface EnvAnnotations extends Schema.Annotations.Documentation { @@ -52,12 +63,29 @@ export const secret = (annotations?: SecretAnnotations) => // and the value is still a string, coerce it. This mirrors Go's // mapstructure chain where `LoadEnvHook` returns a string and subsequent // hooks convert it to the target type. -// - Coercion is only attempted on strings produced by env() substitution. -// Pre-existing string literals at non-string paths are left untouched — -// they'll surface as schema errors at decode time with their original -// value, preserving error clarity. +// - Number/boolean coercion is only attempted on strings produced by env() +// substitution. Pre-existing string literals at non-string paths are left +// untouched — they'll surface as schema errors at decode time with their +// original value, preserving error clarity. +// - Array coercion is the one exception: if the schema at that path expects +// a homogeneous string array, ANY string leaf (substituted or a plain +// literal) is split on `,` — mirroring Go's `StringToSliceHookFunc(",")` +// (`apps/cli-go/pkg/config/config.go:775-784`), which is wired +// unconditionally into the decode hook chain regardless of where the +// string came from (e.g. `additional_redirect_urls = "http://a,http://b"` +// decodes fine in Go today, not just via `env(...)`). -type ExpectedType = "number" | "boolean" | "string" | "unknown"; +type ExpectedType = "number" | "boolean" | "string" | "array" | "unknown"; + +// Go decodes an env()-substituted boolean via mapstructure's weakly-typed +// `decodeBool`, which runs `strconv.ParseBool` on the string — a wider +// acceptance set than the literal `"true"`/`"false"` this module used to +// require. Mirrors `legacyParseGoBool`'s `GO_BOOL_TRUE`/`GO_BOOL_FALSE` +// (`apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts:615-616`); +// duplicated here (not imported) so `packages/config` doesn't depend on +// `apps/cli`. +const GO_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); +const GO_BOOL_FALSE = new Set(["0", "f", "F", "FALSE", "false", "False", ""]); // Unwrap Suspend (lazy AST refs from recursive schemas). Other transformation // wrappers expose the target type via `.ast` directly, so no additional @@ -69,6 +97,20 @@ function unwrapAst(ast: SchemaAST.AST): SchemaAST.AST { return ast; } +// A homogeneous `Schema.Array(Schema.String)` compiles to an `Arrays` AST +// node with no fixed tuple `elements` and a single `rest` spread type. Only +// this shape (not a fixed string tuple, and not a mixed-type array) is +// eligible for Go's `StringToSliceHookFunc(",")` coercion below — mirroring +// that Go itself only wires the hook for `[]string`-kind targets +// (`apps/cli-go/pkg/config/config.go:775-784`), not fixed-arity tuples. +function isHomogeneousStringArray(node: SchemaAST.AST): boolean { + if (node._tag !== "Arrays" || node.elements.length !== 0 || node.rest.length !== 1) { + return false; + } + const spread = node.rest[0]; + return spread !== undefined && unwrapAst(spread)._tag === "String"; +} + function leafExpectedType(ast: SchemaAST.AST): ExpectedType { const node = unwrapAst(ast); switch (node._tag) { @@ -78,6 +120,8 @@ function leafExpectedType(ast: SchemaAST.AST): ExpectedType { return "boolean"; case "String": return "string"; + case "Arrays": + return isHomogeneousStringArray(node) ? "array" : "unknown"; case "Union": { // Walk Union branches in declared order; first concrete primitive wins. // For unions like `Schema.Union(Schema.Number, Schema.Null)` this picks @@ -156,23 +200,39 @@ function coerceLeaf(value: unknown, expected: ExpectedType): unknown { return value; } if (expected === "boolean") { - if (value === "true") return true; - if (value === "false") return false; + if (GO_BOOL_TRUE.has(value)) return true; + if (GO_BOOL_FALSE.has(value)) return false; return value; } + if (expected === "array") { + // Go's `mapstructure.StringToSliceHookFunc(",")` (wired in + // `apps/cli-go/pkg/config/config.go:775-784`): an empty string decodes to + // an empty slice, otherwise the string is split on the separator with no + // further trimming of the resulting elements. + return value === "" ? [] : value.split(","); + } return value; } -function substituteEnvLeaf(value: string, env: Readonly>): string { - const match = ENV_CAPTURE_REGEX.exec(value); +function substituteEnvLeaf( + value: string, + env: Readonly>, + goViperCompat: boolean, +): string { + const match = (goViperCompat ? ENV_CAPTURE_REGEX : ENV_CAPTURE_REGEX_STRICT).exec(value); if (match === null) { return value; } const envName = match[1]; - if (envName === undefined || !Object.prototype.hasOwnProperty.call(env, envName)) { + const resolved = envName === undefined ? undefined : env[envName]; + // Go's LoadEnvHook only substitutes when the env var is non-empty + // (`apps/cli-go/pkg/config/decode_hooks.go:19-24`: `len(env) > 0`), so a + // key that's present but empty (e.g. a dotenv `KEY=` line) preserves the + // `env(KEY)` literal exactly like an unset key, rather than substituting "". + if (resolved === undefined || resolved === "") { return value; } - return env[envName] ?? value; + return resolved; } function isDeferredEnvField(ast: SchemaAST.AST): boolean { @@ -196,11 +256,12 @@ function walk( document: unknown, env: Readonly>, ast: SchemaAST.AST | null, + goViperCompat: boolean, ): unknown { if (Array.isArray(document)) { return document.map((item, index) => { const child = ast === null ? null : descendAst(ast, String(index)); - return walk(item, env, child); + return walk(item, env, child, goViperCompat); }); } @@ -208,7 +269,7 @@ function walk( const result: Record = {}; for (const [key, value] of Object.entries(document)) { const child = ast === null ? null : descendAst(ast, key); - result[key] = walk(value, env, child); + result[key] = walk(value, env, child, goViperCompat); } return result; } @@ -220,18 +281,37 @@ function walk( if (ast !== null && isDeferredEnvField(ast)) { return document; } + + const substituted = substituteEnvLeaf(document, env, goViperCompat); + const expected = ast === null ? "unknown" : leafExpectedType(ast); + + // Go's `StringToSliceHookFunc(",")` (`apps/cli-go/pkg/config/config.go: + // 775-784`) is wired unconditionally into `v.UnmarshalExact`'s decode + // hook chain, so it splits ANY string being decoded into a `[]string` + // field — a plain TOML literal (`additional_redirect_urls = "a,b"`) just + // as much as an `env()`-substituted one. Unlike the number/boolean + // coercion below (scoped to substituted values only, since TOML already + // decodes literal numbers/booleans to their native type), array coercion + // must also apply to literal strings that never went through + // `substituteEnvLeaf`. Gated by `goViperCompat`: when off, the string is + // left unsplit — literal and substituted alike — so an array-typed field + // fed a string fails decode instead of silently coercing, matching + // pre-PR-#5765 behavior. + if (expected === "array") { + return goViperCompat ? coerceLeaf(substituted, expected) : substituted; + } + // Substitute env() then coerce based on the schema's expected type at this // path. Only the substituted form is fed to coercion — literal strings at // non-string paths are left untouched so the decoder can report them with // their original value. - const substituted = substituteEnvLeaf(document, env); if (substituted === document) { return document; } if (ast === null) { return substituted; } - return coerceLeaf(substituted, leafExpectedType(ast)); + return coerceLeaf(substituted, expected); } return document; @@ -242,8 +322,11 @@ function walk( * * Walks the raw parsed document and the schema AST in parallel. For every * string leaf matching `env(VAR)`: - * 1. Substitutes `env[VAR]` if set, else preserves the literal verbatim - * (Go-parity with `apps/cli-go/pkg/config/decode_hooks.go:14-21`). + * 1. Substitutes `env[VAR]` if set AND non-empty, else preserves the + * literal verbatim (Go-parity with + * `apps/cli-go/pkg/config/decode_hooks.go:14-21`, which gates on + * `len(env) > 0` — a set-but-empty var, e.g. a dotenv `KEY=` line, + * leaves the `env(KEY)` literal untouched just like an unset one). * 2. If the schema at that path expects Number or Boolean, coerces the * substituted string to the expected primitive — mirroring Go's * mapstructure chain where `LoadEnvHook` returns a string that the next @@ -255,6 +338,7 @@ export function interpolateEnvReferencesAgainstSchema( document: unknown, env: Readonly>, schema: { readonly ast: SchemaAST.AST }, + options?: { readonly goViperCompat?: boolean }, ): unknown { - return walk(document, env, schema.ast); + return walk(document, env, schema.ast, options?.goViperCompat ?? false); } diff --git a/packages/config/src/paths.ts b/packages/config/src/paths.ts index e41017793d..bf29f44dcc 100644 --- a/packages/config/src/paths.ts +++ b/packages/config/src/paths.ts @@ -31,10 +31,41 @@ const findConfigInRoot = Effect.fnUntraced(function* (root: string) { } satisfies ProjectPaths; }); -export const findProjectPaths = Effect.fnUntraced(function* (cwd: string) { +export interface FindProjectPathsOptions { + /** + * When `false`, only `cwd` itself is checked for `supabase/config.{json,toml}` — + * no ancestor climb. Go's own resolution never searches twice: an explicit + * `--workdir`/`SUPABASE_WORKDIR` is used exactly as given (`ChangeWorkDir`, + * `apps/cli-go/internal/utils/misc.go:231-247`), and once `os.Chdir`'d there, + * `config.toml` is read as a plain relative path with no further ancestor + * search (`NewPathBuilder`, `pkg/config/utils.go:43-48`). Ancestor climbing in + * Go only ever happens once, as the *default* when workdir is unset + * (`getProjectRoot`, `internal/utils/misc.go:209-224`). + * + * Callers that already hold an authoritative, Go-equivalent project root + * (e.g. the legacy `stop`/`status` ports' `cliConfig.workdir`, which mirrors + * `ChangeWorkDir`'s own explicit-vs-default resolution) should pass `false` + * here to avoid a second, un-Go-like ancestor search that could otherwise + * pick up an unrelated ancestor project's config. + * + * Defaults to `true` (the original ancestor-search behavior), so existing + * callers are unaffected. + */ + readonly search?: boolean; +} + +export const findProjectPaths = Effect.fnUntraced(function* ( + cwd: string, + options?: FindProjectPathsOptions, +) { const path = yield* Path.Path; - let current = path.resolve(cwd); + const start = path.resolve(cwd); + + if (options?.search === false) { + return yield* findConfigInRoot(start); + } + let current = start; while (true) { const match = yield* findConfigInRoot(current); diff --git a/packages/config/src/project.ts b/packages/config/src/project.ts index 68953f2b1b..daa9cc0e08 100644 --- a/packages/config/src/project.ts +++ b/packages/config/src/project.ts @@ -1,10 +1,9 @@ import { Effect, FileSystem, Redacted } from "effect"; import { ProjectConfigSchema } from "./base.ts"; import { ProjectEnvParseError } from "./errors.ts"; -import { ENV_CAPTURE_REGEX, isEnvReference } from "./lib/env.ts"; +import { ENV_CAPTURE_REGEX, ENV_CAPTURE_REGEX_STRICT, isEnvReference } from "./lib/env.ts"; import { findProjectPaths, type ProjectPaths } from "./paths.ts"; -const envReferencePattern = ENV_CAPTURE_REGEX; const dotEnvLinePattern = /^\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?$/; @@ -45,6 +44,41 @@ function normalizeAmbientEnv( return values; } +// Detects a line of the form `KEY=...` (or `KEY: ...`) whose +// quoted value does NOT close on that same physical line — the start of a +// godotenv-style multiline quoted value (e.g. a PEM block). Returns the quote +// character and the index of the opening quote within `line`, or `null` if +// the line doesn't open an unterminated quote (either no quote at all, or one +// that already closes on this line). +const dotEnvValueOpenerPattern = /^\s*(?:export\s+)?[\w.-]+(?:\s*=\s*?|:\s+?)(['"`])/; + +function findUnescapedQuoteIndex(text: string, quote: string, from: number): number { + for (let i = from; i < text.length; i += 1) { + if (text[i] === quote && text[i - 1] !== "\\") { + return i; + } + } + return -1; +} + +function detectOpenQuoteStart(line: string): { quote: string; openIndex: number } | null { + const openerMatch = dotEnvValueOpenerPattern.exec(line); + if (openerMatch === null) { + return null; + } + const quote = openerMatch[1]; + if (quote === undefined) { + return null; + } + const openIndex = openerMatch[0].length - 1; + if (findUnescapedQuoteIndex(line, quote, openIndex + 1) !== -1) { + // Already closes on this same line — this isn't the multiline case, so + // whatever made the outer match fail is a genuine parse error. + return null; + } + return { quote, openIndex }; +} + function parseDotEnvValue(rawValue: string): string { let value = rawValue.trim(); const maybeQuote = value[0]; @@ -78,7 +112,40 @@ function parseDotEnv( continue; } - const match = dotEnvLinePattern.exec(line); + let candidate = line; + let consumedThrough = index; + + // Check for an unterminated quote BEFORE attempting the single-line + // match: `dotEnvLinePattern`'s value alternatives fall back to an + // unquoted match (`[^#\r\n]+`) when none of the quoted alternatives + // close on this line, which would otherwise "succeed" with a truncated, + // still-quote-prefixed value instead of signaling a multiline value — + // masking the real bug rather than triggering accumulation. This is a + // godotenv-style quoted value spanning multiple physical lines (e.g. a + // PEM block); Go's `loadNestedEnv` parses this fine (`godotenv@v1.5.1`'s + // cursor-based scanner never splits into lines up front; see + // `legacy-dotenv.ts` for the Go-compatible reference implementation used + // elsewhere in this repo). Accumulate subsequent lines until the opened + // quote closes (or EOF), then match the same per-line pattern against + // the joined multiline chunk — its quoted-value alternatives use + // negated character classes (`[^"]` etc.), which already match embedded + // newlines once given the full span. + const opener = detectOpenQuoteStart(line); + if (opener !== null) { + for (let next = index + 1; next < lines.length; next += 1) { + const nextLine = lines[next]; + if (nextLine === undefined) { + continue; + } + candidate += "\n" + nextLine; + consumedThrough = next; + if (findUnescapedQuoteIndex(candidate, opener.quote, opener.openIndex + 1) !== -1) { + break; + } + } + } + + const match = dotEnvLinePattern.exec(candidate); if (match === null) { return yield* Effect.fail(new ProjectEnvParseError({ path, line: index + 1 })); @@ -92,6 +159,7 @@ function parseDotEnv( } values[key] = parseDotEnvValue(rawValue); + index = consumedThrough; } return values; @@ -113,13 +181,36 @@ function applySource( export interface LoadProjectEnvironmentOptions { readonly cwd: string; readonly baseEnv?: Readonly>; + /** See {@link FindProjectPathsOptions.search}. */ + readonly search?: boolean; + /** + * Skip reading/parsing `paths.envLocalPath` (`supabase/.env.local`) + * entirely. Mirrors Go's `loadDefaultEnv` (`apps/cli-go/pkg/config/ + * config.go:1243-1250`), which omits `.env.local` from its candidate + * filename list whenever `SUPABASE_ENV=test` — so a malformed or + * intentionally non-test `.env.local` is invisible to Go in that mode and + * must not fail config loading here either. Defaults to `false` so + * existing callers that don't have a `SUPABASE_ENV` gate of their own + * (`next/`, `secrets set`) are unaffected. + */ + readonly skipEnvLocal?: boolean; +} + +export interface ResolveProjectOptions { + /** + * Opt into Go/viper-parity `env()` matching (case-agnostic + * `^env\((.*)\)$`). Defaults to `false`, which uses the pre-PR-#5765 strict + * SCREAMING_SNAKE_CASE matcher (`ENV_CAPTURE_REGEX_STRICT`). Only the + * Go-parity legacy shell sets this to `true`. + */ + readonly goViperCompat?: boolean; } export const loadProjectEnvironment = Effect.fnUntraced(function* ( options: LoadProjectEnvironmentOptions, ) { const fs = yield* FileSystem.FileSystem; - const paths = yield* findProjectPaths(options.cwd); + const paths = yield* findProjectPaths(options.cwd, { search: options.search }); if (paths === null) { return null; @@ -136,7 +227,7 @@ export const loadProjectEnvironment = Effect.fnUntraced(function* ( loadedPaths.push(paths.envPath); } - if (yield* fs.exists(paths.envLocalPath)) { + if (!options.skipEnvLocal && (yield* fs.exists(paths.envLocalPath))) { const contents = yield* fs.readFileString(paths.envLocalPath); const parsed = yield* parseDotEnv(paths.envLocalPath, contents); applySource(values, sources, parsed, ".env.local"); @@ -216,21 +307,33 @@ function isSecretPath(path: ReadonlyArray): boolean { return secretPathPatterns.some((pattern) => matchesPathPattern(pattern, path)); } -function interpolateLeafValue(value: string, env: Readonly>): string { - const match = envReferencePattern.exec(value); +function interpolateLeafValue( + value: string, + env: Readonly>, + goViperCompat: boolean, +): string { + const match = (goViperCompat ? ENV_CAPTURE_REGEX : ENV_CAPTURE_REGEX_STRICT).exec(value); const envName = match?.[1]; if (envName === undefined) { return value; } - // Preserve the literal `env(VAR)` verbatim when VAR is unset. Matches Go's - // `apps/cli-go/pkg/config/decode_hooks.go:14-21` (LoadEnvHook). - if (!Object.prototype.hasOwnProperty.call(env, envName)) { + const resolved = env[envName]; + // Preserve the literal `env(VAR)` verbatim when VAR is unset OR present but + // empty (e.g. a dotenv `KEY=` line). Matches Go's `LoadEnvHook` + // (`apps/cli-go/pkg/config/decode_hooks.go:19-24`: `len(env) > 0`), which + // only substitutes a non-empty value — same gate as `substituteEnvLeaf` in + // `lib/env.ts`. Without this, a present-but-empty `env(...)` secret (e.g. + // `edge_runtime.secrets.FOO = "env(EMPTY)"`) resolves to `""` here, gets + // redacted by `redactValue` as a real value instead of skipped as an + // unresolved literal, and `secrets set` uploads a blank secret Go would + // never send. + if (resolved === undefined || resolved === "") { return value; } - return env[envName] ?? value; + return resolved; } function toPathSegments(path: string): ReadonlyArray { @@ -241,44 +344,48 @@ function toPathSegments(path: string): ReadonlyArray { return path.split(".").filter((segment) => segment.length > 0); } -function interpolateValue(value: unknown, env: Readonly>): unknown { +function interpolateValue( + value: unknown, + env: Readonly>, + goViperCompat: boolean, +): unknown { if (Array.isArray(value)) { - return value.map((item) => interpolateValue(item, env)); + return value.map((item) => interpolateValue(item, env, goViperCompat)); } if (typeof value === "object" && value !== null) { const result: Record = {}; for (const [key, child] of Object.entries(value)) { - result[key] = interpolateValue(child, env); + result[key] = interpolateValue(child, env, goViperCompat); } return result; } if (typeof value === "string") { - return interpolateLeafValue(value, env); + return interpolateLeafValue(value, env, goViperCompat); } return value; } -function redactValue(value: unknown, path: ReadonlyArray = []): unknown { +function redactValue(value: unknown, path: ReadonlyArray, goViperCompat: boolean): unknown { if (Array.isArray(value)) { - return value.map((item, index) => redactValue(item, [...path, String(index)])); + return value.map((item, index) => redactValue(item, [...path, String(index)], goViperCompat)); } if (typeof value === "object" && value !== null) { const result: Record = {}; for (const [key, child] of Object.entries(value)) { - result[key] = redactValue(child, [...path, key]); + result[key] = redactValue(child, [...path, key], goViperCompat); } return result; } - if (typeof value === "string" && isSecretPath(path) && !isEnvReference(value)) { + if (typeof value === "string" && isSecretPath(path) && !isEnvReference(value, goViperCompat)) { return Redacted.make(value, { label: path.join(".") }); } @@ -289,15 +396,17 @@ function resolveProjectValueAtPath( value: unknown, projectEnv: ProjectEnvironment, path: ReadonlyArray, + goViperCompat: boolean, ): unknown { - const interpolated = interpolateValue(value, projectEnv.values); - return redactValue(interpolated, path); + const interpolated = interpolateValue(value, projectEnv.values, goViperCompat); + return redactValue(interpolated, path, goViperCompat); } export function resolveProjectValue( value: T, projectEnv: ProjectEnvironment, configPath: string, + options?: ResolveProjectOptions, ): Effect.Effect> { return Effect.sync( () => @@ -305,6 +414,7 @@ export function resolveProjectValue( value, projectEnv, toPathSegments(configPath), + options?.goViperCompat ?? false, ) as ResolvedProjectValue, ); } @@ -313,6 +423,7 @@ export function resolveProjectSubtree( value: T, projectEnv: ProjectEnvironment, pathPrefix: string, + options?: ResolveProjectOptions, ): Effect.Effect> { return Effect.sync( () => @@ -320,6 +431,7 @@ export function resolveProjectSubtree( value, projectEnv, toPathSegments(pathPrefix), + options?.goViperCompat ?? false, ) as ResolvedProjectValue, ); } diff --git a/packages/config/src/project.unit.test.ts b/packages/config/src/project.unit.test.ts index de22a465fe..9f29094bc2 100644 --- a/packages/config/src/project.unit.test.ts +++ b/packages/config/src/project.unit.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Effect, FileSystem, Path, Redacted } from "effect"; import { findProjectRootFor, loadProjectEnvironmentFor } from "./bun.ts"; -import { ProjectConfigParseError } from "./errors.ts"; +import { ProjectConfigParseError, ProjectEnvParseError } from "./errors.ts"; import { findProjectPaths, loadProjectConfig, @@ -50,6 +50,41 @@ describe("project discovery and lazy env resolution", () => { } }); + test("search: false only checks cwd itself, matching Go's exact-workdir resolution", async () => { + // Mirrors Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-247`): + // an explicit workdir is used exactly as given, with no ancestor climb — + // callers that already hold a Go-equivalent project root (e.g. the legacy + // `stop`/`status` ports' `cliConfig.workdir`) pass `search: false` to avoid + // picking up an unrelated ancestor project. + const cwd = makeTempProject(); + const repoRoot = join(cwd, "repo"); + const packageRoot = join(repoRoot, "apps", "web"); + const nestedCwd = join(packageRoot, "src", "components"); + + try { + await mkdir(join(repoRoot, "supabase"), { recursive: true }); + await mkdir(nestedCwd, { recursive: true }); + await writeFile(join(repoRoot, "supabase", "config.toml"), 'project_id = "repo"\n'); + + // nestedCwd has no supabase/ of its own; only an ancestor (repoRoot) does. + const searched = await runConfigEffect(findProjectPaths(nestedCwd)); + expect(searched?.projectRoot).toBe(repoRoot); + + const unsearched = await runConfigEffect(findProjectPaths(nestedCwd, { search: false })); + expect(unsearched).toBeNull(); + + const configAtRepoRoot = await runConfigEffect(findProjectPaths(repoRoot, { search: false })); + expect(configAtRepoRoot?.projectRoot).toBe(repoRoot); + + expect(await runConfigEffect(loadProjectConfig(nestedCwd, { search: false }))).toBeNull(); + expect( + await runConfigEffect(loadProjectEnvironment({ cwd: nestedCwd, search: false })), + ).toBeNull(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("loads env from the discovered supabase directory with the right precedence", async () => { const cwd = makeTempProject(); const repoRoot = join(cwd, "repo"); @@ -107,6 +142,105 @@ describe("project discovery and lazy env resolution", () => { } }); + test("parses a multiline double-quoted .env value (godotenv/Go parity)", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "ref_123"\n'); + await writeFile( + join(cwd, "supabase", ".env"), + [ + 'PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----', + "MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aumga", + '-----END RSA PRIVATE KEY-----"', + "OTHER=value", + "", + ].join("\n"), + ); + + const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd })); + + expect(projectEnv).not.toBeNull(); + expect(projectEnv?.values.PRIVATE_KEY).toBe( + [ + "-----BEGIN RSA PRIVATE KEY-----", + "MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aumga", + "-----END RSA PRIVATE KEY-----", + ].join("\n"), + ); + expect(projectEnv?.values.OTHER).toBe("value"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("parses a multiline single-quoted .env value followed by a trailing comment", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "ref_123"\n'); + await writeFile( + join(cwd, "supabase", ".env"), + ["MULTI='line one", "line two' # trailing comment", "AFTER=ok", ""].join("\n"), + ); + + const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd })); + + expect(projectEnv).not.toBeNull(); + expect(projectEnv?.values.MULTI).toBe(["line one", "line two"].join("\n")); + expect(projectEnv?.values.AFTER).toBe("ok"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("still fails a genuinely malformed .env line (not a multiline quote)", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "ref_123"\n'); + await writeFile(join(cwd, "supabase", ".env"), "!!!not-a-valid-line\n"); + + await expect(runConfigEffect(loadProjectEnvironment({ cwd }))).rejects.toBeInstanceOf( + ProjectEnvParseError, + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("skipEnvLocal ignores .env.local entirely, matching Go's SUPABASE_ENV=test gate", async () => { + // Go's `loadDefaultEnv` (`apps/cli-go/pkg/config/config.go:1243-1250`) omits + // `.env.local` from its candidate filename list whenever `SUPABASE_ENV=test`, + // so a malformed `.env.local` is invisible to Go in that mode. Callers that + // reproduce this gate (`status`/`stop` handlers) pass `skipEnvLocal: true`. + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "ref_123"\n'); + await writeFile(join(cwd, "supabase", ".env"), "FROM_ENV=1\n"); + // Malformed — would normally throw ProjectEnvParseError. + await writeFile(join(cwd, "supabase", ".env.local"), "!!!not-a-valid-line\n"); + + const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd, skipEnvLocal: true })); + + expect(projectEnv).not.toBeNull(); + expect(projectEnv?.values.FROM_ENV).toBe("1"); + expect(projectEnv?.loadedPaths).toEqual([join(cwd, "supabase", ".env")]); + + // Without the flag, the same malformed file still fails as before. + await expect(runConfigEffect(loadProjectEnvironment({ cwd }))).rejects.toBeInstanceOf( + ProjectEnvParseError, + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("leaves [api].auto_expose_new_tables unset by default and round-trips an explicit value", async () => { const cwd = makeTempProject(); const projectRoot = join(cwd, "repo"); @@ -214,6 +348,9 @@ jwt_secret = "env(AUTH_JWT_SECRET)" [edge_runtime.secrets] api_key = "env(EDGE_API_KEY)" +[remotes.preview] +project_id = "previewrefaaaaaaaaaa" + [remotes.preview.auth] jwt_secret = "env(PREVIEW_JWT_SECRET)" `, @@ -282,6 +419,44 @@ jwt_secret = "env(MISSING_SECRET)" } }); + // Go's `LoadEnvHook` (`apps/cli-go/pkg/config/decode_hooks.go:19-24`) only + // substitutes a non-empty env var (`len(env) > 0`) — a present-but-empty + // dotenv line (`EMPTY_SECRET=`) is treated the same as an unset var, so the + // literal `env(...)` reference is preserved rather than resolved to `""`. + test("resolveProjectValue preserves env() literal when the env var is present but empty (Go parity)", async () => { + const cwd = makeTempProject(); + const projectRoot = join(cwd, "repo"); + + try { + await mkdir(join(projectRoot, "supabase"), { recursive: true }); + await writeFile( + join(projectRoot, "supabase", "config.toml"), + `project_id = "ref_123" + +[edge_runtime.secrets] +foo = "env(EMPTY_SECRET)" +`, + ); + await writeFile(join(projectRoot, "supabase", ".env"), "EMPTY_SECRET=\n"); + + const loaded = await runConfigEffect(loadProjectConfig(projectRoot)); + const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd: projectRoot })); + + const resolved = await runConfigEffect( + resolveProjectValue( + loaded!.config.edge_runtime.secrets!.foo, + projectEnv!, + "edge_runtime.secrets.foo", + ), + ); + + expect(Redacted.isRedacted(resolved)).toBe(false); + expect(resolved).toBe("env(EMPTY_SECRET)"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("resolveProjectSubtree preserves env() literals nested inside the selected subtree", async () => { const cwd = makeTempProject(); const projectRoot = join(cwd, "repo"); @@ -334,4 +509,75 @@ account_sid = "AC123" await rm(cwd, { recursive: true, force: true }); } }); + + // Pins the pre-PR-#5765 strict SCREAMING_SNAKE_CASE `env()` matcher as the + // default for `resolveProjectValue`/`resolveProjectSubtree`, since `next/` + // and `packages/stack` call these without ever passing `goViperCompat`. + test("resolveProjectValue does not resolve a lowercase-named env() reference by default", async () => { + const cwd = makeTempProject(); + const projectRoot = join(cwd, "repo"); + + try { + await mkdir(join(projectRoot, "supabase"), { recursive: true }); + await writeFile( + join(projectRoot, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +jwt_secret = "env(lowercase_secret)" +`, + ); + await writeFile(join(projectRoot, "supabase", ".env"), "lowercase_secret=super-secret\n"); + + const loaded = await runConfigEffect(loadProjectConfig(projectRoot)); + const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd: projectRoot })); + + const resolved = await runConfigEffect( + resolveProjectValue(loaded!.config.auth.jwt_secret, projectEnv!, "auth.jwt_secret"), + ); + + expect(Redacted.isRedacted(resolved)).toBe(true); + if (!Redacted.isRedacted(resolved)) { + throw new Error("Expected auth.jwt_secret to be redacted."); + } + expect(Redacted.value(resolved)).toBe("env(lowercase_secret)"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("resolveProjectValue resolves a lowercase-named env() reference when goViperCompat is true", async () => { + const cwd = makeTempProject(); + const projectRoot = join(cwd, "repo"); + + try { + await mkdir(join(projectRoot, "supabase"), { recursive: true }); + await writeFile( + join(projectRoot, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +jwt_secret = "env(lowercase_secret)" +`, + ); + await writeFile(join(projectRoot, "supabase", ".env"), "lowercase_secret=super-secret\n"); + + const loaded = await runConfigEffect(loadProjectConfig(projectRoot)); + const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd: projectRoot })); + + const resolved = await runConfigEffect( + resolveProjectValue(loaded!.config.auth.jwt_secret, projectEnv!, "auth.jwt_secret", { + goViperCompat: true, + }), + ); + + expect(Redacted.isRedacted(resolved)).toBe(true); + if (!Redacted.isRedacted(resolved)) { + throw new Error("Expected auth.jwt_secret to be redacted."); + } + expect(Redacted.value(resolved)).toBe("super-secret"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); }); From 9173a5418127e31976f4d8218a863e1378896668 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:14:00 +0000 Subject: [PATCH 25/49] fix(deps): bump the npm-major group with 6 updates (#5835) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the npm-major group with 6 updates: | Package | From | To | | --- | --- | --- | | [@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript) | `0.3.197` | `0.3.198` | | [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript) | `0.109.0` | `0.109.1` | | [posthog-node](https://github.com/PostHog/posthog-js/tree/HEAD/packages/node) | `5.39.1` | `5.39.2` | | [next](https://github.com/vercel/next.js) | `16.2.9` | `16.2.10` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.0.1` | `26.1.0` | | [@typescript/native-preview](https://github.com/microsoft/typescript-go) | `7.0.0-dev.20260630.1` | `7.0.0-dev.20260701.1` | Updates `@anthropic-ai/claude-agent-sdk` from 0.3.197 to 0.3.198
Release notes

Sourced from @​anthropic-ai/claude-agent-sdk's releases.

v0.3.198

What's changed

  • Added a runtime warning when canUseTool is configured alongside allowedTools or bypassPermissions, which shadow the callback
  • Added per-server request_timeout_ms option to mcp_set_servers control request
  • Fixed SDKUserMessage.isSynthetic not being mapped to isMeta on ingestion, which could cause synthetic messages to be treated as real user messages
  • Fixed workflow progress events silently dropping earliest agents from the list while the phase counter remained correct

Update

npm install @anthropic-ai/claude-agent-sdk@0.3.198
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.198
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.198
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.198
Changelog

Sourced from @​anthropic-ai/claude-agent-sdk's changelog.

0.3.198

  • Added a runtime warning when canUseTool is configured alongside allowedTools or bypassPermissions, which shadow the callback
  • Added per-server request_timeout_ms option to mcp_set_servers control request
  • Fixed SDKUserMessage.isSynthetic not being mapped to isMeta on ingestion, which could cause synthetic messages to be treated as real user messages
  • Fixed workflow progress events silently dropping earliest agents from the list while the phase counter remained correct
Commits

Updates `@anthropic-ai/sdk` from 0.109.0 to 0.109.1
Release notes

Sourced from @​anthropic-ai/sdk's releases.

sdk: v0.109.1

0.109.1 (2026-07-01)

Full Changelog: sdk-v0.109.0...sdk-v0.109.1

Chores

  • api: remove some nonfunctional types from the SDKs (cc4dd4e)
Changelog

Sourced from @​anthropic-ai/sdk's changelog.

0.109.1 (2026-07-01)

Full Changelog: sdk-v0.109.0...sdk-v0.109.1

Chores

  • api: remove some nonfunctional types from the SDKs (cc4dd4e)
Commits

Updates `posthog-node` from 5.39.1 to 5.39.2
Release notes

Sourced from posthog-node's releases.

posthog-node@5.39.2

5.39.2

Patch Changes

  • #4028 a664b81 Thanks @​marandaneto! - Make Node flush() wait for pending asynchronous SDK work before draining the event queue, so events produced by helpers like captureException() are not missed. Pending work rejections no longer prevent queued events from flushing. (2026-07-01)
  • Updated dependencies [a664b81]:
    • @​posthog/core@​1.39.3
Changelog

Sourced from posthog-node's changelog.

5.39.2

Patch Changes

  • #4028 a664b81 Thanks @​marandaneto! - Make Node flush() wait for pending asynchronous SDK work before draining the event queue, so events produced by helpers like captureException() are not missed. Pending work rejections no longer prevent queued events from flushing. (2026-07-01)
  • Updated dependencies [a664b81]:
    • @​posthog/core@​1.39.3
Commits

Updates `next` from 16.2.9 to 16.2.10
Release notes

Sourced from next's releases.

v16.2.10

Contains no changes except publishing @next/swc-wasm-web which was accidentally not published since 16.2.4.

Commits

Updates `@types/node` from 26.0.1 to 26.1.0
Commits

Updates `@typescript/native-preview` from 7.0.0-dev.20260630.1 to 7.0.0-dev.20260701.1
Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli/package.json | 6 +- apps/docs/package.json | 4 +- pnpm-lock.yaml | 389 +++++++++++++++++++++-------------------- pnpm-workspace.yaml | 2 +- 4 files changed, 201 insertions(+), 200 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 345a397df7..c6c604f1ff 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -43,8 +43,8 @@ "jose": "^6.2.3" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.197", - "@anthropic-ai/sdk": "^0.109.0", + "@anthropic-ai/claude-agent-sdk": "^0.3.198", + "@anthropic-ai/sdk": "^0.109.1", "@clack/prompts": "^1.6.0", "@effect/atom-react": "catalog:", "@effect/platform-bun": "catalog:", @@ -76,7 +76,7 @@ "oxlint-tsgolint": "catalog:", "pg": "^8.22.0", "pg-copy-streams": "^7.0.0", - "posthog-node": "^5.39.1", + "posthog-node": "^5.39.2", "react": "^19.2.7", "react-devtools-core": "^7.0.1", "semantic-release": "^25.0.5", diff --git a/apps/docs/package.json b/apps/docs/package.json index 492c9218e3..ff5e585113 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -11,13 +11,13 @@ "fumadocs-core": "^16.10.7", "fumadocs-mdx": "^15.0.13", "fumadocs-ui": "^16.10.7", - "next": "^16.2.9", + "next": "^16.2.10", "react": "^19.2.7", "react-dom": "^19.2.7" }, "devDependencies": { "@types/mdx": "^2.0.14", - "@types/node": "^26.0.0", + "@types/node": "^26.1.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.1.6", "typescript": "^6.0.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32618873a5..ef5928c0fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,8 +37,8 @@ catalogs: specifier: ^1.3.14 version: 1.3.14 '@typescript/native-preview': - specifier: 7.0.0-dev.20260630.1 - version: 7.0.0-dev.20260630.1 + specifier: 7.0.0-dev.20260701.1 + version: 7.0.0-dev.20260701.1 '@vitest/coverage-istanbul': specifier: ^4.1.9 version: 4.1.9 @@ -97,11 +97,11 @@ importers: version: 6.2.3 devDependencies: '@anthropic-ai/claude-agent-sdk': - specifier: ^0.3.197 - version: 0.3.197(@anthropic-ai/sdk@0.109.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + specifier: ^0.3.198 + version: 0.3.198(@anthropic-ai/sdk@0.109.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@anthropic-ai/sdk': - specifier: ^0.109.0 - version: 0.109.0(zod@4.4.3) + specifier: ^0.109.1 + version: 0.109.1(zod@4.4.3) '@clack/prompts': specifier: ^1.6.0 version: 1.6.0 @@ -155,7 +155,7 @@ importers: version: 19.2.17 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260630.1 + version: 7.0.0-dev.20260701.1 '@vercel/detect-agent': specifier: ^1.2.3 version: 1.2.3 @@ -196,8 +196,8 @@ importers: specifier: ^7.0.0 version: 7.0.0 posthog-node: - specifier: ^5.39.1 - version: 5.39.1 + specifier: ^5.39.2 + version: 5.39.2 react: specifier: ^19.2.7 version: 19.2.7 @@ -215,7 +215,7 @@ importers: version: 7.4.5 vitest: specifier: 'catalog:' - version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.9(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) yaml: specifier: ^2.9.0 version: 2.9.0 @@ -259,7 +259,7 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260630.1 + version: 7.0.0-dev.20260701.1 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -277,22 +277,22 @@ importers: version: 0.24.0 vitest: specifier: 'catalog:' - version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.9(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) apps/docs: dependencies: fumadocs-core: specifier: ^16.10.7 - version: 16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + version: 16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) fumadocs-mdx: specifier: ^15.0.13 - version: 15.0.13(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(rolldown@1.0.2)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 15.0.13(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(rolldown@1.0.2)(vite@8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) fumadocs-ui: specifier: ^16.10.7 - version: 16.10.7(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 16.10.7(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next: - specifier: ^16.2.9 - version: 16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: ^16.2.10 + version: 16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: specifier: ^19.2.7 version: 19.2.7 @@ -304,8 +304,8 @@ importers: specifier: ^2.0.14 version: 2.0.14 '@types/node': - specifier: ^26.0.0 - version: 26.0.1 + specifier: ^26.1.0 + version: 26.1.1 '@types/react': specifier: ^19.2.17 version: 19.2.17 @@ -339,7 +339,7 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260630.1 + version: 7.0.0-dev.20260701.1 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -357,7 +357,7 @@ importers: version: 0.24.0 vitest: specifier: 'catalog:' - version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.9(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) packages/cli-darwin-arm64: {} @@ -381,7 +381,7 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260630.1 + version: 7.0.0-dev.20260701.1 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -399,7 +399,7 @@ importers: version: 0.24.0 vitest: specifier: 'catalog:' - version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.9(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) packages/cli-windows-arm64: {} @@ -431,7 +431,7 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260630.1 + version: 7.0.0-dev.20260701.1 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -449,7 +449,7 @@ importers: version: 0.24.0 vitest: specifier: 'catalog:' - version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.9(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) packages/process-compose: dependencies: @@ -471,7 +471,7 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260630.1 + version: 7.0.0-dev.20260701.1 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -489,7 +489,7 @@ importers: version: 0.24.0 vitest: specifier: 'catalog:' - version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.9(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) packages/stack: dependencies: @@ -523,7 +523,7 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260630.1 + version: 7.0.0-dev.20260701.1 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -541,7 +541,7 @@ importers: version: 0.24.0 vitest: specifier: 'catalog:' - version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.9(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) tools/nx-plugins: dependencies: @@ -550,7 +550,7 @@ importers: version: 23.0.1(nx@23.0.1(@swc-node/register@1.11.1(@swc/core@1.15.43)(@swc/types@0.1.27)(typescript@6.0.3))(@swc/core@1.15.43)) vitest: specifier: 'catalog:' - version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.9(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) packages: @@ -570,60 +570,60 @@ packages: resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} engines: {node: '>=18'} - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.197': - resolution: {integrity: sha512-jC6WvH5Hr6APTfbMjo4nC6LlyMMqbpCMwiHXIw7/AsQXIHQhZ+cRRMesQlV6UFI1l3O53gLZHzsG9cXwfrPHKw==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.198': + resolution: {integrity: sha512-ZmiAybQKIKcP1qEAE/vfXvfxtKxG9CnJn98QTXC5Zxiwuy7Mllx2ALXh9dfmsf0V87CGEodlZQmMgUJotNIsUw==} cpu: [arm64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.197': - resolution: {integrity: sha512-ZQNvGkMrTyatBlHTIQ4w2i2aLBuvq355UP/FDLnVXIH8l23RsL1x/0w9P+dqB7EmY9OZi/cPxSrpskpo+dZWLA==} + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.198': + resolution: {integrity: sha512-XwH5vgN46WSwg8aC1OagNofnJpV/G1ciEu118GEKer8ZhVkq/dvK/DqShxMkb6r1jV7u5IJ7zPXu9uKliyNJAw==} cpu: [x64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.197': - resolution: {integrity: sha512-VuIGXsLGK/aqSQ0tTBqqPVNzjefWS5SWnK8mlYyQitT4s5UDzHXJm0UZBTGxRtlcS0e2+QAHKwbGBCq1ZKSXjg==} + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.198': + resolution: {integrity: sha512-Q7lKVNjIrUQ2B/AR77OvRf0zeOdEjonFVaR9FYrrwtzGeEqum69WSht5nM7Y7el3wjbNi0/eV0QTUM0DlsTEfw==} cpu: [arm64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.197': - resolution: {integrity: sha512-pWhQgCtAft4EGM4Zn24HRad1a/k2u6oA+2uM/KCdjehfKtooDiHfMNd1yzXY/n9AEBWP0RHB2Vz3mJ30X2pVAg==} + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.198': + resolution: {integrity: sha512-qmz8dxEtDIlKntU5qYe0R4aWTxTue5S7zIQknatLX7aJ6HN/nq1aCNXWn5smTH2FViBkUPPR+sCIsNwSk6AT6Q==} cpu: [arm64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.197': - resolution: {integrity: sha512-3Tuy7XhD4UIKE4A4RPmKJcbL7Q/3dcB1hEWQt2lKP7c/DlixeEv+tRzvpnFZKhFX2hy0tkBk3QjkozSAacMC/w==} + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.198': + resolution: {integrity: sha512-h1SrWVIMjLInYNPlf+TxXuKTOdoiOfJLBSoQG97315Z2Nh0IpBfqWExlqYTtPCgKE7q2iga31U283QfHpIDlSQ==} cpu: [x64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.197': - resolution: {integrity: sha512-AUccrbdcv4Hy/GteP/gYLjG/zDP+fe2BFtDMctEfRFVz40DazYDcOyW1+nIgSTQtxf5jSTAVVf3cNuXB2CZwlw==} + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.198': + resolution: {integrity: sha512-Zqxyz2AT1UM5WlOOoLJhLssZDgZo8rBK5ku6daveK12zp+UTJGZhGsjFghz1/ASxH08KqOTbUePNTORnPhHAEQ==} cpu: [x64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.197': - resolution: {integrity: sha512-Wx8uiAKBenDuL8lWQmrqnX5ppljaH5unQ9cKiCz2/9Kgf09dgnrwbX8n/FhndCZR8PmYw539eWwYVrSVc/bl6w==} + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.198': + resolution: {integrity: sha512-mjIHf1HFiRuXefewWTaNZFlTZlCaEt/xsRjc1nSTCEEpFolZayVhrDKz+O2QFVcDtPl8x8GeYSL0kiikg1DZjQ==} cpu: [arm64] os: [win32] - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.197': - resolution: {integrity: sha512-ZXJO/VvR3SI4G0gwthWeFXWdHB5RXPu3rtfGRcKZ/YgtDeW17rQ+LZIJTk2ywzbLb8EvlghR5JPgn293hC179Q==} + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.198': + resolution: {integrity: sha512-y3HLuCCz1kDwUrhd6OnqO+d5BUpTFSzNUsPT9kf3r1vk9HYKF+eMC9eIlcOhiW2kX491kxEvuEOfqgIkGx15cg==} cpu: [x64] os: [win32] - '@anthropic-ai/claude-agent-sdk@0.3.197': - resolution: {integrity: sha512-XNIi8W1tb+QfMkcK+5kepOC6BsxG8wtupd72H+pIPzIJypVQhHy7FoX+KBMtTRYwtl+5dsjKyABhjWXebeUilw==} + '@anthropic-ai/claude-agent-sdk@0.3.198': + resolution: {integrity: sha512-xt469sSCyclTPtzpLAg0Aschy665GiRMgZKabSmESbGUA5/H56HcILVOiFxclXswkeMUk2fQxfHJUY9UZfiTnA==} engines: {node: '>=18.0.0'} peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' '@modelcontextprotocol/sdk': ^1.29.0 zod: ^4.0.0 - '@anthropic-ai/sdk@0.109.0': - resolution: {integrity: sha512-y7P4eLyW5uNut4fXpOUEHqhJwx7dnxrWAfCQE4Lcgm0hSFQuIeHa7CWEKE5dFolEjQJE/RFKkppjri05r2OK/Q==} + '@anthropic-ai/sdk@0.109.1': + resolution: {integrity: sha512-q9OnEKLr5H9nxSuXdgDgJhxfYMiE+AaUEBze2Gk91UcaaLnsN+Lx5fbCYywiqurU/APLdwv23x03Wm6WN3EBsg==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -781,6 +781,9 @@ packages: '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.4.5': resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==} @@ -1304,57 +1307,57 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@next/env@16.2.9': - resolution: {integrity: sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==} + '@next/env@16.2.10': + resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==} - '@next/swc-darwin-arm64@16.2.9': - resolution: {integrity: sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==} + '@next/swc-darwin-arm64@16.2.10': + resolution: {integrity: sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.9': - resolution: {integrity: sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==} + '@next/swc-darwin-x64@16.2.10': + resolution: {integrity: sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.9': - resolution: {integrity: sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==} + '@next/swc-linux-arm64-gnu@16.2.10': + resolution: {integrity: sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.9': - resolution: {integrity: sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==} + '@next/swc-linux-arm64-musl@16.2.10': + resolution: {integrity: sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.9': - resolution: {integrity: sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==} + '@next/swc-linux-x64-gnu@16.2.10': + resolution: {integrity: sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.9': - resolution: {integrity: sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==} + '@next/swc-linux-x64-musl@16.2.10': + resolution: {integrity: sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.9': - resolution: {integrity: sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==} + '@next/swc-win32-arm64-msvc@16.2.10': + resolution: {integrity: sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.9': - resolution: {integrity: sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==} + '@next/swc-win32-x64-msvc@16.2.10': + resolution: {integrity: sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2107,11 +2110,11 @@ packages: resolution: {integrity: sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==} engines: {node: '>=12'} - '@posthog/core@1.39.6': - resolution: {integrity: sha512-o6ajIwN5zXoNP0D4H/QPmOyibNTUkSyOR6ya7AG5U2ywXx4awo72L2KnCoiZPQM5x/bXv6jPBdimH8M18Ax0aw==} + '@posthog/core@1.40.0': + resolution: {integrity: sha512-oGDbIwlTquNwdHbEL5ZLEkuW4UFkkEanfx3QAxDgyVbISv+OAA6YGQwrvo0JD3MUJEbJZvyh8XsX+WYDGw9XHw==} - '@posthog/types@1.392.1': - resolution: {integrity: sha512-Qg6Gl7/1vlr8+gPtBi5gwnLgAgiyFoKOVmTvTtDcvya9cpTwZfna7rQmkGQ4B63CunUYNNbOlqcwiUwUDyTK6w==} + '@posthog/types@1.393.0': + resolution: {integrity: sha512-vzWeEJZ7ERQhFRoQYaP5jzN1JvIu46UJyHXsuv+dTGW2r3sMgREOhNxXLZjmFHwZ8/FOHQoyqqQmXTCXZSfMSg==} '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} @@ -2832,8 +2835,8 @@ packages: '@types/node@25.9.3': resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} - '@types/node@26.0.1': - resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -2864,50 +2867,50 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260630.1': - resolution: {integrity: sha512-zo1TYD32r+MKOTnzhB1YGr2Jrw14tzGR/rpfr6hRMDz0kV9k2CWVvtOYOmOtRdmuO4KWZcWtVX13QyaPGhA8Hw==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260701.1': + resolution: {integrity: sha512-rm3v+3Mcrj91NPwG0mjaLfbJjydCDX/skF82cQw0K6XWsEK7wHmpLEF1xPOWGnV05RhTYJac9VoKUjgcADkbXA==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260630.1': - resolution: {integrity: sha512-57RYyZQQ6+drfu+CagVdqUvQwNesvuu/rJb/au66jv+figfpDOugD0S6ruQl1SxpFFfr0lCny3KEplsBdqFDEg==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260701.1': + resolution: {integrity: sha512-jtbtV3A+cmkmDuqs56Qj3Hb+NmOAMHFX+FTLhCagJybjsng1lOl1h8vYaYg3F6EMgSiZI66hIpB9TMi3n8jFEw==} engines: {node: '>=16.20.0'} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260630.1': - resolution: {integrity: sha512-QQxNyZ9rVbE6lUqctrrRiTtA5Z0w2FFBy8SkiP/eDkuRy2WXrVpMQYntNn6d4nO1nWTmWJR1z/CS+H2rsrl8gg==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260701.1': + resolution: {integrity: sha512-SPEfXZhCRff6kPYrqJIkKkKBy1rxPk0Wmam7U0AOuMQtXTNPmqCtx54J/F6nfGR89ATTDTDr69MujFAG+lFQVg==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260630.1': - resolution: {integrity: sha512-FLJSSt3bUmRpzWlr5cDE+td40FoDy/MT+VDYk4JGouisJo7g7GPjuF+fYOfsKI/RbD9f6gDFTyFPAoTLVVR/9g==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260701.1': + resolution: {integrity: sha512-c22PWjLMecd2xsAZRlpC1mnr8GZUjnFS32URPd2NhKOEx/6Dp7bPZvI+7NNXDXTew/fkgAaZvhLTWHGG64gqGw==} engines: {node: '>=16.20.0'} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260630.1': - resolution: {integrity: sha512-WqNPvUBngGfnkunqn3IuIkkF6PA/HPSSbVucolO7stc9DxWZqfRals6/C8RTvuQaif9qt+bcY9U6lLXuDFyClA==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260701.1': + resolution: {integrity: sha512-Y9NX6qGOCQD4zdCXJCABB8TL3IkFsWVlwMQ3OGtQ8A/OzyIFN01QyHWFgB5AJFZQoasryb+nnhu9r2IY5KpBwA==} engines: {node: '>=16.20.0'} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260630.1': - resolution: {integrity: sha512-awATkrGGm2L1IraQgaw21VXWtAqv79DJ57No/J65Y+bL8InOVR2zu+zCH+5E44m8bh9IXwnShI7cAdvp02WNEw==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260701.1': + resolution: {integrity: sha512-7IRD34O2Kp7mIplQEC/HgLyehmKL1FGlTYhlAqxxZfKhSt694yytd2dCCnxMMA/aPHjcqBke04wNPOOhQ2ezcA==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260630.1': - resolution: {integrity: sha512-M/+P66Gsss/CANV+MkKVFeNi9jljYYR3N2COf/WrVYcDwrHyiYHZYExfTw2cEbbkH8LcawXAekp9d23bevBR4Q==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260701.1': + resolution: {integrity: sha512-zVfayHxvFZAQ0hJbegDcfhJamyQpLn6ahMDATAra+EkA3+/nbTW3VjCJ3h1V2PSNTY9rTPzGroFURV5cvErh3A==} engines: {node: '>=16.20.0'} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260630.1': - resolution: {integrity: sha512-5OTvy2YpoDsOwAPqj4LkI4mrlAtc3mRzNdHAPp/1JWUvsKSRTzqAB2JPVD4qHUkAd9qY89yb758kc7fGyn3gbw==} + '@typescript/native-preview@7.0.0-dev.20260701.1': + resolution: {integrity: sha512-eXPtWsAj0s06kHWVDlBj7ABwoyNDHuW2gCbQ+bYGlyymDYteiAydelhyhyzUsenzGraPLdd/OPwEUB8/KUdpBw==} engines: {node: '>=16.20.0'} hasBin: true @@ -3205,8 +3208,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.37: - resolution: {integrity: sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==} + baseline-browser-mapping@2.10.42: + resolution: {integrity: sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==} engines: {node: '>=6.0.0'} hasBin: true @@ -3291,8 +3294,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001799: - resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + caniuse-lite@1.0.30001803: + resolution: {integrity: sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==} caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} @@ -5125,11 +5128,6 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -5159,8 +5157,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.2.9: - resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==} + next@16.2.10: + resolution: {integrity: sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -5667,8 +5665,8 @@ packages: postgres-range@1.1.4: resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} - posthog-node@5.39.1: - resolution: {integrity: sha512-ZvpNfbTg6mhuPScGXE+Yqh2mJpELHShHT98qjKD7cBdXVtIDOZ8Xoh7m3JkK9H1r0GiqEztnoBNe9aL1qOZCeg==} + posthog-node@5.39.2: + resolution: {integrity: sha512-5piMedjlQ2x+UKLvHWTC5ls5/T1dDZKE1Pu5AKkYh9EkbZOjvu0cac6lWFB7mgbGkKQ0I1bhbjDx1QAYRJ7Unw==} engines: {node: ^20.20.0 || >=22.22.0} peerDependencies: rxjs: ^7.0.0 @@ -6854,46 +6852,46 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.197': + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.198': optional: true - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.197': + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.198': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.197': + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.198': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.197': + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.198': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.197': + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.198': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.197': + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.198': optional: true - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.197': + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.198': optional: true - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.197': + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.198': optional: true - '@anthropic-ai/claude-agent-sdk@0.3.197(@anthropic-ai/sdk@0.109.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.198(@anthropic-ai/sdk@0.109.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: - '@anthropic-ai/sdk': 0.109.0(zod@4.4.3) + '@anthropic-ai/sdk': 0.109.1(zod@4.4.3) '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) zod: 4.4.3 optionalDependencies: - '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.197 - '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.197 - '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.197 - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.197 - '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.197 - '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.197 - '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.197 - '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.197 + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.198 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.198 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.198 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.198 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.198 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.198 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.198 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.198 - '@anthropic-ai/sdk@0.109.0(zod@4.4.3)': + '@anthropic-ai/sdk@0.109.1(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 standardwebhooks: 1.0.0 @@ -7090,7 +7088,7 @@ snapshots: '@effect/vitest@4.0.0-beta.93(effect@4.0.0-beta.93)(vitest@4.1.9)': dependencies: effect: 4.0.0-beta.93 - vitest: 4.1.9(@types/node@26.0.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) '@emnapi/core@1.10.0': dependencies: @@ -7128,6 +7126,11 @@ snapshots: dependencies: tslib: 2.8.1 + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.4.5': dependencies: tslib: 2.8.1 @@ -7338,7 +7341,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.11.1 + '@emnapi/runtime': 1.11.2 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -7523,30 +7526,30 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@next/env@16.2.9': {} + '@next/env@16.2.10': {} - '@next/swc-darwin-arm64@16.2.9': + '@next/swc-darwin-arm64@16.2.10': optional: true - '@next/swc-darwin-x64@16.2.9': + '@next/swc-darwin-x64@16.2.10': optional: true - '@next/swc-linux-arm64-gnu@16.2.9': + '@next/swc-linux-arm64-gnu@16.2.10': optional: true - '@next/swc-linux-arm64-musl@16.2.9': + '@next/swc-linux-arm64-musl@16.2.10': optional: true - '@next/swc-linux-x64-gnu@16.2.9': + '@next/swc-linux-x64-gnu@16.2.10': optional: true - '@next/swc-linux-x64-musl@16.2.9': + '@next/swc-linux-x64-musl@16.2.10': optional: true - '@next/swc-win32-arm64-msvc@16.2.9': + '@next/swc-win32-arm64-msvc@16.2.10': optional: true - '@next/swc-win32-x64-msvc@16.2.9': + '@next/swc-win32-x64-msvc@16.2.10': optional: true '@noble/ciphers@1.3.0': {} @@ -8007,11 +8010,11 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 - '@posthog/core@1.39.6': + '@posthog/core@1.40.0': dependencies: - '@posthog/types': 1.392.1 + '@posthog/types': 1.393.0 - '@posthog/types@1.392.1': {} + '@posthog/types@1.393.0': {} '@radix-ui/number@1.1.2': {} @@ -8711,7 +8714,7 @@ snapshots: dependencies: undici-types: 7.24.6 - '@types/node@26.0.1': + '@types/node@26.1.1': dependencies: undici-types: 8.3.0 @@ -8738,7 +8741,7 @@ snapshots: '@types/responselike@1.0.0': dependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.1 '@types/unist@2.0.11': {} @@ -8746,38 +8749,38 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.1 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260630.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260701.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260630.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260701.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260630.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260701.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260630.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260701.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260630.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260701.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260630.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260701.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260630.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260701.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260630.1': + '@typescript/native-preview@7.0.0-dev.20260701.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260630.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260630.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260630.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260630.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260630.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260630.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260630.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260701.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260701.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260701.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260701.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260701.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260701.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260701.1 '@ungap/structured-clone@1.3.2': {} @@ -8957,7 +8960,7 @@ snapshots: magicast: 0.5.3 obug: 2.1.1 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@26.0.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) transitivePeerDependencies: - supports-color @@ -8970,13 +8973,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.9(vite@8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.9': dependencies: @@ -9137,7 +9140,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.37: {} + baseline-browser-mapping@2.10.42: {} bcrypt-pbkdf@1.0.2: dependencies: @@ -9204,8 +9207,8 @@ snapshots: browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.37 - caniuse-lite: 1.0.30001799 + baseline-browser-mapping: 2.10.42 + caniuse-lite: 1.0.30001803 electron-to-chromium: 1.5.363 node-releases: 2.0.46 update-browserslist-db: 1.2.3(browserslist@4.28.2) @@ -9226,7 +9229,7 @@ snapshots: bun-types@1.3.14: dependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.1 bytes@3.1.2: {} @@ -9254,7 +9257,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001799: {} + caniuse-lite@1.0.30001803: {} caseless@0.12.0: {} @@ -10057,7 +10060,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3): + fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3): dependencies: '@orama/orama': 3.1.18 estree-util-value-to-estree: 3.5.0 @@ -10083,21 +10086,21 @@ snapshots: '@types/mdast': 4.0.4 '@types/react': 19.2.17 lucide-react: 1.23.0(react@19.2.7) - next: 16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) zod: 4.4.3 transitivePeerDependencies: - supports-color - fumadocs-mdx@15.0.13(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(rolldown@1.0.2)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)): + fumadocs-mdx@15.0.13(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(rolldown@1.0.2)(vite@8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.1 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + fumadocs-core: 16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) js-yaml: 5.2.1 mdast-util-mdx: 3.0.0 picocolors: 1.1.1 @@ -10113,14 +10116,14 @@ snapshots: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 '@types/react': 19.2.17 - next: 16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 rolldown: 1.0.2 - vite: 8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color - fumadocs-ui@16.10.7(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + fumadocs-ui@16.10.7(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@fumadocs/tailwind': 0.0.5 @@ -10136,7 +10139,7 @@ snapshots: '@radix-ui/react-tabs': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) class-variance-authority: 0.7.1 cnfast: 0.0.8 - fumadocs-core: 16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + fumadocs-core: 16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) lucide-react: 1.23.0(react@19.2.7) motion: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-themes: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -10150,7 +10153,7 @@ snapshots: optionalDependencies: '@types/mdx': 2.0.14 '@types/react': 19.2.17 - next: 16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7) transitivePeerDependencies: - '@emotion/is-prop-valid' - '@tailwindcss/oxide' @@ -11451,8 +11454,6 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.12: {} - nanoid@3.3.15: {} negotiator@0.6.3: {} @@ -11470,25 +11471,25 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - '@next/env': 16.2.9 + '@next/env': 16.2.10 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.37 - caniuse-lite: 1.0.30001799 + baseline-browser-mapping: 2.10.42 + caniuse-lite: 1.0.30001803 postcss: 8.4.31 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) styled-jsx: 5.1.6(react@19.2.7) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.9 - '@next/swc-darwin-x64': 16.2.9 - '@next/swc-linux-arm64-gnu': 16.2.9 - '@next/swc-linux-arm64-musl': 16.2.9 - '@next/swc-linux-x64-gnu': 16.2.9 - '@next/swc-linux-x64-musl': 16.2.9 - '@next/swc-win32-arm64-msvc': 16.2.9 - '@next/swc-win32-x64-msvc': 16.2.9 + '@next/swc-darwin-arm64': 16.2.10 + '@next/swc-darwin-x64': 16.2.10 + '@next/swc-linux-arm64-gnu': 16.2.10 + '@next/swc-linux-arm64-musl': 16.2.10 + '@next/swc-linux-x64-gnu': 16.2.10 + '@next/swc-linux-x64-musl': 16.2.10 + '@next/swc-win32-arm64-msvc': 16.2.10 + '@next/swc-win32-x64-msvc': 16.2.10 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -12038,7 +12039,7 @@ snapshots: postcss@8.4.31: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.15 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -12070,9 +12071,9 @@ snapshots: postgres-range@1.1.4: {} - posthog-node@5.39.1: + posthog-node@5.39.2: dependencies: - '@posthog/core': 1.39.6 + '@posthog/core': 1.40.0 pretty-ms@9.3.0: dependencies: @@ -13191,7 +13192,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0): + vite@8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 @@ -13199,16 +13200,16 @@ snapshots: rolldown: 1.0.2 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.1 esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 yaml: 2.9.0 - vitest@4.1.9(@types/node@26.0.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.9(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(vite@8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -13225,10 +13226,10 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.1 '@vitest/coverage-istanbul': 4.1.9(vitest@4.1.9) transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index eb8f8de15a..c4b733934e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,7 +22,7 @@ catalog: "@swc/core": "^1.15.43" "@tsconfig/bun": "^1.0.10" "@types/bun": "^1.3.14" - "@typescript/native-preview": "7.0.0-dev.20260630.1" + "@typescript/native-preview": "7.0.0-dev.20260701.1" "@vitest/coverage-istanbul": "^4.1.9" "effect": "4.0.0-beta.93" "knip": "^6.17.1" From 9e35980e40d4839c4a5ece81b753428e97c7e3d9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:09:12 +0000 Subject: [PATCH 26/49] chore(ci): bump the actions-major group with 4 updates (#5836) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the actions-major group with 4 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action), [docker/login-action](https://github.com/docker/login-action) and [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action). Updates `github/codeql-action/init` from 4.36.2 to 4.36.3
Release notes

Sourced from github/codeql-action/init's releases.

v4.36.3

No user facing changes.

Changelog

Sourced from github/codeql-action/init's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973

4.36.3 - 01 Jul 2026

No user facing changes.

4.36.2 - 04 Jun 2026

  • Cache CodeQL CLI version information across Actions steps. #3943
  • Reduce requests while waiting for analysis processing by using exponential backoff when polling SARIF processing status. #3937
  • Update default CodeQL bundle version to 2.25.6. #3948

4.36.1 - 02 Jun 2026

No user facing changes.

4.36.0 - 22 May 2026

  • Breaking change: Bump the minimum required CodeQL bundle version to 2.19.4. #3894
  • Add support for SHA-256 Git object IDs. #3893
  • Update default CodeQL bundle version to 2.25.5. #3926

4.35.5 - 15 May 2026

  • We have improved how the JavaScript bundles for the CodeQL Action are generated to avoid duplication across bundles and reduce the size of the repository by around 70%. This should have no effect on the runtime behaviour of the CodeQL Action. #3899
  • For performance and accuracy reasons, improved incremental analysis will now only be enabled on a pull request when diff-informed analysis is also enabled for that run. If diff-informed analysis is unavailable (for example, because the PR diff ranges could not be computed), the action will fall back to a full analysis. #3791
  • If multiple inputs are provided for the GitHub-internal analysis-kinds input, only code-scanning will be enabled. The analysis-kinds input is experimental, for GitHub-internal use only, and may change without notice at any time. #3892
  • Added an experimental change which, when running a Code Scanning analysis for a PR with improved incremental analysis enabled, prefers CodeQL CLI versions that have a cached overlay-base database for the configured languages. This speeds up analysis for a repository when there is not yet a cached overlay-base database for the latest CLI version. We expect to roll this change out to everyone in May. #3880

4.35.4 - 07 May 2026

  • Update default CodeQL bundle version to 2.25.4. #3881

4.35.3 - 01 May 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.19.3 and earlier. These versions of CodeQL were discontinued on 9 April 2026 alongside GitHub Enterprise Server 3.15, and will be unsupported by the next minor release of the CodeQL Action. #3837
  • Configurations for private registries that use Cloudsmith or GCP OIDC are now accepted. #3850
  • Best-effort connection tests for private registries now use GET requests instead of HEAD for better compatibility with various registry implementations. For NuGet feeds, the test is now always performed against the service index. #3853
  • Fixed a bug where two diagnostics produced within the same millisecond could overwrite each other on disk, causing one of them to be lost. #3852

... (truncated)

Commits
  • 54f647b Merge pull request #3984 from github/update-v4.36.3-1f34ec164
  • e78819e Trigger checks
  • 2c9d3d6 Update changelog for v4.36.3
  • 1f34ec1 Merge pull request #3983 from github/mbg/repo-props/ff-for-config-file-prop
  • d5f0145 Log when repository property has a value but is ignored
  • f27f563 Add test for when the FF is off
  • 0025d0f Use FF
  • f7fa18f Add FF for config file repo property
  • 628fc3f Merge pull request #3979 from github/henrymercer/overlay-db-cleanup-size-tele...
  • 9cfb67b Add clarifying comments
  • Additional commits viewable in compare view

Updates `github/codeql-action/analyze` from 4.36.2 to 4.36.3
Release notes

Sourced from github/codeql-action/analyze's releases.

v4.36.3

No user facing changes.

Changelog

Sourced from github/codeql-action/analyze's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973

4.36.3 - 01 Jul 2026

No user facing changes.

4.36.2 - 04 Jun 2026

  • Cache CodeQL CLI version information across Actions steps. #3943
  • Reduce requests while waiting for analysis processing by using exponential backoff when polling SARIF processing status. #3937
  • Update default CodeQL bundle version to 2.25.6. #3948

4.36.1 - 02 Jun 2026

No user facing changes.

4.36.0 - 22 May 2026

  • Breaking change: Bump the minimum required CodeQL bundle version to 2.19.4. #3894
  • Add support for SHA-256 Git object IDs. #3893
  • Update default CodeQL bundle version to 2.25.5. #3926

4.35.5 - 15 May 2026

  • We have improved how the JavaScript bundles for the CodeQL Action are generated to avoid duplication across bundles and reduce the size of the repository by around 70%. This should have no effect on the runtime behaviour of the CodeQL Action. #3899
  • For performance and accuracy reasons, improved incremental analysis will now only be enabled on a pull request when diff-informed analysis is also enabled for that run. If diff-informed analysis is unavailable (for example, because the PR diff ranges could not be computed), the action will fall back to a full analysis. #3791
  • If multiple inputs are provided for the GitHub-internal analysis-kinds input, only code-scanning will be enabled. The analysis-kinds input is experimental, for GitHub-internal use only, and may change without notice at any time. #3892
  • Added an experimental change which, when running a Code Scanning analysis for a PR with improved incremental analysis enabled, prefers CodeQL CLI versions that have a cached overlay-base database for the configured languages. This speeds up analysis for a repository when there is not yet a cached overlay-base database for the latest CLI version. We expect to roll this change out to everyone in May. #3880

4.35.4 - 07 May 2026

  • Update default CodeQL bundle version to 2.25.4. #3881

4.35.3 - 01 May 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.19.3 and earlier. These versions of CodeQL were discontinued on 9 April 2026 alongside GitHub Enterprise Server 3.15, and will be unsupported by the next minor release of the CodeQL Action. #3837
  • Configurations for private registries that use Cloudsmith or GCP OIDC are now accepted. #3850
  • Best-effort connection tests for private registries now use GET requests instead of HEAD for better compatibility with various registry implementations. For NuGet feeds, the test is now always performed against the service index. #3853
  • Fixed a bug where two diagnostics produced within the same millisecond could overwrite each other on disk, causing one of them to be lost. #3852

... (truncated)

Commits
  • 54f647b Merge pull request #3984 from github/update-v4.36.3-1f34ec164
  • e78819e Trigger checks
  • 2c9d3d6 Update changelog for v4.36.3
  • 1f34ec1 Merge pull request #3983 from github/mbg/repo-props/ff-for-config-file-prop
  • d5f0145 Log when repository property has a value but is ignored
  • f27f563 Add test for when the FF is off
  • 0025d0f Use FF
  • f7fa18f Add FF for config file repo property
  • 628fc3f Merge pull request #3979 from github/henrymercer/overlay-db-cleanup-size-tele...
  • 9cfb67b Add clarifying comments
  • Additional commits viewable in compare view

Updates `docker/login-action` from 4.2.0 to 4.3.0
Release notes

Sourced from docker/login-action's releases.

v4.3.0

Full Changelog: https://github.com/docker/login-action/compare/v4.2.0...v4.3.0

Commits
  • c99871d Merge pull request #1030 from docker/dependabot/npm_and_yarn/aws-sdk-dependen...
  • b433555 [dependabot skip] chore: update generated content
  • 678a46a build(deps): bump the aws-sdk-dependencies group across 1 directory with 2 up...
  • f9a0aea Merge pull request #1031 from docker/dependabot/npm_and_yarn/sigstore-4.1.1
  • cc1e4cb build(deps): bump sigstore from 4.1.0 to 4.1.1
  • 02e1730 Merge pull request #1029 from docker/dependabot/npm_and_yarn/sigstore/verify-...
  • b548518 build(deps): bump @​sigstore/verify from 3.1.0 to 3.1.1
  • a244be3 Merge pull request #1027 from docker/dependabot/npm_and_yarn/docker/actions-t...
  • ee0d698 [dependabot skip] chore: update generated content
  • 127dc2c build(deps): bump @​docker/actions-toolkit from 0.91.0 to 0.92.0
  • Additional commits viewable in compare view

Updates `docker/setup-buildx-action` from 4.1.0 to 4.2.0
Release notes

Sourced from docker/setup-buildx-action's releases.

v4.2.0

Full Changelog: https://github.com/docker/setup-buildx-action/compare/v4.1.0...v4.2.0

Commits
  • bb05f3f Merge pull request #580 from docker/dependabot/npm_and_yarn/docker/actions-to...
  • 321c814 [dependabot skip] chore: update generated content
  • b9a36ef build(deps): bump @​docker/actions-toolkit from 0.91.0 to 0.92.0
  • ebeab24 Merge pull request #570 from docker/dependabot/npm_and_yarn/undici-6.27.0
  • 5c7b8ae [dependabot skip] chore: update generated content
  • 037e618 build(deps): bump undici from 6.25.0 to 6.27.0
  • 66080e5 Merge pull request #577 from docker/dependabot/npm_and_yarn/sigstore-4.1.1
  • 409aef0 Merge pull request #562 from docker/dependabot/npm_and_yarn/js-yaml-4.2.0
  • 49c6e42 build(deps): bump sigstore from 4.1.0 to 4.1.1
  • 2211273 [dependabot skip] chore: update generated content
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Julien Goux --- .github/workflows/build-cli-artifacts.yml | 14 +++++++++++--- .github/workflows/cli-go-codeql.yml | 4 ++-- .github/workflows/cli-go-mirror-image.yml | 6 +++--- .github/workflows/cli-go-pg-prove.yml | 10 +++++----- .github/workflows/cli-go-publish-migra.yml | 10 +++++----- .github/workflows/mirror-template-images.yml | 2 +- 6 files changed, 27 insertions(+), 19 deletions(-) diff --git a/.github/workflows/build-cli-artifacts.yml b/.github/workflows/build-cli-artifacts.yml index 8faf9d0377..af7110def1 100644 --- a/.github/workflows/build-cli-artifacts.yml +++ b/.github/workflows/build-cli-artifacts.yml @@ -81,10 +81,18 @@ jobs: run: go mod download -x - name: Install nfpm + env: + NFPM_VERSION: "2.47.0" + NFPM_SHA256: "0660ca602b2d2d2ae4781a06c692b3eeb9d437ffea05b831d76e41f4a3188783" run: | - echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/apt/sources.list.d/goreleaser.list - sudo apt-get update - sudo apt-get install -y nfpm + set -euo pipefail + asset="nfpm_${NFPM_VERSION}_Linux_x86_64.tar.gz" + curl -fsSL -o /tmp/nfpm.tar.gz \ + "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/${asset}" + echo "${NFPM_SHA256} /tmp/nfpm.tar.gz" | sha256sum -c + tar -xzf /tmp/nfpm.tar.gz -C /tmp nfpm + sudo install -m 0755 /tmp/nfpm /usr/local/bin/nfpm + nfpm --version - name: Install rcodesign env: diff --git a/.github/workflows/cli-go-codeql.yml b/.github/workflows/cli-go-codeql.yml index 3a7c41cbed..9759f2f7e6 100644 --- a/.github/workflows/cli-go-codeql.yml +++ b/.github/workflows/cli-go-codeql.yml @@ -67,7 +67,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -95,7 +95,7 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 with: category: "/language:${{matrix.language}}" defaults: diff --git a/.github/workflows/cli-go-mirror-image.yml b/.github/workflows/cli-go-mirror-image.yml index 3c9484e266..18921bf791 100644 --- a/.github/workflows/cli-go-mirror-image.yml +++ b/.github/workflows/cli-go-mirror-image.yml @@ -38,15 +38,15 @@ jobs: with: role-to-assume: ${{ secrets.PROD_AWS_ROLE }} aws-region: us-east-1 - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: registry: public.ecr.aws - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Mirror image env: SOURCE_IMAGE: docker.io/${{ github.event.client_payload.image || inputs.image }} diff --git a/.github/workflows/cli-go-pg-prove.yml b/.github/workflows/cli-go-pg-prove.yml index 4cd2b086e5..993e78bb0c 100644 --- a/.github/workflows/cli-go-pg-prove.yml +++ b/.github/workflows/cli-go-pg-prove.yml @@ -11,7 +11,7 @@ jobs: outputs: image_tag: supabase/pg_prove:${{ steps.version.outputs.pg_prove }} steps: - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: load: true @@ -42,10 +42,10 @@ jobs: image_digest: ${{ steps.build.outputs.digest }} steps: - run: docker context create builders - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 with: endpoint: builders - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} @@ -66,8 +66,8 @@ jobs: - build_image runs-on: ubuntu-latest steps: - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/cli-go-publish-migra.yml b/.github/workflows/cli-go-publish-migra.yml index 95449a8baa..189e6beea4 100644 --- a/.github/workflows/cli-go-publish-migra.yml +++ b/.github/workflows/cli-go-publish-migra.yml @@ -11,7 +11,7 @@ jobs: outputs: image_tag: supabase/migra:${{ steps.version.outputs.migra }} steps: - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: load: true @@ -42,10 +42,10 @@ jobs: image_digest: ${{ steps.build.outputs.digest }} steps: - run: docker context create builders - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 with: endpoint: builders - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} @@ -66,8 +66,8 @@ jobs: - build_image runs-on: ubuntu-latest steps: - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/mirror-template-images.yml b/.github/workflows/mirror-template-images.yml index 40fa3be500..ee7483d2e9 100644 --- a/.github/workflows/mirror-template-images.yml +++ b/.github/workflows/mirror-template-images.yml @@ -50,7 +50,7 @@ jobs: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - name: Log in to ghcr.io - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: registry: ghcr.io username: ${{ github.actor }} From fa397576f77e71d61ddc5e2638015bfbf8e72327 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:11:26 +0200 Subject: [PATCH 27/49] fix(docker): bump supabase/storage-api from v1.63.1 to v1.64.1 in /apps/cli-go/pkg/config/templates in the docker-minor group (#5834) Bumps the docker-minor group in /apps/cli-go/pkg/config/templates with 1 update: supabase/storage-api. Updates `supabase/storage-api` from v1.63.1 to v1.64.1 [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=supabase/storage-api&package-manager=docker&previous-version=v1.63.1&new-version=v1.64.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- packages/stack/src/versions.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index b2703b51fc..f285fe5ead 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -12,7 +12,7 @@ FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.7 AS supavisor FROM supabase/gotrue:v2.192.0 AS gotrue FROM supabase/realtime:v2.112.9 AS realtime -FROM supabase/storage-api:v1.63.1 AS storage +FROM supabase/storage-api:v1.64.1 AS storage FROM supabase/logflare:1.46.0 AS logflare # Append to JobImages when adding new dependencies below FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ diff --git a/packages/stack/src/versions.ts b/packages/stack/src/versions.ts index bd675abf2d..e9196cea68 100644 --- a/packages/stack/src/versions.ts +++ b/packages/stack/src/versions.ts @@ -51,7 +51,7 @@ export const DEFAULT_VERSIONS: VersionManifest = { auth: "2.192.0", "edge-runtime": "1.74.2", realtime: "2.112.9", - storage: "1.63.1", + storage: "1.64.1", imgproxy: "v3.8.0", mailpit: "v1.30.2", pgmeta: "0.96.6", From 425be7db4acf8b695bbebd067ac3cbfbe1e7a012 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 9 Jul 2026 10:01:08 +0100 Subject: [PATCH 28/49] fix(cli): remove doubled "Expected: Expected" prefix from Flag.choice/primitive errors (#5831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed Any `Flag.choice(...)`/`Flag.choiceWithValue(...)`-backed flag (`--size` on `projects create`/`branches create`, `--dns-resolver`, `--output-format`, `--output`/`-o`, `--agent`) showed a doubled "Expected: Expected ..." prefix when given an invalid value, e.g.: ``` Invalid value for flag --size: "nano". Expected: Expected "micro" | "small" | ... , got "nano" ``` Root cause: several `Primitive`s under `effect@4.0.0-beta.93`'s `effect/unstable/cli` (`choice` — used by `Flag.choice`/`Flag.choiceWithValue` — plus the schema-backed `integer`, `float`, `boolean`, and `date`) fail with a raw message that already starts with the word "Expected" (e.g. `` `Expected ${validChoices}, got ${value}` `` for `choice`, or `Expected a valid date, got Invalid Date` for `date`), and `CliError.InvalidValue`'s own `message` getter (`CliError.ts:359-364`) independently prepends its own `"Expected: "` label on top of that — producing the doubling for any flag or argument backed by one of these primitives. This is a bug in the vendored `effect` beta package itself, not in this repo's code. Workaround (until upstream `effect` is fixed) added to the shared CLI error-formatting layer (`subcommand-flag-suggestions.ts`), which already carries similar per-tag rewrites for `UnrecognizedOption`/`UnknownSubcommand`: collapse the literal `"Expected: Expected "` substring down to a single `"Expected "` whenever it appears in an `InvalidValue` error's rendered message. Collapsing the exact doubled substring (rather than rebuilding the surrounding "Invalid value for ..." template ourselves) keeps the fix tied to the precise defect, covers every affected primitive uniformly, and lets every other part of the message keep tracking upstream's own wording if it changes. Verified against `go-parity-auditor`: Go's real CLI shows a completely different message for the same scenario (`Error: invalid argument "bogus" for "--size" flag: must be one of [ large | medium | ... ]`, from `apps/cli-go/internal/utils/enum.go` wrapped by cobra/pflag). Full byte-parity with Go's wording is out of scope here — this ticket is specifically about the doubled-prefix defect in the TS/Effect-native message, not a "TS wording differs from Go" report; rewriting every `Flag.choice` flag's phrasing to match Go's template would be a separate, larger change. ## Known related (not fixed here) The underlying `Primitive.choice` failure text also appends a redundant `, got "X"` suffix, so the invalid value still appears twice in the final message (once right after the flag name, once after "got"). That's a pre-existing wart in the same vendored `effect` failure string, not something this fix introduces, and collapsing it would mean post-processing `effect`'s `expected` string further — bigger than this "doubled prefix" ticket calls for. Left as-is. Fixes CLI-1898 --- .../src/shared/cli/invalid-value-message.ts | 54 +++++++++++ .../shared/cli/subcommand-flag-suggestions.ts | 15 +++ .../subcommand-flag-suggestions.unit.test.ts | 97 +++++++++++++++++++ apps/cli/src/shared/output/normalize-error.ts | 40 ++++++++ .../output/normalize-error.unit.test.ts | 93 ++++++++++++++++++ .../shared/output/text-formatter.unit.test.ts | 18 ++++ 6 files changed, 317 insertions(+) create mode 100644 apps/cli/src/shared/cli/invalid-value-message.ts diff --git a/apps/cli/src/shared/cli/invalid-value-message.ts b/apps/cli/src/shared/cli/invalid-value-message.ts new file mode 100644 index 0000000000..8bf079581d --- /dev/null +++ b/apps/cli/src/shared/cli/invalid-value-message.ts @@ -0,0 +1,54 @@ +// Workaround for a doubled "Expected: Expected ..." prefix in +// effect@4.0.0-beta.93's own primitive parsers. Several `Primitive`s under +// `effect/unstable/cli` (`choice` — used by `Flag.choice`/ +// `Flag.choiceWithValue` — plus the schema-backed `integer`, `float`, +// `boolean`, and `date`) fail with a raw message that already starts with +// the word "Expected" (e.g. `Expected "micro" | "small", got "nano"` or +// `Expected a valid date, got Invalid Date`), and `CliError.InvalidValue`'s +// own `message` getter independently prepends its own `"Expected: "` label +// on top of that — so any flag or argument backed by one of these +// primitives renders "Expected: Expected ...". Detect this from +// `error.expected` (the field the buggy primitives actually populate) +// rather than searching the fully composed `error.message`: `error.value` +// is user-controlled and interpolated into that same message (including a +// second time inside `expected` itself, via `choice`'s "got " +// suffix), so a message-wide, first-occurrence string replace can target +// the wrong spot if the value itself happens to contain the literal text +// "Expected: Expected ". Anchoring on `error.expected` and rebuilding the +// message from the same template `CliError.InvalidValue` uses avoids ever +// scanning `error.value`. Remove once upstream `effect` fixes this (see +// CLI-1898). +// +// Shared by two call sites that each see `InvalidValue` failures at a +// different point in `effect`'s CLI runtime: +// - `subcommand-flag-suggestions.ts` formats errors that reach the +// `CliOutput.Formatter` via the `ShowHelp` envelope — i.e. ordinary +// subcommand/argument flags, validated while `Command.runWith` parses the +// command tree. +// - `normalize-error.ts` formats errors from `GlobalFlag.setting` flags +// (`--output-format`, and the legacy `--output`/`-o`, `--dns-resolver`, +// `--agent`), which `Command.runWith` validates in a later step that runs +// *outside* the `ShowHelp` path and therefore never reaches the +// formatter — it surfaces as a raw failure through `runCli`'s catch-all +// instead. +const EXPECTED_PREFIX = "Expected "; + +export interface InvalidValueMessageFields { + readonly option: string; + readonly value: string; + readonly expected: string; + readonly kind: "flag" | "argument"; +} + +/** + * Rebuilds a `CliError.InvalidValue` message from its own template when + * `expected` carries the doubled "Expected" prefix. Returns `undefined` when + * `expected` is unaffected, so callers can fall back to the error's own + * untouched `message`. + */ +export function formatInvalidValueMessage(error: InvalidValueMessageFields): string | undefined { + if (!error.expected.startsWith(EXPECTED_PREFIX)) return undefined; + return error.kind === "argument" + ? `Invalid value for argument <${error.option}>: "${error.value}". ${error.expected}` + : `Invalid value for flag --${error.option}: "${error.value}". ${error.expected}`; +} diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts index 1cc5124f98..c6da39c2e1 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts @@ -1,4 +1,5 @@ import type { CliError, Command, HelpDoc } from "effect/unstable/cli"; +import { formatInvalidValueMessage } from "./invalid-value-message.ts"; export interface CliErrorSuggestionContext { readonly rootCommand: Command.Command.Any; @@ -228,6 +229,20 @@ export function formatCliErrorsForDisplay( continue; } + if (error._tag === "InvalidValue") { + const message = formatInvalidValueMessage(error); + if (message !== undefined) { + changed = true; + formatted.push({ + _tag: error._tag, + message, + source: error, + changed: true, + }); + continue; + } + } + formatted.push({ _tag: error._tag, message: error.message, source: error, changed: false }); } diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts index 079256d354..595b0969c6 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts @@ -122,4 +122,101 @@ describe("subcommand flag placement suggestions", () => { ); expect(errors.errors[0]?.message).not.toContain("--project-ref=jacraenyzrorgjhsdvvf "); }); + + it("collapses the doubled 'Expected: Expected' prefix for an invalid choice flag value", () => { + const errors = formatCliErrorsForDisplay([ + new CliError.InvalidValue({ + option: "size", + value: "nano", + expected: 'Expected "micro" | "small" | "medium", got "nano"', + kind: "flag", + }), + ]); + + expect(errors.changed).toBe(true); + expect(errors.errors).toHaveLength(1); + expect(errors.errors[0]?.message).toBe( + 'Invalid value for flag --size: "nano". Expected "micro" | "small" | "medium", got "nano"', + ); + expect(errors.errors[0]?.message).not.toMatch(/Expected:\s*Expected/); + }); + + it("collapses the doubled 'Expected: Expected' prefix for an invalid choice argument value", () => { + const errors = formatCliErrorsForDisplay([ + new CliError.InvalidValue({ + option: "level", + value: "bogus", + expected: 'Expected "debug" | "info", got "bogus"', + kind: "argument", + }), + ]); + + expect(errors.changed).toBe(true); + expect(errors.errors[0]?.message).toBe( + 'Invalid value for argument : "bogus". Expected "debug" | "info", got "bogus"', + ); + expect(errors.errors[0]?.message).not.toMatch(/Expected:\s*Expected/); + }); + + it("also collapses the doubled prefix for a non-choice primitive whose failure text starts with 'Expected' (e.g. an invalid integer flag value)", () => { + // Real failure text from effect@4.0.0-beta.93's schema-backed `Primitive.integer` + // (also affects `float`, `boolean`, and `date` — every primitive whose parse + // failure happens to start with the word "Expected" hits the same doubling). + const errors = formatCliErrorsForDisplay([ + new CliError.InvalidValue({ + option: "port", + value: "abc", + expected: 'Expected a string representing a finite number, got "abc"', + kind: "flag", + }), + ]); + + expect(errors.changed).toBe(true); + expect(errors.errors[0]?.message).toBe( + 'Invalid value for flag --port: "abc". Expected a string representing a finite number, got "abc"', + ); + expect(errors.errors[0]?.message).not.toMatch(/Expected:\s*Expected/); + }); + + it("leaves invalid-value errors whose expected text does not start with 'Expected' unchanged", () => { + // Real failure text from effect@4.0.0-beta.93's `Primitive.keyValuePair` — + // it never starts with the word "Expected", so it isn't doubled by + // `CliError.InvalidValue`'s own "Expected: " prefix and needs no rewriting. + const errors = formatCliErrorsForDisplay([ + new CliError.InvalidValue({ + option: "define", + value: "bogus", + expected: "Invalid key=value format. Expected format: key=value, got: bogus", + kind: "flag", + }), + ]); + + expect(errors.changed).toBe(false); + expect(errors.errors[0]?.changed).toBe(false); + expect(errors.errors[0]?.message).toBe( + 'Invalid value for flag --define: "bogus". Expected: Invalid key=value format. Expected format: key=value, got: bogus', + ); + }); + + it("does not corrupt a value that itself contains the literal 'Expected: Expected' text", () => { + // Regression test: the fix must anchor on `error.expected` (the field the + // buggy primitive actually populates) rather than searching the fully + // composed `error.message`, since `error.value` is user-controlled and is + // interpolated into that same message twice (once directly, once again + // inside `expected`'s "got " suffix). A value that happens to + // contain the literal doubled-prefix text must be left untouched. + const errors = formatCliErrorsForDisplay([ + new CliError.InvalidValue({ + option: "env", + value: "Expected: Expected nano", + expected: 'Expected "dev" | "staging" | "prod", got "Expected: Expected nano"', + kind: "flag", + }), + ]); + + expect(errors.changed).toBe(true); + expect(errors.errors[0]?.message).toBe( + 'Invalid value for flag --env: "Expected: Expected nano". Expected "dev" | "staging" | "prod", got "Expected: Expected nano"', + ); + }); }); diff --git a/apps/cli/src/shared/output/normalize-error.ts b/apps/cli/src/shared/output/normalize-error.ts index a143b414b3..19cc375913 100644 --- a/apps/cli/src/shared/output/normalize-error.ts +++ b/apps/cli/src/shared/output/normalize-error.ts @@ -1,4 +1,5 @@ import { Cause, Option } from "effect"; +import { formatInvalidValueMessage } from "../cli/invalid-value-message.ts"; type NormalizedCliError = { readonly code: string; @@ -17,6 +18,16 @@ const readString = (value: ErrorRecord, key: string): string | undefined => { return typeof field === "string" && field.trim().length > 0 ? field.trim() : undefined; }; +// Unlike `readString`, does not trim or reject empty strings. Use this for +// fields that carry raw user input (e.g. `CliError.InvalidValue#value`), +// where an empty string or meaningful surrounding whitespace is a legitimate +// value the user typed (`supabase --output-format ''`) and must be preserved +// and reported verbatim rather than normalized away. +const readRawString = (value: ErrorRecord, key: string): string | undefined => { + const field = value[key]; + return typeof field === "string" ? field : undefined; +}; + const mappedError = (error: ErrorRecord): NormalizedCliError | undefined => { const tag = readString(error, "_tag"); switch (tag) { @@ -75,6 +86,35 @@ const mappedError = (error: ErrorRecord): NormalizedCliError | undefined => { : "Error: required flag(s) not set", }; } + case "InvalidValue": { + // `CliError.InvalidValue` for a `GlobalFlag.setting` flag (e.g. + // `--output-format`, or the legacy `--output`/`-o`, `--dns-resolver`, + // `--agent`) never reaches `CliOutput.Formatter` — `Command.runWith` + // validates those flags in a step that runs outside the `ShowHelp` + // path, so the failure lands here instead. Apply the same + // doubled-"Expected"-prefix workaround `subcommand-flag-suggestions.ts` + // applies for the `ShowHelp`-formatted case (see CLI-1898), so every + // `InvalidValue` failure — whichever path it takes — renders the same + // way. + const option = readString(error, "option"); + // Raw read: `value` is the exact argv token the user typed and can + // legitimately be `""` or carry surrounding whitespace — `readString` + // would trim it or drop it entirely, either masking the bug this case + // exists to fix or misreporting what the user actually typed. + const value = readRawString(error, "value"); + const expected = readString(error, "expected"); + const kind = readString(error, "kind"); + if ( + option !== undefined && + value !== undefined && + expected !== undefined && + (kind === "flag" || kind === "argument") + ) { + const message = formatInvalidValueMessage({ option, value, expected, kind }); + if (message !== undefined) return { code: tag, message }; + } + return undefined; + } case "ShowHelp": { // Effect CLI wraps parse errors in a ShowHelp envelope (`CliError.ts`) // whose `errors` array holds the underlying causes. If exactly one of diff --git a/apps/cli/src/shared/output/normalize-error.unit.test.ts b/apps/cli/src/shared/output/normalize-error.unit.test.ts index 1eba0f07f6..107600ac13 100644 --- a/apps/cli/src/shared/output/normalize-error.unit.test.ts +++ b/apps/cli/src/shared/output/normalize-error.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { Cause } from "effect"; +import { CliError } from "effect/unstable/cli"; import { formatCliError, normalizeCause, normalizeCliError } from "./normalize-error.ts"; describe("normalizeCliError", () => { @@ -50,6 +51,98 @@ describe("normalizeCliError", () => { }); }); + test("InvalidValue collapses the doubled 'Expected: Expected' prefix (e.g. a bad GlobalFlag.setting value)", () => { + // Regression test for CLI-1898: `--output-format`/`--dns-resolver`/`--agent`/ + // legacy `--output` are `GlobalFlag.setting` flags backed by `Flag.choice`. + // `Command.runWith` validates their values in a step that runs outside the + // `ShowHelp` path, so a bad value never reaches `CliOutput.Formatter` (and + // `subcommand-flag-suggestions.ts`'s fix) — it surfaces here instead. + const error = new CliError.InvalidValue({ + option: "output-format", + value: "bogus", + expected: 'Expected "text" | "json" | "stream-json", got "bogus"', + kind: "flag", + }); + + expect(normalizeCliError(error)).toEqual({ + code: "InvalidValue", + message: + 'Invalid value for flag --output-format: "bogus". Expected "text" | "json" | "stream-json", got "bogus"', + }); + }); + + test("InvalidValue preserves an empty invalid value (e.g. `--output-format ''`)", () => { + // Regression test for a Codex review finding on CLI-1898: `value` is raw + // user input read straight off argv, so `''` is a legitimate way to + // trigger this failure. Reading it through the trim-and-reject-empty + // `readString` helper would fail the guard and leak the original + // doubled "Expected: Expected" message instead of fixing it. + const error = new CliError.InvalidValue({ + option: "output-format", + value: "", + expected: 'Expected "text" | "json" | "stream-json", got ""', + kind: "flag", + }); + + expect(normalizeCliError(error)).toEqual({ + code: "InvalidValue", + message: + 'Invalid value for flag --output-format: "". Expected "text" | "json" | "stream-json", got ""', + }); + }); + + test("InvalidValue preserves surrounding whitespace in the invalid value (e.g. `--output-format ' json'`)", () => { + // Regression test for the same Codex finding: trimming `value` would + // report a different string than what the user actually typed. + const error = new CliError.InvalidValue({ + option: "output-format", + value: " json", + expected: 'Expected "text" | "json" | "stream-json", got " json"', + kind: "flag", + }); + + expect(normalizeCliError(error)).toEqual({ + code: "InvalidValue", + message: + 'Invalid value for flag --output-format: " json". Expected "text" | "json" | "stream-json", got " json"', + }); + }); + + test("InvalidValue leaves an already-clean 'expected' message untouched", () => { + const error = new CliError.InvalidValue({ + option: "define", + value: "bogus", + expected: "Invalid key=value format. Expected format: key=value, got: bogus", + kind: "flag", + }); + + expect(normalizeCliError(error)).toEqual({ + code: "InvalidValue", + message: + 'Invalid value for flag --define: "bogus". Expected: Invalid key=value format. Expected format: key=value, got: bogus', + }); + }); + + test("ShowHelp envelope unwraps a single InvalidValue with the same doubled-prefix fix", () => { + const error = { + _tag: "ShowHelp", + commandPath: ["db", "lint"], + errors: [ + new CliError.InvalidValue({ + option: "level", + value: "bogus", + expected: 'Expected "warning" | "error", got "bogus"', + kind: "flag", + }), + ], + }; + + expect(normalizeCliError(error)).toEqual({ + code: "InvalidValue", + message: 'Invalid value for flag --level: "bogus". Expected "warning" | "error", got "bogus"', + }); + }); + test("ShowHelp envelope unwraps a single MissingOption to Cobra wording", () => { // Effect CLI raises `ShowHelp` containing the parse error in its `errors` // array. We unwrap to surface the actionable message instead of "Help requested". diff --git a/apps/cli/src/shared/output/text-formatter.unit.test.ts b/apps/cli/src/shared/output/text-formatter.unit.test.ts index f49cf597ea..fff1a4a17b 100644 --- a/apps/cli/src/shared/output/text-formatter.unit.test.ts +++ b/apps/cli/src/shared/output/text-formatter.unit.test.ts @@ -53,4 +53,22 @@ describe("textCliOutputFormatter", () => { expect(text).toContain("Did you mean this?"); expect(text).toContain("--plan"); }); + + it("does not double the 'Expected' prefix for an invalid choice flag value", () => { + const formatter = textCliOutputFormatter(); + + const text = formatter.formatErrors([ + new CliError.InvalidValue({ + option: "size", + value: "nano", + expected: 'Expected "micro" | "small" | "medium", got "nano"', + kind: "flag", + }), + ]); + + expect(text).toContain( + 'Invalid value for flag --size: "nano". Expected "micro" | "small" | "medium", got "nano"', + ); + expect(text).not.toMatch(/Expected:\s*Expected/); + }); }); From f59048754023de58f8c182c6272829f9102c260c Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 9 Jul 2026 10:46:09 +0100 Subject: [PATCH 29/49] fix(cli): resolve global/persistent flag values in legacy telemetry (#5830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed Go's `changedFlags(cmd)` (`apps/cli-go/cmd/root_analytics.go:52-76`) walks `cmd.Parent()` at every ancestor collecting `PersistentFlags()` (global/root flags: `--debug`, `--yes`, `--experimental`, `--create-ticket`, `--output`, `--dns-resolver`, `--agent`, `--profile`, `--workdir`, `--network-id`) in addition to a command's own flags, then reports booleans and enum-typed flags verbatim in `cli_command_executed` telemetry. The TS port's `withLegacyCommandInstrumentation` had no way to resolve *any* global/persistent flag's value — only the invoking command's own locally-declared `flags` option was ever consulted. So a changed global flag always fell back to the literal string `""`, even a boolean like `--debug`, diverging from Go's `flags: {debug: true}`. This adds `legacyGlobalFlagValues` (`shared/legacy/global-flags.ts`) to resolve every global flag's live value via `Effect.serviceOption` (a no-op outside the real CLI tree, so it adds no `R` requirement and no per-command wiring), and threads it into `buildFlagsMap` as a fallback used only when a changed CLI flag name isn't already declared in the invoking command's own `flags` record — mirroring Go's ancestor-walk precedence (a command's own flag always wins on a name collision, e.g. `db diff`'s local `--output` file-path flag shadowing the global `--output` enum, exactly as it does in Go's cobra tree). `safeFlags`/`config`-based safety classification is now restricted to values that actually came from the handler's own `flags` record, so a future per-command safe/choice annotation can never be misapplied to an unrelated global flag's value purely by CLI-name collision (raised in review). Scope is deliberately narrow: this fixes the 4 boolean globals (`--debug`, `--yes`, `--experimental`, `--create-ticket`) immediately. The 3 global choice flags (`--output`, `--dns-resolver`, `--agent`) remain redacted — that gap is the already-tracked CLI-1904, not this ticket. Fixes CLI-1896. ## Why Found during CLI-1868's review (architect-reviewer finding) as a systemic gap affecting all ~73 `withLegacyCommandInstrumentation({ flags })` call sites — it was previously unreachable for `telemetry enable`/`disable` specifically (hardcoded to `analytics: false`), but CLI-1868 made it reachable there, surfacing the gap. ## Reviewer-relevant context - One judgement call deliberately left open: global choice flags (`--output`/`--dns-resolver`/`--agent`) still redact. This is intentional — CLI-1904 already tracks teaching the safety pipeline about global `EnumFlag`s, and folding it into this change would blur two independent tickets. - Heads-up for whoever owns the CLI PostHog dashboards: `flags.debug` (and `yes`/`experimental`/`create-ticket`) will now emit real booleans going forward instead of the literal string `""` for these four flags specifically. Any saved insight/funnel doing exact-string matching on `""` for these keys will stop matching after this ships. --- apps/cli/AGENTS.md | 1 + .../legacy/shared/legacy-db-target-flags.ts | 90 ++++++++ .../legacy-db-target-flags.unit.test.ts | 107 ++++++++- .../legacy-command-instrumentation.ts | 146 ++++++++---- ...egacy-command-instrumentation.unit.test.ts | 217 +++++++++++++++++- apps/cli/src/shared/legacy/global-flags.ts | 44 +++- .../shared/legacy/global-flags.unit.test.ts | 83 +++++++ 7 files changed, 646 insertions(+), 42 deletions(-) create mode 100644 apps/cli/src/shared/legacy/global-flags.unit.test.ts diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 3d136879cd..da6b1bb60b 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -286,6 +286,7 @@ The legacy shell sends the same PostHog events to the same product analytics pip - **Pass `flags` to the wrapper** so boolean flag values can be detected and logged verbatim: `handler(flags).pipe(withLegacyCommandInstrumentation({ flags }), ...)`. Sensitive values become the literal string `""` to match Go. - **Use `safeFlags: ["flag-name"]`** to whitelist flags that Go marks with `markFlagTelemetrySafe` (grep `apps/cli-go/cmd/*.go`). Today these are `--project-ref` (sso, branches, link, functions, projects/api-keys), `--project-id` (gen/types), `--org-id` (projects/create), and `--version` (migration/squash). - **Pass `config` (the command's own flag config record) to the wrapper** if it has any `Flag.choice`/`Flag.choiceWithValue` flags: `withLegacyCommandInstrumentation({ flags, config })`. Every choice flag declared in that command's own `config` is auto-detected and treated as safe, mirroring Go's `isEnumFlag` (`cmd/root_analytics.go:110-116`), which checks `flag.Value.(*utils.EnumFlag)` unconditionally — no per-flag `safeFlags` entry needed, and it stays correct as choices are added or removed. This does NOT cover global/root flags (`--output`, `--dns-resolver`, `--agent` in `shared/legacy/global-flags.ts`) even though Go's equivalents are also `EnumFlag` — see CLI-1904. +- **Global/persistent flags (`shared/legacy/global-flags.ts`) resolve automatically** — the wrapper reads `legacyGlobalFlagValues` (via `Effect.serviceOption`, so it's a no-op outside the real CLI tree) and falls back to it whenever a changed flag name isn't in the handler's own `flags` record, mirroring Go's `changedFlags()` walking `cmd.Parent()`'s `PersistentFlags()` (`cmd/root_analytics.go:53-76`). No per-command wiring needed. Boolean globals (`--debug`, `--yes`, `--experimental`, `--create-ticket`) therefore already report their real value through the existing boolean-is-safe rule — but ONLY when a command's own `flags` record doesn't already declare that CLI name (a command's own flag always wins, e.g. `db diff`'s local `--output`); the three global choice flags (`--output`, `--dns-resolver`, `--agent`) still redact until CLI-1904 teaches the safety pipeline about global `EnumFlag`s. - **Proxy handlers (`LegacyGoProxy.exec`) must NOT wrap with any instrumentation.** The Go subprocess fires its own telemetry; a TS wrapper would double-count `cli_command_executed`. - **When promoting a command from proxy to native, reproduce every `phtelemetry.*` call in the Go counterpart.** Grep `apps/cli-go/internal//` for `service.Capture`, `service.Alias`, `service.Identify`, `service.GroupIdentify`, and `TrackUpgradeSuggested`. The current Go custom events that legacy ports must reproduce when natively ported: diff --git a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts index eb891c924a..6eac57c3cd 100644 --- a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts +++ b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts @@ -45,6 +45,25 @@ export interface LegacyDbTargetSelection { * (`src/shared/legacy/global-flags.ts`, `src/shared/cli/global-flags.ts`). * `Flag.string` / `Flag.choice` / `Flag.integer` → value-consuming; * `Flag.boolean` → not. + * + * Also consulted by `extractChangedFlagNames` + * (`legacy/telemetry/legacy-command-instrumentation.ts`), which scans EVERY + * legacy command's raw argv, not just the db-target/global subset above — so + * this set additionally lists every other value-consuming (non-boolean) flag + * declared anywhere under `legacy/commands/`. Without an entry here, a bare + * `--some-local-flag ` is mis-scanned: the value token is treated as a + * separate flag rather than skipped, and if that token happens to look like a + * global flag's long name (e.g. `secrets set --env-file --debug`, where + * `--debug` is `--env-file`'s value under pflag semantics), CLI-1896's + * global-flag fallback fabricates a `flags.debug` value Go never records for + * that invocation. `legacy-db-target-flags.unit.test.ts` statically scans + * every `*.command.ts` file's directly-declared `Flag.string`/`Flag.integer`/ + * `Flag.choice`/`Flag.choiceWithValue` calls and asserts they're all + * represented here, so a new command that adds a value-consuming flag and + * forgets to register it fails CI. That scan cannot see flag names built + * through a helper indirection (`issue.command.ts`'s + * `legacyIssueOptionalTextFlag`, `status.command.ts`'s `csvStringSliceFlag`) + * — those flags are listed below by hand and excluded from the scan. */ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ // db-family command flags @@ -72,6 +91,74 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "network-id", "dns-resolver", "agent", + // Every other value-consuming flag declared directly across legacy/commands/ + // (CLI-1896 review follow-up — see the doc comment above). + "add-domains", + "algorithm", + "attribute-mapping-file", + "auth", + "config", + "custom-hostname", + "db-allow-cidr", + "db-password", + "db-unban-ip", + "desired-subdomain", + "diff-engine", + "domains", + "env-file", + "exclude", + "exp", + "file", + "from", + "from-backup", + "git-branch", + "import-map", + "inspect-mode", + "lang", + "last", + "metadata-file", + "metadata-url", + "name", + "name-id-format", + "notify-url", + "org-id", + "override-name", + "payload", + "plan", + "project-id", + "project-ref", + "query-timeout", + "region", + "remove-domains", + "role", + "size", + "status", + "sub", + "swift-access-control", + "template", + "timestamp", + "to", + "token", + "valid-for", + "version", + // Declared through a name-parameterized helper, invisible to the static + // scan (see the doc comment above): `issue.command.ts`'s + // `legacyIssueOptionalTextFlag` and `status.command.ts`'s + // `csvStringSliceFlag`. + "additional-context", + "area", + "command", + "actual-output", + "expected-behavior", + "reproduce", + "crash-report-id", + "docker-services", + "problem", + "proposed-solution", + "alternatives", + "link", + "issue-type", + "improvement", ]); /** @@ -83,6 +170,9 @@ export const VALUE_CONSUMING_SHORT_FLAGS = new Set([ "o", // --output / -o "p", // --password / -p (migration list, db push/pull/dump/remote) "j", // --jobs / -j (storage cp) + "f", // --file / -f (db dump/diff/query, db schema declarative sync) + "t", // --template / -t (test new); --type / -t (sso add); --timestamp / -t (backups restore) + "x", // --exclude / -x (start, db dump) ]); /** diff --git a/apps/cli/src/legacy/shared/legacy-db-target-flags.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-target-flags.unit.test.ts index 2b32ca5ca5..9799b0a636 100644 --- a/apps/cli/src/legacy/shared/legacy-db-target-flags.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-target-flags.unit.test.ts @@ -1,5 +1,12 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { resolveLegacyDbTargetFlags } from "./legacy-db-target-flags.ts"; +import { + resolveLegacyDbTargetFlags, + VALUE_CONSUMING_LONG_FLAGS, + VALUE_CONSUMING_SHORT_FLAGS, +} from "./legacy-db-target-flags.ts"; describe("resolveLegacyDbTargetFlags", () => { it("returns empty setFlags and undefined connType when no args", () => { @@ -185,3 +192,101 @@ describe("resolveLegacyDbTargetFlags", () => { expect(result.setFlags).toEqual(["local"]); }); }); + +describe("VALUE_CONSUMING_LONG_FLAGS / VALUE_CONSUMING_SHORT_FLAGS completeness (CLI-1896 review)", () => { + // `legacy/telemetry/legacy-command-instrumentation.ts`'s `extractChangedFlagNames` + // relies on these two sets to know which flag consumes the next raw-argv + // token as its value, across EVERY legacy command (not just the db-target + // subset this file's other describe block covers) — see the doc comment on + // `VALUE_CONSUMING_LONG_FLAGS` in `legacy-db-target-flags.ts`. This scan is + // static-source-based (same technique as + // `shared/cli/code-structure.unit.test.ts`) rather than importing every + // command module, so it can only see flag names declared as a literal + // string argument to `Flag.string`/`Flag.integer`/`Flag.choice`/ + // `Flag.choiceWithValue`/`Flag.float` — it cannot trace a name passed + // through a helper function (`issue.command.ts`'s + // `legacyIssueOptionalTextFlag`, `status.command.ts`'s + // `csvStringSliceFlag`), so those two files are excluded below; their flag + // names are registered by hand in `VALUE_CONSUMING_LONG_FLAGS` instead. + const commandsDir = fileURLToPath(new URL("../commands", import.meta.url)); + const INDIRECT_NAME_FILES = new Set(["issue.command.ts", "status.command.ts"]); + const VALUE_FLAG_KINDS = ["string", "integer", "choice", "choiceWithValue", "float"]; + + function walk(dir: string): Array { + return readdirSync(dir).flatMap((entry) => { + const fullPath = path.join(dir, entry); + const stats = statSync(fullPath); + if (stats.isDirectory()) return walk(fullPath); + return entry.endsWith(".command.ts") ? [fullPath] : []; + }); + } + + interface DeclaredFlag { + readonly file: string; + readonly name: string; + readonly alias: string | undefined; + } + + function extractDeclaredFlags(filePath: string): Array { + const source = readFileSync(filePath, "utf8"); + const callRegex = /Flag\.(string|integer|choice|choiceWithValue|float|boolean)\(/g; + const calls = Array.from(source.matchAll(callRegex), (match) => ({ + index: match.index, + kind: match[1]!, + })); + + const declared: Array = []; + for (let i = 0; i < calls.length; i++) { + const current = calls[i]!; + if (!VALUE_FLAG_KINDS.includes(current.kind)) continue; + + // Name declared as a literal string (e.g. `Flag.string("schema")`). + // A name passed as an identifier (`Flag.string(name)`) doesn't match + // and is silently skipped — see INDIRECT_NAME_FILES above. + const remainder = source.slice(current.index); + const nameMatch = remainder.match(/^Flag\.\w+\(\s*"([a-zA-Z0-9-]+)"/); + if (!nameMatch) continue; + + // The alias, if any, is somewhere in the `.pipe(...)` chain between + // this flag declaration and the next one. + const windowEnd = i + 1 < calls.length ? calls[i + 1]!.index : source.length; + const window = source.slice(current.index, windowEnd); + const aliasMatch = window.match(/withAlias\(\s*"([a-zA-Z0-9])"\s*\)/); + + declared.push({ file: filePath, name: nameMatch[1]!, alias: aliasMatch?.[1] }); + } + return declared; + } + + it("registers every directly-declared value-consuming flag name in VALUE_CONSUMING_LONG_FLAGS", () => { + const missing: Array = []; + + for (const filePath of walk(commandsDir)) { + if (INDIRECT_NAME_FILES.has(path.basename(filePath))) continue; + + for (const flag of extractDeclaredFlags(filePath)) { + if (!VALUE_CONSUMING_LONG_FLAGS.has(flag.name)) { + missing.push(`${flag.name} (${path.relative(commandsDir, flag.file)})`); + } + } + } + + expect(missing).toEqual([]); + }); + + it("registers every directly-declared value-consuming flag's shorthand in VALUE_CONSUMING_SHORT_FLAGS", () => { + const missing: Array = []; + + for (const filePath of walk(commandsDir)) { + if (INDIRECT_NAME_FILES.has(path.basename(filePath))) continue; + + for (const flag of extractDeclaredFlags(filePath)) { + if (flag.alias !== undefined && !VALUE_CONSUMING_SHORT_FLAGS.has(flag.alias)) { + missing.push(`-${flag.alias} (--${flag.name}, ${path.relative(commandsDir, flag.file)})`); + } + } + } + + expect(missing).toEqual([]); + }); +}); diff --git a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts index 206a0e5c6f..8b38786463 100644 --- a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts +++ b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts @@ -6,7 +6,11 @@ import { getCommandRuntimeSpanName, } from "../../shared/runtime/command-runtime.service.ts"; import { Output } from "../../shared/output/output.service.ts"; -import { LegacyOutputFlag } from "../../shared/legacy/global-flags.ts"; +import { + LEGACY_GLOBAL_FLAGS, + LegacyOutputFlag, + legacyGlobalFlagValues, +} from "../../shared/legacy/global-flags.ts"; import { ProcessControl } from "../../shared/runtime/process-control.service.ts"; import { withAnalyticsContext } from "../../shared/telemetry/analytics-context.ts"; import { Analytics } from "../../shared/telemetry/analytics.service.ts"; @@ -50,11 +54,15 @@ interface LegacyCommandInstrumentationOptions; - // Short-flag → canonical-flag-name map (e.g. `{ s: "schema" }`). Go's - // `changedFlags()` uses pflag's `Visit`, which reports the CANONICAL flag name - // whether the user typed the long form (`--schema`) or the registered shorthand - // (`-s`). Pass a command's shorthands here so a `-s public` invocation records - // the `schema` flag in telemetry, matching Go (cmd/root_analytics.go:53-76). + // Short-flag → canonical-flag-name map (e.g. `{ s: "schema" }`) for this + // command's OWN flags. Go's `changedFlags()` uses pflag's `Visit`, which + // reports the CANONICAL flag name whether the user typed the long form + // (`--schema`) or the registered shorthand (`-s`). Pass a command's + // shorthands here so a `-s public` invocation records the `schema` flag in + // telemetry, matching Go (cmd/root_analytics.go:53-76). Global shorthands + // (currently just `-o` for `--output`, cmd/root.go:330) are merged in + // automatically via `GLOBAL_SHORT_ALIASES` below — no per-command wiring + // needed for those (CLI-1896 review follow-up). readonly aliases?: Readonly>; } @@ -226,41 +234,79 @@ function isWrappedParam(param: Param.Any): param is Param.Any & WrappedParam { return "param" in param; } +// Unwraps down to the underlying `Single` param the same way `--help` +// rendering does. Shared by `getChoiceFlagNames` and `GLOBAL_SHORT_ALIASES` +// below — both need the leaf `Single` to read its type-visible `name`/ +// `aliases`/`primitiveType` fields. Returns `undefined` only if the variant +// union gains an unrecognized future case (fails closed, see the +// `isWrappedParam` doc above for why this hand-rolled unwrap exists instead of +// the `@internal` `Param.extractSingleParams`). +function unwrapToSingleParam(param: Param.Any): Param.Single | undefined { + if (Param.isSingle(param)) return param; + if (isWrappedParam(param)) return unwrapToSingleParam(param.param); + return undefined; +} + // Mirrors Go's `isEnumFlag` (`cmd/root_analytics.go:110-116`), which checks // `flag.Value.(*utils.EnumFlag)` unconditionally — every enum flag is -// telemetry-safe, no per-flag annotation needed. Unwraps down to the -// underlying `Single` param the same way `--help` rendering does, then checks -// its primitive's `_tag` for `Flag.choice`/`Flag.choiceWithValue`. Restricted to -// `kind === Param.flagKind` so a same-named `Argument.choice` positional (none -// exist today) can never be mistaken for a `--flag`. +// telemetry-safe, no per-flag annotation needed. Checks the unwrapped +// `Single`'s primitive `_tag` for `Flag.choice`/`Flag.choiceWithValue`. +// Restricted to `kind === Param.flagKind` so a same-named `Argument.choice` +// positional (none exist today) can never be mistaken for a `--flag`. function getChoiceFlagNames(config: Record | undefined): ReadonlySet { const names = new Set(); if (config === undefined) return names; - const visit = (param: Param.Any): void => { - if (Param.isSingle(param)) { - if (param.kind === Param.flagKind && param.primitiveType._tag === "Choice") { - names.add(param.name); - } - return; - } - if (isWrappedParam(param)) { - visit(param.param); - } - }; - for (const param of Object.values(config)) { - visit(param); + const single = unwrapToSingleParam(param); + if ( + single !== undefined && + single.kind === Param.flagKind && + single.primitiveType._tag === "Choice" + ) { + names.add(single.name); + } } return names; } -function buildFlagsMap>( - flags: Flags | undefined, - safeFlagSet: ReadonlySet, - changedFlagNames: ReadonlyArray, - choiceFlagNames: ReadonlySet, -): Record | undefined { +// Short-flag → canonical-name entries for every global/persistent flag that +// declares a shorthand alias, derived from `LEGACY_GLOBAL_FLAGS` itself so +// this never drifts from the single source of truth — today that resolves to +// just `{ o: "output" }`, from `LegacyOutputFlag`'s own `Flag.withAlias("o")`. +// Mirrors Go's persistent-flag shorthand registration: `-o` is the only +// global with a real shorthand (`cmd/root.go:330` registers it on +// `--output`; every other persistent flag has none). `pflag.Visit` reports +// the canonical `flag.Name` for either form (`cmd/root_analytics.go:53-76`), +// so `-o json` must resolve to `output` here the same way `--output json` +// already does. Merged ahead of each command's own `aliases` in +// `extractChangedFlagNames` so a command's own alias still wins on conflict, +// consistent with `buildFlagsMap`'s local-flag-shadows-global rule +// (CLI-1896 review follow-up). +const GLOBAL_SHORT_ALIASES: Readonly> = (() => { + const aliases: Record = {}; + for (const globalFlag of LEGACY_GLOBAL_FLAGS) { + const single = unwrapToSingleParam(globalFlag.flag); + if (single === undefined) continue; + for (const alias of single.aliases) { + aliases[alias] = single.name; + } + } + return aliases; +})(); + +function buildFlagsMap>(options: { + readonly flags: Flags | undefined; + // Live global/persistent flag values (`legacyGlobalFlagValues`), keyed by + // CLI flag name — the fallback source for a changed flag the handler never + // declared locally (e.g. `debug`), matching Go's `changedFlags()` walking + // `cmd.Parent()`'s `PersistentFlags()` (`cmd/root_analytics.go:53-76`). + readonly globalFlagValues: Record; + readonly safeFlagSet: ReadonlySet; + readonly changedFlagNames: ReadonlyArray; + readonly choiceFlagNames: ReadonlySet; +}): Record | undefined { + const { flags, globalFlagValues, safeFlagSet, changedFlagNames, choiceFlagNames } = options; if (changedFlagNames.length === 0) return undefined; const result: Record = {}; @@ -272,15 +318,27 @@ function buildFlagsMap>( } for (const cliName of changedFlagNames) { - const rawValue = handlerFlagsByCliName.get(cliName); + // A command's own flag always wins over a global/persistent flag sharing + // the same CLI name — mirrored from Go's cobra flag-shadowing, e.g. `db + // diff`'s local `--output` file-path flag (`cmd/db.go:622`) shadows the + // root's global `--output` enum (`cmd/root.go:330`). Only fall back to + // the live global-flag value when the handler never declared this name. + const isFromHandler = handlerFlagsByCliName.has(cliName); + const rawValue = isFromHandler ? handlerFlagsByCliName.get(cliName) : globalFlagValues[cliName]; const value = normalizeFlagValue(rawValue); - if (safeFlagSet.has(cliName) || choiceFlagNames.has(cliName) || typeof value === "boolean") { - result[cliName] = value ?? REDACTED_VALUE; - continue; - } - - result[cliName] = REDACTED_VALUE; + // `safeFlagSet`/`choiceFlagNames` classify a flag as safe by CLI NAME, + // sourced from this command's own `safeFlags`/`config` options — they may + // only vouch for a value that actually came from this command's own + // `flags` record. A value resolved from the global-flag fallback is safe + // solely because it's boolean (Go's `isBooleanFlag` branch applies + // unconditionally); it must never inherit a *different* command's + // per-flag safe/choice annotation just because the CLI name matches. + const isSafe = + typeof value === "boolean" || + (isFromHandler && (safeFlagSet.has(cliName) || choiceFlagNames.has(cliName))); + + result[cliName] = isSafe ? (value ?? REDACTED_VALUE) : REDACTED_VALUE; } return result; @@ -324,8 +382,18 @@ function withLegacyCommandAnalyticsImplementation { ); }); + // Global/persistent flag parity (CLI-1896): Go's changedFlags() walks + // cmd.Parent()'s PersistentFlags() in addition to the leaf's own flags + // (cmd/root_analytics.go:53-76), so a global flag like --debug resolves to + // its real value even though no command declares it locally. The wrapper + // reads shared/legacy/global-flags.ts itself rather than relying on the + // per-command `flags` option to carry global flag values. + + it.live("records a changed global boolean flag's real value (e.g. --debug)", () => { + // Go reports `flags: {debug: true}` for `supabase --debug telemetry disable` + // (isBooleanFlag is always safe, regardless of markFlagTelemetrySafe) — the + // TS port previously had no way to resolve `debug` at all and fell back to + // "" for every global flag, even booleans. + const analytics = mockContextualAnalytics(); + + return Effect.void.pipe( + withLegacyCommandInstrumentation({ flags: {} }), + Effect.provide(analytics.layer), + Effect.provide(mockProcessControl().layer), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "--debug"]) })), + Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(Layer.succeed(LegacyDebugFlag, true)), + Effect.tap(() => + Effect.sync(() => { + const event = analytics.captured[0]; + expect(event?.properties.flags).toEqual({ debug: true }); + }), + ), + ); + }); + + it.live("merges a changed global flag alongside the command's own local flags", () => { + const analytics = mockContextualAnalytics(); + + return Effect.void.pipe( + withLegacyCommandInstrumentation({ + flags: { projectRef: Option.some("abcdefghijklmnopqrst") }, + }), + Effect.provide(analytics.layer), + Effect.provide(mockProcessControl().layer), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide( + Stdio.layerTest({ + args: Effect.succeed([ + "secrets", + "list", + "--project-ref", + "abcdefghijklmnopqrst", + "--debug", + ]), + }), + ), + Effect.provide(commandRuntimeLayer(["secrets", "list"])), + Effect.provide(Layer.succeed(LegacyDebugFlag, true)), + Effect.tap(() => + Effect.sync(() => { + const event = analytics.captured[0]; + expect(event?.properties.flags).toEqual({ + "project-ref": "", + debug: true, + }); + }), + ), + ); + }); + + it.live( + "still redacts a changed global string flag like --workdir (Go never marks it telemetry-safe)", + () => { + const analytics = mockContextualAnalytics(); + + return Effect.void.pipe( + withLegacyCommandInstrumentation({ flags: {} }), + Effect.provide(analytics.layer), + Effect.provide(mockProcessControl().layer), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide( + Stdio.layerTest({ + args: Effect.succeed(["backups", "list", "--workdir", "/tmp/project"]), + }), + ), + Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(Layer.succeed(LegacyWorkdirFlag, Option.some("/tmp/project"))), + Effect.tap(() => + Effect.sync(() => { + const event = analytics.captured[0]; + expect(event?.properties.flags).toEqual({ workdir: "" }); + }), + ), + ); + }, + ); + + it.live( + "still redacts a changed global choice flag like --dns-resolver (global EnumFlag safety is CLI-1904's scope, not this fix's)", + () => { + const analytics = mockContextualAnalytics(); + + return Effect.void.pipe( + withLegacyCommandInstrumentation({ flags: {} }), + Effect.provide(analytics.layer), + Effect.provide(mockProcessControl().layer), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide( + Stdio.layerTest({ + args: Effect.succeed(["backups", "list", "--dns-resolver", "https"]), + }), + ), + Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(Layer.succeed(LegacyDnsResolverFlag, "https" as const)), + Effect.tap(() => + Effect.sync(() => { + const event = analytics.captured[0]; + expect(event?.properties.flags).toEqual({ "dns-resolver": "" }); + }), + ), + ); + }, + ); + + it.live("falls back to redacted when a changed global flag's service isn't wired", () => { + // Defensive case: `Effect.serviceOption` must never throw/defect when a + // narrow harness (or, hypothetically, an incompletely-wired real command) + // doesn't provide a global flag's context — it degrades to the prior + // REDACTED_VALUE behavior instead of crashing. + const analytics = mockContextualAnalytics(); + + return Effect.void.pipe( + withLegacyCommandInstrumentation({ flags: {} }), + Effect.provide(analytics.layer), + Effect.provide(mockProcessControl().layer), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "--debug"]) })), + Effect.provide(commandRuntimeLayer(["backups", "list"])), + // Note: no LegacyDebugFlag layer provided. + Effect.tap(() => + Effect.sync(() => { + const event = analytics.captured[0]; + expect(event?.properties.flags).toEqual({ debug: "" }); + }), + ), + ); + }); + it.live("stops recording flags at the -- end-of-options sentinel", () => { // `test db -- --linked`: pflag stops parsing flags at `--`, so `--linked` // is a positional arg, not a changed flag. changedFlags() never sees it. @@ -999,4 +1148,70 @@ describe("withLegacyCommandInstrumentation", () => { ), ); }); + + // CLI-1896 review follow-up (Codex): a global flag's SHORTHAND must resolve + // through the same fallback its long form already does. + + it.live("resolves a global flag's shorthand (-o) through the global fallback", () => { + // `-o json` must resolve to the canonical `output` flag the same way + // `--output json` already does: Go's `pflag.Visit` reports the canonical + // `flag.Name` for either form (`cmd/root_analytics.go:53-76`), and `-o` is + // `--output`'s only registered persistent shorthand (`cmd/root.go:330`). + const analytics = mockContextualAnalytics(); + + return Effect.void.pipe( + withLegacyCommandInstrumentation({ flags: {} }), + Effect.provide(analytics.layer), + Effect.provide(mockProcessControl().layer), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "-o", "json"]) })), + Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(Layer.succeed(LegacyOutputFlag, Option.some("json" as const))), + Effect.tap(() => + Effect.sync(() => { + const event = analytics.captured[0]; + // `output` is a global choice flag — still redacted (CLI-1904's + // scope), but it must be PRESENT, not silently dropped. + expect(event?.properties.flags).toEqual({ output: "" }); + }), + ), + ); + }); + + it.live( + "does not fabricate a global flag from a local flag's value token (secrets set --env-file --debug)", + () => { + // `--env-file` is a value-consuming local string flag. In bare + // space-separated form, pflag consumes the very next token as its + // VALUE regardless of its shape, so Go's changedFlags() never marks + // `debug` as changed for this invocation — the whole token is + // `env-file`'s value. Without `env-file` registered in + // VALUE_CONSUMING_LONG_FLAGS, extractChangedFlagNames would wrongly + // treat the trailing `--debug` as a separate flag, and CLI-1896's + // global-flag fallback would then fabricate a `flags.debug` value Go + // never records. + const analytics = mockContextualAnalytics(); + + return Effect.void.pipe( + withLegacyCommandInstrumentation({ + flags: { envFile: Option.some("--debug") }, + }), + Effect.provide(analytics.layer), + Effect.provide(mockProcessControl().layer), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide( + Stdio.layerTest({ + args: Effect.succeed(["secrets", "set", "--env-file", "--debug"]), + }), + ), + Effect.provide(commandRuntimeLayer(["secrets", "set"])), + Effect.tap(() => + Effect.sync(() => { + const event = analytics.captured[0]; + expect(event?.properties.flags).toEqual({ "env-file": "" }); + }), + ), + ); + }, + ); }); diff --git a/apps/cli/src/shared/legacy/global-flags.ts b/apps/cli/src/shared/legacy/global-flags.ts index c53c3ea999..59796bb6aa 100644 --- a/apps/cli/src/shared/legacy/global-flags.ts +++ b/apps/cli/src/shared/legacy/global-flags.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, Option } from "effect"; import { Flag, GlobalFlag } from "effect/unstable/cli"; import { CliArgs } from "../cli/cli-args.service.ts"; @@ -95,6 +95,48 @@ export const LEGACY_GLOBAL_FLAGS = [ LegacyAgentFlag, ] as const; +/** + * Resolves the current value of every global/persistent flag above, keyed by + * its own CLI flag name (each flag's `.id`, e.g. `debug`, `workdir`). Used by + * `legacy/telemetry/legacy-command-instrumentation.ts` to mirror Go's + * `changedFlags()` walking `cmd.Parent()`'s `PersistentFlags()` in addition to + * a command's own flags (`cmd/root_analytics.go:53-76`) — global flags here + * live in a single Effect-context-wide registry rather than per-ancestor + * `pflag.FlagSet`s, so this reads all of them unconditionally instead of + * walking a parent chain (CLI-1896). + * + * Read via `Effect.serviceOption` (adds no `R` requirement) so a caller that + * hasn't wired the global-flag context — e.g. a focused unit test — simply + * gets an empty record instead of a missing-service defect; production always + * provides every global flag through `Command.withGlobalFlags` at the CLI + * root (`legacy/cli/root.ts`). + * + * Reads each flag individually (rather than looping `LEGACY_GLOBAL_FLAGS`) + * because each `Setting` has a distinct value type `A` — a homogeneous + * loop widens the union in a way `Effect.serviceOption` can't resolve back to + * a single service lookup without an `as` cast, which this codebase forbids. + * `global-flags.unit.test.ts` asserts the resolved id set stays exactly in + * sync with `LEGACY_GLOBAL_FLAGS` — extend both together when adding a new + * global flag. + */ +export const legacyGlobalFlagValues = Effect.gen(function* () { + const values: Record = {}; + const setIfPresent = (id: string, option: Option.Option) => { + if (Option.isSome(option)) values[id] = option.value; + }; + setIfPresent(LegacyAgentFlag.id, yield* Effect.serviceOption(LegacyAgentFlag)); + setIfPresent(LegacyCreateTicketFlag.id, yield* Effect.serviceOption(LegacyCreateTicketFlag)); + setIfPresent(LegacyDebugFlag.id, yield* Effect.serviceOption(LegacyDebugFlag)); + setIfPresent(LegacyDnsResolverFlag.id, yield* Effect.serviceOption(LegacyDnsResolverFlag)); + setIfPresent(LegacyExperimentalFlag.id, yield* Effect.serviceOption(LegacyExperimentalFlag)); + setIfPresent(LegacyNetworkIdFlag.id, yield* Effect.serviceOption(LegacyNetworkIdFlag)); + setIfPresent(LegacyOutputFlag.id, yield* Effect.serviceOption(LegacyOutputFlag)); + setIfPresent(LegacyProfileFlag.id, yield* Effect.serviceOption(LegacyProfileFlag)); + setIfPresent(LegacyWorkdirFlag.id, yield* Effect.serviceOption(LegacyWorkdirFlag)); + setIfPresent(LegacyYesFlag.id, yield* Effect.serviceOption(LegacyYesFlag)); + return values; +}); + const PFLAG_FALSE_VALUES = new Set(["0", "f", "F", "false", "FALSE", "False"]); /** diff --git a/apps/cli/src/shared/legacy/global-flags.unit.test.ts b/apps/cli/src/shared/legacy/global-flags.unit.test.ts new file mode 100644 index 0000000000..ec54f34982 --- /dev/null +++ b/apps/cli/src/shared/legacy/global-flags.unit.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; +import { + LEGACY_GLOBAL_FLAGS, + LegacyAgentFlag, + LegacyCreateTicketFlag, + LegacyDebugFlag, + LegacyDnsResolverFlag, + LegacyExperimentalFlag, + LegacyNetworkIdFlag, + LegacyOutputFlag, + LegacyProfileFlag, + LegacyWorkdirFlag, + LegacyYesFlag, + legacyGlobalFlagValues, +} from "./global-flags.ts"; + +describe("legacyGlobalFlagValues", () => { + it.live( + "resolves every flag declared in LEGACY_GLOBAL_FLAGS by id (CLI-1896 drift guard: an 11th global flag added here without a matching read in legacyGlobalFlagValues fails this test instead of silently redacting forever)", + () => { + const layer = Layer.mergeAll( + Layer.succeed(LegacyAgentFlag, "yes" as const), + Layer.succeed(LegacyCreateTicketFlag, true), + Layer.succeed(LegacyDebugFlag, true), + Layer.succeed(LegacyDnsResolverFlag, "https" as const), + Layer.succeed(LegacyExperimentalFlag, true), + Layer.succeed(LegacyNetworkIdFlag, Option.some("my-network")), + Layer.succeed(LegacyOutputFlag, Option.some("json" as const)), + Layer.succeed(LegacyProfileFlag, "custom-profile"), + Layer.succeed(LegacyWorkdirFlag, Option.some("/tmp/project")), + Layer.succeed(LegacyYesFlag, true), + ); + + return legacyGlobalFlagValues.pipe( + Effect.provide(layer), + Effect.tap((values) => + Effect.sync(() => { + // The key set must exactly match LEGACY_GLOBAL_FLAGS's own ids — + // this is what fails loudly if the array grows without a matching + // read here. + expect(Object.keys(values).sort()).toEqual( + LEGACY_GLOBAL_FLAGS.map((flag) => flag.id).sort(), + ); + expect(values).toEqual({ + agent: "yes", + "create-ticket": true, + debug: true, + "dns-resolver": "https", + experimental: true, + "network-id": Option.some("my-network"), + output: Option.some("json"), + profile: "custom-profile", + workdir: Option.some("/tmp/project"), + yes: true, + }); + }), + ), + ); + }, + ); + + it.live("omits every flag when no global-flag context is provided", () => { + return legacyGlobalFlagValues.pipe( + Effect.tap((values) => + Effect.sync(() => { + expect(values).toEqual({}); + }), + ), + ); + }); + + it.live("only includes flags whose service was actually provided", () => { + return legacyGlobalFlagValues.pipe( + Effect.provide(Layer.succeed(LegacyDebugFlag, true)), + Effect.tap((values) => + Effect.sync(() => { + expect(values).toEqual({ debug: true }); + }), + ), + ); + }); +}); From 98a3d87d08d95d38f745a691d4dae0af947c2e97 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 9 Jul 2026 10:47:48 +0100 Subject: [PATCH 30/49] fix(cli): use cobra's mutual-exclusivity error template for sso update (#5832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Bug fix (Go-parity divergence). ## What is the current behavior? `sso update`'s `--domains`/`--add-domains`/`--remove-domains` mutex checks emitted custom, hand-written messages (`"only one of --domains or --add-domains may be set"`) instead of cobra's real `validateExclusiveFlagGroups` template (`flag_groups.go:204`), and gated "is this flag set" on the *parsed value's* `.length > 0` rather than cobra's actual `pflag.Changed` semantics — so `--domains=` (explicit but empty) was silently treated as unset, the same "changed vs truthy" class of bug CLI-1860 fixed for `functions download`'s `--use-docker`. Fixes [CLI-1902](https://linear.app/supabase/issue/CLI-1902/sso-update-use-cobras-mutual-exclusivity-error-template-for-domains). ## What is the new behavior? Reuses the `hasExplicitLongFlag`/`cobraMutuallyExclusiveErrorMessage` helpers already hoisted to `shared/cli/cobra-flag-groups.ts` (#5804) so `sso update` emits cobra's byte-exact error text for `--domains`/`--add-domains`/`--remove-domains`, keyed off raw argv rather than the parsed flag value. While reviewing, three independent reviewer passes (architect/engineer/DX) converged on two more in-scope gaps in the same handler that were cheap to close alongside the ticket's stated fix: - `--metadata-file`/`--metadata-url` had the identical divergence (Go also registers this pair with `MarkFlagsMutuallyExclusive`, `cmd/sso.go:178`) — folded into the same cobra-parity check rather than leaving one mutex group byte-exact and the other hand-written in the same command. - The mutex checks ran *after* the provider-ID UUID validation, but cobra runs `ValidateFlagGroups` before `RunE` (`command.go:1010,1014`) while Go's UUID check lives inside `RunE` (`cmd/sso.go:90-91`) — so a flag-group violation must win over an invalid provider ID when both apply. Reordered to match. Added regression tests for the exact cobra error text (both `--domains` groups and the metadata group), the changed-vs-truthy gap, the two-groups-not-a-3-way-group behavior, the group-check ordering when all three domain flags collide, and the mutex-before-UUID precedence — plus a flag-parser unit test proving `--domains=` really resolves to `[]`. `update.handler.ts`'s pre-existing branch-coverage gaps (GET/PUT network-failure paths, `--skip-url-validation`, no-access-token, malformed GET body) are unrelated to this change and were left alone; independently verified via `git stash` that none of them were introduced by this diff. --- .../sso/update/update.command.unit.test.ts | 17 ++ .../commands/sso/update/update.handler.ts | 100 +++++-- .../sso/update/update.integration.test.ts | 247 ++++++++++++++++-- apps/cli/src/shared/cli/cobra-flag-groups.ts | 53 ++++ .../shared/cli/cobra-flag-groups.unit.test.ts | 88 ++++++- 5 files changed, 462 insertions(+), 43 deletions(-) diff --git a/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts b/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts index 0afeb0e4d2..6f88558830 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts @@ -47,6 +47,23 @@ describe("legacy sso update domain flags (pflag StringSlice parity)", () => { expect(removeDomains).toEqual(["example.com", "example.org"]); }); + test("--domains= (explicit empty value) parses to an empty array, not a missing flag", async () => { + // Backs the "changed vs truthy" mutex-check fix (CLI-1902): the handler's + // `hasExplicitLongFlag` reads raw argv rather than this parsed value + // precisely because `--domains=` collapses to `[]` here, indistinguishable + // from the flag never being passed at all if you only looked at `.length`. + const [, domains] = await Effect.runPromise( + legacySsoUpdateDomainsFlag + .parse({ + flags: { domains: [""] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(domains).toEqual([]); + }); + test("rejects malformed CSV (bare quote)", async () => { const exit = await Effect.runPromise( legacySsoUpdateDomainsFlag diff --git a/apps/cli/src/legacy/commands/sso/update/update.handler.ts b/apps/cli/src/legacy/commands/sso/update/update.handler.ts index ca86765455..aba1d6833f 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.handler.ts @@ -1,5 +1,5 @@ import type { SupabaseApiError } from "@supabase/api/effect"; -import { Effect, Option, Result } from "effect"; +import { Effect, Option, Result, Stdio } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -7,6 +7,10 @@ import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts" import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + cobraMutuallyExclusiveErrorMessage, + hasExplicitValueFlag, +} from "../../../../shared/cli/cobra-flag-groups.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { encodeGoJson, @@ -48,6 +52,40 @@ const mapGetStatusOrNetwork = mapLegacyHttpError({ statusMessage: (_status, body) => `unexpected error fetching identity provider: ${body}`, }); +const SSO_UPDATE_COMMAND_PATH = ["sso", "update"] as const; + +/** + * Registration order mirrors Go's `cmd/sso.go:178-180` — three independent + * `MarkFlagsMutuallyExclusive` groups (`metadata-file`/`metadata-url` plus two + * 2-element groups sharing `--domains`, not one 3-way group). Cobra checks + * groups in `sort.Strings`-order of the joined group key (`flag_groups.go:189`), + * which happens to match registration order here: "domains add-domains" < + * "domains remove-domains" < "metadata-file metadata-url" alphabetically. + */ +const SSO_UPDATE_MUTEX_GROUPS = [ + ["domains", "add-domains"], + ["domains", "remove-domains"], + ["metadata-file", "metadata-url"], +] as const; + +/** + * Every value-taking (non-boolean) flag `sso update` declares + * (`update.command.ts`) — tells `hasExplicitValueFlag` which bare tokens + * consume the next argv token as their value. `--skip-url-validation` is this + * command's only boolean flag and is deliberately excluded; booleans never + * consume a following token. + */ +const SSO_UPDATE_VALUE_FLAG_NAMES = new Set([ + "project-ref", + "domains", + "add-domains", + "remove-domains", + "metadata-file", + "metadata-url", + "attribute-mapping-file", + "name-id-format", +]); + const handleGetError = (ref: string, providerId: string, cause: SupabaseApiError) => Effect.gen(function* () { const mapped = yield* Effect.flip(mapGetStatusOrNetwork(cause)); @@ -104,34 +142,50 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( const resolver = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; + const stdio = yield* Stdio.Stdio; + const rawArgs = yield* stdio.args; yield* Effect.gen(function* () { + // cobra runs `ValidateFlagGroups` (`command.go:1010`) before `RunE` + // (`command.go:1014`), and Go's provider-ID format check lives inside + // `RunE` (`cmd/sso.go:90-91`) — so a mutex violation must win over an + // invalid provider ID when both apply. Keep this block ahead of + // `validateUuid` below to match that precedence. + // + // "Set" follows cobra's `pflag.Changed` — whether the flag was passed at + // all — not the resulting value. `--domains`/`--add-domains`/ + // `--remove-domains` all default to `[]`, so `--domains=` (parses to an + // empty array) must still count as "set"; gating on `.length > 0` would + // miss it, the same "changed vs truthy" gap CLI-1860 fixed for + // `functions download`'s `--use-docker`. + // + // `hasExplicitValueFlag` (not the simpler `hasExplicitLongFlag`) is + // required here because every flag in these groups takes a value: a bare + // `--metadata-file --metadata-url` is pflag consuming `--metadata-url` as + // `metadata-file`'s (oddly named) value, not two flags being set — see + // that function's doc comment. + for (const group of SSO_UPDATE_MUTEX_GROUPS) { + const changed = group.filter((flagName) => + hasExplicitValueFlag( + rawArgs, + SSO_UPDATE_COMMAND_PATH, + SSO_UPDATE_VALUE_FLAG_NAMES, + flagName, + ), + ); + if (changed.length > 1) { + return yield* Effect.fail( + new LegacySsoMutexFlagError({ + message: cobraMutuallyExclusiveErrorMessage(group, changed), + }), + ); + } + } + const providerId = yield* validateUuid(flags.providerId).pipe( Result.match({ onFailure: Effect.fail, onSuccess: Effect.succeed }), ); - if (flags.domains.length > 0 && flags.addDomains.length > 0) { - return yield* Effect.fail( - new LegacySsoMutexFlagError({ - message: "only one of --domains or --add-domains may be set", - }), - ); - } - if (flags.domains.length > 0 && flags.removeDomains.length > 0) { - return yield* Effect.fail( - new LegacySsoMutexFlagError({ - message: "only one of --domains or --remove-domains may be set", - }), - ); - } - if (Option.isSome(flags.metadataFile) && Option.isSome(flags.metadataUrl)) { - return yield* Effect.fail( - new LegacySsoMutexFlagError({ - message: "only one of --metadata-file or --metadata-url may be set", - }), - ); - } - const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts b/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts index c1060092cb..c76206a29b 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts @@ -2,7 +2,7 @@ import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Layer, Option, Stdio } from "effect"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; @@ -45,6 +45,13 @@ interface SetupOpts { putStatus?: number; putBody?: unknown; upgradeGate?: "gated" | "notGated"; + /** + * Raw argv the handler sees via `Stdio.Stdio` — drives the + * `hasExplicitLongFlag`-based mutex checks. Defaults to a bare invocation + * with none of the mutually-exclusive domain flags present; tests that + * exercise those checks must pass the matching flags explicitly here. + */ + cliArgs?: ReadonlyArray; } function jsonResponse( @@ -122,15 +129,20 @@ function setup(opts: SetupOpts = {}) { }); const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); - const layer = buildLegacyTestRuntime({ - out, - api: { layer: api.layer, httpClientLayer: api.httpClientLayer }, - cliConfig, - telemetry: telemetry.layer, - linkedProjectCache: cache.layer, - analytics, - goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), - }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api: { layer: api.layer, httpClientLayer: api.httpClientLayer }, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + analytics, + goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + }), + Stdio.layerTest({ + args: Effect.succeed(opts.cliArgs ?? ["sso", "update", VALID_PROVIDER_ID]), + }), + ); return { layer, out, api, analytics, telemetry, cache }; } @@ -200,40 +212,237 @@ describe("legacy sso update integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("mutex check: --domains + --add-domains fails", () => { - const { layer } = setup(); + it.live("mutex check: --domains + --add-domains fails with cobra's exact error text", () => { + const { layer } = setup({ + cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--domains", "a.com", "--add-domains", "b.com"], + }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacySsoUpdate({ ...defaultFlags, domains: ["a.com"], addDomains: ["b.com"] }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySsoMutexFlagError"); + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoMutexFlagError"); + // Byte-matches cobra's `validateExclusiveFlagGroups` template + // (`flag_groups.go:204`): group in registration order, changed flags + // sorted alphabetically — "add-domains" < "domains". + expect(dump).toContain( + "if any flags in the group [domains add-domains] are set none of the others can be; [add-domains domains] were all set", + ); } }).pipe(Effect.provide(layer)); }); - it.live("mutex check: --domains + --remove-domains fails", () => { - const { layer } = setup(); + it.live("mutex check: --domains + --remove-domains fails with cobra's exact error text", () => { + const { layer } = setup({ + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--domains", + "a.com", + "--remove-domains", + "b.com", + ], + }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacySsoUpdate({ ...defaultFlags, domains: ["a.com"], removeDomains: ["b.com"] }), ); expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoMutexFlagError"); + expect(dump).toContain( + "if any flags in the group [domains remove-domains] are set none of the others can be; [domains remove-domains] were all set", + ); + } }).pipe(Effect.provide(layer)); }); - it.live("mutex check: --metadata-file + --metadata-url fails", () => { - const { layer } = setup(); + it.live( + "mutex check: an explicit but empty --domains= still conflicts with --add-domains (changed, not truthy)", + () => { + // `--domains=` parses to an empty array, but cobra's `pflag.Changed` + // tracks that the flag was passed at all, not the resulting value — the + // same "changed vs truthy" gap CLI-1860 fixed for `functions download`'s + // `--use-docker`. Gating on `.length > 0` would miss this combination. + const { layer } = setup({ + cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--domains=", "--add-domains", "b.com"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySsoUpdate({ ...defaultFlags, domains: [], addDomains: ["b.com"] }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacySsoMutexFlagError"); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "mutex check: --add-domains and --remove-domains together are not mutually exclusive", + () => { + // Go only registers ("domains","add-domains") and ("domains","remove-domains") + // as separate 2-element groups (`cmd/sso.go:179-180`) — add-domains and + // remove-domains together, without --domains, is not a violation. + const { layer } = setup({ + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--add-domains", + "b.com", + "--remove-domains", + "c.com", + ], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySsoUpdate({ ...defaultFlags, addDomains: ["b.com"], removeDomains: ["c.com"] }), + ); + expect(Exit.isSuccess(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("mutex check: all three domain flags set reports the --add-domains group first", () => { + // Pins the `SSO_UPDATE_MUTEX_GROUPS` array order: cobra's sorted-key + // iteration ("domains add-domains" < "domains remove-domains") means the + // add-domains group is checked — and its error returned — first when all + // three domain flags collide at once. + const { layer } = setup({ + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--domains", + "a.com", + "--add-domains", + "b.com", + "--remove-domains", + "c.com", + ], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySsoUpdate({ + ...defaultFlags, + domains: ["a.com"], + addDomains: ["b.com"], + removeDomains: ["c.com"], + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain( + "if any flags in the group [domains add-domains] are set none of the others can be; [add-domains domains] were all set", + ); + expect(dump).not.toContain("remove-domains"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("mutex check: a flag-group violation wins over an invalid provider ID", () => { + // Cobra runs `ValidateFlagGroups` before `RunE` (`command.go:1010,1014`); + // Go's provider-ID format check lives inside `RunE` (`cmd/sso.go:90-91`). + // So an invalid UUID combined with a mutex violation must surface the + // mutex error, not `LegacySsoInvalidUuidError`. + const { layer } = setup({ + cliArgs: ["sso", "update", "not-a-uuid", "--domains", "a.com", "--add-domains", "b.com"], + }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacySsoUpdate({ ...defaultFlags, - metadataFile: Option.some("/tmp/x.xml"), - metadataUrl: Option.some("https://idp.example.com/m"), + providerId: "not-a-uuid", + domains: ["a.com"], + addDomains: ["b.com"], }), ); expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoMutexFlagError"); + expect(dump).not.toContain("LegacySsoInvalidUuidError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live( + "mutex check: --metadata-file + --metadata-url fails with cobra's exact error text", + () => { + const { layer } = setup({ + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--metadata-file", + "/tmp/x.xml", + "--metadata-url", + "https://idp.example.com/m", + ], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySsoUpdate({ + ...defaultFlags, + metadataFile: Option.some("/tmp/x.xml"), + metadataUrl: Option.some("https://idp.example.com/m"), + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoMutexFlagError"); + // Go registers this pair too (`cmd/sso.go:178`) — it was left emitting + // a hand-written message alongside the domains groups' custom text + // before this fix; now all three of `sso update`'s mutex groups on + // this command share the same byte-exact cobra template. + expect(dump).toContain( + "if any flags in the group [metadata-file metadata-url] are set none of the others can be; [metadata-file metadata-url] were all set", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "mutex check: a bare --metadata-file followed by --metadata-url is not a violation", + () => { + // pflag's `--flag arg` branch consumes the very next argv token as the + // value unconditionally (`flag.go:1013-1031`), so real cobra parses this + // as `metadata-file` receiving the literal value `"--metadata-url"` — + // `metadata-url` is never parsed as its own flag and stays unset. The + // TS CLI's own parser (unlike pflag) never hands a dash-prefixed token + // to a non-boolean flag as a bare value, so here both flags resolve to + // `Option.none()` — but the raw-argv mutex scan must reach the same + // "not a violation" conclusion pflag does, not double-count the + // `--metadata-url` token as a second explicit flag. + const { layer } = setup({ + cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--metadata-file", "--metadata-url"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); + expect(Exit.isSuccess(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("mutex check: a bare --add-domains followed by --domains=... is not a violation", () => { + // Same consumed-value class as the metadata-file/metadata-url case + // above, but for the domains group: pflag would hand `add-domains` the + // literal value `"--domains=x.com"` and never parse `--domains` at all. + const { layer } = setup({ + cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--add-domains", "--domains=x.com"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); + expect(Exit.isSuccess(exit)).toBe(true); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/shared/cli/cobra-flag-groups.ts b/apps/cli/src/shared/cli/cobra-flag-groups.ts index 56a58da563..bb3e20716a 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.ts @@ -28,6 +28,59 @@ export function hasExplicitLongFlag( return false; } +/** + * Like `hasExplicitLongFlag`, but aware that a bare (`=`-less) occurrence of a + * *value-taking* flag consumes the very next argv token as its value — + * matching pflag's `parseLongArg` (`flag.go:1013-1031`), which takes the next + * raw arg unconditionally once a long flag needs a value, with no check that + * the token looks like another flag. + * + * Without this, scanning independently per flag name (as `hasExplicitLongFlag` + * does) can mistake a consumed value for a literal occurrence of a sibling + * mutex flag: `--metadata-file --metadata-url` is pflag's `metadata-file` + * flag being handed the (oddly named, but valid) string value + * `"--metadata-url"` — cobra never parses `--metadata-url` as its own flag, + * so `metadata-url.Changed` stays `false`. A naive scan sees both tokens and + * wrongly reports both as set. + * + * `valueFlagNames` must list every value-taking (non-boolean) flag declared + * on the command being scanned, so the scan knows which bare tokens consume a + * following value; boolean flags never consume one and must be omitted. This + * only covers flags local to the command — a global/inherited value-taking + * flag immediately preceding a mutex flag without `=` can still be misread + * the same way; closing that fully would mean teaching this scan about every + * flag reachable at parse time, not just the command's own, which is a + * bigger, cross-cutting change. + */ +export function hasExplicitValueFlag( + rawArgs: ReadonlyArray, + commandPath: ReadonlyArray, + valueFlagNames: ReadonlySet, + flagName: string, +): boolean { + const commandIndex = rawArgs.findIndex((_, index) => + commandPath.every((segment, offset) => rawArgs[index + offset] === segment), + ); + const scoped = commandIndex !== -1; + const tokens = scoped ? rawArgs.slice(commandIndex + commandPath.length) : rawArgs; + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === undefined || (scoped && token === "--")) { + return false; + } + if (token === `--${flagName}` || token.startsWith(`--${flagName}=`)) { + return true; + } + if (token.startsWith("--") && !token.includes("=") && valueFlagNames.has(token.slice(2))) { + // Bare occurrence of a value-taking flag — skip the token it consumes + // so it can't be mistaken for a literal occurrence of `flagName`. + index += 1; + } + } + return false; +} + /** * Byte-matches cobra's `validateExclusiveFlagGroups` error * (`flag_groups.go:204`): `group` is the full mutually-exclusive set in diff --git a/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts b/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts index d1318cf4c2..2a410121da 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "vitest"; -import { cobraMutuallyExclusiveErrorMessage, hasExplicitLongFlag } from "./cobra-flag-groups.ts"; +import { + cobraMutuallyExclusiveErrorMessage, + hasExplicitLongFlag, + hasExplicitValueFlag, +} from "./cobra-flag-groups.ts"; const COMMAND_PATH = ["functions", "deploy"] as const; @@ -44,6 +48,88 @@ describe("hasExplicitLongFlag", () => { }); }); +describe("hasExplicitValueFlag", () => { + const SSO_UPDATE_PATH = ["sso", "update"] as const; + const VALUE_FLAGS = new Set(["metadata-file", "metadata-url", "domains", "add-domains"]); + + test("finds a bare flag after the command path", () => { + expect( + hasExplicitValueFlag( + ["sso", "update", "id", "--metadata-file", "foo.xml"], + SSO_UPDATE_PATH, + VALUE_FLAGS, + "metadata-file", + ), + ).toBe(true); + }); + + test("finds a flag with an inline value", () => { + expect( + hasExplicitValueFlag( + ["sso", "update", "id", "--domains=a.com"], + SSO_UPDATE_PATH, + VALUE_FLAGS, + "domains", + ), + ).toBe(true); + }); + + test("does not mistake a value-taking flag's consumed value for a sibling flag", () => { + // pflag's `--flag arg` branch consumes the next token unconditionally + // (`flag.go:1013-1031`), so `--metadata-file --metadata-url` gives + // `metadata-file` the literal value `"--metadata-url"` and never parses + // `--metadata-url` as its own flag. + const args = ["sso", "update", "id", "--metadata-file", "--metadata-url"]; + expect(hasExplicitValueFlag(args, SSO_UPDATE_PATH, VALUE_FLAGS, "metadata-file")).toBe(true); + expect(hasExplicitValueFlag(args, SSO_UPDATE_PATH, VALUE_FLAGS, "metadata-url")).toBe(false); + }); + + test("does not mistake a value-taking flag's consumed value for a sibling flag, reversed", () => { + const args = ["sso", "update", "id", "--metadata-url", "--metadata-file"]; + expect(hasExplicitValueFlag(args, SSO_UPDATE_PATH, VALUE_FLAGS, "metadata-url")).toBe(true); + expect(hasExplicitValueFlag(args, SSO_UPDATE_PATH, VALUE_FLAGS, "metadata-file")).toBe(false); + }); + + test("an inline (`=`) value is never treated as consuming the next token", () => { + // `--metadata-file=--metadata-url` is one token: metadata-file's value is + // the literal string "--metadata-url", and no token is consumed after it. + const args = ["sso", "update", "id", "--metadata-file=--metadata-url", "--domains", "a.com"]; + expect(hasExplicitValueFlag(args, SSO_UPDATE_PATH, VALUE_FLAGS, "metadata-file")).toBe(true); + expect(hasExplicitValueFlag(args, SSO_UPDATE_PATH, VALUE_FLAGS, "metadata-url")).toBe(false); + expect(hasExplicitValueFlag(args, SSO_UPDATE_PATH, VALUE_FLAGS, "domains")).toBe(true); + }); + + test("a real, non-adjacent occurrence of both flags is still detected", () => { + const args = ["sso", "update", "id", "--metadata-file", "foo.xml", "--metadata-url", "url"]; + expect(hasExplicitValueFlag(args, SSO_UPDATE_PATH, VALUE_FLAGS, "metadata-file")).toBe(true); + expect(hasExplicitValueFlag(args, SSO_UPDATE_PATH, VALUE_FLAGS, "metadata-url")).toBe(true); + }); + + test("returns false when the flag is absent", () => { + expect( + hasExplicitValueFlag(["sso", "update", "id"], SSO_UPDATE_PATH, VALUE_FLAGS, "domains"), + ).toBe(false); + }); + + test("stops scanning at a -- terminator", () => { + expect( + hasExplicitValueFlag( + ["sso", "update", "id", "--", "--domains"], + SSO_UPDATE_PATH, + VALUE_FLAGS, + "domains", + ), + ).toBe(false); + }); + + test("falls back to a bare scan when the command path is not found", () => { + expect(hasExplicitValueFlag(["--domains"], SSO_UPDATE_PATH, VALUE_FLAGS, "domains")).toBe(true); + expect(hasExplicitValueFlag(["--metadata-file"], SSO_UPDATE_PATH, VALUE_FLAGS, "domains")).toBe( + false, + ); + }); +}); + describe("cobraMutuallyExclusiveErrorMessage", () => { test("byte-matches cobra's validateExclusiveFlagGroups template", () => { expect( From eadafcb88f26033191a01d2ed1059760206bf3f1 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 9 Jul 2026 11:03:05 +0100 Subject: [PATCH 31/49] docs(cli): link Effect-TS upstream issue for doubled-Expected workaround (#5837) ## What changed Files [Effect-TS/effect#6312](https://github.com/Effect-TS/effect/issues/6312) for the doubled `"Expected: Expected ..."` prefix bug in `effect@4.0.0-beta.93`'s CLI primitive parsers (`Primitive.choice`/schema-backed `integer`/`float`/`boolean`/`date`), per [review feedback on #5831](https://github.com/supabase/cli/pull/5831#pullrequestreview-4661453095), and adds a `TODO` in `invalid-value-message.ts` linking to it, so the workaround is easy to find and remove once upstream fixes the underlying bug. --- apps/cli/src/shared/cli/invalid-value-message.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/cli/src/shared/cli/invalid-value-message.ts b/apps/cli/src/shared/cli/invalid-value-message.ts index 8bf079581d..ff895e2a94 100644 --- a/apps/cli/src/shared/cli/invalid-value-message.ts +++ b/apps/cli/src/shared/cli/invalid-value-message.ts @@ -19,6 +19,9 @@ // scanning `error.value`. Remove once upstream `effect` fixes this (see // CLI-1898). // +// TODO: remove once Effect-TS/effect#6312 is fixed upstream. +// https://github.com/Effect-TS/effect/issues/6312 +// // Shared by two call sites that each see `InvalidValue` failures at a // different point in `effect`'s CLI runtime: // - `subcommand-flag-suggestions.ts` formats errors that reach the From 7f3a7e1efbd0b525db115e5b0b686c90fca9671d Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 9 Jul 2026 13:29:18 +0100 Subject: [PATCH 32/49] fix(cli): validate config before the destructive db reset --local recreate (#5840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed `supabase db reset --local` now runs full Go-parity config validation (`legacyCheckDbToml`, matching Go's `flags.LoadConfig`) at the top of its local branch, before `AssertSupabaseDbIsRunning`/the destructive container recreate — the same pre-destructive-work gate `db start` and `db push` already have. A broken `supabase/config.toml` (unterminated TOML, an undecryptable `encrypted:` vault secret, an unparseable `env(VAR)` boolean, an explicit empty `project_id`) now aborts before the local database is ever recreated, instead of only surfacing later (or never) during bucket seeding. A genuinely **missing** `config.toml` is still tolerated, unchanged: Go's `Config.Load` defaults `project_id` to the current directory's basename when no config file exists, so `Validate` never rejects it — this is exactly the mechanism the `cli-e2e` parity suite relies on when it runs `db push --local` / `db reset --local` from a project with no `config.toml`. Only a config file that is *present but broken* is now caught earlier. `db reset --local`'s post-recreate bucket-seeding step catches a `LegacySeedConfigLoadError` from its own config reload and warns-and-continues rather than failing the command. An earlier version of this PR removed that fallback (reasoning: Go loads `config.toml` exactly once into memory, so it can never reach "recreate succeeded, then a later reload of the same file fails"). A Codex review on this PR caught that this doesn't transfer to the TS port: the new pre-recreate gate resolves `env(VAR)` via the Go-parity nested-env reader (sees `supabase/.env.development`, the project root, etc.), but the post-recreate reload goes through `@supabase/config`'s narrower loader (`supabase/.env`/`.env.local` only) — so a genuinely Go-valid config can pass the gate and the real recreate, then hard-fail only at the later, narrower reload, after the local database has already been dropped and rebuilt. The fallback is restored, with a comment naming the actual env-file-set gap instead of generic "loader strictness." ## Why Linear [CLI-1877](https://linear.app/supabase/issue/CLI-1877/reject-directlocal-db-push-db-reset-without-project-config-go): a Codex P1 finding on #5715 flagged that the native `db reset`/`db push`/`db start` port tolerates a missing project config more broadly than Go, and that `db reset --local`'s config validation happened too late relative to the destructive recreate. Investigation (via `go-parity-auditor`, cross-checked against the compiled Go binary) found the issue's literal premise — "reject direct/local db push + db reset without project config" — does **not** match current Go behavior: Go tolerates a missing config file by defaulting `project_id` to the cwd basename, and the TS port already matches that. Implementing a hard reject-on-missing-config would have been a *new* divergence from Go and would have broken the currently-green `cli-e2e` parity tests (exactly the regression risk the issue's own "deferred from #5715" note called out). `db start`'s and `db push`'s validation-ordering guarantees were also found to already be correct and already covered by existing tests. The one real, narrow gap was `db reset --local`'s ordering guarantee being implicit — buried inside a config resolver whose test double bypasses it entirely — rather than explicit and independently testable. This PR closes that gap. ## Test plan - Added regression tests in `reset.integration.test.ts` for a local reset: malformed config.toml, an unparseable boolean, an undecryptable vault secret, and an explicit empty `project_id`, all asserting the destructive recreate never runs; a test pinning that a broken config wins over the "not running" error; and a test confirming a genuinely missing config.toml is still tolerated. - `pnpm check:all` and the full `apps/cli` unit + integration suite (4758 tests) pass. - Ran the 4-agent `review-changes` procedure (architect/engineer/security/DX) against the diff and worked every finding; see "Judgement calls" below for what a `review-adjudicator` settled and what remains a documented, deliberate trade-off. ## Judgement calls / open notes - The `review-changes` engineer pass flagged that rewriting a pre-existing test orphaned the bucket-seed warn-and-skip branch, regressing coverage. A `review-adjudicator` pass at the time concluded there was no Go-parity reason to keep that fallback and recommended deleting it (done in the second commit) — a subsequent Codex review on the open PR found a concrete Go-valid config shape (an `env(VAR)` value sourced from `supabase/.env.development`) that the deletion broke, so the fallback was restored with an accurate comment (see "What changed" above). - The new pre-recreate gate is currently unreachable in production (the resolver's own internal read already validates first and would already reject a broken config identically) — it exists for defense-in-depth and so the "validate before destroy" guarantee is enforced directly by the handler and stays covered by a test even if the resolver is ever mocked or refactored to stop validating. Both the architect and DX review passes flagged this as worth calling out explicitly rather than as a blocking issue. - Not addressed here (flagged by review but out of scope): `LegacyDbConfigResolver.resolve` could return the validated config it already reads, so callers stop re-parsing `config.toml` up to 2-3 times on the local reset path. This is a pre-existing, codebase-wide pattern (`db push` does the same double-read), not something introduced by this PR, and is better done as its own resolver-contract refactor. Fixes CLI-1877. --- .../legacy/commands/db/reset/SIDE_EFFECTS.md | 18 +- .../legacy/commands/db/reset/reset.handler.ts | 37 +++- .../db/reset/reset.integration.test.ts | 169 ++++++++++++++++-- 3 files changed, 196 insertions(+), 28 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md index 6089303d32..27a67ad560 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -11,15 +11,15 @@ primitives run behind the hidden Go `db __db-bootstrap` seam. Only the niche ## Files Read -| Path | Format | When | -| ------------------------------------------------------ | ---------- | ------------------------------------------------------------------------- | -| `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations | -| `/supabase/config.toml` | TOML | remote path + local bucket seeding (embedded defaults when absent) | -| `/.git/HEAD` (walked upward) | plain text | local path, for the `Finished … on branch .` line | -| `~/.supabase//project-ref` | plain text | `--linked`, to resolve the ref | -| `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted | -| seed files from `--sql-paths` or `[db.seed].sql_paths` | SQL | when seeding is enabled (not `--no-seed`); `--sql-paths` overrides config | -| `/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects | +| Path | Format | When | +| ------------------------------------------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------- | +| `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations | +| `/supabase/config.toml` | TOML | always, parsed up front before any destructive work (embedded defaults when absent); re-read for local bucket seeding | +| `/.git/HEAD` (walked upward) | plain text | local path, for the `Finished … on branch .` line | +| `~/.supabase//project-ref` | plain text | `--linked`, to resolve the ref | +| `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted | +| seed files from `--sql-paths` or `[db.seed].sql_paths` | SQL | when seeding is enabled (not `--no-seed`); `--sql-paths` overrides config | +| `/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects | ## Files Written diff --git a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts index 640392a7c0..46ecc8236f 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -243,6 +243,20 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega // (running check, messages, bucket seeding, git-branch line, output shaping). // Mirrors `internal/db/reset/reset.go:57-77`. if (cfg.isLocal) { + // Go's `flags.LoadConfig` (root `PersistentPreRunE` → the local target's + // per-connType `LoadConfig`, `internal/utils/flags/db_url.go:77-80`) runs full + // config validation before `reset.Run` ever reaches `AssertSupabaseDbIsRunning` + // / the destructive `resetDatabase` (`internal/db/reset/reset.go:57-61`). The + // resolver's own local read (above, line 239) already performs the identical + // validation and would already reject a broken config before this point is + // reached — so today this re-validates for its own sake. Repeat it here anyway, + // as an explicit, independent gate (the same pattern `db start` and `db push` + // use), so the "malformed config aborts before the local database is recreated" + // guarantee is enforced by this handler directly and stays covered by a + // handler-level test even if the resolver's own internal read is ever mocked, + // relaxed, or refactored to stop validating. + yield* legacyCheckDbToml(fs, path, workdir); + // AssertSupabaseDbIsRunning — error if the local db container is down. const running = yield* seam.isDbRunning(); if (!running) { @@ -268,13 +282,22 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega // Go's `buckets.Run(ctx, "", false, fsys)` — non-interactive: overwrite/prune // confirmations take their defaults instead of blocking on input. // - // Bucket seeding re-loads config.toml through the strict `@supabase/config` - // loader, which (unlike the Go-parity reader used elsewhere in reset) rejects some - // Go-valid configs — e.g. `[db.seed] enabled = "env(SEED_ENABLED)"`. The seam's Go - // `recreate` has already run Go's full `LoadConfig`+`Validate` on this same config, - // so a parse failure HERE is that loader-strictness gap, not a genuinely invalid - // config. Recreate already dropped/rebuilt the DB, so aborting now would leave the - // reset half-done; warn and skip buckets so `db reset` finishes like Go instead. + // `legacyCheckDbToml` above resolves `env(VAR)` via `legacyLoadProjectEnv`, + // which mirrors Go's full nested-env walk (`.env..local`, + // `.env.local`, `.env.`, `.env`, across both `supabase/` and the + // project root — `pkg/config/config.go:1220-1257`). This reload instead goes + // through `@supabase/config`'s `loadProjectConfig` → `loadProjectEnvironment`, + // which only ever reads `supabase/.env`/`.env.local` plus ambient env + // (`packages/config/src/project.ts:209-245`) — regardless of `goViperCompat`, + // which only widens `env(VAR)` matching, not the file set consulted. So a + // config whose `env(VAR)` reference is backed by e.g. `supabase/.env.development` + // is genuinely Go-valid (Go's `godotenv.Load` calls `os.Setenv`, so the value is + // real ambient env by the time Go resolves it — `config.go:1260-1261`) and + // already passed `legacyCheckDbToml` and the real recreate above, but this + // narrower reload can still reject it. A `LegacySeedConfigLoadError` here is + // that env-file-set gap, not a genuinely invalid config — and recreate already + // dropped/rebuilt the DB, so aborting now would leave the reset half-done; warn + // and skip buckets so `db reset` finishes like Go instead. yield* legacySeedBucketsRun({ projectRef: "", emitSummary: false, diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts index 853f933134..cc3e1f47a3 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -329,6 +329,117 @@ describe("legacy db reset", () => { }); }); + it.live("proceeds with a local reset when no config file is present", () => { + // Go's `Config.Load` tolerates a missing `config.toml`: `Eject` defaults an empty + // `project_id` to the cwd basename (`pkg/config/config.go:563-570`), so `Validate` + // never sees an empty required field and the CLI proceeds — exactly the mechanism + // the cli-e2e parity suite relies on when it runs `db reset --local` from a project + // with no config.toml. A missing config must not become a hard failure here. + const { layer, seam } = setup(tmp.current, { + args: ["db", "reset"], + isLocal: true, + running: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.recreateCalls).toHaveLength(1); + }); + }); + + it.live("fails a local reset before the destructive recreate on a malformed config.toml", () => { + // Go's `flags.LoadConfig` (the local target's `LoadConfig`, `db_url.go:77-80`) runs + // full config validation before `reset.Run` reaches `AssertSupabaseDbIsRunning` / + // `resetDatabase` (`internal/db/reset/reset.go:57-61`). A broken config.toml must + // abort before the local database is ever recreated. + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "unterminated\n', + args: ["db", "reset"], + isLocal: true, + running: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + } + expect(seam.recreateCalls).toHaveLength(0); + }); + }); + + it.live( + "fails a local reset on a malformed config.toml even when the database is not running", + () => { + // Pins Go's exact ordering: `flags.LoadConfig` runs in the root `PersistentPreRunE`, + // strictly before `reset.Run` ever calls `AssertSupabaseDbIsRunning` + // (`internal/db/reset/reset.go:57`). So a broken config must surface as a config + // error even when the local database is ALSO not running — the config check must + // win the race, not the "is not running" check. + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "unterminated\n', + args: ["db", "reset"], + isLocal: true, + running: false, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const cause = JSON.stringify(exit.cause); + expect(cause).toContain("failed to load config"); + expect(cause).not.toContain("is not running."); + } + expect(seam.recreateCalls).toHaveLength(0); + }); + }, + ); + + it.live("fails a local reset before the destructive recreate on an undecryptable secret", () => { + // Regression: Go's `flags.LoadConfig` decrypts every `encrypted:` secret before + // `reset.Run` recreates the local database, so an undecryptable secret must abort + // before the destructive recreate, not surface later (or never) during bucket + // seeding. + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.vault]\nmy_secret = "encrypted:anything"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + // Assert on the stable "failed to parse config:" prefix rather than the exact + // decrypt-failure tail, which depends on whether an ambient `DOTENV_PRIVATE_KEY*` + // is set (missing key vs. a base64/decrypt failure) — either way, the config + // load must fail before the destructive recreate. + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to parse config:"); + } + expect(seam.recreateCalls).toHaveLength(0); + }); + }); + + it.live("fails a local reset before the destructive recreate on an empty project_id", () => { + // Go's `config.Validate` rejects an explicit `project_id = ""` (a present override + // that resolved to empty, unlike an absent field) before the local recreate. + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = ""\n', + args: ["db", "reset"], + isLocal: true, + running: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "Missing required field in config: project_id", + ); + } + expect(seam.recreateCalls).toHaveLength(0); + }); + }); + it.live("seeds buckets after a local reset when storage is ready", () => { const { layer, seam } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -346,16 +457,16 @@ describe("legacy db reset", () => { }); }); - it.live("finishes a local reset when bucket seeding hits a strict-loader-rejected config", () => { - // The bucket-seeding core re-loads config via the strict `@supabase/config` loader. - // `SEED_ENABLED=maybe` is invalid for both Go's `strconv.ParseBool` and the TS - // loader's coercion, so the reload fails during decode (unlike e.g. `1`/`true`, - // which both now accept). The seam's Go recreate already validated + rebuilt the - // DB, so aborting here would leave the reset half-done — warn and skip buckets so - // reset finishes like Go. + it.live("fails a local reset before the destructive recreate on an unparseable boolean", () => { + // `SEED_ENABLED=maybe` cannot be resolved by Go's `strconv.ParseBool`, so + // `flags.LoadConfig` aborts on this config before `reset.Run` ever reaches + // `AssertSupabaseDbIsRunning`/`resetDatabase`. Previously this surfaced only much + // later (if at all) via the bucket-seeding core's own reload, AFTER the local + // database had already been recreated — this must now abort up front instead, + // via the pre-recreate `legacyCheckDbToml` gate. const previous = process.env["SEED_ENABLED"]; process.env["SEED_ENABLED"] = "maybe"; - const { layer, out, seam } = setup(tmp.current, { + const { layer, seam } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n', args: ["db", "reset"], isLocal: true, @@ -363,10 +474,12 @@ describe("legacy db reset", () => { storageReady: true, }); return Effect.gen(function* () { - yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("skipped seeding storage buckets"); - expect(out.stderrText).toContain("Finished "); - expect(seam.recreateCalls).toHaveLength(1); + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("invalid db.seed.enabled"); + } + expect(seam.recreateCalls).toHaveLength(0); }).pipe( Effect.ensuring( Effect.sync(() => { @@ -377,6 +490,38 @@ describe("legacy db reset", () => { ); }); + it.live( + "finishes a local reset when bucket seeding can't see an env(VAR) value the pre-recreate gate saw", + () => { + // `legacyCheckDbToml` (the pre-recreate gate) resolves `env(VAR)` via + // `legacyLoadProjectEnv`, which mirrors Go's full nested-env walk and sees + // `supabase/.env.development` — a real, Go-valid env source + // (`pkg/config/config.go:1220-1257`; `godotenv.Load` calls `os.Setenv`, so this + // is genuinely ambient env by the time Go itself resolves `env(VAR)`, + // `config.go:1260-1261`). The post-recreate bucket-seed reload instead goes + // through `@supabase/config`'s `loadProjectEnvironment`, which only ever reads + // `supabase/.env`/`.env.local` + ambient env (`packages/config/src/project.ts: + // 209-245) — it can't see `.env.development` at all. So this Go-valid config + // passes the gate and the real recreate, then can't be re-resolved by the + // reload; the reset must still finish (warn-and-skip), not hard-fail after the + // local database has already been dropped and rebuilt. + const { layer, out, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n', + files: { "supabase/.env.development": "SEED_ENABLED=true\n" }, + args: ["db", "reset"], + isLocal: true, + running: true, + storageReady: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.recreateCalls).toHaveLength(1); + expect(out.stderrText).toContain("skipped seeding storage buckets"); + expect(out.stderrText).toContain("Finished "); + }); + }, + ); + it.live("uses the detected git branch in the Finished line", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', From 7bc8457b89a78aeb097e9ecfdbb9b0a48e45ab7d Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 9 Jul 2026 13:29:29 +0100 Subject: [PATCH 33/49] fix(cli): propagate delegated Go child exit codes through finalizers (#5841) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Bug fix — legacy-shell child exit-code and delegated-path semantics. ## What is the current behavior? Fixes [CLI-1879](https://linear.app/supabase/issue/CLI-1879/legacy-shell-child-exit-code-delegated-path-semantics-finalizers-json). Three related gaps around Go-delegated subprocess paths in the legacy shell: 1. `LegacyGoProxy.exec`/`execCapture` (`shared/legacy/go-proxy.layer.ts`) called `ProcessControl.exit()` directly on a non-zero child exit or "binary not found". That's a real `process.exit()` — it skips every `Effect.ensuring` finalizer between the call site and `runCli` (telemetry flush, command instrumentation), and loses the child's exact exit code (collapsing everything to whatever `process.exit()` was called with, with no chance for `runCli`'s own exit-code logic to run). 2. Because `execCapture`'s non-zero-exit branch hard-exited the process, it never reached `withJsonErrorHandling`, so a `--output-format json`/`stream-json` `db reset --experimental` failure emitted no structured error envelope at all — the process just died mid-flight. 3. `db reset --experimental --linked` (no resolved version) unconditionally resolved the linked DB connection — including minting/verifying a temporary Postgres login role over the Management API — before checking whether the remaining flow delegates to the Go child. On that branch the resolved connection was never used: the delegated Go child re-runs its own `ParseDatabaseConfig` (and mints its own temp role) once it starts, so the TS-side mint was pure duplicate privileged work. (A fourth item from the same issue — forwarding `--linked=false`'s target selector verbatim to the delegated child — was already correctly implemented and already covered by a passing test; no code change was needed there.) ## What is the new behavior? - New `LegacyGoChildExitError` (`shared/legacy/legacy-go-child-exit.error.ts`) carries a spawned child's exact exit code via Effect's `Runtime.errorExitCode` marker. `LegacyGoProxy.exec`/`execCapture` and the hidden `db __db-bootstrap` seam now fail with this typed error instead of calling `ProcessControl.exit()`, so the failure flows through the normal Effect channel: finalizers run first, then `runCli`/`withJsonErrorHandling` exit with the child's real code — in every output format, including a Ctrl-C mid-recreate (e.g. exit `130`) instead of a generic `1`. - `runCli`'s `handledProgram` special-cases `LegacyGoChildExitError` (by concrete type) to skip its own generic `output.fail` stderr line, since the child already printed its own detailed failure to the inherited stderr and Go itself never prints a second line on top of that. This is deliberately **not** keyed on Effect's shared `Runtime.errorReported` marker — `CliError.ShowHelp` also sets that marker, and gating on it would have silently suppressed the Go-parity `Error: required flag(s) "..." not set` message for every missing-required-flag error. (Caught by architect review before merge; regression test added.) - `withJsonErrorHandling` now reads `Runtime.getErrorExitCode` instead of hardcoding `1` when setting the process exit code, so the exact code propagates under `json`/`stream-json` too, not just text mode. - `db reset --experimental --linked` now checks whether it's about to delegate to the Go child *before* calling `LegacyDbConfigResolver.resolve()`, skipping the redundant temp-role mint on that path. The linked-project-cache finalizer is unaffected (it already reads a separately pre-loaded ref, exactly so this case still works). - Updated `SIDE_EFFECTS.md`'s exit-code tables for `db reset`/`db start` to reflect the child's exact code, and touched-up a couple of stale doc comments describing the old `process.exit()`-based behavior. ### Deliberately left open (judgement calls, not blockers) - The JSON error envelope for a delegated linked+experimental connection/mint failure is now a generic `"supabase-go exited with code N (see stderr for details)"` rather than the specific TS error the old (duplicate-minting) code path used to surface. This is an accepted tradeoff: the real detail is on the inherited stderr, and Go itself has no JSON error-envelope concept to hold this to a parity standard against. - The bootstrap seam's JSON error `code` field changes from `LegacyDbBootstrapError` to `LegacyGoChildExitError` for a non-zero child exit specifically. Nothing in this codebase treats that field as a stable public contract (most `Legacy*Error` tags already leak their raw class name into it), so this wasn't treated as a compatibility break. - The sibling `db __shadow` seam (`legacy-pgdelta.seam.layer.ts`) intentionally keeps its own generic domain error rather than adopting `LegacyGoChildExitError` — its failure is a TS-authored summary over noisy docker/pgdelta stderr (not a passthrough of a real user-facing Go child), and Go itself collapses shadow-DB failures to a generic exit `1`. Left a comment in place explaining the divergence so a future reader doesn't "fix" it into inconsistency. ## Test plan New/updated coverage in `go-proxy.layer.unit.test.ts`, `run.unit.test.ts`, `json-error-handling.unit.test.ts`, `reset.integration.test.ts`, and `start.integration.test.ts` — exact exit-code propagation, finalizers running after a non-zero exit, the JSON error envelope, the skipped pre-delegation resolve (and that the sibling `--db-url` delegate path still resolves), and a regression guard for the `ShowHelp`/`MissingOption` suppression bug caught during review. --- .../legacy/commands/bootstrap/SIDE_EFFECTS.md | 9 +- .../legacy/commands/db/reset/SIDE_EFFECTS.md | 29 +-- .../legacy/commands/db/reset/reset.handler.ts | 82 ++++--- .../db/reset/reset.integration.test.ts | 201 ++++++++++++++++-- .../declarative/declarative.smart-target.ts | 7 +- .../shared/legacy-db-bootstrap.seam.layer.ts | 29 ++- .../legacy-db-bootstrap.seam.service.ts | 10 +- .../db/shared/legacy-pgdelta.seam.layer.ts | 7 + .../legacy/commands/db/start/SIDE_EFFECTS.md | 16 +- .../db/start/start.integration.test.ts | 60 +++++- apps/cli/src/shared/cli/run.ts | 36 +++- apps/cli/src/shared/cli/run.unit.test.ts | 59 ++++- apps/cli/src/shared/legacy/go-proxy.layer.ts | 36 +++- .../shared/legacy/go-proxy.layer.unit.test.ts | 166 +++++++++++---- .../cli/src/shared/legacy/go-proxy.service.ts | 19 +- .../legacy/legacy-go-child-exit.error.ts | 55 +++++ .../src/shared/output/json-error-handling.ts | 10 +- .../output/json-error-handling.unit.test.ts | 20 ++ 18 files changed, 688 insertions(+), 163 deletions(-) create mode 100644 apps/cli/src/shared/legacy/legacy-go-child-exit.error.ts diff --git a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md index 6c5c7cefbe..2a28897a48 100644 --- a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md @@ -99,10 +99,11 @@ Human banners are suppressed; a single structured result is emitted: - **Interim Go-proxy delegation for migration push.** The push step shells out to the bundled Go binary (`db push --include-roles --include-seed`) until `db push` gets its own native port (separate Linear issue). The sub-step is **not** instrumentation-wrapped (the subprocess fires - its own push telemetry). Known divergence: `LegacyGoProxy.exec` propagates the exit code, so Go's - push backoff is **not** reproduced (single attempt) — to be restored when `db push` is natively - ported. (`LegacyGoProxy.exec` exits the process on a non-zero exit rather than returning a - failure, so the step cannot be wrapped in `Effect.retry`.) + its own push telemetry). Known divergence: Go's push backoff is **not** reproduced (single + attempt) — to be restored when `db push` is natively ported. (`LegacyGoProxy.exec` fails with a + typed `LegacyGoChildExitError` on a non-zero exit rather than exiting the process — CLI-1879 — so + the step COULD now be wrapped in `Effect.retry`; leaving that unimplemented here is a deliberate + scope decision for the native `db push` port, not a technical blocker.) - **DB password is forwarded on the same channel the user supplied it (CLI-1617).** The proxy must be called 1:1 with the user's input: a flag stays a flag, an env var stays an env var. So when the user passed `-p/--password`, the push sub-step receives `--password ` (flag → flag); when diff --git a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md index 27a67ad560..0934154974 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -81,18 +81,23 @@ seeded over the Storage gateway (reusing the `seed buckets` local path). ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------- | -| `0` | success | -| `1` | mutually exclusive target flags (`[db-url linked local]`) | -| `1` | `--version` + `--last` together (`[last version]`) | -| `1` | `--version` not an integer (`invalid version number`) | -| `1` | `--version` has no matching migration file | -| `1` | local: database not running (`supabase start is not running.`) | -| `1` | user declined the reset confirmation (`context canceled`) | -| `1` | `config.toml` parse failure | -| `1` | drop / migrate / seed / vault apply failure, or connection error | -| `1` | local: container recreate / storage health-gate failure (seam) | +| Code | Condition | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | mutually exclusive target flags (`[db-url linked local]`) | +| `1` | `--version` + `--last` together (`[last version]`) | +| `1` | `--version` not an integer (`invalid version number`) | +| `1` | `--version` has no matching migration file | +| `1` | local: database not running (`supabase start is not running.`) | +| `1` | user declined the reset confirmation (`context canceled`) | +| `1` | `config.toml` parse failure | +| `1` | drop / migrate / seed / vault apply failure, or connection error | +| child's exact code\* | local: container recreate / storage health-gate failure (seam), or `--experimental`/`--linked` delegate (proxy) child exit | + +\* The `db __db-bootstrap` seam and the `--experimental` remote delegate both +propagate the spawned `supabase-go` child's real exit code (e.g. `130` after a +Ctrl-C mid-recreate) instead of collapsing every failure to `1` — in every +`--output-format` (CLI-1879). ## Output diff --git a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts index 46ecc8236f..f1637df50a 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -227,6 +227,36 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega } const connType = target.connType ?? "local"; + // Single source of truth for "does this reset delegate to the Go child?" — + // checked at both delegation sites below (before `resolve()` for a linked + // target, after it for a `--db-url` target) so the two call sites can never + // drift apart. + const shouldDelegateExperimental = experimental && resolvedVersion === ""; + + // Delegates the remaining `--experimental` schema-files apply path + // (`apply.MigrateAndSeed`, not ported) to the Go child. In text mode inherit + // its stdio. Under a machine-output mode (`--output-format json|stream-json`) + // the Go child emits no TS envelope, so suppress its stdout (capture + discard) + // and emit the same structured success the native local and remote paths do, + // keeping the JSON contract consistent across all reset paths. + const delegateExperimentalReset = () => + Effect.gen(function* () { + const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; + if (output.format === "text") { + yield* proxy.exec(buildResetArgs(flags, connType, yes), { env }); + } else { + // Machine-output mode is non-interactive: give the Go child a non-TTY stdin + // (`stdin: "ignore"`) so it can't block on (or be answered at) Go's + // destructive reset prompt — it takes the default `false`, matching the + // native reset path which suppresses prompts under json/stream-json. + yield* proxy.execCapture(buildResetArgs(flags, connType, yes), { env, stdin: "ignore" }); + yield* output.success("Reset remote database.", { + target: "remote", + version: resolvedVersion, + }); + } + }); + // Go's ParseDatabaseConfig runs LoadProjectRef BEFORE the fallible linked // resolution (db_url.go:87-95), and Execute() writes the linked-project cache // even when a later step errors (root.go:171-181). Pre-load the ref so the @@ -235,7 +265,23 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega if (connType === "linked") { const refResolver = yield* LegacyProjectRefResolver; linkedRefForCache = yield* refResolver.loadProjectRef(Option.none()); + + // A linked target is never local (`resolver.resolve()`'s "linked" branch + // always returns `isLocal: false`), so the delegated-experimental check can + // run BEFORE calling `resolve()`. This matters: for `connType === "linked"`, + // `resolve()` mints/verifies a temporary Postgres login role over the + // Management API — and the delegated Go child re-runs that exact same + // `ParseDatabaseConfig` work itself once delegation happens. Calling + // `resolve()` here would mint the temp role twice for zero downstream use on + // this branch (Go's own reset flow mints it exactly once, as part of the code + // path being delegated to — confirmed against `apps/cli-go/internal/utils/ + // flags/db_url.go`'s `NewDbConfigWithPassword`/`initLoginRole`). CLI-1879. + if (shouldDelegateExperimental) { + yield* delegateExperimentalReset(); + return; + } } + const cfg = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType, dnsResolver }); // Local target → native local reset. The container-recreate primitives live @@ -331,34 +377,20 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega return; } - // Resolve the linked ref before any return so the post-run cache (Go's - // `PersistentPostRun` `ensureProjectGroupsCached`) is written even on the - // delegated `--experimental` path below — the Go child runs with telemetry - // disabled and skips that cache, so the TS finalizer must own it. + // Re-confirm `linkedRefForCache` from the now-resolved `cfg.ref` for the native + // remote linked path below (a linked+experimental+versionless target already + // delegated and returned above, before `resolve()` was ever called — see the + // `connType === "linked"` block earlier in this function). A `connType === + // "db-url"` target leaves `linkedRefForCache` as whatever the pre-load block + // set (nothing, for `db-url`), since this assignment only fires when linked. const linkedRef = Option.getOrUndefined(cfg.ref ?? Option.none()); if (connType === "linked" && linkedRef !== undefined) linkedRefForCache = linkedRef; - // Remote path. The niche `--experimental` schema-files apply path - // (`apply.MigrateAndSeed`) is not ported; delegate it to the Go child. In text - // mode inherit its stdio. Under a machine-output mode (`--output-format - // json|stream-json`) the Go child emits no TS envelope, so suppress its stdout - // (capture + discard) and emit the same structured success the native local and - // remote paths do, keeping the JSON contract consistent across all reset paths. - if (experimental && resolvedVersion === "") { - const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; - if (output.format === "text") { - yield* proxy.exec(buildResetArgs(flags, connType, yes), { env }); - } else { - // Machine-output mode is non-interactive: give the Go child a non-TTY stdin - // (`stdin: "ignore"`) so it can't block on (or be answered at) Go's - // destructive reset prompt — it takes the default `false`, matching the - // native reset path which suppresses prompts under json/stream-json. - yield* proxy.execCapture(buildResetArgs(flags, connType, yes), { env, stdin: "ignore" }); - yield* output.success("Reset remote database.", { - target: "remote", - version: resolvedVersion, - }); - } + // Remaining remote target: a `--db-url` pointing at a non-local host (the + // `connType === "linked"` case already delegated above, before `resolve()`, + // without resolving a connection at all). + if (shouldDelegateExperimental) { + yield* delegateExperimentalReset(); return; } diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts index cc3e1f47a3..48d25519c8 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -3,7 +3,7 @@ import { dirname, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -31,6 +31,7 @@ import { LegacyYesFlag, } from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import type { @@ -69,15 +70,24 @@ const DEFAULT_FLAGS: LegacyDbResetFlags = { last: Option.none(), }; +/** + * Tracks every `resolve`/`resolvePoolerFallback` invocation so tests can prove a + * connection was (or, for the delegated-experimental path, was NOT) resolved — + * `resolve()` mints/verifies a temporary Postgres login role over the Management + * API, so calling it on a path that immediately discards the result is wasted + * (and duplicated) work (CLI-1879). + */ function mockResolver(opts: { isLocal: boolean; ref?: string; omitRef?: boolean; resolveFails?: boolean; }) { - return Layer.succeed(LegacyDbConfigResolver, { - resolve: (_flags: LegacyDbConfigFlags) => - opts.resolveFails === true + let calls = 0; + const layer = Layer.succeed(LegacyDbConfigResolver, { + resolve: (_flags: LegacyDbConfigFlags) => { + calls++; + return opts.resolveFails === true ? Effect.fail( new LegacyDbConfigConnectTempRoleError({ message: "failed to create login role: network error", @@ -91,9 +101,19 @@ function mockResolver(opts: { isLocal: opts.isLocal, ref: opts.ref !== undefined ? Option.some(opts.ref) : Option.none(), }) satisfies LegacyResolvedDbConfig, - ), - resolvePoolerFallback: () => Effect.succeed(Option.none()), + ); + }, + resolvePoolerFallback: () => { + calls++; + return Effect.succeed(Option.none()); + }, }); + return { + layer, + get calls() { + return calls; + }, + }; } function mockConnection(opts: { remoteSeeds?: Readonly> }) { @@ -142,8 +162,15 @@ function mockConnection(opts: { remoteSeeds?: Readonly> } * Stateful mock of the container-bootstrap seam. `running` drives * `AssertSupabaseDbIsRunning`; `storageReady` drives the bucket-seed gate. Records * the recreate args so tests can assert version / `--no-seed` propagation. + * `awaitStorageReadyExitCode`, when set, fails `awaitStorageReady` with a + * `LegacyGoChildExitError` carrying that code — simulating the seam's real + * `captureStdout` bootstrap-child path exiting non-zero (CLI-1879). */ -function mockBootstrapSeam(opts: { running?: boolean; storageReady?: boolean }) { +function mockBootstrapSeam(opts: { + running?: boolean; + storageReady?: boolean; + awaitStorageReadyExitCode?: number; +}) { const recreateCalls: Array<{ version: string; noSeed: boolean; @@ -164,8 +191,18 @@ function mockBootstrapSeam(opts: { running?: boolean; storageReady?: boolean }) awaitStorageReady: () => Effect.sync(() => { storageChecked = true; - return opts.storageReady ?? false; - }), + }).pipe( + Effect.flatMap(() => + opts.awaitStorageReadyExitCode !== undefined + ? Effect.fail( + new LegacyGoChildExitError({ + exitCode: opts.awaitStorageReadyExitCode, + message: `failed to bootstrap the local database: exit ${opts.awaitStorageReadyExitCode}`, + }), + ) + : Effect.succeed(opts.storageReady ?? false), + ), + ), }); return { layer, @@ -188,14 +225,33 @@ const mockStorageHttp = Layer.succeed( ), ); -function mockProxy() { +/** + * `execCaptureExitCode`, when set, makes `execCapture` fail with a + * `LegacyGoChildExitError` carrying that code instead of succeeding — simulating + * a delegated Go child exiting non-zero under a machine-output mode (CLI-1879). + */ +function mockProxy(opts: { execCaptureExitCode?: number } = {}) { const calls: Array<{ args: ReadonlyArray; env?: Record }> = []; const layer = Layer.succeed(LegacyGoProxy, { - exec: (args, opts) => + exec: (args, execOpts) => Effect.sync(() => { - calls.push({ args, env: opts?.env }); + calls.push({ args, env: execOpts?.env }); }), - execCapture: () => Effect.succeed(""), + execCapture: (args, execOpts) => + Effect.sync(() => { + calls.push({ args, env: execOpts?.env }); + }).pipe( + Effect.flatMap(() => + opts.execCaptureExitCode !== undefined + ? Effect.fail( + new LegacyGoChildExitError({ + exitCode: opts.execCaptureExitCode, + message: `supabase-go exited with code ${opts.execCaptureExitCode}`, + }), + ) + : Effect.succeed(""), + ), + ), }); return { layer, @@ -222,6 +278,8 @@ function setup( resolveFails?: boolean; running?: boolean; storageReady?: boolean; + awaitStorageReadyExitCode?: number; + execCaptureExitCode?: number; }, ) { if (opts.toml !== undefined) { @@ -236,25 +294,30 @@ function setup( const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); const conn = mockConnection(opts); - const proxy = mockProxy(); - const seam = mockBootstrapSeam({ running: opts.running, storageReady: opts.storageReady }); + const proxy = mockProxy({ execCaptureExitCode: opts.execCaptureExitCode }); + const seam = mockBootstrapSeam({ + running: opts.running, + storageReady: opts.storageReady, + awaitStorageReadyExitCode: opts.awaitStorageReadyExitCode, + }); const telemetry = mockLegacyTelemetryStateTracked(); const linkedCache = mockLegacyLinkedProjectCacheTracked(); // The local-reset bucket-seed core statically requires the (lazy) Management-API // factory; never invoked on `--local` (projectRef === ""). const platformApi = mockLegacyPlatformApiService({}); + const resolver = mockResolver({ + isLocal: opts.isLocal ?? false, + ref: opts.ref ?? LEGACY_VALID_REF, + omitRef: opts.omitRef, + resolveFails: opts.resolveFails, + }); const layer = Layer.mergeAll( out.layer, conn.layer, proxy.layer, seam.layer, - mockResolver({ - isLocal: opts.isLocal ?? false, - ref: opts.ref ?? LEGACY_VALID_REF, - omitRef: opts.omitRef, - resolveFails: opts.resolveFails, - }), + resolver.layer, mockLegacyCliConfig({ workdir }), BunServices.layer, mockRuntimeInfo(), @@ -284,7 +347,7 @@ function setup( telemetry.layer, linkedCache.layer, ); - return { layer, out, conn, proxy, seam, telemetry, linkedCache }; + return { layer, out, conn, proxy, seam, telemetry, linkedCache, resolver }; } const migrationFile = (version: string, body = "create table t ();") => ({ @@ -863,6 +926,94 @@ describe("legacy db reset", () => { }); }); + it.live("does not resolve a linked DB connection before delegating an experimental reset", () => { + const { layer, proxy, resolver } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + // The delegated Go child re-runs its own connection resolution (including + // minting/verifying the temp login role) once it starts — the TS wrapper + // must not do that same Management-API work first only to discard it (CLI-1879). + expect(resolver.calls).toBe(0); + }); + }); + + it.live("still caches the linked ref when delegating an experimental reset", () => { + // `linkedRefForCache` is pre-loaded via `LegacyProjectRefResolver.loadProjectRef` + // separately from `resolver.resolve()`, specifically so the post-run + // linked-project-cache finalizer still fires on this path even though + // `resolve()` itself is skipped entirely (CLI-1879). + const { layer, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + ref: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); + }); + }); + + it.live( + "surfaces a delegated experimental-reset child failure as a LegacyGoChildExitError under json output", + () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + format: "json", + execCaptureExitCode: 3, + }); + return Effect.gen(function* () { + // Under json/stream-json, the delegated path uses `execCapture` (non-text + // branch of `delegateExperimentalReset`) — this must flow through the normal + // Effect failure channel (reachable by `withJsonErrorHandling` at the + // command-wiring layer) instead of an immediate `ProcessControl.exit()` that a + // handler-level test could never observe (CLI-1879). + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(LegacyGoChildExitError); + expect((error as LegacyGoChildExitError).exitCode).toBe(3); + } + }); + }, + ); + + it.live( + "propagates the storage-ready check's exact exit code and still flushes telemetry on a local reset", + () => { + // The bootstrap seam's `awaitStorageReady` (the `captureStdout` bootstrap-child + // path) failing non-zero must reach the handler as the exact `LegacyGoChildExitError` + // it fails with, and the handler's own `Effect.ensuring(telemetryState.flush)` + // finalizer must still run despite the typed failure (CLI-1879). + const { layer, telemetry } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + awaitStorageReadyExitCode: 4, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(LegacyGoChildExitError); + expect((error as LegacyGoChildExitError).exitCode).toBe(4); + } + expect(telemetry.flushed).toBe(true); + }); + }, + ); + it.live("forwards the linked selector to the delegate even for --linked=false", () => { // Cobra `Changed` semantics: `--linked=false` still selects the linked/remote target in // the parent, so the delegated argv must carry `--linked` — otherwise the Go child falls @@ -962,7 +1113,7 @@ describe("legacy db reset", () => { }); it.live("forwards --db-url and --no-seed on an experimental remote db-url reset", () => { - const { layer, proxy } = setup(tmp.current, { + const { layer, proxy, resolver } = setup(tmp.current, { toml: 'project_id = "test"\n', experimental: true, args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], @@ -981,6 +1132,10 @@ describe("legacy db reset", () => { "--no-seed", "--yes=false", ]); + // Unlike the `connType === "linked"` branch above, a `--db-url` target still + // resolves a connection before delegating — the pre-delegation skip (CLI-1879) + // is scoped to the linked branch only, not "never call resolve when delegating". + expect(resolver.calls).toBe(1); }); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts index 7e6cbab323..77fb087c26 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts @@ -169,9 +169,10 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( } if (shouldReset) { // Go runs reset in-process and returns the error (`cmd/db_schema_declarative.go:262-267`). - // Use the non-exiting seam (not LegacyGoProxy.exec, which process.exits on a - // non-zero child and would skip the handler's telemetry flush / error handling), - // and propagate a failure on a non-zero reset exit. + // `execInherit` (not `LegacyGoProxy.exec`) returns the child's exit code as a + // catchable value rather than exiting the host process — the same + // typed-failure design CLI-1879 gave `LegacyGoProxy.exec` itself, predating + // it here as its own seam. Propagate a failure on a non-zero reset exit. const seam = yield* LegacyDeclarativeSeam; // Forward --network-id: Go's in-process reset.Run honors the root viper // network-id (`apps/cli-go/internal/utils/docker.go:267-271`), so the diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts index bcf5735978..d6b1793166 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts @@ -8,6 +8,7 @@ import { legacyResolveExperimental, } from "../../../../shared/legacy/global-flags.ts"; import { resolveBinary } from "../../../../shared/legacy/go-proxy.layer.ts"; +import { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; import { ProcessControl } from "../../../../shared/runtime/process-control.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; @@ -112,15 +113,19 @@ export const legacyDbBootstrapSeamLayer = Layer.effect( .exitCode(command) .pipe(Effect.mapError(() => seamFailure("failed to run supabase-go."))); if (exitCode !== 0) { - // Fail (rather than `processControl.exit`) so the handler's finalizers — - // `Effect.ensuring(telemetryState.flush)` + the legacy command - // instrumentation — still run; an immediate `process.exit` here would - // skip them. Go likewise exits non-zero on a bootstrap error only after - // its `PersistentPostRun`. The child's detailed failure is already on the - // inherited stderr. (Preserving the child's *exact* exit code while still - // running finalizers would require a shared `runCli` change — deferred.) + // `LegacyGoChildExitError` (not `seamFailure`/`processControl.exit`) so the + // handler's finalizers — `Effect.ensuring(telemetryState.flush)` + the legacy + // command instrumentation — still run (an immediate `process.exit` would skip + // them), AND the child's exact exit code (e.g. 130 after Ctrl-C cleanup) reaches + // `runCli`'s `processControl.exit()` instead of collapsing to a generic 1. The + // child's detailed failure is already on the inherited stderr, so `runCli` + // special-cases this error class to suppress its own normally-would-print + // generic stderr line — Go itself never prints a second line here. CLI-1879. return yield* Effect.fail( - seamFailure(`failed to bootstrap the local database: exit ${exitCode}`), + new LegacyGoChildExitError({ + exitCode, + message: `failed to bootstrap the local database: exit ${exitCode}`, + }), ); } return ""; @@ -138,8 +143,14 @@ export const legacyDbBootstrapSeamLayer = Layer.effect( Effect.mapError(() => seamFailure("failed to bootstrap the local database.")), ); if (exitCode !== 0) { + // See the `!captureStdout` branch above for why `LegacyGoChildExitError` + // replaces `seamFailure` here — same exact-code + finalizer + no-duplicate-line + // reasoning (CLI-1879). return yield* Effect.fail( - seamFailure(`failed to bootstrap the local database: exit ${exitCode}`), + new LegacyGoChildExitError({ + exitCode, + message: `failed to bootstrap the local database: exit ${exitCode}`, + }), ); } return decodeChunks(chunks); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts index 4c29ddb67a..157b290c4b 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts @@ -1,5 +1,6 @@ import { Context, type Effect } from "effect"; +import type { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; import type { LegacyDbBootstrapError } from "./legacy-db-bootstrap.errors.ts"; /** @@ -35,7 +36,7 @@ interface LegacyDbBootstrapSeamShape { */ readonly startDatabase: (opts: { readonly fromBackup?: string; - }) => Effect.Effect; + }) => Effect.Effect; /** * The PG14/PG15 container-recreate half of local `db reset` * (`reset.RecreateLocalDatabase`): recreate the db container/volume, init schema, @@ -51,7 +52,7 @@ interface LegacyDbBootstrapSeamShape { readonly version: string; readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray; - }) => Effect.Effect; + }) => Effect.Effect; /** * The storage health gate local `db reset` runs before seeding buckets * (`reset.AwaitStorageReady`): if the storage container exists but is unhealthy, @@ -59,7 +60,10 @@ interface LegacyDbBootstrapSeamShape { * the caller should run the ported bucket seeding) and `false` when it does not * — matching Go, which silently skips buckets when storage is absent. */ - readonly awaitStorageReady: () => Effect.Effect; + readonly awaitStorageReady: () => Effect.Effect< + boolean, + LegacyDbBootstrapError | LegacyGoChildExitError + >; } export class LegacyDbBootstrapSeam extends Context.Service< diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index 29469069f8..6a52434c89 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -505,6 +505,13 @@ export const legacyDeclarativeSeamLayer = Layer.effect( }), ); +// Intentionally NOT `LegacyGoChildExitError` (contrast `legacy-db-bootstrap.seam.layer.ts`, +// fixed under CLI-1879): this seam's failure is a TS-authored domain summary over noisy +// docker/pgdelta child stderr, not a passthrough of a real Go-CLI child the user invoked +// directly — Go itself wraps every shadow-DB failure into a generic error that `cmd/root.go`'s +// `recoverAndExit` exits `1` for, so propagating THIS child's exact exit code would itself +// diverge from Go, and suppressing this message (as `LegacyGoChildExitError` does for its own, +// already-detailed child stderr) would drop the only actionable line the user sees. const failure = (exitCode?: number) => new LegacyDeclarativeShadowDbError({ message: diff --git a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md index dcf8466552..dacbedff73 100644 --- a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md @@ -49,12 +49,16 @@ fresh PG15 volume; that is internal to the seam, not the TS handler.) ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------- | -| `0` | success — database started, or already running | -| `1` | malformed `supabase/config.toml` | -| `1` | Docker daemon unreachable / inspect failure | -| `1` | container bootstrap failed (the seam cleans up via `DockerRemoveAll`) | +| Code | Condition | +| -------------------- | --------------------------------------------------------------------- | +| `0` | success — database started, or already running | +| `1` | malformed `supabase/config.toml` | +| `1` | Docker daemon unreachable / inspect failure | +| child's exact code\* | container bootstrap failed (the seam cleans up via `DockerRemoveAll`) | + +\* The `db __db-bootstrap` seam propagates the spawned `supabase-go` child's +real exit code (e.g. `130` after a Ctrl-C mid-bootstrap) instead of collapsing +every failure to `1` — in every `--output-format` (CLI-1879). ## Output diff --git a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts index bc560dd44e..e53edc5f2b 100644 --- a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; import { @@ -11,6 +11,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; import { LegacyDbBootstrapError } from "../shared/legacy-db-bootstrap.errors.ts"; import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; @@ -22,22 +23,40 @@ const DEFAULT_FLAGS: LegacyDbStartFlags = { fromBackup: Option.none() }; /** * Stateful mock of the container-bootstrap seam. `running` drives * `AssertSupabaseDbIsRunning`; `runningFails` / `startFails` make the respective - * call fail (Docker daemon down / StartDatabase error). Records the args passed to - * `startDatabase`. + * call fail (Docker daemon down / StartDatabase error). `startExitCode`, when set, + * fails `startDatabase` with a `LegacyGoChildExitError` carrying that code instead — + * simulating the seam's real bootstrap child (`db __db-bootstrap --mode start`) + * exiting non-zero (CLI-1879). Records the args passed to `startDatabase`. */ -function mockSeam(opts: { running?: boolean; runningFails?: boolean; startFails?: boolean } = {}) { +function mockSeam( + opts: { + running?: boolean; + runningFails?: boolean; + startFails?: boolean; + startExitCode?: number; + } = {}, +) { const startCalls: Array<{ fromBackup?: string }> = []; const layer = Layer.succeed(LegacyDbBootstrapSeam, { isDbRunning: () => opts.runningFails === true ? Effect.fail(new LegacyDbBootstrapError({ message: "failed to inspect service" })) : Effect.succeed(opts.running ?? false), - startDatabase: (args: { fromBackup?: string }) => - opts.startFails === true + startDatabase: (args: { fromBackup?: string }) => { + if (opts.startExitCode !== undefined) { + return Effect.fail( + new LegacyGoChildExitError({ + exitCode: opts.startExitCode, + message: `failed to bootstrap the local database: exit ${opts.startExitCode}`, + }), + ); + } + return opts.startFails === true ? Effect.fail(new LegacyDbBootstrapError({ message: "failed to bootstrap" })) : Effect.sync(() => { startCalls.push(args); - }), + }); + }, recreateDatabase: () => Effect.void, awaitStorageReady: () => Effect.succeed(false), }); @@ -57,6 +76,7 @@ function setup( running?: boolean; runningFails?: boolean; startFails?: boolean; + startExitCode?: number; /** Caller cwd (Go's `CurrentDirAbs`) for relative `--from-backup` resolution. */ cwd?: string; }, @@ -211,6 +231,32 @@ describe("legacy db start", () => { }); }); + it.live( + "propagates the bootstrap child's exact exit code as LegacyGoChildExitError and still flushes telemetry", + () => { + // The bootstrap seam's `startDatabase` (the `!captureStdout` bootstrap-child + // path) failing non-zero must reach the handler as the exact `LegacyGoChildExitError` + // it fails with — not a generic `LegacyDbBootstrapError` collapsing every exit code to + // 1 — and the handler's own `Effect.ensuring(telemetryState.flush)` finalizer must + // still run despite the typed failure (CLI-1879). + const { layer, telemetry } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: false, + startExitCode: 3, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(LegacyGoChildExitError); + expect((error as LegacyGoChildExitError).exitCode).toBe(3); + } + expect(telemetry.flushed).toBe(true); + }); + }, + ); + it.live("emits a json result when the database is already running", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 48ab86fa05..b0db350a35 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -11,6 +11,7 @@ import { outputLayerFor } from "../output/output.layer.ts"; import { normalizeCause } from "../output/normalize-error.ts"; import type { OutputFormat } from "../output/types.ts"; import { Output } from "../output/output.service.ts"; +import { LegacyGoChildExitError } from "../legacy/legacy-go-child-exit.error.ts"; import { cliConfigLayer } from "../../next/config/cli-config.layer.ts"; import { projectHomeLayer } from "../../next/config/project-home.layer.ts"; import { ProjectLocalServiceVersions } from "../../next/config/project-local-service-versions.service.ts"; @@ -115,6 +116,27 @@ export function exitCodeForFailure(cause: Cause.Cause): number { return Runtime.getErrorExitCode(Cause.squash(cause)); } +/** + * Whether `handledProgram` should render its generic `output.fail` stderr line + * for a failed run, given the run's cause and the exit code `exitCodeForFailure` + * already computed for it. False for a clean exit (`0`), an interrupt (`130`), + * and a `LegacyGoChildExitError` (CLI-1879) — a delegated Go child already wrote + * its own detailed failure to the inherited stderr, so a second generic line + * here would be a line Go itself never prints. + * + * Checked by concrete type, NOT Effect's shared `[Runtime.errorReported]` + * marker: `CliError.ShowHelp` also sets that marker to `false`, for an + * unrelated reason (the CLI framework already rendered help/usage text) — + * gating on the marker would ALSO suppress `normalizeCause`'s Go-parity + * rendering for a `MissingOption` wrapped in `ShowHelp` (e.g. `Error: required + * flag(s) "type" not set`), a real parity regression. See the test suite for + * the regression this guards. + */ +export function shouldReportFailure(cause: Cause.Cause, exitCode: number): boolean { + if (exitCode === 0 || exitCode === 130) return false; + return !(Cause.squash(cause) instanceof LegacyGoChildExitError); +} + function projectContextLayerFor(runtimeLayer: Layer.Layer) { return projectContextLayer.pipe(Layer.provide(runtimeLayer), Layer.provide(BunServices.layer)); } @@ -242,13 +264,13 @@ export async function runCli(rootCommand: Command.Command.Any, options: RunCliOp const exit = yield* program.pipe(Effect.exit); if (Exit.isFailure(exit)) { const exitCode = exitCodeForFailure(exit.cause); - // Skip reporting for an interrupted run (130 — a signal, not a - // reportable error) and for a clean `ShowHelp` failure (0). Literal - // `--help` never reaches this branch — it's handled as a successful - // `GlobalFlag.Action` and exits 0 via the success path below. See - // `exitCodeForFailure` for why a "clean" ShowHelp failure (e.g. a bare - // group command with no subcommand) also maps to exit 0. - if (exitCode !== 0 && exitCode !== 130) { + // See `shouldReportFailure` for the reporting rules (and why they're + // NOT keyed on Effect's shared `[Runtime.errorReported]` marker). + // Literal `--help` never reaches this branch — it's handled as a + // successful `GlobalFlag.Action` and exits 0 via the success path + // below. See `exitCodeForFailure` for why a "clean" ShowHelp failure + // (e.g. a bare group command with no subcommand) also maps to exit 0. + if (shouldReportFailure(exit.cause, exitCode)) { yield* output.fail(normalizeCause(exit.cause)); } return yield* processControl.exit(exitCode); diff --git a/apps/cli/src/shared/cli/run.unit.test.ts b/apps/cli/src/shared/cli/run.unit.test.ts index f87d160533..290c9e9557 100644 --- a/apps/cli/src/shared/cli/run.unit.test.ts +++ b/apps/cli/src/shared/cli/run.unit.test.ts @@ -2,7 +2,13 @@ import { Cause } from "effect"; import { CliError } from "effect/unstable/cli"; import { describe, expect, it } from "vitest"; -import { exitCodeForFailure, extractCommandPath, shouldUseGlobalSignalInterrupt } from "./run.ts"; +import { LegacyGoChildExitError } from "../legacy/legacy-go-child-exit.error.ts"; +import { + exitCodeForFailure, + extractCommandPath, + shouldReportFailure, + shouldUseGlobalSignalInterrupt, +} from "./run.ts"; describe("extractCommandPath", () => { it("returns positional command-path tokens", () => { @@ -87,4 +93,55 @@ describe("exitCodeForFailure", () => { it("exits 130 when interrupted, regardless of any other failure reason", () => { expect(exitCodeForFailure(Cause.interrupt())).toBe(130); }); + + // CLI-1879: a delegated Go child's exact exit code (not just a generic 1) + // must reach the user, via the `LegacyGoChildExitError`'s + // `[Runtime.errorExitCode]` marker. + it("exits with a LegacyGoChildExitError's exact exit code", () => { + const cause = Cause.fail( + new LegacyGoChildExitError({ exitCode: 130, message: "supabase-go exited with code 130" }), + ); + expect(exitCodeForFailure(cause)).toBe(130); + }); +}); + +describe("shouldReportFailure", () => { + it("does not report a clean exit (0)", () => { + expect(shouldReportFailure(Cause.fail(new Error("unused")), 0)).toBe(false); + }); + + it("does not report an interrupt (130)", () => { + expect(shouldReportFailure(Cause.interrupt(), 130)).toBe(false); + }); + + // CLI-1879: the child already wrote its own detailed failure to the + // inherited stderr, so `runCli`'s generic line would be a duplicate Go + // itself never prints. + it("does not report a LegacyGoChildExitError", () => { + const cause = Cause.fail( + new LegacyGoChildExitError({ exitCode: 1, message: "supabase-go exited with code 1" }), + ); + expect(shouldReportFailure(cause, 1)).toBe(false); + }); + + it("reports a non-ShowHelp failure", () => { + expect(shouldReportFailure(Cause.fail(new Error("boom")), 1)).toBe(true); + }); + + // Regression guard: `CliError.ShowHelp` ALSO sets Effect's shared + // `[Runtime.errorReported]` marker to `false` (for an unrelated reason — the + // CLI framework already rendered help/usage text). `shouldReportFailure` + // must NOT key on that shared marker, or it would also suppress + // `normalizeCause`'s Go-parity rendering for a `MissingOption` wrapped in + // `ShowHelp` (e.g. `Error: required flag(s) "type" not set`) — silently + // dropping that message for every command with a required flag. + it("still reports a ShowHelp failure carrying a genuine validation error (e.g. a missing required flag)", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ + commandPath: ["sso", "add"], + errors: [new CliError.MissingOption({ option: "--type" })], + }), + ); + expect(shouldReportFailure(cause, 1)).toBe(true); + }); }); diff --git a/apps/cli/src/shared/legacy/go-proxy.layer.ts b/apps/cli/src/shared/legacy/go-proxy.layer.ts index b190d9f109..d359113450 100644 --- a/apps/cli/src/shared/legacy/go-proxy.layer.ts +++ b/apps/cli/src/shared/legacy/go-proxy.layer.ts @@ -8,6 +8,7 @@ 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 { LegacyGoChildExitError } from "./legacy-go-child-exit.error.ts"; import { LegacyGoProxy } from "./go-proxy.service.ts"; // --------------------------------------------------------------------------- @@ -139,8 +140,8 @@ export function makeGoProxyLayer(opts?: { * 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. + * - `{ notFound: [...] }` — simulate the not-found path; `.exec` will print + * the diagnostic and fail with a non-zero exit code. * * In production, leave unset and let `resolveBinary()` pick the right * artifact for the host platform. @@ -166,12 +167,16 @@ export function makeGoProxyLayer(opts?: { // 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. + // Print a specific diagnostic and fail non-zero instead. yield* Effect.sync(() => { process.stderr.write(`${formatGoBinaryNotFoundError(resolved.notFound)}\n`); }); - yield* processControl.exit(1); - return; + return yield* Effect.fail( + new LegacyGoChildExitError({ + exitCode: 1, + message: "supabase-go binary not found", + }), + ); } const binary = resolved.found; @@ -212,7 +217,12 @@ export function makeGoProxyLayer(opts?: { }); const exitCode = yield* spawner.exitCode(command).pipe(Effect.orDie); if (exitCode !== 0) { - yield* processControl.exit(exitCode); + return yield* Effect.fail( + new LegacyGoChildExitError({ + exitCode, + message: `supabase-go exited with code ${exitCode} (see stderr for details)`, + }), + ); } }), ), @@ -223,7 +233,12 @@ export function makeGoProxyLayer(opts?: { yield* Effect.sync(() => { process.stderr.write(`${formatGoBinaryNotFoundError(resolved.notFound)}\n`); }); - return yield* processControl.exit(1); + return yield* Effect.fail( + new LegacyGoChildExitError({ + exitCode: 1, + message: "supabase-go binary not found", + }), + ); } const binary = resolved.found; yield* processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]); @@ -252,7 +267,12 @@ export function makeGoProxyLayer(opts?: { ); const exitCode = yield* handle.exitCode.pipe(Effect.orDie); if (exitCode !== 0) { - return yield* processControl.exit(exitCode); + return yield* Effect.fail( + new LegacyGoChildExitError({ + exitCode, + message: `supabase-go exited with code ${exitCode} (see stderr for details)`, + }), + ); } return captured; }), 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 8fc4713d7b..7a305197a8 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,7 +1,8 @@ import { describe, expect, it, vi } from "@effect/vitest"; -import { Deferred, Effect, Fiber, Layer, Sink, Stream } from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { type CliProcessSignal, ProcessControl } from "../runtime/process-control.service.ts"; +import { LegacyGoChildExitError } from "./legacy-go-child-exit.error.ts"; import { LegacyGoProxy } from "./go-proxy.service.ts"; import { formatGoBinaryNotFoundError, makeGoProxyLayer } from "./go-proxy.layer.ts"; @@ -54,12 +55,14 @@ type HoldEvent = * event log. Each acquire gets a monotonically increasing id so tests can * pair an acquire with its release and distinguish concurrent scopes. * - * `exitBehavior`: - * - "never" → exit() blocks on Effect.never (test manages the fiber) - * - "terminateDie" → exit() dies with a tagged defect so callers can - * observe via Effect.exit without juggling fibers + * The layer under test no longer calls `ProcessControl.exit()` itself on a + * non-zero exit or an unresolved binary (CLI-1879 routes both through + * `LegacyGoChildExitError` instead, so `runCli` can run finalizers before + * exiting) — `exit()` here only guards against a future regression that + * reintroduces a direct call; it blocks on `Effect.never` since nothing in + * this file exercises it. */ -function mockProcessControl(opts: { exitBehavior?: "never" | "terminateDie" } = {}) { +function mockProcessControl() { const holdEvents: HoldEvent[] = []; const exitCalls: number[] = []; let nextHoldId = 0; @@ -67,11 +70,7 @@ function mockProcessControl(opts: { exitBehavior?: "never" | "terminateDie" } = const exit = (code: number) => Effect.sync(() => { exitCalls.push(code); - }).pipe( - Effect.flatMap(() => - opts.exitBehavior === "terminateDie" ? Effect.die("EXIT_CALLED" as const) : Effect.never, - ), - ); + }).pipe(Effect.flatMap(() => Effect.never)); return { get holdEvents() { @@ -284,18 +283,52 @@ describe("makeGoProxyLayer", () => { }).pipe(Effect.provide(layer)); }); - it.effect("propagates non-zero exit codes via ProcessControl.exit", () => { + it.effect("propagates non-zero exit codes via LegacyGoChildExitError", () => { const spawner = mockSpawner({ kind: "success", code: 7 }); - // Use the terminating exit variant so we can observe via Effect.exit - // without juggling forked fibers around Effect.never. - const pc = mockProcessControl({ exitBehavior: "terminateDie" }); + const pc = mockProcessControl(); const layer = makeGoProxyLayer({ binary: TEST_BINARY }).pipe( Layer.provide(Layer.mergeAll(spawner.layer, pc.layer)), ); return Effect.gen(function* () { const proxy = yield* LegacyGoProxy; - yield* proxy.exec(["some", "command"]).pipe(Effect.exit); - expect(pc.exitCalls).toEqual([7]); + const exit = yield* proxy.exec(["some", "command"]).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(LegacyGoChildExitError); + expect((error as LegacyGoChildExitError).exitCode).toBe(7); + } + // The layer itself never calls `ProcessControl.exit` — that's now + // `runCli`'s job, after finalizers have run. + expect(pc.exitCalls).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lets an Effect.ensuring finalizer run after a non-zero exit (CLI-1879)", () => { + // The whole point of routing a non-zero exit through `LegacyGoChildExitError` + // instead of `ProcessControl.exit()` (a real `process.exit()` in production): + // a caller's own `Effect.ensuring` finalizer — e.g. a handler's + // `Effect.ensuring(telemetryState.flush)` — must still run. Under the old + // `processControl.exit()`-based implementation this finalizer would never fire + // (production: the process would already be dead; this mock's `exit()` blocks + // forever on `Effect.never`, so the fiber never reaches `Effect.exit` either). + const spawner = mockSpawner({ kind: "success", code: 5 }); + const pc = mockProcessControl(); + const layer = makeGoProxyLayer({ binary: TEST_BINARY }).pipe( + Layer.provide(Layer.mergeAll(spawner.layer, pc.layer)), + ); + let finalizerRan = false; + return Effect.gen(function* () { + const proxy = yield* LegacyGoProxy; + yield* proxy.exec(["some", "command"]).pipe( + Effect.ensuring( + Effect.sync(() => { + finalizerRan = true; + }), + ), + Effect.exit, + ); + expect(finalizerRan).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -401,36 +434,75 @@ describe("makeGoProxyLayer", () => { // 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)); - }); + // refuse to spawn anything and surface a specific diagnostic + a + // `LegacyGoChildExitError` carrying exit code 1. + it.effect( + "prints a diagnostic and fails with exit code 1 when supabase-go cannot be resolved", + () => { + const spawner = mockSpawner({ kind: "success", code: 0 }); + const pc = mockProcessControl(); + 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; + const exit = 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); + // Failed with a LegacyGoChildExitError carrying exit code 1, rather than + // calling `ProcessControl.exit` directly — that's now `runCli`'s job. + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(LegacyGoChildExitError); + expect((error as LegacyGoChildExitError).exitCode).toBe(1); + } + expect(pc.exitCalls).toEqual([]); + // 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( + "execCapture also prints a diagnostic and fails with exit code 1 when supabase-go cannot be resolved", + () => { + const spawner = mockSpawner({ kind: "success", code: 0 }); + const pc = mockProcessControl(); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const tried = ["$SUPABASE_GO_BINARY (unset)"]; + const layer = makeGoProxyLayer({ binary: { notFound: tried } }).pipe( + Layer.provide(Layer.mergeAll(spawner.layer, pc.layer)), + ); + return Effect.gen(function* () { + const proxy = yield* LegacyGoProxy; + const exit = yield* proxy.execCapture(["db", "dump"]).pipe(Effect.exit); + + expect(spawner.spawned).toHaveLength(0); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(LegacyGoChildExitError); + expect((error as LegacyGoChildExitError).exitCode).toBe(1); + } + expect(pc.exitCalls).toEqual([]); + expect(stderr).toHaveBeenCalledTimes(1); + 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 }); diff --git a/apps/cli/src/shared/legacy/go-proxy.service.ts b/apps/cli/src/shared/legacy/go-proxy.service.ts index e9539e75a4..6ea6a50eb4 100644 --- a/apps/cli/src/shared/legacy/go-proxy.service.ts +++ b/apps/cli/src/shared/legacy/go-proxy.service.ts @@ -1,11 +1,15 @@ import type { Effect } from "effect"; import { Context } from "effect"; +import type { LegacyGoChildExitError } from "./legacy-go-child-exit.error.ts"; interface LegacyGoProxyShape { /** * Forward the given args to the Go binary, inheriting stdin/stdout/stderr - * and propagating the exit code. On a non-zero exit the process exits with - * the same code — callers do not need to handle the failure case. + * and propagating the exit code. On a non-zero exit (or when the binary + * cannot be resolved at all), fails with `LegacyGoChildExitError` carrying + * the child's exact exit code; callers don't need to special-case it — it + * flows through the normal Effect failure channel up to `runCli`, which + * maps it to the real process exit code after running any finalizers. * * `opts.cwd` overrides the working directory for this call (falls back to the * layer's construction-time cwd). `opts.env` overlays extra environment @@ -17,13 +21,16 @@ interface LegacyGoProxyShape { readonly exec: ( args: ReadonlyArray, opts?: { readonly cwd?: string; readonly env?: Record }, - ) => Effect.Effect; + ) => Effect.Effect; /** * Like `exec`, but captures the child's stdout and returns it as a string * instead of inheriting stdout. stderr is still inherited (so progress / - * diagnostics pass straight through), and a non-zero exit still terminates the - * process with the same code. + * diagnostics pass straight through). On a non-zero exit (or when the binary + * cannot be resolved at all), fails with `LegacyGoChildExitError` carrying + * the child's exact exit code; callers don't need to special-case it — it + * flows through the normal Effect failure channel up to `runCli`, which + * maps it to the real process exit code after running any finalizers. * * `opts.stdin` controls the child's stdin: `"inherit"` (default) keeps the * child interactive (its prompts reach the terminal); `"ignore"` gives it a @@ -43,7 +50,7 @@ interface LegacyGoProxyShape { readonly env?: Record; readonly stdin?: "inherit" | "ignore"; }, - ) => Effect.Effect; + ) => Effect.Effect; } export class LegacyGoProxy extends Context.Service()( diff --git a/apps/cli/src/shared/legacy/legacy-go-child-exit.error.ts b/apps/cli/src/shared/legacy/legacy-go-child-exit.error.ts new file mode 100644 index 0000000000..d2b7f22ef3 --- /dev/null +++ b/apps/cli/src/shared/legacy/legacy-go-child-exit.error.ts @@ -0,0 +1,55 @@ +import { Data, Runtime } from "effect"; + +/** + * A spawned `supabase-go` child process — via `LegacyGoProxy.exec`/`execCapture`, + * or the hidden `db __db-bootstrap` seam (`legacy-db-bootstrap.seam.layer.ts`) — + * exited non-zero, or could not be spawned at all (binary not found). + * + * Carries the child's exact exit code through Effect's `Runtime.errorExitCode` + * marker, so both `runCli` (`shared/cli/run.ts`, text mode) and + * `withJsonErrorHandling` (`shared/output/json-error-handling.ts`, `json`/ + * `stream-json` mode) map the process's own exit code to this EXACT number — + * not a generic `1` — and only AFTER every `Effect.ensuring` finalizer between + * the call site and there has already run (telemetry flush, command + * instrumentation). Calling `ProcessControl.exit()` directly from deep inside a + * handler skips those finalizers entirely (`process.exit()` halts the process + * before the Effect runtime can unwind the remaining scopes) — this error type + * lets the child's status flow through the normal Effect failure channel + * instead, all the way up to the single `ProcessControl.exit()` call `runCli` + * itself makes once finalizers are done. See CLI-1879. + * + * `runCli`'s `handledProgram` special-cases this exact class (an `instanceof` + * check, not a shared Effect marker) to skip its generic `output.fail` stderr + * line in text mode — the child already wrote its own detailed failure (or, + * for the not-found case, `LegacyGoProxy`'s own specific diagnostic) to the + * parent's inherited stderr, and Go itself never prints a second, generic line + * on top of that. This is deliberately NOT keyed on Effect's shared + * `[Runtime.errorReported]` marker: `CliError.ShowHelp` also sets that marker + * to `false` for an unrelated reason (the CLI framework already rendered + * help/usage text), and gating on the marker there would ALSO suppress + * `normalizeCause`'s Go-parity rendering for a `MissingOption` wrapped in + * `ShowHelp` (e.g. `Error: required flag(s) "type" not set`) — a real parity + * regression. `withJsonErrorHandling` has no such collision (it runs upstream + * of `runCli`, catching every error uniformly) and still emits the structured + * JSON error envelope for this error like any other. + * + * The envelope's `message` is deliberately generic (`"supabase-go exited with + * code N (see stderr for details)"`) rather than the child's specific failure + * reason: the child's real detail is on stderr (see above), which a + * machine-output consumer reading only stdout won't see — this is an accepted, + * TS-only tradeoff (Go itself has no JSON error-envelope concept to match + * against here), not a parity gap. + * + * Invariant: `exitCode` must be a real non-zero child exit status (1-255, + * matching `ChildProcessSpawner.ExitCode`'s POSIX range), never `0` — every + * construction site guards on `exitCode !== 0` (or hardcodes `1` for the + * binary-not-found case) before constructing this error, since a `0` here + * would be a failure that both `runCli` and `withJsonErrorHandling` read back + * as a *successful* exit. + */ +export class LegacyGoChildExitError extends Data.TaggedError("LegacyGoChildExitError")<{ + readonly exitCode: number; + readonly message: string; +}> { + override readonly [Runtime.errorExitCode] = this.exitCode; +} diff --git a/apps/cli/src/shared/output/json-error-handling.ts b/apps/cli/src/shared/output/json-error-handling.ts index 756077692f..2a886e8cf8 100644 --- a/apps/cli/src/shared/output/json-error-handling.ts +++ b/apps/cli/src/shared/output/json-error-handling.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, Runtime } from "effect"; import { Output } from "./output.service.ts"; import { ProcessControl } from "../runtime/process-control.service.ts"; import { normalizeCliError } from "./normalize-error.ts"; @@ -13,7 +13,13 @@ export const withJsonErrorHandling = ( const processControl = yield* ProcessControl; if (output.format === "text") return yield* Effect.fail(error); yield* output.fail(normalizeCliError(error)); - yield* processControl.setExitCode(1); + // `Runtime.getErrorExitCode` defaults to 1 for any error without a + // `[Runtime.errorExitCode]` marker, so this is a no-op for every existing + // error type. It only changes behavior for an error that opts in — e.g. + // `LegacyGoChildExitError` (CLI-1879), so a delegated Go child's exact exit + // code (not just a generic 1) still reaches the user under json/stream-json, + // matching the exit code `runCli`'s text-mode path already propagates. + yield* processControl.setExitCode(Runtime.getErrorExitCode(error)); }), ), ); diff --git a/apps/cli/src/shared/output/json-error-handling.unit.test.ts b/apps/cli/src/shared/output/json-error-handling.unit.test.ts index e763b8df19..29e7ec90c8 100644 --- a/apps/cli/src/shared/output/json-error-handling.unit.test.ts +++ b/apps/cli/src/shared/output/json-error-handling.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Data, Effect, Exit, Layer, Option } from "effect"; import { mockProcessControl } from "../../../tests/helpers/mocks.ts"; +import { LegacyGoChildExitError } from "../legacy/legacy-go-child-exit.error.ts"; import { Output } from "./output.service.ts"; import { withJsonErrorHandling } from "./json-error-handling.ts"; @@ -181,5 +182,24 @@ describe("withJsonErrorHandling", () => { expect(out.failCalls[0]?.message).toBe("plain error message"); }).pipe(Effect.provide(out.layer), Effect.provide(processControl.layer)); }); + + // CLI-1879: a delegated Go child's exact exit code must reach the user under + // json/stream-json too, not just a generic 1 — matching the exit code + // `runCli`'s text-mode path already propagates via the same + // `[Runtime.errorExitCode]` marker. + it.live("sets the exact exit code for a LegacyGoChildExitError, not a generic 1", () => { + const out = mockOutput("json"); + const processControl = mockProcessControl(); + return Effect.gen(function* () { + const error = new LegacyGoChildExitError({ + exitCode: 130, + message: "supabase-go exited with code 130 (see stderr for details)", + }); + yield* withJsonErrorHandling(Effect.fail(error)).pipe(Effect.provide(out.layer)); + expect(out.failCalls).toHaveLength(1); + expect(out.failCalls[0]?.code).toBe("LegacyGoChildExitError"); + expect(processControl.exitCode).toBe(130); + }).pipe(Effect.provide(out.layer), Effect.provide(processControl.layer)); + }); }); }); From 1bd4085cfc04129c32c139d5ba112aac6f0a87dd Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 9 Jul 2026 14:34:14 +0100 Subject: [PATCH 34/49] fix(cli): legacy shell honors project .env for SUPABASE_YES + auth.enabled remote precedence (CLI-1878) (#5839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Closes the remaining gaps in [CLI-1878](https://linear.app/supabase/issue/CLI-1878/legacy-shell-full-viper-env-override-semantics-project-env-remote): full viper env-override semantics (project `.env`, remote precedence, malformed bools) in the TS legacy shell. A `go-parity-auditor` pass determined most of the issue's original claims had already been fixed by an earlier PR (#5715) — project-`.env`-aware `SUPABASE_YES`/`SUPABASE_EXPERIMENTAL` resolvers, remote-config precedence, malformed-bool-fails-the-load, explicit-flag-beats-env, and case-agnostic `env(...)` resolution all already exist for `db push`/`db reset`/`db pull`/`config push`/`migration down`/`repair`/declarative-schema. This PR closes the **5 concrete gaps** that pass left open: 1. **`gen signing-key`** — the overwrite-confirmation prompt only consulted the shell env for `SUPABASE_YES`. Go's `flags.LoadConfig` loads the project `.env` before the prompt (`signingkeys.go:99,130`). 2. **`storage rm`** — same gap for the delete-confirmation prompt (both the `--local` and default `--linked` branches of Go's `ParseDatabaseConfig` load the project `.env` first). 3. **`migration fetch`** — same gap for the migrations-dir overwrite prompt (defaults to `--linked`, same `ParseDatabaseConfig` path). 4. **standalone `seed buckets`** — its fallback `yes` resolution (used when `db reset` doesn't pass a pre-resolved value) had the same gap. `db reset`'s own passthrough was already correct. 5. **`[remotes.*].auth.enabled` remote precedence** — this key was missing from `LEGACY_ENV_OVERRIDABLE_KEYS`, so a linked remote's `auth.enabled` TOML value could lose to a `SUPABASE_AUTH_ENABLED` env var instead of winning, unlike every other allowlisted key (Go's `mergeRemoteConfig` applies the whole matched block above `AutomaticEnv`). Each of the 4 handler fixes follows the existing `legacyLoadProjectEnv` + `legacyResolveYesWithProjectEnv` pattern already used by `db push`/`config push`/`migration down`/`repair`. ### Fixed along the way - `migration fetch`'s new project-`.env` load initially ran *before* the `[db-url linked local]` flag-conflict check — an ordering regression relative to Go and sibling `migration down`/`repair` (caught by `architect-reviewer`). Reordered so the flag check runs first; added a regression test that fails without the fix. ## Behavior changes to be aware of - `gen signing-key`, `storage rm`, `migration fetch`, and `seed buckets` now honor `SUPABASE_YES` set only in `supabase/.env`/`.env.local`/`.env.[.local]` (previously they only saw the shell env). This is a pure Go-parity fix, but on an earlier build of this TS legacy shell it was inert — a stale `SUPABASE_YES=true` left in a project's `.env` will now silently auto-confirm these destructive prompts. - A linked project's `[remotes.].auth.enabled` TOML value now correctly beats a `SUPABASE_AUTH_ENABLED` env var (previously the reverse). If anything relied on the env var overriding a remote block's explicit `auth.enabled`, that no longer happens. ## Test plan - `bun run test:core` — all unit + integration tests green (300 tests across the touched files, full suite unaffected). - `bun run check:all` — types/lint/fmt/knip clean. - New regression tests (integration, per this workspace's testing pyramid): - `signing-key.integration.test.ts` — project-`.env` `SUPABASE_YES` auto-confirms the overwrite even with a piped `n` (defensively clears any leaked shell `SUPABASE_YES` first). - `rm.integration.test.ts` — same, for the delete confirmation. - `buckets.integration.test.ts` — same, for the standalone `seed buckets` overwrite prompt. - `fetch.integration.test.ts` — same, for the migrations-dir overwrite prompt; plus a new test locking in the flag-conflict-before-env-read ordering fix (verified it fails without the reorder). - `legacy-db-config.toml-read.unit.test.ts` — 2 new unit tests for `auth.enabled`: a matched remote block beats the env var, and a control case where the env var still wins when the block omits the key. - Relocated one pre-existing test's fixture (`fetch.integration.test.ts`, "reports a write failure"): the file-collision now lives at `/supabase/migrations` instead of `/supabase` itself, since the latter would break the new project-`.env` read before ever reaching the `mkdir` under test. Verified this preserves the original test's coverage. ## Judgement calls deliberately left open - **`[y/N] y` echo doesn't say *why* it auto-confirmed.** `supabase-dx-reviewer` flagged that the prompt echo is byte-identical whether the answer came from `--yes`, the shell env, or a forgotten project `.env` value — but explicitly recommended *not* annotating it, since that would diverge from Go's byte-identical `console.PromptYesNo` output (this is a strict 1:1 port). Not changed here; a source annotation would need to land in the Go CLI first if ever desired. - **DB-bootstrap seam explicit `--experimental=false` forwarding.** `go-parity-auditor` flagged (but could not fully confirm, since it didn't trace the hidden `db __db-bootstrap` subprocess) that `legacy-db-bootstrap.seam.layer.ts` forwards `--experimental` to the Go child only when true, never an explicit false — for `db reset --experimental=false` with `SUPABASE_EXPERIMENTAL=true` inherited by the child, the child might re-resolve `true` independently. Flagged as a caveat, not a confirmed gap; out of scope here. - **`storage.enabled`/`realtime.enabled` don't read any `SUPABASE_*` env override at all** in `legacy-db-config.toml-read.ts`, unlike `auth.enabled`. This is a latent divergence noted by `go-parity-auditor` but is outside CLI-1878's 5 named items; left as a separate follow-up. 🤖 Generated with the `issue-autopilot` skill. --- .../commands/gen/signing-key/SIDE_EFFECTS.md | 19 +++--- .../gen/signing-key/signing-key.handler.ts | 13 +++- .../signing-key.integration.test.ts | 67 +++++++++++++++++++ .../commands/migration/fetch/SIDE_EFFECTS.md | 19 +++--- .../commands/migration/fetch/fetch.handler.ts | 18 ++++- .../migration/fetch/fetch.integration.test.ts | 62 ++++++++++++++++- .../commands/seed/buckets/SIDE_EFFECTS.md | 14 ++-- .../seed/buckets/buckets.integration.test.ts | 31 +++++++++ .../commands/storage/rm/SIDE_EFFECTS.md | 17 +++-- .../legacy/commands/storage/rm/rm.handler.ts | 22 ++++-- .../storage/rm/rm.integration.test.ts | 51 ++++++++++++++ .../shared/legacy-db-config.toml-read.ts | 8 ++- .../legacy-db-config.toml-read.unit.test.ts | 55 +++++++++++++++ .../src/legacy/shared/legacy-seed-buckets.ts | 19 ++++-- 14 files changed, 367 insertions(+), 48 deletions(-) diff --git a/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md index 2ea04a7fab..f7dab774da 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md @@ -2,11 +2,12 @@ ## Files Read -| Path | Format | When | -| ----------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------ | -| `supabase/config.toml` / `supabase/config.json` | TOML / JSON | always when present in the active workdir; used to discover `auth.signing_keys_path` | -| `` | JSON array of JWKs | when `auth.signing_keys_path` is configured; loaded before overwrite or append | -| git ignore rules | git metadata / ignore files | best-effort after a successful write when the resulting file contains exactly 1 key | +| Path | Format | When | +| ----------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------- | +| `supabase/config.toml` / `supabase/config.json` | TOML / JSON | always when present in the active workdir; used to discover `auth.signing_keys_path` | +| `` | JSON array of JWKs | when `auth.signing_keys_path` is configured; loaded before overwrite or append | +| git ignore rules | git metadata / ignore files | best-effort after a successful write when the resulting file contains exactly 1 key | +| `/supabase/.env*`, `/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878; Go's `flags.LoadConfig`/`loadNestedEnv`) | ## Files Written @@ -22,9 +23,9 @@ ## Environment Variables -| Variable | Purpose | Required? | -| -------------- | ---------------------------------------------------------------------------------- | --------- | -| `SUPABASE_YES` | Auto-confirms the overwrite prompt, same as `--yes` (Go's `viper.GetBool("YES")`). | No | +| Variable | Purpose | Required? | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | +| `SUPABASE_YES` | Auto-confirms the overwrite prompt, same as `--yes` (Go's `viper.GetBool("YES")`). Read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` files (shell wins; CLI-1878). | No | ## Exit Codes @@ -57,7 +58,7 @@ Same as `--output-format json` above. - `--algorithm` accepts `ES256` (default, recommended) or `RS256`. - `--append` appends the new key to an existing keys file instead of overwriting. -- The overwrite prompt honors `SUPABASE_YES` and an explicit `--yes=false` override, matching Go's `viper.GetBool("YES")` precedence (flag wins over env; an omitted flag falls back to the env var). On non-TTY stdin, a piped `y`/`n` line is read within a 100ms timeout and honored before falling back to the default (`y`), matching Go's `Console.ReadLine`/`PromptYesNo` — a piped answer other than an exact `y`/`yes`/`n`/`no` (case-insensitive) also falls back to the default. +- The overwrite prompt honors `SUPABASE_YES` (shell env or the project `.env`/`.env.local`/`.env.[.local]` files, shell wins) and an explicit `--yes=false` override, matching Go's `viper.GetBool("YES")` precedence (flag wins over env; an omitted flag falls back to the env var, resolved after `flags.LoadConfig` loads the project env — CLI-1878). On non-TTY stdin, a piped `y`/`n` line is read within a 100ms timeout and honored before falling back to the default (`y`), matching Go's `Console.ReadLine`/`PromptYesNo` — a piped answer other than an exact `y`/`yes`/`n`/`no` (case-insensitive) also falls back to the default. - `auth.signing_keys_path` is resolved relative to the active `supabase/config.toml` or `supabase/config.json`. - Generated keys are JWKs, not PEM files. - No network or Management API calls are involved. diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts index defbaad723..87a734da73 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts @@ -6,10 +6,11 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { findGitRootPath } from "../../../../shared/git/git-root.ts"; +import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { Tty } from "../../../../shared/runtime/tty.service.ts"; import type { LegacyGenSigningKeyFlags } from "./signing-key.command.ts"; @@ -255,13 +256,21 @@ export const legacyGenSigningKey = Effect.fn("legacy.gen.signing-key")(function* const telemetryState = yield* LegacyTelemetryState; const output = yield* Output; const tty = yield* Tty; - const yes = yield* legacyResolveYes; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const emphasize = (text: string) => styleIfTty(tty.stdoutIsTty, "bold", text); const warnText = (text: string) => styleIfTty(tty.stdoutIsTty, "yellow", text); return yield* Effect.gen(function* () { + // Go's `flags.LoadConfig` (`signingkeys.go:99`) loads the project `.env` files before the + // overwrite prompt reads `viper.GetBool("YES")` (`console.PromptYesNo`, `signingkeys.go:130`), + // so a `SUPABASE_YES` set only in `supabase/.env` must auto-confirm here too. Resolved inside + // this block (not above it) so a malformed/unreadable `.env` still flushes telemetry below, + // matching Go: telemetry attaches in root's `PersistentPreRunE` (`cmd/root.go:131-155`) + // before this command's own `RunE` runs `flags.LoadConfig`, so `service.Capture` still fires + // in Go even when that load fails. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); // Match Go's order: LoadConfig validates the configured signing-keys file before any key is // generated, so a broken config fails fast without doing throwaway crypto work. const signingKeysConfig = yield* loadSigningKeysConfig(cliConfig.workdir); diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts index 549b3151f5..95439473a8 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts @@ -618,6 +618,49 @@ describe("legacy gen signing-key integration", () => { ); }); + it.live( + "auto-confirms from SUPABASE_YES in the project .env, even with a piped 'n' (CLI-1878)", + () => { + // SUPABASE_YES lives only in supabase/.env, not the shell. Go's `flags.LoadConfig` + // (`signingkeys.go:99`) loads the project `.env` files before the overwrite prompt reads + // `viper.GetBool("YES")` (`signingkeys.go:130`), so the overwrite auto-confirms and the + // piped `n` is never consumed — same precedence as the shell-env case above. + // + // Defensively clear a shell SUPABASE_YES: this test must prove the project-.env source + // specifically, not accidentally pass because a prior test in this file left the shell + // env set (the sibling shell-env tests above save/restore theirs). + const prev = process.env["SUPABASE_YES"]; + delete process.env["SUPABASE_YES"]; + const { layer } = setup({ stdinIsTty: false, pipedAnswer: "n" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", ".env"), "SUPABASE_YES=true\n"), + ); + + yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + const parsed = JSON.parse(saved) as ReadonlyArray>; + expect(parsed).toHaveLength(1); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev !== undefined) process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ); + }, + ); + it.live("an explicit --yes=false overrides SUPABASE_YES and honors a piped 'n'", () => { const prev = process.env["SUPABASE_YES"]; process.env["SUPABASE_YES"] = "1"; @@ -662,4 +705,28 @@ describe("legacy gen signing-key integration", () => { expect(telemetry?.flushed).toBe(true); }).pipe(Effect.provide(layer)); }); + + it.live("flushes telemetry state even when the project .env is malformed (Codex review)", () => { + // Go attaches the telemetry service in root's `PersistentPreRunE` (cmd/root.go:131-155), + // before this command's own `RunE` runs `flags.LoadConfig` (signingkeys.go:99), so + // `service.Capture` still fires even when that project-.env load fails. The project-env + // resolution here must live inside the `Effect.ensuring(telemetryState.flush)`-wrapped + // block for the same reason — locks in that fix. + const { layer, telemetry } = setup({ trackTelemetry: true }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", ".env"), "!=broken\n"), + ); + + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyDbConfigLoadError"); + } + expect(telemetry?.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/cli/src/legacy/commands/migration/fetch/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/fetch/SIDE_EFFECTS.md index 7da5a26931..398edd9745 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/fetch/SIDE_EFFECTS.md @@ -2,9 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ---------- | ------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| Path | Format | When | +| --------------------------------------------- | ---------- | ------------------------------------------------------------------ | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| `/supabase/.env*`, `/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878; Go's `loadNestedEnv`) | ## Files Written @@ -20,9 +21,10 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ------------------------------ | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_YES` | auto-confirm the overwrite prompt | no — read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` files (shell wins; CLI-1878) | ## Exit Codes @@ -54,8 +56,9 @@ Same structured `files` result delivered as an NDJSON `result` event. - When the migrations directory is non-empty, prompts `Do you want to overwrite existing files in supabase/migrations directory?` - (default **YES**). Declining exits non-zero (`context canceled`). `--yes` - auto-confirms; a non-interactive / machine-output run takes the default (YES). + (default **YES**). Declining exits non-zero (`context canceled`). `--yes` or + `SUPABASE_YES` (shell env or project `.env`) auto-confirms; a non-interactive / + machine-output run takes the default (YES). ## Notes diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts index ccc5b69f43..6fadfea2b4 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts @@ -1,6 +1,9 @@ import { Effect, FileSystem, Option, Path } from "effect"; -import { LegacyDnsResolverFlag, legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { + LegacyDnsResolverFlag, + legacyResolveYesWithProjectEnv, +} from "../../../../shared/legacy/global-flags.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; @@ -8,6 +11,7 @@ import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.ser import { legacyBold } from "../../../shared/legacy-colors.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-flags.ts"; import { legacyReadMigrationTable } from "../../../shared/legacy-migration-history.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; @@ -31,8 +35,10 @@ const runFetch = Effect.fnUntraced(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dnsResolver = yield* LegacyDnsResolverFlag; - const yes = yield* legacyResolveYes; // --yes OR SUPABASE_YES (Go viper AutomaticEnv, root.go:318-334). + // Flag-group mutual-exclusion first: cobra's `MarkFlagsMutuallyExclusive` validates at + // parse time, ahead of the root `PersistentPreRunE` (same ordering as `migration down`/ + // `repair`). if (target.setFlags.length > 1) { return yield* Effect.fail( new LegacyMigrationTargetFlagsError({ @@ -55,6 +61,14 @@ const runFetch = Effect.fnUntraced(function* ( dnsResolver, }); + // Go loads the project .env via loadNestedEnv INSIDE ParseDatabaseConfig (config.go:701), + // i.e. after the parse-time flag-group validation above — so a SUPABASE_YES set only in + // supabase/.env auto-confirms, but a flag conflict still surfaces before any .env read. + // Resolve --yes against the project env here, not just process.env (root.go:318-334). + // Same ordering as `migration down`/`repair`. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + // Linked fetch caches the project ref on success (Go's `PersistentPostRun`). The ref is // loaded now (pre-run), but the cache write is attached to the body via `Effect.ensuring`, // so a declined prompt returns before it runs — matching Go (PostRun is skipped on a diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts index f0fcd50ccf..b4c408487d 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts @@ -43,6 +43,8 @@ interface SetupOpts { readonly confirm?: boolean; readonly rows?: ReadonlyArray; readonly resolveFails?: boolean; + /** Raw argv seen by `resolveLegacyDbTargetFlags` (e.g. to exercise a flag conflict). */ + readonly cliArgs?: ReadonlyArray; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -109,7 +111,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockLegacyCliConfig({ workdir }), Layer.succeed(LegacyDnsResolverFlag, "native"), Layer.succeed(LegacyYesFlag, opts.yes ?? false), - Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: opts.cliArgs ?? [] }), mockTty({ stdinIsTty: opts.isTTY ?? true }), mockStdin( opts.isTTY ?? true, @@ -237,6 +239,27 @@ describe("legacy migration fetch", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "auto-confirms the overwrite prompt from SUPABASE_YES in the project .env (Go loadNestedEnv)", + () => { + // SUPABASE_YES lives only in supabase/.env, not the shell — `fetch` defaults to + // `--linked` (Go: migration.go:161), and root's `ParseDatabaseConfig` loads the project + // `.env` files before `fetch.Run`'s overwrite prompt (root.go:118), so the overwrite + // auto-confirms with no --yes flag and no piped stdin answer (CLI-1878). + mkdirSync(migrationsDir(tmp.current), { recursive: true }); + writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_YES=true\n"); + const { layer, out } = setup(tmp.current, { + rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], + }); + return Effect.gen(function* () { + yield* legacyMigrationFetch(flags()); + expect(out.stderrText).toContain("[Y/n] y"); + expect(readdirSync(migrationsDir(tmp.current))).toContain("20240101000000_init.sql"); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("still prompts on stderr in json mode and proceeds on a piped yes", () => { // Go writes the prompt to stderr and reads stdin regardless of --output (console.go), // so --output-format json must NOT silently auto-accept: the overwrite prompt fires on @@ -332,8 +355,12 @@ describe("legacy migration fetch", () => { }); it.live("reports a write failure", () => { - // A file at /supabase makes `makeDirectory(supabase/migrations)` fail. - writeFileSync(join(tmp.current, "supabase"), "not a directory"); + // A file at /supabase/migrations makes `makeDirectory` fail. `supabase` itself + // must stay a real directory here: the handler's project-env load (CLI-1878, honoring + // Go's `loadNestedEnv`) reads `/supabase/.env*` before this mkdir, and a plain + // file at `/supabase` would make that read fail first (ENOTDIR) instead. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "migrations"), "not a directory"); const { layer } = setup(tmp.current, { rows: [] }); return Effect.gen(function* () { const exit = yield* legacyMigrationFetch(flags()).pipe(Effect.exit); @@ -362,4 +389,33 @@ describe("legacy migration fetch", () => { expect(out.promptConfirmCalls.length).toBe(0); }).pipe(Effect.provide(layer)); }); + + it.live( + "rejects --db-url combined with --linked before reading the project .env (CLI-1878)", + () => { + // Cobra's `MarkFlagsMutuallyExclusive` validates at parse time, ahead of the root + // `PersistentPreRunE` that runs `ParseDatabaseConfig`/`loadNestedEnv` — so a flag + // conflict must surface even when `supabase/.env` is malformed (which would abort a + // project-env load with a DIFFERENT error, `LegacyDbConfigLoadError`, if the env load + // ran first). Locks in the fix that reordered the project-env load in `fetch.handler.ts` + // to run after this flag-group check. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", ".env"), "!=broken\n"); + const { layer } = setup(tmp.current, { + cliArgs: ["--db-url", "postgresql://x", "--linked"], + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationFetch( + flags({ dbUrl: Option.some("postgresql://x") }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe( + "LegacyMigrationTargetFlagsError", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md index 67676ca81d..1f33b689e5 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md @@ -7,12 +7,13 @@ stack is used; with `--linked` the remote project is used. ## Files Read -| Path | Format | When | -| ---------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, to read `[storage.buckets]` / `[storage.vector]` config; on `--linked`, the matching `[remotes.]` block (whose `project_id` equals the resolved project ref) is merged over the base config before decode, so remote-specific storage config takes effect | -| `/supabase//**` | any (bytes) | per configured bucket with a non-empty `objects_path`, recursively; a relative `objects_path` resolves under `supabase/` (Go `config.go:757-759`), an absolute path is used as-is | -| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.cert_path` is set; the file is read to obtain the CA certificate for trusting the local Kong HTTPS gateway. If `cert_path` is not set, the embedded `kong.local.crt` constant is used instead (no file read). | -| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.key_path` is set; read purely to validate the cert/key pairing (Go `config.go:845-861`) — the key content is not used by the CLI. If `cert_path` is set without `key_path` (or vice-versa), the command exits `1`. | +| Path | Format | When | +| --------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, to read `[storage.buckets]` / `[storage.vector]` config; on `--linked`, the matching `[remotes.]` block (whose `project_id` equals the resolved project ref) is merged over the base config before decode, so remote-specific storage config takes effect | +| `/supabase//**` | any (bytes) | per configured bucket with a non-empty `objects_path`, recursively; a relative `objects_path` resolves under `supabase/` (Go `config.go:757-759`), an absolute path is used as-is | +| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.cert_path` is set; the file is read to obtain the CA certificate for trusting the local Kong HTTPS gateway. If `cert_path` is not set, the embedded `kong.local.crt` constant is used instead (no file read). | +| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.key_path` is set; read purely to validate the cert/key pairing (Go `config.go:845-861`) — the key content is not used by the CLI. If `cert_path` is set without `key_path` (or vice-versa), the command exits `1`. | +| `/supabase/.env*`, `/.env*` | dotenv | when no pre-resolved `yes` is passed in (the standalone command; `db reset --local` passes its own), to resolve `SUPABASE_YES` for the overwrite/prune prompts (CLI-1878; Go's `loadNestedEnv`) | ## Files Written @@ -71,6 +72,7 @@ Analytics bucket routes (`/storage/v1/iceberg/...`) are only reached when | `DOCKER_HOST` | when a `tcp://host:port` endpoint, the local services host falls back to it before `127.0.0.1` | no | | `SUPABASE_AUTH_SERVICE_ROLE_KEY` | when set and non-empty: for `--linked`, used as the service-role key (skips Management API key fetch); for local runs, used as the service-role key instead of `auth.service_role_key` (Go Viper AutomaticEnv parity) | no | | `SUPABASE_AUTH_JWT_SECRET` | local runs only: when set and non-empty, overrides `auth.jwt_secret` for service-role key derivation (Go Viper `AutomaticEnv`+`SUPABASE_` prefix parity, `config.go:492-497`) | no | +| `SUPABASE_YES` | auto-confirms the overwrite/prune prompts, same as `--yes`; read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` files (shell wins; CLI-1878) | no | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts index c91d172f5e..1d3ef890c9 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts @@ -1411,6 +1411,37 @@ describe("legacy seed buckets", () => { }); }); + it.live( + "auto-confirms the overwrite from SUPABASE_YES in the project .env (Go loadNestedEnv, CLI-1878)", + () => { + // SUPABASE_YES lives only in supabase/.env, not the shell or the --yes flag. `seed + // buckets` defaults to `--local` (Go: `cmd/seed.go:31`), and root's + // `ParseDatabaseConfig` loads the project `.env` files before `buckets.Run`'s + // overwrite prompt (`root.go:118`), so the standalone command's own fallback + // resolution (not the `db reset`-passed `opts.yes`) must read it too. + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.buckets.assets]\npublic = true\n", + files: { "supabase/.env": "SUPABASE_YES=true\n" }, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [{ name: "assets", id: "assets" }] }, + { method: "PUT", match: "/storage/v1/bucket/assets", body: {} }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain( + "already exists. Do you want to overwrite its properties? [Y/n] y", + ); + expect(out.stderrText).toContain("Updating Storage bucket: assets"); + expect(requests.some((r) => r.method === "PUT")).toBe(true); + }); + }, + ); + it.live("--yes prunes a stale vector bucket and echoes Go's prompt line", () => { const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.vec1]\n", diff --git a/apps/cli/src/legacy/commands/storage/rm/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/storage/rm/SIDE_EFFECTS.md index 92ad3b6ccd..5bf3726586 100644 --- a/apps/cli/src/legacy/commands/storage/rm/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/storage/rm/SIDE_EFFECTS.md @@ -7,12 +7,13 @@ when `-r` is set. With no paths and `-r`, every bucket is cleared and deleted. ## Files Read -| Path | Format | When | -| ---------------------------------------- | ---------- | ----------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked) | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | -| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | +| Path | Format | When | +| --------------------------------------------- | ---------- | ------------------------------------------------------------------ | +| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked) | +| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | +| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | +| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | +| `/supabase/.env*`, `/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878; Go's `loadNestedEnv`) | ## Files Written @@ -36,7 +37,9 @@ Auth: `apikey` always; `Authorization: Bearer ` unless the key is `sb_`-pre ## Environment Variables `SUPABASE_AUTH_SERVICE_ROLE_KEY`, `SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_ACCESS_TOKEN`, -`SUPABASE_PROJECT_ID`, `SUPABASE_SERVICES_HOSTNAME`, plus `SUPABASE_YES` (auto-confirm). +`SUPABASE_PROJECT_ID`, `SUPABASE_SERVICES_HOSTNAME`, plus `SUPABASE_YES` (auto-confirm) — +read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` files +(shell wins; CLI-1878, matching Go's `loadNestedEnv` before `viper.GetBool("YES")`). `storage` is an experimental command (Go `root.go:63`): `rm` requires `--experimental` (or `SUPABASE_EXPERIMENTAL`), else it exits 1 with diff --git a/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts b/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts index dda8bc12fe..aa361c58e9 100644 --- a/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts +++ b/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts @@ -1,12 +1,13 @@ -import { Effect, Option } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { legacyBold } from "../../../shared/legacy-colors.ts"; +import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; import { LEGACY_DELETE_OBJECTS_LIMIT, @@ -54,14 +55,27 @@ export const legacyStorageRm = Effect.fn("legacy.storage.rm")(function* ( const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; const resolver = yield* LegacyProjectRefResolver; - // `--yes` OR `SUPABASE_YES` (Go's viper AutomaticEnv, root.go:318-320). - const yes = yield* legacyResolveYes; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; let linkedRef = ""; yield* Effect.gen(function* () { + // Resolve the project ref BEFORE reading the project `.env`, matching Go's + // `ParseDatabaseConfig` `case linked:` (`db_url.go:87-93`), which calls + // `LoadProjectRef` strictly before `LoadConfig` (the `.env`/`loadNestedEnv` + // work). An unlinked workdir must fail fast with the not-linked guidance + // before a malformed/unreadable `supabase/.env` gets a chance to mask it + // with an env-parse error. const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(Option.none()); linkedRef = projectRef; + // `--yes` OR `SUPABASE_YES` (Go's viper AutomaticEnv, root.go:318-320). Both the + // `--local` and (default) `--linked` branches of `ParseDatabaseConfig` call + // `LoadConfig` — which loads the project `.env` files — before `rm.Run`'s + // confirmation prompt (`root.go:118` → `db_url.go:78` (`local`) / `:91` (`linked`)), + // so a `SUPABASE_YES` set only in `supabase/.env` must auto-confirm here too. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); const loaded = yield* legacyLoadStorageConfig(cliConfig.workdir, projectRef); if (loaded.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); diff --git a/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts b/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts index c279b49460..858ea9953a 100644 --- a/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts @@ -100,6 +100,57 @@ describe("legacy storage rm", () => { }); }); + it.live("auto-confirms from SUPABASE_YES in the project .env (Go loadNestedEnv)", () => { + // SUPABASE_YES lives only in supabase/.env, not the shell — both the `--local` and + // (default) `--linked` branches of Go's `ParseDatabaseConfig` load the project `.env` + // files before `rm.Run`'s confirmation prompt (root.go:118), so the deletion + // auto-confirms with no --yes flag and no env var set in the shell (CLI-1878). + const { layer, out, requests } = setupLegacyStorage(tmp.current, { + toml: 'project_id = "test"\n', + local: true, + files: { "supabase/.env": "SUPABASE_YES=true\n" }, + routes: [{ method: "DELETE", match: DELETE_OBJECT("private"), body: [{ name: "a.pdf" }] }], + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageRm({ + files: ["ss:///private/a.pdf"], + recursive: false, + linked: true, + local: true, + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("[y/N] y"); + expect(requests.some((r) => r.method === "DELETE")).toBe(true); + }); + }); + + it.live( + "surfaces not-linked guidance before a malformed project .env (Go LoadProjectRef-before-LoadConfig)", + () => { + // Go's `ParseDatabaseConfig` `case linked:` (db_url.go:87-93) calls `LoadProjectRef` + // strictly before `LoadConfig` (which reads the project `.env` files), so an unlinked + // workdir must fail with the not-linked guidance even when `supabase/.env` is malformed + // — the malformed file must never be reached (CLI-1878). + const { layer, requests } = setupLegacyStorage(tmp.current, { + toml: 'project_id = "test"\n', + linkedFails: true, + files: { "supabase/.env": "!=\n" }, + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageRm({ + files: ["ss:///private/a.pdf"], + recursive: false, + linked: true, + local: false, + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("Cannot find project ref"); + expect(JSON.stringify(exit)).not.toContain("failed to parse environment file"); + expect(requests).toHaveLength(0); + }); + }, + ); + it.live("skips the bucket when the confirmation is declined", () => { const { layer, requests } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index 639c1169ca..10fcddfcf8 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -279,6 +279,7 @@ const LEGACY_ENV_OVERRIDABLE_KEYS: ReadonlyArray = [ "db.migrations.enabled", "db.seed.enabled", "db.seed.sql_paths", + "auth.enabled", "edge_runtime.deno_version", "experimental.pgdelta.enabled", "experimental.pgdelta.declarative_schema_path", @@ -1267,13 +1268,16 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // Go's config.Validate runs the full `if c.Auth.Enabled` block (`config.go:1036-1102`) after // the bucket/function checks. Gated on `auth.enabled` (default true); Go's viper AutomaticEnv // binds `auth.enabled` to `SUPABASE_AUTH_ENABLED` before Validate (`config.go:529-535`), so the - // env override decides whether the auth block is validated. + // env override decides whether the auth block is validated — UNLESS a matched `[remotes.*]` + // block supplies `auth.enabled` itself, in which case `mergeRemoteConfig`'s `v.Set` (override + // tier, above `AutomaticEnv`) wins, matching the same suppression every other + // `LEGACY_ENV_OVERRIDABLE_KEYS` entry gets below. const authEnabled = yield* resolveBoolOrFail( "auth.enabled", authRaw?.["enabled"], true, lookup, - envOverride("SUPABASE_AUTH_ENABLED"), + remoteOverrideKeys.has("auth.enabled") ? undefined : envOverride("SUPABASE_AUTH_ENABLED"), ); // Local helpers mirroring the deleted `legacyValidateAuthConfig`'s closures — its Go-parity diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts index 673fd52d41..493f1f8276 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts @@ -453,6 +453,61 @@ describe("legacyReadDbToml", () => { ); }); + it.effect("an explicit remote auth.enabled beats its SUPABASE_AUTH_ENABLED env var", () => { + // Same v.Set-above-AutomaticEnv precedence as db.migrations.enabled / pgdelta.enabled + // (config.go:635-637), but for auth.enabled specifically (CLI-1878): a matched remote + // block's auth.enabled must win over SUPABASE_AUTH_ENABLED. + const ref = "abcdefghijklmnopqrst"; + const previous = process.env["SUPABASE_AUTH_ENABLED"]; + process.env["SUPABASE_AUTH_ENABLED"] = "true"; + const dir = withConfig( + [ + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.auth]", + "enabled = false", + "", + ].join("\n"), + ); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.baseline.authEnabled).toBe(false); + }), + ), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_AUTH_ENABLED"]; + else process.env["SUPABASE_AUTH_ENABLED"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("SUPABASE_AUTH_ENABLED still wins when the remote block omits auth.enabled", () => { + // Control: the env override is suppressed only for keys the matched block explicitly + // set; a block that omits auth.enabled leaves the env override in force. + const ref = "abcdefghijklmnopqrst"; + const previous = process.env["SUPABASE_AUTH_ENABLED"]; + process.env["SUPABASE_AUTH_ENABLED"] = "false"; + const dir = withConfig(["[remotes.prod]", `project_id = "${ref}"`, ""].join("\n")); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.baseline.authEnabled).toBe(false); + }), + ), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_AUTH_ENABLED"]; + else process.env["SUPABASE_AUTH_ENABLED"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + it.effect("matches a remote block by a SUPABASE_REMOTES__PROJECT_ID env override", () => { // Viper AutomaticEnv supplies/overrides remotes.prod.project_id, so the block merges // even with no TOML project_id (here it lifts major_version 15 over the base default). diff --git a/apps/cli/src/legacy/shared/legacy-seed-buckets.ts b/apps/cli/src/legacy/shared/legacy-seed-buckets.ts index 0ec17002b5..ff7754d5c5 100644 --- a/apps/cli/src/legacy/shared/legacy-seed-buckets.ts +++ b/apps/cli/src/legacy/shared/legacy-seed-buckets.ts @@ -8,9 +8,10 @@ import { FetchHttpClient } from "effect/unstable/http"; import type { PlatformError } from "effect/PlatformError"; import { Output } from "../../shared/output/output.service.ts"; -import { legacyResolveYes } from "../../shared/legacy/global-flags.ts"; +import { legacyResolveYesWithProjectEnv } from "../../shared/legacy/global-flags.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { legacyBold, legacyYellow } from "./legacy-colors.ts"; +import { legacyLoadProjectEnv } from "./legacy-db-config.toml-read.ts"; import { legacyPromptYesNo } from "./legacy-prompt-yes-no.ts"; import { legacyResolveStorageCredentials, @@ -127,9 +128,13 @@ export const legacySeedBucketsRun = Effect.fnUntraced(function* (opts: { /** * Pre-resolved auto-confirm value. `db reset` resolves `yes` with the nested project * `.env` loaded (Go's `loadNestedEnv` runs before `buckets.Run`), so pass it through here - * — the internal `legacyResolveYes` only sees the shell env and would skip the - * bucket/vector/analytics prune that a `SUPABASE_YES` in `supabase/.env` should confirm. - * When omitted (the standalone `seed buckets` command), fall back to `legacyResolveYes`. + * — the internal fallback below only loads whatever THIS command's own project would + * supply. When omitted (the standalone `seed buckets` command), fall back to + * `legacyResolveYesWithProjectEnv`, loading the project env ourselves — `seed buckets` + * defaults to `--local` (Go's `seedFlags.Bool("local", true, ...)`, `cmd/seed.go:31`), + * and root's `ParseDatabaseConfig` calls `LoadConfig` — loading the project `.env` files + * — before `buckets.Run`'s overwrite/prune prompts (`root.go:118`), so a `SUPABASE_YES` + * set only in `supabase/.env` must auto-confirm here too. */ readonly yes?: boolean; }) { @@ -138,7 +143,11 @@ export const legacySeedBucketsRun = Effect.fnUntraced(function* (opts: { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; // `--yes` OR `SUPABASE_YES` (Go's viper AutomaticEnv, root.go:318-320). - const yes = opts.yes ?? (yield* legacyResolveYes); + const yes = + opts.yes ?? + (yield* legacyResolveYesWithProjectEnv( + yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir), + )); const { projectRef, emitSummary } = opts; const interactive = opts.interactive ?? true; From 31a10172b9071681e3ebec3f3d17784bf59bf2a4 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Thu, 9 Jul 2026 16:07:27 +0200 Subject: [PATCH 35/49] ci: add contribution gate workflow for external PRs (#5843) Implement an automated contribution gate that enforces the Supabase CLI contribution workflow for external pull requests. The gate requires PRs from external contributors to link to an open GitHub issue carrying the `open-for-contribution` label, while exempting maintainers and bots. ## Changes - **Contribution gate script** (`contribution-gate.ts`): Pure decision logic and GitHub I/O for evaluating PRs against the gate policy. Supports two modes: - Single-PR mode: reacts to individual PRs on `pull_request_target` events - All-PRs sweep mode: evaluates every open PR on-demand via `workflow_dispatch` - **Gate tests** (`contribution-gate.test.ts`): Comprehensive unit tests for the decision logic and orchestration, with injected I/O for network-free testing - **Workflow** (`contribution-gate.yml`): GitHub Actions workflow that runs the gate reactively on PR open/reopen/edit and supports manual sweeps with dry-run capability - **Documentation**: - `MAINTAINERS.md`: Internal guide for maintainers on applying the `open-for-contribution` label and running manual sweeps - Updated `CONTRIBUTING.md`: Contributor-facing workflow requiring issues to be opened first and labeled before PR submission - Updated issue templates and PR template: Guidance on the new workflow - Updated issue config: Link to contribution workflow ## Implementation details - Non-conforming PRs are auto-closed with explanatory comments directing contributors to the workflow - Cross-repository closing keywords are rejected as a security measure (contributors cannot control external repos) - Repository name matching is case-insensitive - Internal authors (OWNER, MEMBER, COLLABORATOR) and bots are exempt from the gate - The workflow checks out the base branch, ensuring only trusted code executes https://claude.ai/code/session_01YY1sNQLXPxaeN6JX1NFSWj --------- Co-authored-by: Claude --- .github/ISSUE_TEMPLATE/bug-report.yml | 2 + .github/ISSUE_TEMPLATE/config.yml | 3 + .github/ISSUE_TEMPLATE/docs.yml | 5 + .github/ISSUE_TEMPLATE/feature-request.yml | 5 + .github/MAINTAINERS.md | 58 +++ .github/PULL_REQUEST_TEMPLATE.md | 22 ++ .github/scripts/contribution-gate.test.ts | 219 +++++++++++ .github/scripts/contribution-gate.ts | 418 +++++++++++++++++++++ .github/workflows/contribution-gate.yml | 74 ++++ CONTRIBUTING.md | 12 + README.md | 6 +- 11 files changed, 823 insertions(+), 1 deletion(-) create mode 100644 .github/MAINTAINERS.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/scripts/contribution-gate.test.ts create mode 100644 .github/scripts/contribution-gate.ts create mode 100644 .github/workflows/contribution-gate.yml diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 37fc048a0e..9304447079 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -8,6 +8,8 @@ body: value: | Thanks for helping improve the Supabase CLI. Please include the exact command and output whenever possible so maintainers can reproduce the issue quickly. + Open this issue **before** starting work — a maintainer will add the `open-for-contribution` label once it is triaged and ready. Pull requests from external contributors that don't link an open, `open-for-contribution` issue are closed automatically. See [CONTRIBUTING.md](../blob/develop/CONTRIBUTING.md). + - type: dropdown id: affected-area attributes: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 8c49825527..00644d304d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: + - name: 📖 Contribution workflow + url: https://github.com/supabase/cli/blob/develop/CONTRIBUTING.md + about: Read this before opening an issue or pull request — PRs must link a labeled, open issue. - name: Supabase support url: https://supabase.com/support about: Get help with your Supabase project, account, billing, or hosted services. diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml index 57501fd9d3..aa04c9c2d3 100644 --- a/.github/ISSUE_TEMPLATE/docs.yml +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -3,6 +3,11 @@ description: Suggest an improvement to Supabase CLI documentation. labels: - 📘 Docs body: + - type: markdown + attributes: + value: | + Thanks for helping improve the docs! Open this issue **before** starting work — a maintainer will add the `open-for-contribution` label once it is triaged and ready. Pull requests from external contributors that don't link an open, `open-for-contribution` issue are closed automatically. See [CONTRIBUTING.md](../blob/develop/CONTRIBUTING.md). + - type: input id: link attributes: diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 12bb26e54e..fd3a0eda1d 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -3,6 +3,11 @@ description: Suggest an improvement or new capability for the Supabase CLI. labels: - ✨ Feature body: + - type: markdown + attributes: + value: | + Thanks for the suggestion! Open this issue **before** starting work — a maintainer will add the `open-for-contribution` label once it is triaged and ready. Pull requests from external contributors that don't link an open, `open-for-contribution` issue are closed automatically. See [CONTRIBUTING.md](../blob/develop/CONTRIBUTING.md). + - type: checkboxes id: existing-issues attributes: diff --git a/.github/MAINTAINERS.md b/.github/MAINTAINERS.md new file mode 100644 index 0000000000..e8321dd3b9 --- /dev/null +++ b/.github/MAINTAINERS.md @@ -0,0 +1,58 @@ +# Maintainers guide + +Internal notes for maintaining the Supabase CLI contribution workflow. See +[`CONTRIBUTING.md`](../CONTRIBUTING.md) for the contributor-facing version. + +## The `open-for-contribution` gate + +External pull requests are only accepted when they link to an **open** GitHub issue +that carries the **`open-for-contribution`** label. This is enforced by the +[`Contribution Gate`](./workflows/contribution-gate.yml) workflow, whose decision logic +lives in [`scripts/contribution-gate.ts`](./scripts/contribution-gate.ts). + +The gate runs **reactively on each PR** so a non-conforming PR is closed right away, and +can also be **swept across every open PR on demand** via the workflow's *Run workflow* +button. + +A pull request is **auto-closed with an explanatory comment** when the author is external +and any of these is true: + +- no issue is linked (via a closing keyword such as `Closes #123`, or the PR's + Development sidebar), or +- the linked issue is closed, or +- the linked issue is missing the `open-for-contribution` label. + +Authors whose `author_association` is `OWNER`, `MEMBER`, or `COLLABORATOR`, and bot +accounts, are **exempt** — Supabase maintainers can keep working from Linear tickets that +aren't public on GitHub. + +### Running the gate manually + +Use **Actions → Contribution Gate → Run workflow** to sweep all open PRs on demand — for +example right after bulk-applying `open-for-contribution` labels, so the whole backlog is +re-evaluated at once instead of waiting for each PR's next edit. Set the **`dry_run`** +input to `true` first to log each PR's decision in the run output without commenting on or +closing anything; run again with `dry_run` unchecked to apply the decisions. + +## Triage: applying the label (manual) + +During triage: + +1. Categorize the issue with one of `✨ Feature`, `🐛 Bug`, or `📘 Docs`. Issues opened + via the templates start with their category label already applied. +2. When the issue is ready to be worked on, add the **`open-for-contribution`** label. + +The `open-for-contribution` label must exist as a repository label for this workflow to +function; create it once from **Issues → Labels** if it is missing. + +Applying `open-for-contribution` is currently a **manual step** — do it on the GitHub +issue directly (from the GitHub UI, or from the Linear-linked issue). + +## Deferred: automatic Linear → GitHub label sync + +We considered auto-applying `open-for-contribution` when a Linear issue moves out of +Triage/Backlog (e.g. to Todo). Linear's native GitHub automations are one-directional +(GitHub events update Linear status) and cannot push a GitHub label, so this would need an +external bridge (a scheduled job polling the Linear API, a Zapier/Make zap, or a Linear +webhook → relay). It is **out of scope for now** and tracked separately; until then, apply +the label manually as above. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..2c8e119819 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ + + +## Summary + + + +## Linked issue + +Closes # + +- [ ] The linked issue is **open** and carries the `open-for-contribution` label (or I'm a Supabase maintainer). + +## Checklist + +- [ ] The PR title follows [Conventional Commits](https://www.conventionalcommits.org/) (e.g. `fix(cli): …`). +- [ ] Tests added or updated for the change. +- [ ] `pnpm check:all` and `pnpm test` pass for the workspace(s) I touched. diff --git a/.github/scripts/contribution-gate.test.ts b/.github/scripts/contribution-gate.test.ts new file mode 100644 index 0000000000..a8292a3a35 --- /dev/null +++ b/.github/scripts/contribution-gate.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, test } from "bun:test"; +import { + evaluateAllOpenPrs, + evaluateGate, + GATE_LABEL, + type GateIo, + type LinkedIssue, + type OpenPr, +} from "./contribution-gate.ts"; + +const REPO = "supabase/cli"; + +describe("evaluateGate", () => { + test("skips bot authors", () => { + const result = evaluateGate({ + repository: REPO, + authorAssociation: "NONE", + isBot: true, + linkedIssues: [], + }); + expect(result.pass).toBe(true); + expect(result.reason).toBe("bot"); + }); + + test.each(["OWNER", "MEMBER", "COLLABORATOR"])( + "skips internal author association %s", + (authorAssociation) => { + const result = evaluateGate({ + repository: REPO, + authorAssociation, + isBot: false, + linkedIssues: [], + }); + expect(result.pass).toBe(true); + expect(result.reason).toBe("internal"); + }, + ); + + test("fails when no issue is linked", () => { + const result = evaluateGate({ + repository: REPO, + authorAssociation: "NONE", + isBot: false, + linkedIssues: [], + }); + expect(result.pass).toBe(false); + expect(result.reason).toBe("no-linked-issue"); + expect(result.message).toContain(GATE_LABEL); + expect(result.message).toContain("CONTRIBUTING.md"); + }); + + test("fails when the linked issue is open but not labeled", () => { + const result = evaluateGate({ + repository: REPO, + authorAssociation: "CONTRIBUTOR", + isBot: false, + linkedIssues: [ + { repository: REPO, number: 12, state: "OPEN", labels: ["🐛 Bug"] }, + ], + }); + expect(result.pass).toBe(false); + expect(result.reason).toBe("missing-label"); + expect(result.message).toContain(GATE_LABEL); + }); + + test("fails when the only labeled issue is closed", () => { + const result = evaluateGate({ + repository: REPO, + authorAssociation: "NONE", + isBot: false, + linkedIssues: [ + { repository: REPO, number: 7, state: "CLOSED", labels: [GATE_LABEL] }, + ], + }); + expect(result.pass).toBe(false); + expect(result.reason).toBe("issue-closed"); + }); + + test("passes when a linked issue is open and carries the gate label", () => { + const result = evaluateGate({ + repository: REPO, + authorAssociation: "NONE", + isBot: false, + linkedIssues: [ + { + repository: REPO, + number: 42, + state: "OPEN", + labels: [GATE_LABEL, "🐛 Bug"], + }, + ], + }); + expect(result.pass).toBe(true); + expect(result.reason).toBe("ok"); + }); + + test("passes when any one of several linked issues qualifies", () => { + const result = evaluateGate({ + repository: REPO, + authorAssociation: "FIRST_TIME_CONTRIBUTOR", + isBot: false, + linkedIssues: [ + { repository: REPO, number: 1, state: "CLOSED", labels: [GATE_LABEL] }, + { repository: REPO, number: 2, state: "OPEN", labels: ["✨ Feature"] }, + { repository: REPO, number: 3, state: "OPEN", labels: [GATE_LABEL] }, + ], + }); + expect(result.pass).toBe(true); + expect(result.reason).toBe("ok"); + }); + + test("ignores an open, labeled issue from a different repository", () => { + // Cross-repo closing keyword (e.g. `Closes attacker/repo#1`): the issue is + // controlled by the contributor, so it must not satisfy the gate. + const result = evaluateGate({ + repository: REPO, + authorAssociation: "NONE", + isBot: false, + linkedIssues: [ + { + repository: "attacker/repo", + number: 1, + state: "OPEN", + labels: [GATE_LABEL], + }, + ], + }); + expect(result.pass).toBe(false); + expect(result.reason).toBe("no-linked-issue"); + }); + + test("matches the repository case-insensitively", () => { + const result = evaluateGate({ + repository: REPO, + authorAssociation: "NONE", + isBot: false, + linkedIssues: [ + { + repository: "Supabase/CLI", + number: 5, + state: "OPEN", + labels: [GATE_LABEL], + }, + ], + }); + expect(result.pass).toBe(true); + expect(result.reason).toBe("ok"); + }); +}); + +describe("evaluateAllOpenPrs", () => { + function makeIo( + openPrs: OpenPr[], + linkedByPr: Record, + ): { io: GateIo; closed: Array<{ number: number; message: string }> } { + const closed: Array<{ number: number; message: string }> = []; + const io: GateIo = { + listOpenPrs: () => Promise.resolve(openPrs), + fetchLinkedIssues: (prNumber) => + Promise.resolve(linkedByPr[prNumber] ?? []), + closePr: (prNumber, message) => { + closed.push({ number: prNumber, message }); + return Promise.resolve(); + }, + }; + return { io, closed }; + } + + test("closes only non-conforming external PRs and leaves the rest", async () => { + const { io, closed } = makeIo( + [ + { number: 1, authorAssociation: "NONE", isBot: false }, // no issue -> close + { number: 2, authorAssociation: "MEMBER", isBot: false }, // internal -> skip + { number: 3, authorAssociation: "NONE", isBot: true }, // bot -> skip + { number: 4, authorAssociation: "CONTRIBUTOR", isBot: false }, // conforming -> keep + { number: 5, authorAssociation: "NONE", isBot: false }, // missing label -> close + ], + { + 4: [ + { repository: REPO, number: 40, state: "OPEN", labels: [GATE_LABEL] }, + ], + 5: [ + { repository: REPO, number: 50, state: "OPEN", labels: ["🐛 Bug"] }, + ], + }, + ); + + const entries = await evaluateAllOpenPrs(io, REPO); + + expect(closed.map((c) => c.number).sort((a, b) => a - b)).toEqual([1, 5]); + const byNumber = Object.fromEntries( + entries.map((entry) => [entry.number, entry.result]), + ); + expect(byNumber[1]?.reason).toBe("no-linked-issue"); + expect(byNumber[2]?.pass).toBe(true); + expect(byNumber[2]?.reason).toBe("internal"); + expect(byNumber[3]?.reason).toBe("bot"); + expect(byNumber[4]?.pass).toBe(true); + expect(byNumber[5]?.reason).toBe("missing-label"); + expect(closed.find((c) => c.number === 1)?.message).toContain(GATE_LABEL); + }); + + test("returns an entry per PR and closes none when all conform", async () => { + const { io, closed } = makeIo( + [{ number: 9, authorAssociation: "NONE", isBot: false }], + { + 9: [ + { repository: REPO, number: 90, state: "OPEN", labels: [GATE_LABEL] }, + ], + }, + ); + + const entries = await evaluateAllOpenPrs(io, REPO); + + expect(entries).toHaveLength(1); + expect(closed).toHaveLength(0); + expect(entries[0]?.result.pass).toBe(true); + }); +}); diff --git a/.github/scripts/contribution-gate.ts b/.github/scripts/contribution-gate.ts new file mode 100644 index 0000000000..ad599b3c81 --- /dev/null +++ b/.github/scripts/contribution-gate.ts @@ -0,0 +1,418 @@ +/** + * Contribution gate: enforces the Supabase CLI contribution workflow across all + * OPEN pull requests opened by external contributors. + * + * A PR passes only when it links to an OPEN GitHub issue that carries the + * `open-for-contribution` label. Members/collaborators/owners and bots are + * exempt (they work from Linear tickets or automation). PRs that do not follow + * the process are commented on and closed. + * + * Two modes, both driven from `main()`: + * - single-PR (default): reacts to one PR on `pull_request_target` + * (`opened`/`reopened`/`edited`), using the PR_* env vars from the event. + * - all-PRs sweep (`GATE_MODE=all`, or the `--all` flag): evaluates every + * open PR, for on-demand `workflow_dispatch` runs. Set `DRY_RUN=true` to + * log decisions without commenting/closing. + * + * In both modes the workflow checks out the base branch, so this only ever + * executes trusted repository code — it never runs a fork's code. + * + * Run in CI as: `bun .github/scripts/contribution-gate.ts`. + * The pure `evaluateGate` decision and the `evaluateAllOpenPrs` orchestrator + * (I/O injected) are unit-tested in `contribution-gate.test.ts`; `main()` wires + * up the real GitHub I/O. + */ + +export const GATE_LABEL = "open-for-contribution"; + +/** Author associations treated as internal (exempt from the gate). */ +const INTERNAL_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); + +export interface LinkedIssue { + /** `owner/name` of the repo the issue lives in (from GraphQL nameWithOwner). */ + repository: string; + number: number; + state: "OPEN" | "CLOSED"; + labels: string[]; +} + +export interface GateInput { + /** `owner/name` of this repository (from GITHUB_REPOSITORY). */ + repository: string; + authorAssociation: string; + isBot: boolean; + linkedIssues: LinkedIssue[]; +} + +export type GateReason = + | "bot" + | "internal" + | "ok" + | "no-linked-issue" + | "missing-label" + | "issue-closed"; + +export interface GateResult { + pass: boolean; + reason: GateReason; + /** Explanatory comment body, present only when `pass` is false. */ + message?: string; +} + +const DOCS_FOOTER = + `\nSee [CONTRIBUTING.md](../blob/develop/CONTRIBUTING.md) for the full workflow. ` + + `Once a maintainer adds the \`${GATE_LABEL}\` label to a linked open issue, ` + + `reopen or open a new pull request and it will be accepted.`; + +function buildMessage(reason: GateReason): string { + switch (reason) { + case "no-linked-issue": + return ( + `👋 Thanks for the contribution! This pull request isn't linked to a ` + + `tracked issue, so it's being closed automatically.\n\n` + + `Please open an issue first, wait for a maintainer to add the ` + + `\`${GATE_LABEL}\` label, then open a pull request that links the issue ` + + `with a closing keyword (e.g. \`Closes #123\`).${DOCS_FOOTER}` + ); + case "missing-label": + return ( + `👋 Thanks! The linked issue hasn't been marked \`${GATE_LABEL}\` yet, ` + + `so this pull request is being closed automatically.\n\n` + + `A maintainer adds that label once an issue is triaged and ready to be ` + + `worked on. Please wait for the label before opening a pull ` + + `request.${DOCS_FOOTER}` + ); + case "issue-closed": + return ( + `👋 The issue linked to this pull request is closed, so it's being ` + + `closed automatically.\n\n` + + `Please link an open issue that carries the \`${GATE_LABEL}\` ` + + `label.${DOCS_FOOTER}` + ); + default: + return ""; + } +} + +/** + * Pure decision function for the contribution gate. Given the PR author context + * and its linked issues, returns whether the PR is allowed and why. + */ +export function evaluateGate(input: GateInput): GateResult { + if (input.isBot) { + return { pass: true, reason: "bot" }; + } + if (INTERNAL_ASSOCIATIONS.has(input.authorAssociation)) { + return { pass: true, reason: "internal" }; + } + + // Only issues in THIS repository count. A cross-repository closing keyword + // (e.g. `Closes attacker/repo#1`) links an issue the contributor controls, + // so it must never satisfy the gate. + const repo = input.repository.toLowerCase(); + const repoIssues = input.linkedIssues.filter( + (issue) => issue.repository.toLowerCase() === repo, + ); + + if (repoIssues.length === 0) { + return { + pass: false, + reason: "no-linked-issue", + message: buildMessage("no-linked-issue"), + }; + } + + const qualifies = repoIssues.some( + (issue) => issue.state === "OPEN" && issue.labels.includes(GATE_LABEL), + ); + if (qualifies) { + return { pass: true, reason: "ok" }; + } + + const hasOpenIssue = repoIssues.some((issue) => issue.state === "OPEN"); + const reason: GateReason = hasOpenIssue ? "missing-label" : "issue-closed"; + return { pass: false, reason, message: buildMessage(reason) }; +} + +/** Minimal open-PR shape the gate needs to decide exemption. */ +export interface OpenPr { + number: number; + authorAssociation: string; + isBot: boolean; +} + +/** Injected GitHub I/O so the sweep can be unit-tested without the network. */ +export interface GateIo { + /** List every open pull request in the repository. */ + listOpenPrs: () => Promise; + /** Fetch the issues a PR closes (via `closingIssuesReferences`). */ + fetchLinkedIssues: (prNumber: number) => Promise; + /** Comment with `message` then close the PR. Called only for failing PRs. */ + closePr: (prNumber: number, message: string) => Promise; +} + +export interface SweepEntry { + number: number; + result: GateResult; +} + +/** + * Evaluate the gate against every open PR, closing the ones that fail. Pure + * orchestration over the injected `io`; returns each PR's decision so the + * caller can log a summary. + */ +export async function evaluateAllOpenPrs( + io: GateIo, + repository: string, +): Promise { + const openPrs = await io.listOpenPrs(); + const entries: SweepEntry[] = []; + for (const pr of openPrs) { + const linkedIssues = await io.fetchLinkedIssues(pr.number); + const result = evaluateGate({ + repository, + authorAssociation: pr.authorAssociation, + isBot: pr.isBot, + linkedIssues, + }); + if (!result.pass && result.message) { + await io.closePr(pr.number, result.message); + } + entries.push({ number: pr.number, result }); + } + return entries; +} + +// --- GitHub I/O (only runs when executed directly) --- + +interface GraphQLIssueNode { + number: number; + state: "OPEN" | "CLOSED"; + repository: { nameWithOwner: string }; + labels: { nodes: Array<{ name: string }> }; +} + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +async function githubFetch( + url: string, + token: string, + init: Omit = {}, +): Promise { + const response = await fetch(url, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const body = await response.text(); + throw new Error( + `GitHub request failed (${response.status}) for ${url}: ${body}`, + ); + } + return response; +} + +async function fetchLinkedIssues( + token: string, + owner: string, + repo: string, + prNumber: number, +): Promise { + const query = ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + closingIssuesReferences(first: 20) { + nodes { + number + state + repository { nameWithOwner } + labels(first: 50) { nodes { name } } + } + } + } + } + }`; + const response = await githubFetch("https://api.github.com/graphql", token, { + method: "POST", + body: JSON.stringify({ + query, + variables: { owner, repo, number: prNumber }, + }), + }); + const payload = (await response.json()) as { + errors?: Array<{ message: string }>; + data?: { + repository?: { + pullRequest?: { + closingIssuesReferences?: { nodes?: GraphQLIssueNode[] }; + }; + }; + }; + }; + if (payload.errors?.length) { + throw new Error( + `GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`, + ); + } + const nodes = + payload.data?.repository?.pullRequest?.closingIssuesReferences?.nodes ?? []; + return nodes.map((node) => ({ + repository: node.repository.nameWithOwner, + number: node.number, + state: node.state, + labels: node.labels.nodes.map((label) => label.name), + })); +} + +interface RestPullRequest { + number: number; + author_association: string; + user: { type: string } | null; +} + +async function fetchOpenPullRequests( + token: string, + owner: string, + repo: string, +): Promise { + const prs: OpenPr[] = []; + for (let page = 1; ; page++) { + const url = + `https://api.github.com/repos/${owner}/${repo}/pulls` + + `?state=open&per_page=100&page=${page}`; + const response = await githubFetch(url, token); + const batch = (await response.json()) as RestPullRequest[]; + for (const pr of batch) { + prs.push({ + number: pr.number, + authorAssociation: pr.author_association, + isBot: pr.user?.type === "Bot", + }); + } + if (batch.length < 100) { + break; + } + } + return prs; +} + +/** + * Build the "comment then close" action for a repo. In dry-run mode it logs the + * intended action instead of mutating the PR. + */ +function makeCloser( + token: string, + base: string, + dryRun: boolean, +): (prNumber: number, message: string) => Promise { + return async (prNumber, message) => { + if (dryRun) { + console.log(`[dry-run] would comment on and close PR #${prNumber}`); + return; + } + await githubFetch(`${base}/issues/${prNumber}/comments`, token, { + method: "POST", + body: JSON.stringify({ body: message }), + }); + await githubFetch(`${base}/issues/${prNumber}`, token, { + method: "PATCH", + body: JSON.stringify({ state: "closed", state_reason: "not_planned" }), + }); + }; +} + +/** Single-PR mode: react to the PR carried by a `pull_request_target` event. */ +async function runSinglePr( + token: string, + owner: string, + repo: string, + repository: string, +): Promise { + const prNumber = Number(requireEnv("PR_NUMBER")); + const authorAssociation = process.env.PR_AUTHOR_ASSOCIATION ?? "NONE"; + const isBot = (process.env.PR_AUTHOR_TYPE ?? "User") === "Bot"; + + const linkedIssues = await fetchLinkedIssues(token, owner, repo, prNumber); + const result = evaluateGate({ + repository, + authorAssociation, + isBot, + linkedIssues, + }); + + console.log( + `Contribution gate for PR #${prNumber}: pass=${result.pass} reason=${result.reason} ` + + `(author_association=${authorAssociation}, bot=${isBot}, linked_issues=${linkedIssues.length})`, + ); + + if (result.pass || !result.message) { + return; + } + + const base = `https://api.github.com/repos/${owner}/${repo}`; + await makeCloser(token, base, false)(prNumber, result.message); + console.log(`Closed PR #${prNumber} (reason=${result.reason}).`); +} + +/** All-PRs mode: sweep every open PR, for on-demand `workflow_dispatch` runs. */ +async function runSweep( + token: string, + owner: string, + repo: string, + repository: string, + dryRun: boolean, +): Promise { + const base = `https://api.github.com/repos/${owner}/${repo}`; + const io: GateIo = { + listOpenPrs: () => fetchOpenPullRequests(token, owner, repo), + fetchLinkedIssues: (prNumber) => + fetchLinkedIssues(token, owner, repo, prNumber), + closePr: makeCloser(token, base, dryRun), + }; + + const entries = await evaluateAllOpenPrs(io, repository); + const failing = entries.filter((entry) => !entry.result.pass); + console.log( + `Contribution gate sweep: ${entries.length} open PR(s) evaluated, ` + + `${failing.length} ${dryRun ? "would be " : ""}closed.`, + ); + for (const entry of entries) { + console.log( + ` PR #${entry.number}: pass=${entry.result.pass} reason=${entry.result.reason}`, + ); + } +} + +async function main(): Promise { + const token = requireEnv("GITHUB_TOKEN"); + const repository = requireEnv("GITHUB_REPOSITORY"); + const [owner, repo] = repository.split("/"); + + const allMode = + process.env.GATE_MODE === "all" || process.argv.includes("--all"); + if (allMode) { + const dryRun = /^(1|true)$/i.test(process.env.DRY_RUN ?? ""); + await runSweep(token, owner!, repo!, repository, dryRun); + } else { + await runSinglePr(token, owner!, repo!, repository); + } +} + +if (import.meta.main) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/.github/workflows/contribution-gate.yml b/.github/workflows/contribution-gate.yml new file mode 100644 index 0000000000..54582d7790 --- /dev/null +++ b/.github/workflows/contribution-gate.yml @@ -0,0 +1,74 @@ +name: Contribution Gate + +on: + pull_request_target: + types: + - opened + - reopened + - edited + # Manual sweep over every open PR — e.g. after bulk-applying the + # `open-for-contribution` label. Set `dry_run` to log decisions without + # commenting or closing. + workflow_dispatch: + inputs: + dry_run: + description: "Evaluate and log decisions only; do not comment or close" + type: boolean + default: false + +permissions: + pull-requests: write + issues: read + contents: read + +concurrency: + # Per-PR for pull_request_target; unique per run for manual sweeps (which + # carry no pull_request payload) so two sweeps never cancel each other. + group: contribution-gate-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + gate-single: + name: Contribution Gate + runs-on: ubuntu-latest + # Exempt maintainers/collaborators and bots. External contributors are gated. + if: > + github.event_name == 'pull_request_target' && + github.event.pull_request.author_association != 'OWNER' && + github.event.pull_request.author_association != 'MEMBER' && + github.event.pull_request.author_association != 'COLLABORATOR' && + github.event.pull_request.user.type != 'Bot' + steps: + # Checks out the base repo (default for pull_request_target), so the gate + # runs trusted code from the base branch, never the untrusted PR head. + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.3.13" + - name: Evaluate contribution gate + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} + PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} + run: bun .github/scripts/contribution-gate.ts + + gate-all: + name: Contribution Gate (all open PRs) + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' + steps: + # Base-branch checkout only — the sweep makes API calls and never runs + # any PR's code. + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.3.13" + - name: Evaluate contribution gate across all open PRs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + GATE_MODE: all + DRY_RUN: ${{ inputs.dry_run || 'false' }} + run: bun .github/scripts/contribution-gate.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4312076c56..cb8978cf1a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,18 @@ Bun monorepo for exploring the next generation of the Supabase CLI and local development stack. +## Contribution workflow + +Before you open a pull request: + +1. **Open an issue first**, using one of the [issue templates](https://github.com/supabase/cli/issues/new/choose). +2. **Wait for maintainer triage.** A maintainer categorizes the issue (`✨ Feature`, `🐛 Bug`, or `📘 Docs`) and adds the **`open-for-contribution`** label once it is ready to be worked on. +3. **Open a pull request only after the `open-for-contribution` label is set**, and link the issue with a closing keyword (for example `Closes #123`). + +Until the `open-for-contribution` label is present, the issue is still in triage, so work should not start and a pull request should not be opened. + +Pull requests from external contributors that do not follow this workflow are commented on and closed automatically by the [Contribution Gate](.github/workflows/contribution-gate.yml). Supabase members are exempt, so they can work from Linear tickets that are not public on GitHub. Maintainers: see [`.github/MAINTAINERS.md`](.github/MAINTAINERS.md). + ## Setup ### Tool versions diff --git a/README.md b/README.md index 1e0b1d528e..9652d3738b 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,11 @@ pnpm repos:install ## Contributing -We love focused pull requests with a clear problem, a small surface area, and tests that match the user-facing behavior. Before opening a PR, run the checks for the workspace you touched. +We love focused pull requests with a clear problem, a small surface area, and tests that match the user-facing behavior. + +Open an issue first and wait for a maintainer to add the `open-for-contribution` label before starting work — external pull requests that don't link a labeled, open issue are closed automatically. See [CONTRIBUTING.md](./CONTRIBUTING.md#contribution-workflow) for the full workflow. + +Before opening a PR, run the checks for the workspace you touched. ```sh pnpm check:all From 351d02529ed33846be30ecfe077a523660327e55 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 9 Jul 2026 16:46:04 +0100 Subject: [PATCH 36/49] fix(config-push): decrypt dotenvx encrypted: secrets instead of skipping (#5842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Bug fix (Go-parity gap). ## What is the current behavior? `config push`'s secret-hashing logic (`config-sync/config-sync.secret.ts`) treats dotenvx `encrypted:` secret values (e.g. `[auth.captcha] secret = "encrypted:..."`) as unresolved: it hashes them to `""`, which silently gates them out of both the diff and the update-request body. The remote secret is left untouched with no error and no feedback — the user has no idea their encrypted secret was never pushed. Go decrypts every `config.Secret` field during `config.Load` (before any network call) and pushes the plaintext, or aborts the whole command with `failed to parse config: ` if it can't decrypt. Fixes [CLI-1881](https://linear.app/supabase/issue/CLI-1881/config-push-decrypt-dotenvx-encrypted-secrets-instead-of-skipping). ## What is the new behavior? - `config-sync.secret.ts` (`secretHash`/`secretPlaintext`) now decrypts an `encrypted:` value with the existing `legacy-vault-decrypt.ts` decryptor (already used by `db push`/`db reset`/`migration up|down`) before hashing it for the diff or sending it as plaintext in the auth update body — the ciphertext itself is never pushed. - `push.handler.ts` now runs a document-wide "assert every `config.Secret` is decryptable" pre-check immediately after loading `config.toml`, before the cost-matrix call or any other network request — mirroring Go's `config.Load` timing, where the decrypt hook runs over the *whole* document (not just `auth.*`) regardless of which fields the current command actually reads. An undecryptable secret anywhere (even one `config push` never itself pushes, e.g. `studio.openai_api_key`) aborts with Go's exact `failed to parse config: ` message and zero network calls. - Reused/exported `legacyAssertDecryptableSecrets` from `legacy-db-config.toml-read.ts` (previously private, used only by the db-config family) rather than duplicating the scan logic — its return type was generalized from a db-config-specific error class to a plain message string so each caller can wrap it in its own domain error. - Removed the now-stale "not implemented" note from `config push`'s `SIDE_EFFECTS.md` and documented the new `DOTENV_PRIVATE_KEY`(`_*`) env vars + the new abort exit condition. ## Test plan - New unit tests in `config-sync.secret.unit.test.ts` covering decrypt-before-hash, multi-key fallback, and the two failure messages (reusing the Go test vector from `apps/cli-go/pkg/config/secret_test.go` / `legacy-vault-decrypt.unit.test.ts`). - Three new `push.integration.test.ts` cases: - an `encrypted:` captcha secret decrypts and the **plaintext** (not ciphertext) reaches the `updateAuthServiceConfig` PATCH body; - an undecryptable `encrypted:` secret aborts with `failed to parse config: missing private key` and **zero** network calls, even with `auth.enabled = false` (proving the check isn't auth-gated); - an undecryptable `studio.openai_api_key` (a field `config push` never reads or pushes) also aborts before any network call, proving the pre-check is genuinely document-wide. - Full `apps/cli` `types:check`, `lint:check`, `fmt:check`, and the touched `unit`/`integration` Vitest projects (`config-sync.secret`, `auth.sync`, `legacy-vault-decrypt`, `legacy-db-config.toml-read`, `push.integration`) all pass. Parity confirmed against `apps/cli-go/pkg/config/secret.go` + `internal/config/push/push.go` via `go-parity-auditor` before implementation (abort timing before any network call, error-message format, `DOTENV_PRIVATE_KEY` env-loading order, and that Go always pushes decrypted plaintext, never ciphertext). Reviewed by `architect-reviewer`, `engineer-reviewer`, `security-reviewer`, and `supabase-dx-reviewer` (all APPROVE/SHIP IT) — every actionable finding from that pass is folded into this PR already (removed a dead defensive branch that broke 100%-branch-coverage, hardened a hex-decode error message so it can't echo malformed key fragments, clarified a shared helper's doc comment, added the `studio.openai_api_key` test, and tidied `SIDE_EFFECTS.md` wording). ## Judgement calls left open (deliberately, not blocking) - **Non-matching `[remotes.*]` blocks aren't covered by the document-wide pre-check.** `@supabase/config`'s `loadProjectConfig` strips the `remotes` key from its returned document once a block matches the target ref, so an undecryptable secret hiding in a *different, unused* remote block escapes the check (Go's decode hook would still abort on it, since it decodes the whole file). Narrow edge case — only matters with multiple remotes where the unused one has a broken secret — and safe-direction (we succeed where Go would abort; we never push ciphertext). Documented in `SIDE_EFFECTS.md`'s KNOWN GAPS and in the shared helper's doc comment. - **A secret reached via `env(VAR)` indirection where the referenced var only resolves through the wider env-file set `legacy-db-config.toml-read.ts`/Go's `loadNestedEnv` reads (not `@supabase/config`'s narrower `supabase/.env`/`.env.local`)** is a pre-existing, CLI-1489-adjacent asymmetry between the two env-resolution paths — out of scope here; the code takes the parity-correct (wider) source for the pre-check, consistent with the shared helper's other caller. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../commands/config/push/SIDE_EFFECTS.md | 23 ++- .../config/push/config-sync/auth.sync.ts | 94 ++++++--- .../push/config-sync/auth.sync.unit.test.ts | 9 +- .../push/config-sync/config-sync.secret.ts | 65 ++++-- .../config-sync.secret.unit.test.ts | 78 +++++-- .../commands/config/push/push.handler.ts | 60 +++++- .../config/push/push.integration.test.ts | 191 ++++++++++++++++++ .../shared/legacy-db-config.toml-read.ts | 55 +++-- .../src/legacy/shared/legacy-vault-decrypt.ts | 8 +- packages/config/src/io.ts | 39 +++- 10 files changed, 522 insertions(+), 100 deletions(-) diff --git a/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md index 374a785a22..3661eb0d7e 100644 --- a/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md @@ -10,7 +10,7 @@ local → if changed, print the unified diff and confirm → PATCH/PUT/POST. | Path | Format | When | | ---------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/supabase/config.toml` | TOML | always, before any network call (parse error aborts, exit 1) | -| `/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` | +| `/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` and to collect `DOTENV_PRIVATE_KEY`(`_*`) values for decrypting `encrypted:` secrets | | Auth email template HTML (`content_path`) | HTML | when `auth.enabled`; paths resolved per Go rules (see below) | | `/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file) | | `/supabase/.temp/linked-project.json` | JSON | existence check only, to decide whether the cache write below is skipped (mirrors Go's `ensureProjectGroupsCached` telemetry cache — see `db/lint`'s Notes for the full mechanism) | @@ -51,13 +51,14 @@ when its local gate is off. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | -| `SUPABASE_YES` | auto-confirm prompts (`--yes`) | no | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | API profile selection | no | -| `env(VAR)` references | interpolated into `config.toml` values at load | no | +| Variable | Purpose | Required? | +| -------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | +| `SUPABASE_YES` | auto-confirm prompts (`--yes`) | no | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | API profile selection | no | +| `env(VAR)` references | interpolated into `config.toml` values at load | no | +| `DOTENV_PRIVATE_KEY`, `DOTENV_PRIVATE_KEY_*` | decrypt `encrypted:` (dotenvx) secret values before hashing/pushing; comma-split, first matching key wins | only if a `config.Secret`-typed field (see below) holds an `encrypted:` value — an `encrypted:`-looking string in a non-secret field (e.g. an email template `subject`) never needs a key | ## Exit Codes @@ -65,6 +66,7 @@ when its local gate is off. | ---- | ------------------------------------------------------------------------------------------ | | `0` | success, **including** declining a confirmation prompt (Go returns nil and continues) | | `1` | malformed `config.toml` | +| `1` | an `encrypted:` (dotenvx) secret anywhere in the document cannot be decrypted (see below) | | `1` | invalid `auth.email.*.content_path` (missing/unreadable template file when `auth.enabled`) | | `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref | | `1` | list-addons failure (network or non-200) | @@ -115,5 +117,8 @@ keys mirror `config.toml` paths. - Diff bytes are byte-for-byte identical to the Go CLI (BurntSushi TOML encoder + anchored diff ports). - Optional `*pointer` sections (`db.ssl_enforcement`, `storage.image_transformation`, `storage.s3_protocol`) are decoded as defaulted-present by `@supabase/config`; their true presence is recovered from the raw (merged) config document so they are skipped when absent, matching Go's nil-pointer behaviour. - **`[remotes.*]` overrides are merged before push.** When a `[remotes.]` block declares `project_id == `, `@supabase/config` merges that block's subtree over the base config at the raw (pre-decode) level — Go's `mergeRemoteConfig` (`apps/cli-go/pkg/config/config.go:550`) — so only the keys the block declares override the base. `Loading config override: [remotes.]` prints to stderr. Two remotes sharing the target `project_id` abort with Go's `duplicate project_id for [remotes.] and [remotes.]` message. +- **`encrypted:` (dotenvx) secrets are decrypted, hashed, and pushed as plaintext**, matching Go's `Secret.Decrypt`/`DecryptSecretHookFunc`. `DOTENV_PRIVATE_KEY`(`_*`) values from the shell + `supabase/.env` decrypt the ciphertext; the decrypted plaintext is what gets hashed for the diff and sent as `Secret.Value` in the update body. The ciphertext itself is never pushed. +- **An undecryptable secret aborts before any network call.** Before the cost-matrix list-addons request or any other service call, every `config.Secret`-typed value in the document is asserted decryptable — not just `auth.*` (the only fields `config push` actually sends), matching Go's document-wide decode hook, which runs the same check regardless of which fields a given command reads. This covers `[db.vault]` (a `map[string]Secret` in Go too, not just an `auth.*` field). An undecryptable value aborts with Go's `failed to parse config: ` message, exit code `1`. +- **Deprecated `[auth.external.{linkedin,slack}]` secrets are checked before they're stripped.** `@supabase/config` strips these deprecated blocks from the decoded document before returning it, but Go's decrypt hook runs at decode time — before its own later `external.validate()` deletes them — so `config push` re-checks the stripped-out sub-objects separately, matching Go's decode-before-delete order rather than missing a secret hiding in one of them. - KNOWN GAPS: - - **`encrypted:` (dotenvx) secret decryption is not reproduced.** The Go CLI decrypts `encrypted:` values before hashing and pushes the plaintext; we cannot decrypt here. Rather than push the ciphertext (which would overwrite the remote secret with garbage), `encrypted:` values are treated as unresolved — exactly like `env()` refs: they hash to `""`, so the empty hash gates them out of both the diff and the update body and the remote secret is left untouched. + - The document-wide decrypt-or-abort pre-check scans the config document `@supabase/config` hands back, which has the _matched_ `[remotes.]` block already merged in and its `remotes` key removed. An `encrypted:` secret that's undecryptable inside a **different, non-matching** `[remotes.*]` block is therefore not caught here (Go's decode hook runs over the whole parsed document, including every remote, so it would abort in that case too). Narrow edge case: it only matters when a project declares multiple remotes and the _unused_ one has a broken secret. diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts index 2321f9d960..75e6d9d484 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts @@ -16,7 +16,7 @@ import { type TomlField, type TomlValue, encodeToml } from "./config-sync.toml.t import { intToUint } from "../../../../shared/legacy-size-units.ts"; import { durationString, parseDuration, secondsToDurationString } from "./config-sync.duration.ts"; import type { AuthEmailContent } from "./config-sync.auth-email-content.ts"; -import { secretHash } from "./config-sync.secret.ts"; +import { secretHash, secretPlaintext } from "./config-sync.secret.ts"; // --------------------------------------------------------------------------- // Sub-types @@ -858,9 +858,13 @@ function normalizeDurationStr(s: string | undefined): string { } } -function projectSecret(projectId: string, value: string | undefined): string { +function projectSecret( + projectId: string, + value: string | undefined, + dotenvPrivateKeys: ReadonlyArray, +): string { if (!value) return ""; - return secretHash(projectId, value); + return secretHash(projectId, value, dotenvPrivateKeys); } /** @@ -908,12 +912,21 @@ export interface AuthPresence { * Projects `config.auth` into the push subset, pre-computing all duration and * secret fields. `projectId` is the HMAC key for secret hashing. `presence` * carries raw-config presence for the optional sub-sections Go skips when nil. + * `dotenvPrivateKeys` (Go's `DOTENV_PRIVATE_KEY`/`DOTENV_PRIVATE_KEY_*` values) + * decrypt any `encrypted:` (dotenvx) secret before it's hashed or copied into + * {@link AuthSubset.rawSecrets} — see `config-sync.secret.ts`. + * + * @throws When an `encrypted:` secret cannot be decrypted with any key. The + * handler's document-wide pre-check (`legacyAssertDecryptableSecrets`) runs + * before this and is expected to have already aborted in that case, so this + * throw is a defensive backstop, not the primary abort path. */ export function authSubsetFromConfig( config: ProjectConfig, projectId: string, presence: AuthPresence, emailContent: AuthEmailContent = { template: {}, notification: {} }, + dotenvPrivateKeys: ReadonlyArray = [], ): AuthSubset { const a = config.auth; @@ -947,7 +960,7 @@ export function authSubsetFromConfig( : { enabled: captchaConfig.enabled ?? false, provider: captchaConfig.provider ?? "", - secret: projectSecret(projectId, captchaConfig.secret ?? ""), + secret: projectSecret(projectId, captchaConfig.secret ?? "", dotenvPrivateKeys), }; // Hooks — Go gates each on `hook. != nil`; absent in raw config → skip. @@ -960,7 +973,7 @@ export function authSubsetFromConfig( return { enabled: hc.enabled, uri: hc.uri ?? "", - secrets: projectSecret(projectId, hc.secrets ?? ""), + secrets: projectSecret(projectId, hc.secrets ?? "", dotenvPrivateKeys), }; } const hook: AuthSubset["hook"] = { @@ -1047,7 +1060,7 @@ export function authSubsetFromConfig( host: smtpConfig.host ?? "", port: smtpConfig.port ?? 0, user: smtpConfig.user ?? "", - pass: projectSecret(projectId, smtpConfig.pass ?? ""), + pass: projectSecret(projectId, smtpConfig.pass ?? "", dotenvPrivateKeys), admin_email: smtpConfig.admin_email ?? "", sender_name: smtpConfig.sender_name ?? "", }; @@ -1064,7 +1077,7 @@ export function authSubsetFromConfig( enabled: tc.enabled, account_sid: tc.account_sid ?? "", message_service_sid: tc.message_service_sid ?? "", - auth_token: projectSecret(projectId, tc.auth_token ?? ""), + auth_token: projectSecret(projectId, tc.auth_token ?? "", dotenvPrivateKeys), }; } const sms: AuthSubset["sms"] = { @@ -1077,18 +1090,18 @@ export function authSubsetFromConfig( messagebird: { enabled: s.messagebird.enabled, originator: s.messagebird.originator ?? "", - access_key: projectSecret(projectId, s.messagebird.access_key ?? ""), + access_key: projectSecret(projectId, s.messagebird.access_key ?? "", dotenvPrivateKeys), }, textlocal: { enabled: s.textlocal.enabled, sender: s.textlocal.sender ?? "", - api_key: projectSecret(projectId, s.textlocal.api_key ?? ""), + api_key: projectSecret(projectId, s.textlocal.api_key ?? "", dotenvPrivateKeys), }, vonage: { enabled: s.vonage.enabled, from: s.vonage.from ?? "", api_key: s.vonage.api_key ?? "", - api_secret: projectSecret(projectId, s.vonage.api_secret ?? ""), + api_secret: projectSecret(projectId, s.vonage.api_secret ?? "", dotenvPrivateKeys), }, test_otp: s.test_otp ?? {}, }; @@ -1105,7 +1118,7 @@ export function authSubsetFromConfig( external[k] = { enabled: p.enabled, client_id: p.client_id ?? "", - secret: projectSecret(projectId, p.secret ?? ""), + secret: projectSecret(projectId, p.secret ?? "", dotenvPrivateKeys), url: p.url ?? "", redirect_uri: p.redirect_uri ?? "", skip_nonce_check: p.skip_nonce_check ?? false, @@ -1193,33 +1206,52 @@ export function authSubsetFromConfig( allow_dynamic_registration: a.oauth_server.allow_dynamic_registration, authorization_url_path: a.oauth_server.authorization_url_path, }, - publishable_key: projectSecret(projectId, a.publishable_key ?? ""), - secret_key: projectSecret(projectId, a.secret_key ?? ""), - jwt_secret: projectSecret(projectId, a.jwt_secret ?? ""), - anon_key: projectSecret(projectId, a.anon_key ?? ""), - service_role_key: projectSecret(projectId, a.service_role_key ?? ""), + publishable_key: projectSecret(projectId, a.publishable_key ?? "", dotenvPrivateKeys), + secret_key: projectSecret(projectId, a.secret_key ?? "", dotenvPrivateKeys), + jwt_secret: projectSecret(projectId, a.jwt_secret ?? "", dotenvPrivateKeys), + anon_key: projectSecret(projectId, a.anon_key ?? "", dotenvPrivateKeys), + service_role_key: projectSecret(projectId, a.service_role_key ?? "", dotenvPrivateKeys), third_party, // Raw plaintext secrets for the update body (see AuthRawSecrets). Sourced - // from the same config accessors used for hashing above. + // from the same config accessors used for hashing above, decrypted the + // same way (`secretPlaintext`) so an `encrypted:` value is never sent + // as-is — Go always pushes `Secret.Value` (plaintext), never the ciphertext. rawSecrets: { - captcha: captchaConfig?.secret ?? "", + captcha: secretPlaintext(captchaConfig?.secret ?? "", dotenvPrivateKeys), hooks: { - mfa_verification_attempt: h.mfa_verification_attempt?.secrets ?? "", - password_verification_attempt: h.password_verification_attempt?.secrets ?? "", - custom_access_token: h.custom_access_token?.secrets ?? "", - send_sms: h.send_sms?.secrets ?? "", - send_email: h.send_email?.secrets ?? "", - before_user_created: h.before_user_created?.secrets ?? "", + mfa_verification_attempt: secretPlaintext( + h.mfa_verification_attempt?.secrets ?? "", + dotenvPrivateKeys, + ), + password_verification_attempt: secretPlaintext( + h.password_verification_attempt?.secrets ?? "", + dotenvPrivateKeys, + ), + custom_access_token: secretPlaintext( + h.custom_access_token?.secrets ?? "", + dotenvPrivateKeys, + ), + send_sms: secretPlaintext(h.send_sms?.secrets ?? "", dotenvPrivateKeys), + send_email: secretPlaintext(h.send_email?.secrets ?? "", dotenvPrivateKeys), + before_user_created: secretPlaintext( + h.before_user_created?.secrets ?? "", + dotenvPrivateKeys, + ), }, - smtp_pass: smtpConfig?.pass ?? "", + smtp_pass: secretPlaintext(smtpConfig?.pass ?? "", dotenvPrivateKeys), sms: { - twilio: s.twilio.auth_token ?? "", - twilio_verify: s.twilio_verify.auth_token ?? "", - messagebird: s.messagebird.access_key ?? "", - textlocal: s.textlocal.api_key ?? "", - vonage: s.vonage.api_secret ?? "", + twilio: secretPlaintext(s.twilio.auth_token ?? "", dotenvPrivateKeys), + twilio_verify: secretPlaintext(s.twilio_verify.auth_token ?? "", dotenvPrivateKeys), + messagebird: secretPlaintext(s.messagebird.access_key ?? "", dotenvPrivateKeys), + textlocal: secretPlaintext(s.textlocal.api_key ?? "", dotenvPrivateKeys), + vonage: secretPlaintext(s.vonage.api_secret ?? "", dotenvPrivateKeys), }, - providers: Object.fromEntries(Object.entries(ext ?? {}).map(([k, p]) => [k, p.secret ?? ""])), + providers: Object.fromEntries( + Object.entries(ext ?? {}).map(([k, p]) => [ + k, + secretPlaintext(p.secret ?? "", dotenvPrivateKeys), + ]), + ), }, }; } diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.unit.test.ts b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.unit.test.ts index 4083093f14..403cb01611 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.unit.test.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.unit.test.ts @@ -1418,10 +1418,11 @@ describe("authToUpdateBody secrets", () => { expect("security_captcha_secret" in body).toBe(false); }); - it("never pushes dotenvx ciphertext (encrypted: hashes to '' so the gate drops it)", () => { - // secretHash returns "" for `encrypted:` values, so the projected captcha - // secret is empty even though the raw (ciphertext) value is still present. - // The empty hash must gate the ciphertext out of the update body. + it("gates the raw secret out of the body when the hashed field is empty", () => { + // `authToUpdateBody` itself only knows the pre-computed hash/rawSecrets + // pair — this exercises the empty-hash gate directly (a decrypt failure + // for a real `encrypted:` value aborts earlier, in `authSubsetFromConfig` / + // the handler's document-wide pre-check; see push.integration.test.ts). const local = bareAuth({ enabled: true, captcha: { enabled: true, provider: "hcaptcha", secret: "" }, diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.ts b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.ts index 7b00046e6f..130bf6c48e 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.ts @@ -5,33 +5,72 @@ * Rules: * - Empty value → "" (no hash prefix). * - Value matching `^env\((.*)\)$` (unresolved env reference) → "" (no hash). - * - Value starting with `encrypted:` (dotenvx ciphertext) → "" (no hash). + * - Value starting with `encrypted:` (dotenvx ciphertext) → decrypt with + * `legacyDecryptSecret`, then hash the decrypted plaintext. * - Otherwise → "hash:" + sha256Hmac(projectId, value). * - * NOTE: `encrypted:` dotenvx decryption is not implemented. The Go CLI decrypts - * such values before hashing and pushes the plaintext; we cannot. Rather than - * hash and push the ciphertext — which would silently overwrite the remote - * secret with garbage — we treat `encrypted:` values as unresolved, exactly like - * `env()` refs: `secretHash` returns "", so the empty hash gates the value out of - * both the diff and the update body and the remote secret is left untouched. - * This is a documented residual gap for local dev use of dotenvx secrets - * (see SIDE_EFFECTS.md). + * `config push`'s handler runs a document-wide decrypt-or-abort pre-check + * (`push.handler.ts`, reusing `legacyAssertDecryptableSecrets`) immediately + * after loading `config.toml` and before any network call — mirroring Go's + * `config.Load` timing, where `DecryptSecretHookFunc` runs as part of the + * generic decode hook chain, before `internal/config/push.Run` ever reads the + * cost matrix or any service. By the time the functions below run (deep in + * the auth service's local/diff computation), decryption is therefore + * expected to always succeed. They still throw a `failed to parse config: + * ` error (Go's own wording) rather than silently gating the secret + * out, in case that invariant is ever violated by a future field addition. */ import { createHmac } from "node:crypto"; +import { legacyDecryptSecret } from "../../../../shared/legacy-vault-decrypt.ts"; + const ENV_PATTERN = /^env\((.*)\)$/; const ENCRYPTED_PREFIX = "encrypted:"; const HASHED_PREFIX = "hash:"; +/** Decrypts `value` when it's a dotenvx `encrypted:` ciphertext; otherwise returns it unchanged. */ +function decryptIfNeeded(value: string, dotenvPrivateKeys: ReadonlyArray): string { + if (!value.startsWith(ENCRYPTED_PREFIX)) return value; + const decrypted = legacyDecryptSecret(value, dotenvPrivateKeys); + if (!decrypted.ok) { + throw new Error(`failed to parse config: ${decrypted.error}`); + } + return decrypted.value; +} + /** * Returns the TOML serialisation of a Secret field, mirroring Go's - * `Secret.MarshalText`. The project ref is the HMAC key. + * `Secret.MarshalText`. The project ref is the HMAC key. `dotenvPrivateKeys` + * are Go's `DOTENV_PRIVATE_KEY`/`DOTENV_PRIVATE_KEY_*` values + * (`legacyCollectDotenvPrivateKeys`), used to decrypt an `encrypted:` value + * before hashing — Go always hashes the decrypted plaintext, never the + * ciphertext. + * + * @throws When an `encrypted:` value cannot be decrypted with any key. */ -export function secretHash(projectId: string, value: string): string { +export function secretHash( + projectId: string, + value: string, + dotenvPrivateKeys: ReadonlyArray, +): string { if (value.length === 0) return ""; if (ENV_PATTERN.test(value)) return ""; - if (value.startsWith(ENCRYPTED_PREFIX)) return ""; - const hmac = createHmac("sha256", projectId).update(value).digest("hex"); + const plaintext = decryptIfNeeded(value, dotenvPrivateKeys); + const hmac = createHmac("sha256", projectId).update(plaintext).digest("hex"); return HASHED_PREFIX + hmac; } + +/** + * Resolves a Secret field to the plaintext Go sends as `Secret.Value` in the + * update-request body — decrypting an `encrypted:` value with + * `dotenvPrivateKeys`, otherwise returning `value` unchanged. Callers gate on + * {@link secretHash}'s result being non-empty before using this (mirrors Go's + * `if len(Secret.SHA256) > 0`), so the empty/unresolved-`env()` cases never + * reach the request body regardless of what this returns for them. + * + * @throws When an `encrypted:` value cannot be decrypted with any key. + */ +export function secretPlaintext(value: string, dotenvPrivateKeys: ReadonlyArray): string { + return decryptIfNeeded(value, dotenvPrivateKeys); +} diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.unit.test.ts b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.unit.test.ts index 9766f1efbb..707ad52f28 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.unit.test.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.unit.test.ts @@ -1,6 +1,6 @@ /** * Unit tests for config-sync.secret.ts — golden parity with Go's - * `Secret.MarshalText` (`apps/cli-go/pkg/config/secret.go`). + * `Secret.MarshalText` + `DecryptSecretHookFunc` (`apps/cli-go/pkg/config/secret.go`). * * The HMAC keys/values below were captured from the same `createHmac` the port * uses; they lock the exact `hash:` serialisation Go emits. @@ -8,42 +8,86 @@ import { describe, expect, it } from "vitest"; -import { secretHash } from "./config-sync.secret.ts"; +import { secretHash, secretPlaintext } from "./config-sync.secret.ts"; + +// Go's test vector — `apps/cli-go/pkg/config/secret_test.go:9-19` (same one +// `legacy-vault-decrypt.unit.test.ts` uses). Decrypts to the plaintext "value". +const PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; +const ENCRYPTED_VALUE = + "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; +const WRONG_KEY = "11".repeat(32); describe("secretHash", () => { it("returns the hash: form for a plaintext secret", () => { - expect(secretHash("abcdefghijklmnopqrst", "my-secret")).toBe( + expect(secretHash("abcdefghijklmnopqrst", "my-secret", [])).toBe( "hash:64800db722cc0be9e1d816d5aed626805e91a939d2dbcbc5239cd31eeef763e9", ); - expect(secretHash("test", "topsecret")).toBe( + expect(secretHash("test", "topsecret", [])).toBe( "hash:8eed2826599c798e072951884ced30954f8322fa1c3648506634e8376a740d72", ); }); it("keys the HMAC on the project ref (same value, different ref → different hash)", () => { - expect(secretHash("ref-a", "same")).not.toBe(secretHash("ref-b", "same")); + expect(secretHash("ref-a", "same", [])).not.toBe(secretHash("ref-b", "same", [])); }); it("returns '' for an empty value", () => { - expect(secretHash("abcdefghijklmnopqrst", "")).toBe(""); + expect(secretHash("abcdefghijklmnopqrst", "", [])).toBe(""); }); it("returns '' for an unresolved env() reference", () => { - expect(secretHash("abcdefghijklmnopqrst", "env(MY_SECRET)")).toBe(""); - expect(secretHash("abcdefghijklmnopqrst", "env()")).toBe(""); - }); - - it("returns '' for a dotenvx encrypted: value (never hashes/pushes ciphertext)", () => { - // Regression: hashing/pushing the ciphertext would overwrite the remote - // secret with garbage. Treated as unresolved, like env(). - expect(secretHash("abcdefghijklmnopqrst", "encrypted:BvEYU1pXk9...")).toBe(""); + expect(secretHash("abcdefghijklmnopqrst", "env(MY_SECRET)", [])).toBe(""); + expect(secretHash("abcdefghijklmnopqrst", "env()", [])).toBe(""); }); it("hashes a value that merely contains (but does not start with) 'encrypted:'", () => { // Only the dotenvx prefix is special; an embedded substring is a real secret. - expect(secretHash("test", "not-encrypted:value")).toBe( - secretHash("test", "not-encrypted:value"), + expect(secretHash("test", "not-encrypted:value", [])).toBe( + secretHash("test", "not-encrypted:value", []), + ); + expect(secretHash("test", "not-encrypted:value", []).startsWith("hash:")).toBe(true); + }); + + describe("dotenvx encrypted: values", () => { + it("decrypts before hashing (hash matches the decrypted plaintext, not the ciphertext)", () => { + expect(secretHash("abcdefghijklmnopqrst", ENCRYPTED_VALUE, [PRIVATE_KEY])).toBe( + secretHash("abcdefghijklmnopqrst", "value", []), + ); + }); + + it("tries each key and the first working one wins", () => { + expect(secretHash("test", ENCRYPTED_VALUE, [WRONG_KEY, PRIVATE_KEY])).toBe( + secretHash("test", "value", []), + ); + }); + + it("throws 'failed to parse config: missing private key' with no keys", () => { + expect(() => secretHash("test", ENCRYPTED_VALUE, [])).toThrow( + "failed to parse config: missing private key", + ); + }); + + it("throws 'failed to parse config: failed to decrypt secret: ...' for a wrong key", () => { + expect(() => secretHash("test", ENCRYPTED_VALUE, [WRONG_KEY])).toThrow( + /^failed to parse config: failed to decrypt secret:/, + ); + }); + }); +}); + +describe("secretPlaintext", () => { + it("returns a plain value unchanged", () => { + expect(secretPlaintext("my-secret", [])).toBe("my-secret"); + expect(secretPlaintext("", [])).toBe(""); + }); + + it("decrypts a dotenvx encrypted: value to its plaintext", () => { + expect(secretPlaintext(ENCRYPTED_VALUE, [PRIVATE_KEY])).toBe("value"); + }); + + it("never returns the ciphertext when decryption fails — throws instead", () => { + expect(() => secretPlaintext(ENCRYPTED_VALUE, [])).toThrow( + "failed to parse config: missing private key", ); - expect(secretHash("test", "not-encrypted:value").startsWith("hash:")).toBe(true); }); }); diff --git a/apps/cli/src/legacy/commands/config/push/push.handler.ts b/apps/cli/src/legacy/commands/config/push/push.handler.ts index 60e1869c1e..e1fc8971f3 100644 --- a/apps/cli/src/legacy/commands/config/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/config/push/push.handler.ts @@ -8,9 +8,13 @@ import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state. import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; +import { + legacyAssertDecryptableSecrets, + legacyLoadProjectEnv, +} from "../../../shared/legacy-db-config.toml-read.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { legacyCollectDotenvPrivateKeys } from "../../../shared/legacy-vault-decrypt.ts"; import { apiSubsetFromConfig, apiToUpdateBody, diffApiWithRemote } from "./config-sync/api.sync.ts"; import { applyRemoteAuthConfig, @@ -101,6 +105,19 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( const projectRoot = (yield* findProjectRoot(runtimeInfo.cwd)) ?? runtimeInfo.cwd; const projectEnv = yield* legacyLoadProjectEnv(fs, path, projectRoot); const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + // dotenvx private keys for decrypting `encrypted:` secrets (Go's + // `DecryptSecretHookFunc`), from the shell + project env — same source/precedence + // as `legacy-db-config.toml-read.ts` (`process.env` wins over `supabase/.env`). + const dotenvPrivateKeys = legacyCollectDotenvPrivateKeys({ ...projectEnv, ...process.env }); + // Only reached by `legacyAssertDecryptableSecrets` below for an `env(VAR)` literal that + // survives `loaded.document`'s own (`@supabase/config`) interpolation pass unresolved — i.e. + // when this wider env source (matching Go's `loadNestedEnv`) resolves `VAR` but + // `@supabase/config`'s narrower one (`supabase/.env`/`.env.local` only) didn't. Practically + // unreachable in the same narrow way the CLI-1489 comment below already documents for + // non-secret fields; kept for parity with the shared function's other caller + // (`legacy-db-config.toml-read.ts`, whose pre-interpolation document relies on this). + const secretEnvLookup = (name: string): string | undefined => + process.env[name] ?? projectEnv[name]; const ref = yield* resolver.resolve(flags.projectRef); @@ -144,6 +161,33 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( const projectId = ref; const config = loaded.config; + // 1b. Assert every `config.Secret`-typed `encrypted:` value in the document + // (not just auth.*) can be decrypted, mirroring Go's global + // `DecryptSecretHookFunc`, which runs as part of `config.Load` — before + // `internal/config/push.Run` ever reads the cost matrix (`push.go:16` vs. + // `push.go:25`) or touches any service. An undecryptable secret anywhere in + // the document (even one `config push` never itself pushes, e.g. + // `studio.openai_api_key`) aborts here with Go's own `failed to parse + // config: ` message, before any remote service is read or updated. + // + // `loaded.document` has already had Go's deprecated `auth.external.{linkedin,slack}` + // blocks stripped by `@supabase/config` (`normalizeDeprecatedExternalProviders`), but + // Go's decrypt hook runs at decode time — before its own later `external.validate()` + // deletes those blocks — so an `encrypted:` secret hiding in one of them still aborts + // Go's load. Fold `removedDeprecatedExternalProviders` back into a synthetic + // `auth.external` view and scan that too, reusing the same path list rather than a + // second scanner. + const secretError = + legacyAssertDecryptableSecrets(loaded.document, secretEnvLookup, dotenvPrivateKeys) ?? + legacyAssertDecryptableSecrets( + { auth: { external: loaded.removedDeprecatedExternalProviders ?? {} } }, + secretEnvLookup, + dotenvPrivateKeys, + ); + if (secretError !== undefined) { + return yield* new LegacyConfigPushLoadConfigError({ message: secretError }); + } + // Optional `*pointer` sections (ssl_enforcement, image_transformation, // s3_protocol) are defaulted-present by @supabase/config and cannot be // recovered from the decoded config, so we inspect the raw (merged) document @@ -363,7 +407,19 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( }), ), ); - let local = authSubsetFromConfig(config, projectId, presence.auth, authEmailContent); + // `dotenvPrivateKeys` decrypts any `encrypted:` auth secret before it's + // hashed/copied into the update body. The document-wide check above + // (step 1b) already scanned every `config.Secret` path — including every + // field `authSubsetFromConfig` reads — and would have aborted by now if + // any were undecryptable, so the decrypt calls inside it are unreachable + // failure paths here, not a real branch to guard with `Effect.try`. + let local = authSubsetFromConfig( + config, + projectId, + presence.auth, + authEmailContent, + dotenvPrivateKeys, + ); const projected = applyRemoteAuthConfig(local, remote); // MFA phone/webauthn are paid addons: confirm cost before enabling. if (mfaPhoneNewlyEnabled(local, projected) && !(yield* keep("auth_mfa_phone"))) { diff --git a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts index 02ccd9d77c..0713bf7be0 100644 --- a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts @@ -29,6 +29,31 @@ function writeConfig(toml: string): void { writeFileSync(join(dir, "config.toml"), toml); } +// Go's test vector — `apps/cli-go/pkg/config/secret_test.go:9-19` (same one +// `legacy-vault-decrypt.unit.test.ts` and `config-sync.secret.unit.test.ts` use). +// Decrypts to the plaintext "value". +const DOTENVX_PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; +const DOTENVX_ENCRYPTED_VALUE = + "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; + +/** Save/restore `DOTENV_PRIVATE_KEY` around a test — mirrors the SUPABASE_YES pattern below. */ +function withDotenvPrivateKey( + value: string | undefined, + effect: Effect.Effect, +): Effect.Effect { + const prev = process.env["DOTENV_PRIVATE_KEY"]; + if (value === undefined) delete process.env["DOTENV_PRIVATE_KEY"]; + else process.env["DOTENV_PRIVATE_KEY"] = value; + return effect.pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["DOTENV_PRIVATE_KEY"]; + else process.env["DOTENV_PRIVATE_KEY"] = prev; + }), + ), + ); +} + // Schema-valid PostgREST GET response with the api disabled remotely (empty // schema). The real API client validates GET bodies against the generated // output schema, so every postgrest GET must carry these fields. @@ -724,6 +749,172 @@ secret = "my-plaintext-secret" }, ); + it.live( + "decrypts a dotenvx encrypted: captcha secret and pushes the plaintext (CLI-1881)", + () => { + const toml = `project_id = "test" +[storage] +enabled = false +[auth] +enabled = true +site_url = "http://localhost:3000" +[auth.captcha] +enabled = true +provider = "hcaptcha" +secret = "${DOTENVX_ENCRYPTED_VALUE}" +`; + const { layer, apiMock } = setupService({ + toml, + yes: true, + v1: { + getAuthServiceConfig: () => Effect.succeed({}), + updateAuthServiceConfig: () => Effect.succeed({}), + }, + }); + return withDotenvPrivateKey( + DOTENVX_PRIVATE_KEY, + Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); + expect(update).toBeDefined(); + const input = update?.input as Record; + // Go decrypts before hashing/pushing — the plaintext goes to the API, + // never the dotenvx ciphertext. + expect(input["security_captcha_secret"]).toBe("value"); + }).pipe(Effect.provide(layer)), + ); + }, + ); + + it.live( + "aborts before any network call when an encrypted: secret cannot be decrypted (CLI-1881)", + () => { + // `auth.enabled = false` on purpose: Go's decrypt hook runs for every + // `config.Secret` field during `config.Load`, before any feature gate is + // consulted — an undecryptable secret aborts even when the section that + // contains it would otherwise be skipped entirely. + const toml = `project_id = "test" +[storage] +enabled = false +[auth] +enabled = false +[auth.captcha] +enabled = true +provider = "hcaptcha" +secret = "${DOTENVX_ENCRYPTED_VALUE}" +`; + const { layer, api } = setup({ toml, yes: true }); + return withDotenvPrivateKey( + undefined, + Effect.gen(function* () { + const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => + Effect.succeed(error.message), + ), + ); + expect(message).toBe("failed to parse config: missing private key"); + // The guard runs during config load, before any network call — not + // even the cost-matrix (list-addons) request that normally runs first. + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)), + ); + }, + ); + + it.live( + "aborts on an undecryptable secret config push never itself reads or pushes (CLI-1881)", + () => { + // `studio.openai_api_key` is a `config.Secret` field Go's `DecryptSecretHookFunc` + // still decrypts during `config.Load` — but no `config-sync/*.sync.ts` file (api, + // db, auth, storage, experimental) ever reads `studio.*`, so this proves the + // pre-check is genuinely document-wide, not merely reachable via `auth.*`. + const toml = `project_id = "test" +[storage] +enabled = false +[auth] +enabled = false +[studio] +openai_api_key = "${DOTENVX_ENCRYPTED_VALUE}" +`; + const { layer, api } = setup({ toml, yes: true }); + return withDotenvPrivateKey( + undefined, + Effect.gen(function* () { + const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => + Effect.succeed(error.message), + ), + ); + expect(message).toBe("failed to parse config: missing private key"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)), + ); + }, + ); + + it.live("aborts on an undecryptable [db.vault] secret (CLI-1881)", () => { + // `db.vault` decodes as `map[string]Secret` in Go (`pkg/config/db.go:96`), so Go's + // decrypt hook covers it during `config.Load` — but the shared + // `legacyAssertDecryptableSecrets` path list used to omit `db.vault` on the theory + // that only the db-config reader's own downstream vault loop needed it. `config push` + // never runs that downstream loop, so this proves the shared path list now covers + // `db.vault` for every caller. + const toml = `project_id = "test" +[storage] +enabled = false +[auth] +enabled = false +[db.vault] +my_secret = "${DOTENVX_ENCRYPTED_VALUE}" +`; + const { layer, api } = setup({ toml, yes: true }); + return withDotenvPrivateKey( + undefined, + Effect.gen(function* () { + const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => + Effect.succeed(error.message), + ), + ); + expect(message).toBe("failed to parse config: missing private key"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)), + ); + }); + + it.live( + "aborts on an undecryptable secret in a deprecated [auth.external.slack] block (CLI-1881)", + () => { + // `@supabase/config` strips `auth.external.{linkedin,slack}` from `loaded.document` + // before returning it (`normalizeDeprecatedExternalProviders`), matching Go's + // `external.validate()` — but Go's decrypt hook runs at DECODE time, strictly before + // that later validate-time deletion, so a `secret` hiding in one of these deprecated + // blocks still aborts Go's load. This proves the pre-check folds + // `removedDeprecatedExternalProviders` back in rather than missing it. + const toml = `project_id = "test" +[storage] +enabled = false +[auth] +enabled = false +[auth.external.slack] +secret = "${DOTENVX_ENCRYPTED_VALUE}" +`; + const { layer, api } = setup({ toml, yes: true }); + return withDotenvPrivateKey( + undefined, + Effect.gen(function* () { + const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => + Effect.succeed(error.message), + ), + ); + expect(message).toBe("failed to parse config: missing private key"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)), + ); + }, + ); + it.live("pushes storage when enabled and changed", () => { const toml = `project_id = "test" [auth] diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index 10fcddfcf8..0e11809a56 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -736,13 +736,20 @@ const resolveOptionalBoolOrFail = Effect.fnUntraced(function* ( * Dotted paths of every `config.Secret`-typed field Go decrypts via its global * `DecryptSecretHookFunc` (`pkg/config/secret.go`, `config.go:730`) — the hook only runs * while decoding INTO a `config.Secret`, never over arbitrary strings. `*` matches any map - * key (`auth.external.`, `auth.hook.`). `[db.vault]` (a `map[string]Secret`) - * is intentionally omitted — the reader decrypts it directly in the body with the same - * fail-on-undecryptable behaviour. Derived from the Go structs (`auth.go`, `db.go`, - * `config.go`); update alongside any new `Secret` field. + * key (`auth.external.`, `auth.hook.`, `db.vault.`). `[db.vault]` + * (a `map[string]Secret`, `pkg/config/db.go:96`) IS included — the db-config reader below + * also decrypts it directly in its own body ({@link legacyReadDbToml}'s `vault` loop) with + * the same fail-on-undecryptable behaviour, so for that caller this just detects the same + * failure a little earlier (no observable difference: same `legacyDecryptSecret` call, same + * `failed to parse config: ` message). For `config push` — the other caller of + * {@link legacyAssertDecryptableSecrets}, which has no such downstream vault pass — omitting + * `db.vault` here would let an undecryptable vault secret through to the API calls, unlike Go. + * Derived from the Go structs (`auth.go`, `db.go`, `config.go`); update alongside any new + * `Secret` field. */ const LEGACY_SECRET_PATHS: ReadonlyArray> = [ ["db", "root_key"], + ["db", "vault", "*"], ["auth", "publishable_key"], ["auth", "secret_key"], ["auth", "jwt_secret"], @@ -786,19 +793,17 @@ const legacyCollectSecretStrings = ( } }; -/** Fails when a single `encrypted:` secret value cannot be decrypted (Go's hook error). */ +/** Returns Go's hook-error message when a single `encrypted:` secret value cannot be decrypted. */ const legacyAssertSecretValue = ( value: string, lookup: EnvLookup, dotenvPrivateKeys: ReadonlyArray, -): LegacyDbConfigLoadError | undefined => { +): string | undefined => { const expanded = legacyExpandEnv(value, lookup); // Unset `env(...)` and plain strings are returned verbatim by Go's hook (no error). if (ENV_PATTERN.test(expanded) || !legacyIsEncryptedSecret(expanded)) return undefined; const decrypted = legacyDecryptSecret(expanded, dotenvPrivateKeys); - return decrypted.ok - ? undefined - : new LegacyDbConfigLoadError({ message: `failed to parse config: ${decrypted.error}` }); + return decrypted.ok ? undefined : `failed to parse config: ${decrypted.error}`; }; /** @@ -808,15 +813,27 @@ const legacyAssertSecretValue = ( * `Secret` field paths ({@link LEGACY_SECRET_PATHS}) are scanned — a non-secret string that * merely starts with `encrypted:` (e.g. an auth email-template `subject`) stays plain text * in Go and must not block the load. Go decodes every `[remotes.]` block into the same - * struct, so the same paths are checked under each remote too. Returns the failure (or - * `undefined`); the caller surfaces it via `Effect.fail`. + * struct, so the same paths are checked under each remote too. Returns Go's error message (or + * `undefined`); callers surface it as their own domain error (e.g. `Effect.fail`). + * + * Shared across command families: the db-config reader below uses it for `db push`/`db + * reset`/`migration up|down`/etc, and `config push`'s handler reuses it directly against its + * own (`@supabase/config`-decoded) document — both need the exact same "decrypt-or-abort before + * anything else runs" behaviour Go gets for free from `config.Load`. + * + * The "check every `[remotes.]` block too" part of that contract only holds when `doc` + * still has an intact `remotes` key. The db-config reader's own remote-merge keeps it (so this + * function really does check every declared remote there), but `@supabase/config`'s + * `loadProjectConfig` strips `remotes` from the document once a block matches the target ref — + * so for `config push`, an undecryptable secret hiding in a *different, non-matching* remote + * block goes unchecked (see that command's SIDE_EFFECTS.md KNOWN GAPS). */ -const legacyAssertDecryptableSecrets = ( +export const legacyAssertDecryptableSecrets = ( doc: unknown, lookup: EnvLookup, dotenvPrivateKeys: ReadonlyArray, -): LegacyDbConfigLoadError | undefined => { - const scan = (node: unknown): LegacyDbConfigLoadError | undefined => { +): string | undefined => { + const scan = (node: unknown): string | undefined => { for (const segs of LEGACY_SECRET_PATHS) { const values: Array = []; legacyCollectSecretStrings(node, segs, 0, values); @@ -990,11 +1007,13 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // EVERY `config.Secret` field during `UnmarshalExact`, so an `encrypted:` secret anywhere // in the merged config that cannot be decrypted (e.g. no DOTENV_PRIVATE_KEY) aborts the // load with `failed to parse config: ` (secret.go:34,103; config.go:704) — before - // Validate and before connecting. The reader otherwise only decrypts `[db.vault]`, so - // assert decryptability across the whole document to match Go (a recursive scan tracks - // Go's "decode the entire config" better than a hand-listed set of Secret paths). + // Validate and before connecting. This also covers `[db.vault]` (see + // `LEGACY_SECRET_PATHS`), so the vault loop below never actually reaches an + // undecryptable value — it just decrypts-and-populates the already-asserted-valid ones. const secretError = legacyAssertDecryptableSecrets(effectiveDoc, lookup, dotenvPrivateKeys); - if (secretError !== undefined) return yield* Effect.fail(secretError); + if (secretError !== undefined) { + return yield* Effect.fail(new LegacyDbConfigLoadError({ message: secretError })); + } } // Go: `config.go:626` — read the linked pooler URL from `.temp/pooler-url` and diff --git a/apps/cli/src/legacy/shared/legacy-vault-decrypt.ts b/apps/cli/src/legacy/shared/legacy-vault-decrypt.ts index 5c1c0d53d2..f5826a5218 100644 --- a/apps/cli/src/legacy/shared/legacy-vault-decrypt.ts +++ b/apps/cli/src/legacy/shared/legacy-vault-decrypt.ts @@ -63,8 +63,12 @@ function decryptWithKey(keyHex: string, encryptedValue: string): LegacyDecrypted let privateKeyHex: string; try { privateKeyHex = PrivateKey.fromHex(keyHex).toHex(); - } catch (cause) { - return { ok: false, error: `failed to hex decode private key: ${errorMessage(cause)}` }; + } catch { + // Fixed message rather than the underlying error (Go's `ecies.NewPrivateKeyFromHex` + // returns the generic "cannot decode hex string" too): the fallback hex decoder some + // runtimes take for a malformed key can otherwise echo a fragment of the bad input + // (offending character + index) into this error, which reaches the user's stderr. + return { ok: false, error: "failed to hex decode private key: cannot decode hex string" }; } const encoded = encryptedValue.slice(ENCRYPTED_PREFIX.length); // Node's `Buffer.from(s, "base64")` silently drops invalid characters, unlike diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index 151ce2a373..8052399091 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -35,6 +35,18 @@ export interface LoadedProjectConfig { * `undefined` when no `projectRef` was requested or none matched. */ readonly appliedRemote?: string; + /** + * The top-level `auth.external.{linkedin,slack}` sub-objects that were stripped from + * {@link document} before it was returned (provider id → the removed object), keyed by + * provider id. Empty when neither deprecated block was present. See + * `normalizeDeprecatedExternalProviders`'s doc comment for why a caller doing its own + * Go-parity scan over `document` (e.g. a decrypt-or-abort secret check) may need to fold + * this back in — Go's decode-time decrypt hook sees these blocks before its later + * validate-time deletion, so `document` alone under-reports what Go would have decrypted. + * Present (possibly `{}`) whenever {@link document} is; absent from `saveProjectConfig`'s + * result, which has no document to strip from. + */ + readonly removedDeprecatedExternalProviders?: Readonly>; } /** @@ -441,6 +453,19 @@ interface NormalizedExternalProvidersDocument { readonly document: unknown; /** Provider ids (`"linkedin"` | `"slack"`) whose deprecated top-level block was `enabled` — drives the WARN. */ readonly deprecatedProviders: ReadonlyArray; + /** + * The removed top-level `auth.external.{linkedin,slack}` sub-objects (provider id → the + * object that was deleted), regardless of `enabled`. Go's global `DecryptSecretHookFunc` + * runs during decode — strictly BEFORE `Config.Validate()` → `external.validate()` (which + * this function mirrors) ever deletes these blocks (`config.go:753,775-783` decode vs. + * `config.go:882,1148,1419-1425` validate) — so an `encrypted:` secret hiding in one of + * these blocks still gets decrypted-or-aborted in Go. A caller that needs to reproduce that + * decrypt-or-abort check against the returned (already-stripped) {@link LoadedProjectConfig.document} + * (e.g. `config push`'s pre-check) can fold this back in. Only the top-level blocks are + * captured, not any surviving `remotes.*.auth.external.{linkedin,slack}` — that's the + * separate, already-documented "non-matching remote" gap. + */ + readonly removedProviders: Readonly>; } const DEPRECATED_EXTERNAL_PROVIDERS = ["linkedin", "slack"] as const; @@ -476,15 +501,17 @@ function normalizeDeprecatedExternalProviders( document: unknown, ): NormalizedExternalProvidersDocument { if (!isObject(document)) { - return { document, deprecatedProviders: [] }; + return { document, deprecatedProviders: [], removedProviders: {} }; } const normalized = { ...document }; const deprecatedProviders: Array = []; + const removedProviders: Record = {}; if (isObject(normalized.auth) && isObject(normalized.auth.external)) { const external = { ...normalized.auth.external }; for (const ext of DEPRECATED_EXTERNAL_PROVIDERS) { const provider = external[ext]; if (provider === undefined) continue; + removedProviders[ext] = provider; if (isObject(provider) && provider.enabled === true) { deprecatedProviders.push(ext); } @@ -506,7 +533,7 @@ function normalizeDeprecatedExternalProviders( }), ); } - return { document: normalized, deprecatedProviders }; + return { document: normalized, deprecatedProviders, removedProviders }; } /** @@ -740,8 +767,11 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( // Strip Go's deprecated `auth.external.{linkedin,slack}` provider ids from // the POST-remote-merge document, matching `external.validate()` running // once on the final effective config (see `normalizeDeprecatedExternalProviders`). - const { document: normalizedForDecode, deprecatedProviders } = - normalizeDeprecatedExternalProviders(documentForDecode); + const { + document: normalizedForDecode, + deprecatedProviders, + removedProviders, + } = normalizeDeprecatedExternalProviders(documentForDecode); // Warn on stderr, matching Go's `external.validate()` (`config.go:1418-1423`). // Go's own format string is a raw string literal ending in a literal // backslash-n (raw string literals never process escapes, and `Fprintf` @@ -768,6 +798,7 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( ignoredPaths: [], document: isObject(normalizedForDecode) ? normalizedForDecode : undefined, appliedRemote, + removedDeprecatedExternalProviders: removedProviders, } satisfies LoadedProjectConfig; }); From e83a6ccea65010ad6f0ca6a6bf5c20d68f521ed5 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Thu, 9 Jul 2026 19:30:44 +0200 Subject: [PATCH 37/49] ci: exempt private org members from the contribution gate (#5848) The contribution gate identified internal maintainers solely from the PR's `author_association`, which GitHub only reports as `MEMBER` when a user's organization membership is public. A private org member (e.g. a `supabase/cli` team member who keeps membership private) is reported as `CONTRIBUTOR`/`NONE`, so the gate wrongly closed their PRs as "no-linked-issue" (see #5847). Resolve maintainer status from the author's effective repository permission (`admin`/`write`), which reflects team/org-granted access that `author_association` does not surface, falling back to it only when the cheap signals (bot, public internal association) are inconclusive. The permission endpoint needs just `Metadata: read`, already covered by the workflow's `contents: read`. Claude-Session: https://claude.ai/code/session_01D41gYiFBSU7adE4ppnUUKg --------- Co-authored-by: Claude --- .github/scripts/contribution-gate.test.ts | 180 ++++++++++++++++------ .github/scripts/contribution-gate.ts | 155 +++++++++++++++---- .github/workflows/contribution-gate.yml | 1 + 3 files changed, 251 insertions(+), 85 deletions(-) diff --git a/.github/scripts/contribution-gate.test.ts b/.github/scripts/contribution-gate.test.ts index a8292a3a35..7951de817a 100644 --- a/.github/scripts/contribution-gate.test.ts +++ b/.github/scripts/contribution-gate.test.ts @@ -1,20 +1,47 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { evaluateAllOpenPrs, evaluateGate, + fetchAuthorPermission, GATE_LABEL, type GateIo, + isInternalAuthor, type LinkedIssue, type OpenPr, } from "./contribution-gate.ts"; const REPO = "supabase/cli"; +describe("isInternalAuthor", () => { + test.each(["OWNER", "MEMBER", "COLLABORATOR"])( + "treats internal association %s as internal without a permission lookup", + (authorAssociation) => { + expect(isInternalAuthor(authorAssociation, undefined)).toBe(true); + }, + ); + + test.each(["admin", "write"])("treats push-capable permission %s as internal", (permission) => { + // A private org member surfaces only as CONTRIBUTOR/NONE via + // author_association, but their effective repo permission gives them + // away as a maintainer. + expect(isInternalAuthor("CONTRIBUTOR", permission)).toBe(true); + expect(isInternalAuthor("NONE", permission)).toBe(true); + }); + + test.each(["read", "none", undefined])( + "treats external author with permission %s as external", + (permission) => { + expect(isInternalAuthor("CONTRIBUTOR", permission)).toBe(false); + expect(isInternalAuthor("NONE", permission)).toBe(false); + }, + ); +}); + describe("evaluateGate", () => { test("skips bot authors", () => { const result = evaluateGate({ repository: REPO, - authorAssociation: "NONE", + isInternal: false, isBot: true, linkedIssues: [], }); @@ -22,24 +49,21 @@ describe("evaluateGate", () => { expect(result.reason).toBe("bot"); }); - test.each(["OWNER", "MEMBER", "COLLABORATOR"])( - "skips internal author association %s", - (authorAssociation) => { - const result = evaluateGate({ - repository: REPO, - authorAssociation, - isBot: false, - linkedIssues: [], - }); - expect(result.pass).toBe(true); - expect(result.reason).toBe("internal"); - }, - ); + test("skips internal authors", () => { + const result = evaluateGate({ + repository: REPO, + isInternal: true, + isBot: false, + linkedIssues: [], + }); + expect(result.pass).toBe(true); + expect(result.reason).toBe("internal"); + }); test("fails when no issue is linked", () => { const result = evaluateGate({ repository: REPO, - authorAssociation: "NONE", + isInternal: false, isBot: false, linkedIssues: [], }); @@ -52,11 +76,9 @@ describe("evaluateGate", () => { test("fails when the linked issue is open but not labeled", () => { const result = evaluateGate({ repository: REPO, - authorAssociation: "CONTRIBUTOR", + isInternal: false, isBot: false, - linkedIssues: [ - { repository: REPO, number: 12, state: "OPEN", labels: ["🐛 Bug"] }, - ], + linkedIssues: [{ repository: REPO, number: 12, state: "OPEN", labels: ["🐛 Bug"] }], }); expect(result.pass).toBe(false); expect(result.reason).toBe("missing-label"); @@ -66,11 +88,9 @@ describe("evaluateGate", () => { test("fails when the only labeled issue is closed", () => { const result = evaluateGate({ repository: REPO, - authorAssociation: "NONE", + isInternal: false, isBot: false, - linkedIssues: [ - { repository: REPO, number: 7, state: "CLOSED", labels: [GATE_LABEL] }, - ], + linkedIssues: [{ repository: REPO, number: 7, state: "CLOSED", labels: [GATE_LABEL] }], }); expect(result.pass).toBe(false); expect(result.reason).toBe("issue-closed"); @@ -79,7 +99,7 @@ describe("evaluateGate", () => { test("passes when a linked issue is open and carries the gate label", () => { const result = evaluateGate({ repository: REPO, - authorAssociation: "NONE", + isInternal: false, isBot: false, linkedIssues: [ { @@ -97,7 +117,7 @@ describe("evaluateGate", () => { test("passes when any one of several linked issues qualifies", () => { const result = evaluateGate({ repository: REPO, - authorAssociation: "FIRST_TIME_CONTRIBUTOR", + isInternal: false, isBot: false, linkedIssues: [ { repository: REPO, number: 1, state: "CLOSED", labels: [GATE_LABEL] }, @@ -114,7 +134,7 @@ describe("evaluateGate", () => { // controlled by the contributor, so it must not satisfy the gate. const result = evaluateGate({ repository: REPO, - authorAssociation: "NONE", + isInternal: false, isBot: false, linkedIssues: [ { @@ -132,7 +152,7 @@ describe("evaluateGate", () => { test("matches the repository case-insensitively", () => { const result = evaluateGate({ repository: REPO, - authorAssociation: "NONE", + isInternal: false, isBot: false, linkedIssues: [ { @@ -152,61 +172,75 @@ describe("evaluateAllOpenPrs", () => { function makeIo( openPrs: OpenPr[], linkedByPr: Record, - ): { io: GateIo; closed: Array<{ number: number; message: string }> } { + permissionByLogin: Record = {}, + ): { + io: GateIo; + closed: Array<{ number: number; message: string }>; + permissionLookups: string[]; + } { const closed: Array<{ number: number; message: string }> = []; + const permissionLookups: string[] = []; const io: GateIo = { listOpenPrs: () => Promise.resolve(openPrs), - fetchLinkedIssues: (prNumber) => - Promise.resolve(linkedByPr[prNumber] ?? []), + fetchPermission: (login) => { + permissionLookups.push(login); + return Promise.resolve(permissionByLogin[login]); + }, + fetchLinkedIssues: (prNumber) => Promise.resolve(linkedByPr[prNumber] ?? []), closePr: (prNumber, message) => { closed.push({ number: prNumber, message }); return Promise.resolve(); }, }; - return { io, closed }; + return { io, closed, permissionLookups }; } test("closes only non-conforming external PRs and leaves the rest", async () => { - const { io, closed } = makeIo( + const { io, closed, permissionLookups } = makeIo( [ - { number: 1, authorAssociation: "NONE", isBot: false }, // no issue -> close - { number: 2, authorAssociation: "MEMBER", isBot: false }, // internal -> skip - { number: 3, authorAssociation: "NONE", isBot: true }, // bot -> skip - { number: 4, authorAssociation: "CONTRIBUTOR", isBot: false }, // conforming -> keep - { number: 5, authorAssociation: "NONE", isBot: false }, // missing label -> close + // no issue -> close + { number: 1, authorLogin: "ext-a", authorAssociation: "NONE", isBot: false }, + // public member -> skip (no permission lookup needed) + { number: 2, authorLogin: "maint", authorAssociation: "MEMBER", isBot: false }, + // bot -> skip + { number: 3, authorLogin: "dependabot", authorAssociation: "NONE", isBot: true }, + // conforming external -> keep + { number: 4, authorLogin: "ext-b", authorAssociation: "CONTRIBUTOR", isBot: false }, + // missing label -> close + { number: 5, authorLogin: "ext-c", authorAssociation: "NONE", isBot: false }, + // private-membership maintainer (CONTRIBUTOR + write access) -> skip + { number: 6, authorLogin: "hidden-maint", authorAssociation: "CONTRIBUTOR", isBot: false }, ], { - 4: [ - { repository: REPO, number: 40, state: "OPEN", labels: [GATE_LABEL] }, - ], - 5: [ - { repository: REPO, number: 50, state: "OPEN", labels: ["🐛 Bug"] }, - ], + 4: [{ repository: REPO, number: 40, state: "OPEN", labels: [GATE_LABEL] }], + 5: [{ repository: REPO, number: 50, state: "OPEN", labels: ["🐛 Bug"] }], }, + { "hidden-maint": "write" }, ); const entries = await evaluateAllOpenPrs(io, REPO); expect(closed.map((c) => c.number).sort((a, b) => a - b)).toEqual([1, 5]); - const byNumber = Object.fromEntries( - entries.map((entry) => [entry.number, entry.result]), - ); + const byNumber = Object.fromEntries(entries.map((entry) => [entry.number, entry.result])); expect(byNumber[1]?.reason).toBe("no-linked-issue"); expect(byNumber[2]?.pass).toBe(true); expect(byNumber[2]?.reason).toBe("internal"); expect(byNumber[3]?.reason).toBe("bot"); expect(byNumber[4]?.pass).toBe(true); expect(byNumber[5]?.reason).toBe("missing-label"); + expect(byNumber[6]?.pass).toBe(true); + expect(byNumber[6]?.reason).toBe("internal"); expect(closed.find((c) => c.number === 1)?.message).toContain(GATE_LABEL); + // Bots and public members are settled without a permission lookup. + expect(permissionLookups).not.toContain("maint"); + expect(permissionLookups).not.toContain("dependabot"); }); test("returns an entry per PR and closes none when all conform", async () => { const { io, closed } = makeIo( - [{ number: 9, authorAssociation: "NONE", isBot: false }], + [{ number: 9, authorLogin: "ext", authorAssociation: "NONE", isBot: false }], { - 9: [ - { repository: REPO, number: 90, state: "OPEN", labels: [GATE_LABEL] }, - ], + 9: [{ repository: REPO, number: 90, state: "OPEN", labels: [GATE_LABEL] }], }, ); @@ -217,3 +251,47 @@ describe("evaluateAllOpenPrs", () => { expect(entries[0]?.result.pass).toBe(true); }); }); + +describe("fetchAuthorPermission", () => { + const fetchSpy = spyOn(globalThis, "fetch"); + afterEach(() => { + fetchSpy.mockReset(); + }); + + function stubFetch(status: number, body: unknown): void { + // `typeof fetch` carries a static `preconnect`, so attach a no-op to keep + // the implementation assignable without casting. + fetchSpy.mockImplementation( + Object.assign(() => Promise.resolve(new Response(JSON.stringify(body), { status })), { + preconnect: () => {}, + }), + ); + } + + test("returns the effective permission for a collaborator", async () => { + stubFetch(200, { permission: "admin" }); + const permission = await fetchAuthorPermission("t", "supabase", "cli", "maint"); + expect(permission).toBe("admin"); + }); + + test("maps a 404 (non-collaborator fork author) to undefined", async () => { + // The endpoint 404s for users who are not collaborators — the common case + // for the external contributors the gate targets. It must map to external, + // not abort the run. + stubFetch(404, { message: "Not Found" }); + const permission = await fetchAuthorPermission("t", "supabase", "cli", "ext"); + expect(permission).toBeUndefined(); + }); + + test("throws on other API failures so the run aborts without closing PRs", async () => { + stubFetch(403, { message: "Forbidden" }); + await expect(fetchAuthorPermission("t", "supabase", "cli", "ext")).rejects.toThrow(/403/); + }); + + test("skips the network call for a blank login", async () => { + stubFetch(200, { permission: "admin" }); + const permission = await fetchAuthorPermission("t", "supabase", "cli", ""); + expect(permission).toBeUndefined(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/.github/scripts/contribution-gate.ts b/.github/scripts/contribution-gate.ts index ad599b3c81..a2b62ee08f 100644 --- a/.github/scripts/contribution-gate.ts +++ b/.github/scripts/contribution-gate.ts @@ -3,9 +3,11 @@ * OPEN pull requests opened by external contributors. * * A PR passes only when it links to an OPEN GitHub issue that carries the - * `open-for-contribution` label. Members/collaborators/owners and bots are - * exempt (they work from Linear tickets or automation). PRs that do not follow - * the process are commented on and closed. + * `open-for-contribution` label. Maintainers (recognised by their effective + * repository permission, so private org members count too — not just the + * `author_association` GitHub exposes for public members) and bots are exempt + * (they work from Linear tickets or automation). PRs that do not follow the + * process are commented on and closed. * * Two modes, both driven from `main()`: * - single-PR (default): reacts to one PR on `pull_request_target` @@ -25,8 +27,43 @@ export const GATE_LABEL = "open-for-contribution"; -/** Author associations treated as internal (exempt from the gate). */ -const INTERNAL_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); +/** + * Author associations treated as internal (exempt from the gate). + * + * NOTE: `author_association` only reports `MEMBER` when a user's organization + * membership is *public*. A private org member (or a team member who keeps + * their membership private) is reported as `CONTRIBUTOR`/`NONE`, so this set + * alone is not enough to identify internal maintainers — see + * `isInternalAuthor`, which also consults the author's effective repository + * permission. + */ +export const INTERNAL_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); + +/** + * Effective repository permissions that mark an author as internal. A user who + * can push to the repository (directly, or via a team/org grant that + * `author_association` does not surface) is a trusted maintainer, not an + * external contributor. The legacy REST `permission` field collapses the + * `maintain` role to `write`, so `admin`/`write` covers every push-capable + * role. + */ +const WRITE_PERMISSIONS = new Set(["admin", "write"]); + +/** + * Decide whether a PR author is internal (exempt from the gate). Combines the + * cheap `author_association` signal with the author's effective repository + * `permission` (undefined when it could not be resolved), so private org + * members whose association is merely `CONTRIBUTOR` are still recognised. + */ +export function isInternalAuthor( + authorAssociation: string, + permission: string | undefined, +): boolean { + return ( + INTERNAL_ASSOCIATIONS.has(authorAssociation) || + (permission !== undefined && WRITE_PERMISSIONS.has(permission)) + ); +} export interface LinkedIssue { /** `owner/name` of the repo the issue lives in (from GraphQL nameWithOwner). */ @@ -39,7 +76,8 @@ export interface LinkedIssue { export interface GateInput { /** `owner/name` of this repository (from GITHUB_REPOSITORY). */ repository: string; - authorAssociation: string; + /** Whether the author is a trusted maintainer (see `isInternalAuthor`). */ + isInternal: boolean; isBot: boolean; linkedIssues: LinkedIssue[]; } @@ -102,7 +140,7 @@ export function evaluateGate(input: GateInput): GateResult { if (input.isBot) { return { pass: true, reason: "bot" }; } - if (INTERNAL_ASSOCIATIONS.has(input.authorAssociation)) { + if (input.isInternal) { return { pass: true, reason: "internal" }; } @@ -110,9 +148,7 @@ export function evaluateGate(input: GateInput): GateResult { // (e.g. `Closes attacker/repo#1`) links an issue the contributor controls, // so it must never satisfy the gate. const repo = input.repository.toLowerCase(); - const repoIssues = input.linkedIssues.filter( - (issue) => issue.repository.toLowerCase() === repo, - ); + const repoIssues = input.linkedIssues.filter((issue) => issue.repository.toLowerCase() === repo); if (repoIssues.length === 0) { return { @@ -137,6 +173,7 @@ export function evaluateGate(input: GateInput): GateResult { /** Minimal open-PR shape the gate needs to decide exemption. */ export interface OpenPr { number: number; + authorLogin: string; authorAssociation: string; isBot: boolean; } @@ -145,6 +182,12 @@ export interface OpenPr { export interface GateIo { /** List every open pull request in the repository. */ listOpenPrs: () => Promise; + /** + * Resolve an author's effective repository permission (`admin`/`write`/ + * `read`/`none`), or `undefined` when it could not be determined. Used to + * recognise maintainers whose org membership is private. + */ + fetchPermission: (login: string) => Promise; /** Fetch the issues a PR closes (via `closingIssuesReferences`). */ fetchLinkedIssues: (prNumber: number) => Promise; /** Comment with `message` then close the PR. Called only for failing PRs. */ @@ -161,17 +204,22 @@ export interface SweepEntry { * orchestration over the injected `io`; returns each PR's decision so the * caller can log a summary. */ -export async function evaluateAllOpenPrs( - io: GateIo, - repository: string, -): Promise { +export async function evaluateAllOpenPrs(io: GateIo, repository: string): Promise { const openPrs = await io.listOpenPrs(); const entries: SweepEntry[] = []; for (const pr of openPrs) { + // Only pay for a permission lookup when the cheap signals are inconclusive: + // bots are exempt outright, and a public internal association already + // proves membership. + let isInternal = pr.isBot || INTERNAL_ASSOCIATIONS.has(pr.authorAssociation); + if (!isInternal) { + const permission = await io.fetchPermission(pr.authorLogin); + isInternal = isInternalAuthor(pr.authorAssociation, permission); + } const linkedIssues = await io.fetchLinkedIssues(pr.number); const result = evaluateGate({ repository, - authorAssociation: pr.authorAssociation, + isInternal, isBot: pr.isBot, linkedIssues, }); @@ -204,6 +252,8 @@ async function githubFetch( url: string, token: string, init: Omit = {}, + /** Non-OK statuses to return to the caller instead of throwing on. */ + allowStatuses: readonly number[] = [], ): Promise { const response = await fetch(url, { ...init, @@ -214,11 +264,9 @@ async function githubFetch( "Content-Type": "application/json", }, }); - if (!response.ok) { + if (!response.ok && !allowStatuses.includes(response.status)) { const body = await response.text(); - throw new Error( - `GitHub request failed (${response.status}) for ${url}: ${body}`, - ); + throw new Error(`GitHub request failed (${response.status}) for ${url}: ${body}`); } return response; } @@ -262,12 +310,9 @@ async function fetchLinkedIssues( }; }; if (payload.errors?.length) { - throw new Error( - `GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`, - ); + throw new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`); } - const nodes = - payload.data?.repository?.pullRequest?.closingIssuesReferences?.nodes ?? []; + const nodes = payload.data?.repository?.pullRequest?.closingIssuesReferences?.nodes ?? []; return nodes.map((node) => ({ repository: node.repository.nameWithOwner, number: node.number, @@ -279,7 +324,7 @@ async function fetchLinkedIssues( interface RestPullRequest { number: number; author_association: string; - user: { type: string } | null; + user: { login: string; type: string } | null; } async function fetchOpenPullRequests( @@ -297,6 +342,7 @@ async function fetchOpenPullRequests( for (const pr of batch) { prs.push({ number: pr.number, + authorLogin: pr.user?.login ?? "", authorAssociation: pr.author_association, isBot: pr.user?.type === "Bot", }); @@ -308,6 +354,38 @@ async function fetchOpenPullRequests( return prs; } +/** + * Resolve an author's effective permission on the repository. Reflects access + * granted directly or through a team/org membership, so it recognises private + * org members that `author_association` reports only as `CONTRIBUTOR`. This + * endpoint needs just `Metadata: read` (covered by the workflow's + * `contents: read`). + * + * A 404 means the author is not a collaborator at all — the common case for + * external fork contributors, who are exactly who the gate targets — so it maps + * to `undefined` (external) rather than throwing. Other failures still throw so + * a transient API error aborts the run without wrongly closing PRs. + */ +export async function fetchAuthorPermission( + token: string, + owner: string, + repo: string, + login: string, +): Promise { + if (!login) { + return undefined; + } + const url = + `https://api.github.com/repos/${owner}/${repo}/collaborators/` + + `${encodeURIComponent(login)}/permission`; + const response = await githubFetch(url, token, {}, [404]); + if (response.status === 404) { + return undefined; + } + const payload = (await response.json()) as { permission?: string }; + return payload.permission; +} + /** * Build the "comment then close" action for a repo. In dry-run mode it logs the * intended action instead of mutating the PR. @@ -342,19 +420,31 @@ async function runSinglePr( ): Promise { const prNumber = Number(requireEnv("PR_NUMBER")); const authorAssociation = process.env.PR_AUTHOR_ASSOCIATION ?? "NONE"; + const authorLogin = process.env.PR_AUTHOR_LOGIN ?? ""; const isBot = (process.env.PR_AUTHOR_TYPE ?? "User") === "Bot"; + // Resolve maintainer status from the effective repository permission unless a + // cheaper signal already settles it. `author_association` only exposes public + // org membership, so a private member must be confirmed via permission. + let permission: string | undefined; + let isInternal = isBot || INTERNAL_ASSOCIATIONS.has(authorAssociation); + if (!isInternal) { + permission = await fetchAuthorPermission(token, owner, repo, authorLogin); + isInternal = isInternalAuthor(authorAssociation, permission); + } + const linkedIssues = await fetchLinkedIssues(token, owner, repo, prNumber); const result = evaluateGate({ repository, - authorAssociation, + isInternal, isBot, linkedIssues, }); console.log( `Contribution gate for PR #${prNumber}: pass=${result.pass} reason=${result.reason} ` + - `(author_association=${authorAssociation}, bot=${isBot}, linked_issues=${linkedIssues.length})`, + `(author_association=${authorAssociation}, permission=${permission ?? "n/a"}, ` + + `bot=${isBot}, linked_issues=${linkedIssues.length})`, ); if (result.pass || !result.message) { @@ -377,8 +467,8 @@ async function runSweep( const base = `https://api.github.com/repos/${owner}/${repo}`; const io: GateIo = { listOpenPrs: () => fetchOpenPullRequests(token, owner, repo), - fetchLinkedIssues: (prNumber) => - fetchLinkedIssues(token, owner, repo, prNumber), + fetchPermission: (login) => fetchAuthorPermission(token, owner, repo, login), + fetchLinkedIssues: (prNumber) => fetchLinkedIssues(token, owner, repo, prNumber), closePr: makeCloser(token, base, dryRun), }; @@ -389,9 +479,7 @@ async function runSweep( `${failing.length} ${dryRun ? "would be " : ""}closed.`, ); for (const entry of entries) { - console.log( - ` PR #${entry.number}: pass=${entry.result.pass} reason=${entry.result.reason}`, - ); + console.log(` PR #${entry.number}: pass=${entry.result.pass} reason=${entry.result.reason}`); } } @@ -400,8 +488,7 @@ async function main(): Promise { const repository = requireEnv("GITHUB_REPOSITORY"); const [owner, repo] = repository.split("/"); - const allMode = - process.env.GATE_MODE === "all" || process.argv.includes("--all"); + const allMode = process.env.GATE_MODE === "all" || process.argv.includes("--all"); if (allMode) { const dryRun = /^(1|true)$/i.test(process.env.DRY_RUN ?? ""); await runSweep(token, owner!, repo!, repository, dryRun); diff --git a/.github/workflows/contribution-gate.yml b/.github/workflows/contribution-gate.yml index 54582d7790..6dfeee2bf0 100644 --- a/.github/workflows/contribution-gate.yml +++ b/.github/workflows/contribution-gate.yml @@ -51,6 +51,7 @@ jobs: GITHUB_REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} PR_AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} + PR_AUTHOR_LOGIN: ${{ github.event.pull_request.user.login }} PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} run: bun .github/scripts/contribution-gate.ts From 58bd3c3099d99e7923cec79b8df3ff4856f0469e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:13:24 +0000 Subject: [PATCH 38/49] fix(deps): bump github.com/posthog/posthog-go from 1.17.2 to 1.17.4 in /apps/cli-go in the go-minor group across 1 directory (#5849) Bumps the go-minor group with 1 update in the /apps/cli-go directory: [github.com/posthog/posthog-go](https://github.com/posthog/posthog-go). Updates `github.com/posthog/posthog-go` from 1.17.2 to 1.17.4
Release notes

Sourced from github.com/posthog/posthog-go's releases.

1.17.4

Unreleased

1.17.3

Unreleased

Changelog

Sourced from github.com/posthog/posthog-go's changelog.

1.17.4

Patch Changes

  • 25e43f8: Stop duplicating distinct_id inside /flags person properties.

1.17.3

Patch Changes

  • dafed74: Retry remote feature flag requests after transient 502 and 504 responses.
Commits
  • 93b9681 chore: release v1.17.4 [version bump] [skip ci]
  • 25e43f8 fix: stop duplicating distinct_id in flags person properties (#250)
  • 01e60bf chore: release v1.17.3 [version bump] [skip ci]
  • dafed74 fix: Retry flags requests on 502 and 504 (#251)
  • ed70fae fix: make TCP drop recovery test deterministic (#248)
  • 2b6e187 ci: update SDK harness to 0.9.0 (#247)
  • 856a0d4 fix: satisfy SDK compliance harness 0.8.0 (#245)
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/posthog/posthog-go&package-manager=go_modules&previous-version=1.17.2&new-version=1.17.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli-go/go.mod | 2 +- apps/cli-go/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli-go/go.mod b/apps/cli-go/go.mod index 507f7f885f..00a84266a6 100644 --- a/apps/cli-go/go.mod +++ b/apps/cli-go/go.mod @@ -42,7 +42,7 @@ require ( github.com/multigres/multigres v0.0.0-20260126223308-f5a52171bbc4 github.com/oapi-codegen/nullable v1.2.0 github.com/olekukonko/tablewriter v1.1.4 - github.com/posthog/posthog-go v1.17.2 + github.com/posthog/posthog-go v1.17.4 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/apps/cli-go/go.sum b/apps/cli-go/go.sum index bdde20104c..9d22c07d69 100644 --- a/apps/cli-go/go.sum +++ b/apps/cli-go/go.sum @@ -970,8 +970,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posthog/posthog-go v1.17.2 h1:w0TaCAd+Z3WoEgVyR/nlcXlqNN2tpoBfIyxuGssDgCE= -github.com/posthog/posthog-go v1.17.2/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU= +github.com/posthog/posthog-go v1.17.4 h1:7olsPzTGzuQvXJtH9n55u/Vgy58nB7V8T85AIpRjd8Q= +github.com/posthog/posthog-go v1.17.4/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU= github.com/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= From 1415b2f6c9d98910a253cc808687c78d00c141c5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:13:58 +0000 Subject: [PATCH 39/49] fix(deps): bump the npm-major group with 7 updates (#5850) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the npm-major group with 7 updates: | Package | From | To | | --- | --- | --- | | [undici](https://github.com/nodejs/undici) | `8.5.0` | `8.6.0` | | [@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript) | `0.3.198` | `0.3.199` | | [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript) | `0.109.1` | `0.110.0` | | [posthog-node](https://github.com/PostHog/posthog-js/tree/HEAD/packages/node) | `5.39.2` | `5.39.4` | | [@typescript/native-preview](https://github.com/microsoft/typescript-go) | `7.0.0-dev.20260701.1` | `7.0.0-dev.20260702.3` | | [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) | `6.23.0` | `6.24.0` | | [tldts](https://github.com/remusao/tldts) | `6.1.86` | `7.4.6` | Updates `undici` from 8.5.0 to 8.6.0
Release notes

Sourced from undici's releases.

v8.6.0

What's Changed

New Contributors

Full Changelog: https://github.com/nodejs/undici/compare/v8.5.0...v8.6.0

Commits
  • 6f86196 Bumped v8.6.0 (#5491)
  • 61ddd8e fix: requeue h2 requests after goaway (#5473)
  • c5085ef feat: support HTTP QUERY method (RFC 10008) (#5459)
  • c144fd6 build(deps): bump github/codeql-action/analyze from 4.36.1 to 4.36.2 (#5477)
  • 782ad38 build(deps): bump github/codeql-action from 4.36.1 to 4.36.2 (#5484)
  • c4e045a build(deps): bump actions/checkout from 6.0.3 to 7.0.0 (#5482)
  • de960db build(deps): bump codecov/codecov-action from 6.0.1 to 7.0.0 (#5478)
  • 1132de8 build(deps): bump fastify/github-action-merge-dependabot (#5480)
  • 3acb7ba build(deps): bump github/codeql-action/upload-sarif (#5481)
  • 88c4254 build(deps): bump github/codeql-action/init from 4.36.1 to 4.36.2 (#5476)
  • Additional commits viewable in compare view

Updates `@anthropic-ai/claude-agent-sdk` from 0.3.198 to 0.3.199
Release notes

Sourced from @​anthropic-ai/claude-agent-sdk's releases.

v0.3.199

What's changed

  • Added requestId to canUseTool callback options for correlating out-of-band permission responses, and support for returning null to suppress the SDK's automatic control response
  • Added blocked field to workflow_agent progress events indicating when an agent was blocked by the auto-mode safety classifier
  • Added mode:"mask" and per-credential injectHosts to sandbox.credentials settings types for injecting masked credentials into sandboxed commands

Update

npm install @anthropic-ai/claude-agent-sdk@0.3.199
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.199
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.199
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.199
Changelog

Sourced from @​anthropic-ai/claude-agent-sdk's changelog.

0.3.199

  • Added requestId to canUseTool callback options for correlating out-of-band permission responses, and support for returning null to suppress the SDK's automatic control response
  • Added blocked field to workflow_agent progress events indicating when an agent was blocked by the auto-mode safety classifier
  • Added mode:"mask" and per-credential injectHosts to sandbox.credentials settings types for injecting masked credentials into sandboxed commands
Commits

Updates `@anthropic-ai/sdk` from 0.109.1 to 0.110.0
Release notes

Sourced from @​anthropic-ai/sdk's releases.

sdk: v0.110.0

0.110.0 (2026-07-02)

Full Changelog: sdk-v0.109.1...sdk-v0.110.0

Features

  • api: add agent-memory-2026-07-22 beta header (a470e10)
Changelog

Sourced from @​anthropic-ai/sdk's changelog.

0.110.0 (2026-07-02)

Full Changelog: sdk-v0.109.1...sdk-v0.110.0

Features

  • api: add agent-memory-2026-07-22 beta header (a470e10)
Commits

Updates `posthog-node` from 5.39.2 to 5.39.4
Release notes

Sourced from posthog-node's releases.

posthog-node@5.39.4

5.39.4

Patch Changes

posthog-node@5.39.3

5.39.3

Patch Changes

  • #4055 64e04ba Thanks @​marandaneto! - Retry /flags requests that receive HTTP 502 or 504 responses across SDKs that use the shared core flags client. (2026-07-02)
  • Updated dependencies [64e04ba]:
    • @​posthog/core@​1.39.4
Changelog

Sourced from posthog-node's changelog.

5.39.4

Patch Changes

5.39.3

Patch Changes

  • #4055 64e04ba Thanks @​marandaneto! - Retry /flags requests that receive HTTP 502 or 504 responses across SDKs that use the shared core flags client. (2026-07-02)
  • Updated dependencies [64e04ba]:
    • @​posthog/core@​1.39.4
Commits
  • 0ba87cb chore: update versions and lockfile [version bump]
  • 0c11747 fix: stop duplicating distinct_id in flags person properties (#4047)
  • 4eb8b21 chore: update versions and lockfile [version bump]
  • dd90e55 test(node): make local polling tests deterministic (#4043)
  • See full diff in compare view

Updates `@typescript/native-preview` from 7.0.0-dev.20260701.1 to 7.0.0-dev.20260702.3
Commits

Updates `knip` from 6.23.0 to 6.24.0
Release notes

Sourced from knip's releases.

Release 6.24.0

  • chore: update year in license (#1833) (32bc844dfd3895884b11fea5ef94bf3fa1974946) - thanks @​trueberryless!
  • ci: pin github actions (#1835) (82a8d0913105a637e9eeccfe3b785be90c873e2a) - thanks @​trueberryless!
  • Assume Node 24+/Bun and remove compilation step (d9ef038429bec06c37fdb520e5c7353c8cae6ce7)
  • Don't report working-directory scripts as unlisted binaries (resolve #1834) (aea7923438f0a8f459458578526f358b02497f77)
  • Add pnpm run lint to CI workflow (ec9aa1cabb58c97b8ecc4815e6cdd6421fe2a89e)
  • feat: add settings for Zed editor to use oxlint and oxfmt (#1836) (111f2e0a17999e3ffdf4c097cd42ba08acd48508) - thanks @​trueberryless!
  • fix: remove format_on_save: true settings from Zed settings to respect user settings (#1838) (dc2a64043035d426eb99a9d1e0eb873d02a09e7d) - thanks @​trueberryless!
  • feat: add Renovate for GitHub actions only (#1839) (ffce88c86f95822ee2a7cf8407e987a3ec79b097) - thanks @​trueberryless!
  • Ignore import() in JSDoc examples (#1844) (6f090f90f04e8202700ed68977faa1dc626ff235) - thanks @​cyphercodes!
  • Don't report types used only in module augmentations as unused (resolve #1843) (7901abd3c4b496f212445bff9768efc9548ded61)
  • Restore CI intent (0d739beab2e224385f449d62d6cc7d904107946e)
  • feat: add less, stylus compilers and astro, svelte, vue import resolution (#1845) (5525759f33a5664e4882aebe239e9c17eeb29f92) - thanks @​trueberryless!
  • Don't report built-in compiler dependencies as unused (3c9d4adf369f0064399d9da7b4f11f0467180bf5)
  • feat: add scss handling to stencil plugin (#1846) (acba6b85ab05f70385228872fafea2b08290d0f6) - thanks @​johnjenkins!
  • fix(reporters): always print the issue-type title for a single group (#1848) (cf997b2408cc0814c2d310d1e8c8680340153fa1) - thanks @​morgan-coded!
  • Export Issue, IssueRecords and IssueType types (resolve #1840) (260f19230f42c9dceaac97d55bf34d17902c5b38)
  • Squeeze every bit of perf out around compilers (bb0eeb6e33d050dab736134b5b692a3d6413f358)
Commits
  • 2979803 Release knip@6.24.0
  • bb0eeb6 Squeeze every bit of perf out around compilers
  • 260f192 Export Issue, IssueRecords and IssueType types (resolve #1840)
  • cf997b2 fix(reporters): always print the issue-type title for a single group (#1848)
  • acba6b8 feat: add scss handling to stencil plugin (#1846)
  • 3c9d4ad Don't report built-in compiler dependencies as unused
  • 5525759 feat: add less, stylus compilers and astro, svelte, vue import resolution (#1...
  • 7901abd Don't report types used only in module augmentations as unused (resolve #1843)
  • 6f090f9 Ignore import() in JSDoc examples (#1844)
  • aea7923 Don't report working-directory scripts as unlisted binaries (resolve #1834)
  • See full diff in compare view

Updates `tldts` from 6.1.86 to 7.4.6
Release notes

Sourced from tldts's releases.

v7.4.6

:scroll: Update Public Suffix List

  • tldts-experimental, tldts

:nut_and_bolt: Dependencies

Authors: 2

v7.4.5

:scroll: Update Public Suffix List

  • tldts-experimental, tldts

Authors: 1

v7.4.4

:scroll: Update Public Suffix List

  • tldts-experimental, tldts

Authors: 1

v7.4.3

:scroll: Update Public Suffix List

  • tldts-experimental, tldts

... (truncated)

Changelog

Sourced from tldts's changelog.

v7.4.6 (Thu Jul 02 2026)

:scroll: Update Public Suffix List

  • tldts-experimental, tldts

:nut_and_bolt: Dependencies

Authors: 2


v7.4.5 (Sun Jun 28 2026)

:scroll: Update Public Suffix List

  • tldts-experimental, tldts

Authors: 1


v7.4.4 (Tue Jun 23 2026)

:scroll: Update Public Suffix List

  • tldts-experimental, tldts

Authors: 1

... (truncated)

Commits
Maintainer changes

This version was pushed to npm by GitHub Actions, a new releaser for tldts since your current version.


Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli/package.json | 6 +- packages/api/package.json | 2 +- pnpm-lock.yaml | 252 +++++++++++++++++++------------------- pnpm-workspace.yaml | 6 +- 4 files changed, 133 insertions(+), 133 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index c6c604f1ff..e7dd1a8292 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -43,8 +43,8 @@ "jose": "^6.2.3" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.198", - "@anthropic-ai/sdk": "^0.109.1", + "@anthropic-ai/claude-agent-sdk": "^0.3.199", + "@anthropic-ai/sdk": "^0.110.0", "@clack/prompts": "^1.6.0", "@effect/atom-react": "catalog:", "@effect/platform-bun": "catalog:", @@ -76,7 +76,7 @@ "oxlint-tsgolint": "catalog:", "pg": "^8.22.0", "pg-copy-streams": "^7.0.0", - "posthog-node": "^5.39.2", + "posthog-node": "^5.39.4", "react": "^19.2.7", "react-devtools-core": "^7.0.1", "semantic-release": "^25.0.5", diff --git a/packages/api/package.json b/packages/api/package.json index 463c09af39..32bf5d93ce 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -23,7 +23,7 @@ "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "effect": "catalog:", - "undici": "^8.5.0" + "undici": "^8.6.0" }, "devDependencies": { "@tsconfig/bun": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef5928c0fa..00f08c3538 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,8 +37,8 @@ catalogs: specifier: ^1.3.14 version: 1.3.14 '@typescript/native-preview': - specifier: 7.0.0-dev.20260701.1 - version: 7.0.0-dev.20260701.1 + specifier: 7.0.0-dev.20260702.3 + version: 7.0.0-dev.20260702.3 '@vitest/coverage-istanbul': specifier: ^4.1.9 version: 4.1.9 @@ -46,8 +46,8 @@ catalogs: specifier: 4.0.0-beta.93 version: 4.0.0-beta.93 knip: - specifier: ^6.17.1 - version: 6.23.0 + specifier: ^6.24.0 + version: 6.25.0 nx: specifier: ^23.0.0 version: 23.0.1 @@ -61,8 +61,8 @@ catalogs: specifier: ^0.24.0 version: 0.24.0 tldts: - specifier: ^7.4.5 - version: 7.4.5 + specifier: ^7.4.6 + version: 7.4.8 vitest: specifier: ^4.1.9 version: 4.1.9 @@ -97,11 +97,11 @@ importers: version: 6.2.3 devDependencies: '@anthropic-ai/claude-agent-sdk': - specifier: ^0.3.198 - version: 0.3.198(@anthropic-ai/sdk@0.109.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + specifier: ^0.3.199 + version: 0.3.199(@anthropic-ai/sdk@0.110.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@anthropic-ai/sdk': - specifier: ^0.109.1 - version: 0.109.1(zod@4.4.3) + specifier: ^0.110.0 + version: 0.110.0(zod@4.4.3) '@clack/prompts': specifier: ^1.6.0 version: 1.6.0 @@ -155,7 +155,7 @@ importers: version: 19.2.17 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260701.1 + version: 7.0.0-dev.20260702.3 '@vercel/detect-agent': specifier: ^1.2.3 version: 1.2.3 @@ -179,7 +179,7 @@ importers: version: 5.0.0(ink@7.1.0(@types/react@19.2.17)(react-devtools-core@7.0.1)(react@19.2.7))(react@19.2.7) knip: specifier: 'catalog:' - version: 6.23.0 + version: 6.25.0 oxfmt: specifier: 'catalog:' version: 0.57.0 @@ -196,8 +196,8 @@ importers: specifier: ^7.0.0 version: 7.0.0 posthog-node: - specifier: ^5.39.2 - version: 5.39.2 + specifier: ^5.39.4 + version: 5.39.4 react: specifier: ^19.2.7 version: 19.2.7 @@ -212,7 +212,7 @@ importers: version: 1.7.0 tldts: specifier: 'catalog:' - version: 7.4.5 + version: 7.4.8 vitest: specifier: 'catalog:' version: 4.1.9(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -259,13 +259,13 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260701.1 + version: 7.0.0-dev.20260702.3 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) knip: specifier: 'catalog:' - version: 6.23.0 + version: 6.25.0 oxfmt: specifier: 'catalog:' version: 0.57.0 @@ -328,8 +328,8 @@ importers: specifier: 'catalog:' version: 4.0.0-beta.93 undici: - specifier: ^8.5.0 - version: 8.5.0 + specifier: ^8.6.0 + version: 8.6.0 devDependencies: '@tsconfig/bun': specifier: 'catalog:' @@ -339,13 +339,13 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260701.1 + version: 7.0.0-dev.20260702.3 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) knip: specifier: 'catalog:' - version: 6.23.0 + version: 6.25.0 oxfmt: specifier: 'catalog:' version: 0.57.0 @@ -381,13 +381,13 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260701.1 + version: 7.0.0-dev.20260702.3 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) knip: specifier: 'catalog:' - version: 6.23.0 + version: 6.25.0 oxfmt: specifier: 'catalog:' version: 0.57.0 @@ -431,13 +431,13 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260701.1 + version: 7.0.0-dev.20260702.3 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) knip: specifier: 'catalog:' - version: 6.23.0 + version: 6.25.0 oxfmt: specifier: 'catalog:' version: 0.57.0 @@ -471,13 +471,13 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260701.1 + version: 7.0.0-dev.20260702.3 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) knip: specifier: 'catalog:' - version: 6.23.0 + version: 6.25.0 oxfmt: specifier: 'catalog:' version: 0.57.0 @@ -523,13 +523,13 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260701.1 + version: 7.0.0-dev.20260702.3 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) knip: specifier: 'catalog:' - version: 6.23.0 + version: 6.25.0 oxfmt: specifier: 'catalog:' version: 0.57.0 @@ -570,60 +570,60 @@ packages: resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} engines: {node: '>=18'} - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.198': - resolution: {integrity: sha512-ZmiAybQKIKcP1qEAE/vfXvfxtKxG9CnJn98QTXC5Zxiwuy7Mllx2ALXh9dfmsf0V87CGEodlZQmMgUJotNIsUw==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.199': + resolution: {integrity: sha512-0813IEsPlA3GQZ86CGmRPh/2DY/iEuBkXV7CRAj55sQ30oIm+N/BbXzI/jH7WiWsHh3pCsRx65dbswf6jA5Uuw==} cpu: [arm64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.198': - resolution: {integrity: sha512-XwH5vgN46WSwg8aC1OagNofnJpV/G1ciEu118GEKer8ZhVkq/dvK/DqShxMkb6r1jV7u5IJ7zPXu9uKliyNJAw==} + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.199': + resolution: {integrity: sha512-iUenoE7UbWFfYjdfaeJkPl4qpXajPcRw8SzMYn0PK2LsBE5SJTfEdZyvtypNaKiWzsCCaU4MV1yzR8S7i/wZQg==} cpu: [x64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.198': - resolution: {integrity: sha512-Q7lKVNjIrUQ2B/AR77OvRf0zeOdEjonFVaR9FYrrwtzGeEqum69WSht5nM7Y7el3wjbNi0/eV0QTUM0DlsTEfw==} + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.199': + resolution: {integrity: sha512-mcqHuNHsA2V589rt0JpCsweS8kZ8LkKZi0qmMLqN3oZz+ar4DsuDjLDaNI1C5f+W/nz5G/st+2W3ZCJubeOqVQ==} cpu: [arm64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.198': - resolution: {integrity: sha512-qmz8dxEtDIlKntU5qYe0R4aWTxTue5S7zIQknatLX7aJ6HN/nq1aCNXWn5smTH2FViBkUPPR+sCIsNwSk6AT6Q==} + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.199': + resolution: {integrity: sha512-wJ4GJCwrdVv4TWTiEwh2gUjrddIg+oMhoUcfXhtZgZeqXfGzxVWJa3HudX5xsVq/wktvM65CfdYly2blVBVG8Q==} cpu: [arm64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.198': - resolution: {integrity: sha512-h1SrWVIMjLInYNPlf+TxXuKTOdoiOfJLBSoQG97315Z2Nh0IpBfqWExlqYTtPCgKE7q2iga31U283QfHpIDlSQ==} + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.199': + resolution: {integrity: sha512-wr7IIQ9d9H7Tt79Mn4ga8iNLzsvoPnAbBtsnUVBjgcAxKWrJ+QV2wCsowhhSc7DWNueiurdxNsLIwd07BzhfCA==} cpu: [x64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.198': - resolution: {integrity: sha512-Zqxyz2AT1UM5WlOOoLJhLssZDgZo8rBK5ku6daveK12zp+UTJGZhGsjFghz1/ASxH08KqOTbUePNTORnPhHAEQ==} + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.199': + resolution: {integrity: sha512-nNLk8KcM33AYQ0P1cNK72kE14iTIuM5RTr0CrQm40FwdrPuVZ/se6KggpE3eje8hFYJu/1WrJqcZxtJSL6qnPg==} cpu: [x64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.198': - resolution: {integrity: sha512-mjIHf1HFiRuXefewWTaNZFlTZlCaEt/xsRjc1nSTCEEpFolZayVhrDKz+O2QFVcDtPl8x8GeYSL0kiikg1DZjQ==} + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.199': + resolution: {integrity: sha512-EQFMATdpp6XXYXw6Ih83BFCDj2PXqMYkM6nq3OrOVUF19xTe6o5dWhGzZ6TQfauvWf2BHFIfBDzZz82m8ffV8w==} cpu: [arm64] os: [win32] - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.198': - resolution: {integrity: sha512-y3HLuCCz1kDwUrhd6OnqO+d5BUpTFSzNUsPT9kf3r1vk9HYKF+eMC9eIlcOhiW2kX491kxEvuEOfqgIkGx15cg==} + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.199': + resolution: {integrity: sha512-6W2djU/NnOCY4NEdDjtZ3LIzFFQMT2OH9m5y5Hc4bko3KIXr0WHLrCJdgGiVycOWEd03OEsC4uYIIYIeueBAHw==} cpu: [x64] os: [win32] - '@anthropic-ai/claude-agent-sdk@0.3.198': - resolution: {integrity: sha512-xt469sSCyclTPtzpLAg0Aschy665GiRMgZKabSmESbGUA5/H56HcILVOiFxclXswkeMUk2fQxfHJUY9UZfiTnA==} + '@anthropic-ai/claude-agent-sdk@0.3.199': + resolution: {integrity: sha512-fET9rfYR2DgjVG4Yri02Q6YL+SWBlcrsd3Dx96CpZSM50W4Jqh1sEC06hXIEHL0uDZ/kS//Y2uPpCQ9GQqh8Bg==} engines: {node: '>=18.0.0'} peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' '@modelcontextprotocol/sdk': ^1.29.0 zod: ^4.0.0 - '@anthropic-ai/sdk@0.109.1': - resolution: {integrity: sha512-q9OnEKLr5H9nxSuXdgDgJhxfYMiE+AaUEBze2Gk91UcaaLnsN+Lx5fbCYywiqurU/APLdwv23x03Wm6WN3EBsg==} + '@anthropic-ai/sdk@0.110.0': + resolution: {integrity: sha512-hOP4bNYXDFHDxxiEgzlILXrxZIYCDnhe8sry0RDRKD/QnsEpvZcQpablCdm9X/WuD/YgOiSIkkqsL1mLLlTqJw==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -2110,8 +2110,8 @@ packages: resolution: {integrity: sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==} engines: {node: '>=12'} - '@posthog/core@1.40.0': - resolution: {integrity: sha512-oGDbIwlTquNwdHbEL5ZLEkuW4UFkkEanfx3QAxDgyVbISv+OAA6YGQwrvo0JD3MUJEbJZvyh8XsX+WYDGw9XHw==} + '@posthog/core@1.40.1': + resolution: {integrity: sha512-jXuMtZCwA7AMpYlo1wjm6GlC58YBlz/ZxpDIsF9hroUfqYoPPDmQbsQb4iiZAS43+o/kwloxdKJIlE0IiwQ5HQ==} '@posthog/types@1.393.0': resolution: {integrity: sha512-vzWeEJZ7ERQhFRoQYaP5jzN1JvIu46UJyHXsuv+dTGW2r3sMgREOhNxXLZjmFHwZ8/FOHQoyqqQmXTCXZSfMSg==} @@ -2867,50 +2867,50 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260701.1': - resolution: {integrity: sha512-rm3v+3Mcrj91NPwG0mjaLfbJjydCDX/skF82cQw0K6XWsEK7wHmpLEF1xPOWGnV05RhTYJac9VoKUjgcADkbXA==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260702.3': + resolution: {integrity: sha512-sswJ7XRSH17/jDv6eqlRyNzjQev4w+TH+16RgeoZkKbQfZnVuLchF4ORCecEuia15X2yE8pbiQGY596LVbGKdA==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260701.1': - resolution: {integrity: sha512-jtbtV3A+cmkmDuqs56Qj3Hb+NmOAMHFX+FTLhCagJybjsng1lOl1h8vYaYg3F6EMgSiZI66hIpB9TMi3n8jFEw==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260702.3': + resolution: {integrity: sha512-j8ZYT/chWtx3QT1Bghp0/+2g6aKLaxRVpXEHt6Fjdj4OXQTpH4pljOE0+LeAqaET88PUo/XM9pi0TJ5TJwHLMg==} engines: {node: '>=16.20.0'} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260701.1': - resolution: {integrity: sha512-SPEfXZhCRff6kPYrqJIkKkKBy1rxPk0Wmam7U0AOuMQtXTNPmqCtx54J/F6nfGR89ATTDTDr69MujFAG+lFQVg==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260702.3': + resolution: {integrity: sha512-FEBt9UER27yvn3o4quXZ9fQ/Hd39hxFPljJe8dxstCvyYLh0A9TwDuuwghWCisp5U9TDWWtl8m1GLjvqIKc6IA==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260701.1': - resolution: {integrity: sha512-c22PWjLMecd2xsAZRlpC1mnr8GZUjnFS32URPd2NhKOEx/6Dp7bPZvI+7NNXDXTew/fkgAaZvhLTWHGG64gqGw==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260702.3': + resolution: {integrity: sha512-SM73Mp3oYUWkyHmym0MVfPnMeLGM6oo1vcPZTxVoL7BOKWkJKK+iM7D1tcqDSZNPwURSkWMxP17Dh+4JPqzikg==} engines: {node: '>=16.20.0'} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260701.1': - resolution: {integrity: sha512-Y9NX6qGOCQD4zdCXJCABB8TL3IkFsWVlwMQ3OGtQ8A/OzyIFN01QyHWFgB5AJFZQoasryb+nnhu9r2IY5KpBwA==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260702.3': + resolution: {integrity: sha512-CY/r7gHjDnf3xWptpQVRQnagMdUDbh+zL01SF+cTJsg1WVY3TtVNrCo48dwAnJL+U97qx1SKaGVsi/8cSPwrZA==} engines: {node: '>=16.20.0'} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260701.1': - resolution: {integrity: sha512-7IRD34O2Kp7mIplQEC/HgLyehmKL1FGlTYhlAqxxZfKhSt694yytd2dCCnxMMA/aPHjcqBke04wNPOOhQ2ezcA==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260702.3': + resolution: {integrity: sha512-9U/ahdJAWZdYxPdNry3VAhB9avaQCiqsEk2PfheeKrKSKCtfs5FoPXy/QXAaIGIBK/Lw4a7OqyVyEbd0i77TkQ==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260701.1': - resolution: {integrity: sha512-zVfayHxvFZAQ0hJbegDcfhJamyQpLn6ahMDATAra+EkA3+/nbTW3VjCJ3h1V2PSNTY9rTPzGroFURV5cvErh3A==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260702.3': + resolution: {integrity: sha512-kPqfivHuzzEK2NFEl2QeCCiWJ4qkGMJYFX9TmJ4ylh784XICgQLX3PJe7OCqZcUicfeMIIDu6m3flcCi+4tW4Q==} engines: {node: '>=16.20.0'} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260701.1': - resolution: {integrity: sha512-eXPtWsAj0s06kHWVDlBj7ABwoyNDHuW2gCbQ+bYGlyymDYteiAydelhyhyzUsenzGraPLdd/OPwEUB8/KUdpBw==} + '@typescript/native-preview@7.0.0-dev.20260702.3': + resolution: {integrity: sha512-j21laxUja23ex6qYGjwiSiQ6YpS/p5E7lCMDYKDBAdBv93Z1NzV96BZV3gdRwl+buH09EPuBAiqxKQqRO1grhw==} engines: {node: '>=16.20.0'} hasBin: true @@ -4633,8 +4633,8 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - knip@6.23.0: - resolution: {integrity: sha512-2DvAOX2pZWiG4SLvRRxOAU0aWGEn1ZoVblI541xIoXFdHqq2THMZXy66/qcY5WGuW3TXhb9T1x1zd/Hd1u+yqg==} + knip@6.25.0: + resolution: {integrity: sha512-Q3n41VjOOB/aqsbxb8kallAcFKrUz3b2S5fD5pTODljVpP01t+rvAgy2x3j0Cq8yEpRRHNdar1vHuqFfGuIakQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -5665,8 +5665,8 @@ packages: postgres-range@1.1.4: resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} - posthog-node@5.39.2: - resolution: {integrity: sha512-5piMedjlQ2x+UKLvHWTC5ls5/T1dDZKE1Pu5AKkYh9EkbZOjvu0cac6lWFB7mgbGkKQ0I1bhbjDx1QAYRJ7Unw==} + posthog-node@5.39.4: + resolution: {integrity: sha512-+fCQ7htBFRQQFbIzl1T0TA7bDwYyaB9XP308ZFMCUoB5LzTzOFxBa6TYVrxdH/VQl43WXTp6sf0QsG2Z4XlNBg==} engines: {node: ^20.20.0 || >=22.22.0} peerDependencies: rxjs: ^7.0.0 @@ -6337,15 +6337,15 @@ packages: tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} - tldts-core@7.4.5: - resolution: {integrity: sha512-pGrwzZDvPwKe+7NNUqAunb6rqTfynr0VOUhCMdqbu5xlvNiszsAJygRzwvpVycdzejlbpY+SWJOn+s75Og7FEA==} + tldts-core@7.4.8: + resolution: {integrity: sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==} tldts@6.1.86: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true - tldts@7.4.5: - resolution: {integrity: sha512-RfEzKWcq5fHUOFq7J3rl3Oz6ylKGtcHqUznzj4EcXsxLSIjJcvpbXAQtWGeJQ0xKnimR5e0Cn+cn9TssfMzm+g==} + tldts@7.4.8: + resolution: {integrity: sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==} hasBin: true tmp@0.2.7: @@ -6459,8 +6459,8 @@ packages: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} - undici@8.5.0: - resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==} + undici@8.6.0: + resolution: {integrity: sha512-l2FlC6I510GawyEd1qgcE/okihKrzy+BRTEBlu6T0fdbM9m5yxtIH5Oa3ysRsH0zC4EhmWUEaSDsy2QngBeRlw==} engines: {node: '>=22.19.0'} unicode-emoji-modifier-base@1.0.0: @@ -6852,46 +6852,46 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.198': + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.199': optional: true - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.198': + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.199': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.198': + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.199': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.198': + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.199': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.198': + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.199': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.198': + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.199': optional: true - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.198': + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.199': optional: true - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.198': + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.199': optional: true - '@anthropic-ai/claude-agent-sdk@0.3.198(@anthropic-ai/sdk@0.109.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.199(@anthropic-ai/sdk@0.110.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: - '@anthropic-ai/sdk': 0.109.1(zod@4.4.3) + '@anthropic-ai/sdk': 0.110.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) zod: 4.4.3 optionalDependencies: - '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.198 - '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.198 - '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.198 - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.198 - '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.198 - '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.198 - '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.198 - '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.198 + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.199 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.199 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.199 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.199 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.199 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.199 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.199 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.199 - '@anthropic-ai/sdk@0.109.1(zod@4.4.3)': + '@anthropic-ai/sdk@0.110.0(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 standardwebhooks: 1.0.0 @@ -7069,7 +7069,7 @@ snapshots: effect: 4.0.0-beta.93 ioredis: 5.11.0 mime: 4.1.0 - undici: 8.5.0 + undici: 8.6.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -8010,7 +8010,7 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 - '@posthog/core@1.40.0': + '@posthog/core@1.40.1': dependencies: '@posthog/types': 1.393.0 @@ -8751,36 +8751,36 @@ snapshots: dependencies: '@types/node': 26.1.1 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260701.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260702.3': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260701.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260702.3': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260701.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260702.3': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260701.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260702.3': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260701.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260702.3': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260701.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260702.3': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260701.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260702.3': optional: true - '@typescript/native-preview@7.0.0-dev.20260701.1': + '@typescript/native-preview@7.0.0-dev.20260702.3': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260701.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260701.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260701.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260701.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260701.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260701.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260701.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260702.3 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260702.3 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260702.3 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260702.3 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260702.3 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260702.3 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260702.3 '@ungap/structured-clone@1.3.2': {} @@ -9958,9 +9958,9 @@ snapshots: dependencies: walk-up-path: 4.0.0 - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.5 figures@2.0.0: dependencies: @@ -10745,15 +10745,15 @@ snapshots: dependencies: json-buffer: 3.0.1 - knip@6.23.0: + knip@6.25.0: dependencies: - fdir: 6.5.0(picomatch@4.0.4) + fdir: 6.5.0(picomatch@4.0.5) formatly: 0.3.0 get-tsconfig: 4.14.0 jiti: 2.7.0 oxc-parser: 0.137.0 oxc-resolver: 11.21.3 - picomatch: 4.0.4 + picomatch: 4.0.5 smol-toml: 1.7.0 strip-json-comments: 5.0.3 tinyglobby: 0.2.17 @@ -12071,9 +12071,9 @@ snapshots: postgres-range@1.1.4: {} - posthog-node@5.39.2: + posthog-node@5.39.4: dependencies: - '@posthog/core': 1.40.0 + '@posthog/core': 1.40.1 pretty-ms@9.3.0: dependencies: @@ -12901,8 +12901,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinypool@2.1.0: {} @@ -12910,15 +12910,15 @@ snapshots: tldts-core@6.1.86: {} - tldts-core@7.4.5: {} + tldts-core@7.4.8: {} tldts@6.1.86: dependencies: tldts-core: 6.1.86 - tldts@7.4.5: + tldts@7.4.8: dependencies: - tldts-core: 7.4.5 + tldts-core: 7.4.8 tmp@0.2.7: {} @@ -13002,7 +13002,7 @@ snapshots: undici@7.28.0: {} - undici@8.5.0: {} + undici@8.6.0: {} unicode-emoji-modifier-base@1.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c4b733934e..e5ca91b394 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,15 +22,15 @@ catalog: "@swc/core": "^1.15.43" "@tsconfig/bun": "^1.0.10" "@types/bun": "^1.3.14" - "@typescript/native-preview": "7.0.0-dev.20260701.1" + "@typescript/native-preview": "7.0.0-dev.20260702.3" "@vitest/coverage-istanbul": "^4.1.9" "effect": "4.0.0-beta.93" - "knip": "^6.17.1" + "knip": "^6.24.0" "nx": "^23.0.0" "oxfmt": "^0.57.0" "oxlint": "^1.72.0" "oxlint-tsgolint": "^0.24.0" - "tldts": "^7.4.5" + "tldts": "^7.4.6" "vitest": "^4.1.9" blockExoticSubdeps: true From b17d93826aa77c1436e31a31be5f99e85649b303 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:15:16 +0000 Subject: [PATCH 40/49] chore(ci): bump docker/login-action from 4.3.0 to 4.4.0 in the actions-major group (#5852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the actions-major group with 1 update: [docker/login-action](https://github.com/docker/login-action). Updates `docker/login-action` from 4.3.0 to 4.4.0
Release notes

Sourced from docker/login-action's releases.

v4.4.0

Full Changelog: https://github.com/docker/login-action/compare/v4.3.0...v4.4.0

Commits
  • af1e73f Merge pull request #1034 from docker/dependabot/npm_and_yarn/aws-sdk-dependen...
  • da722bd [dependabot skip] chore: update generated content
  • 2916ad6 build(deps): bump the aws-sdk-dependencies group across 1 directory with 2 up...
  • ca0a662 Merge pull request #1035 from crazy-max/fix-registry-auth-empty-mask
  • c455755 chore: update generated content
  • 4835190 skip empty registry-auth secret mask
  • 992421c Merge pull request #1033 from docker/dependabot/github_actions/docker/bake-ac...
  • b249b43 Merge pull request #1032 from docker/dependabot/github_actions/docker/bake-ac...
  • 1b67977 build(deps): bump docker/bake-action from 7.2.0 to 7.3.0
  • 9d49d6a build(deps): bump docker/bake-action/subaction/matrix
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=docker/login-action&package-manager=github_actions&previous-version=4.3.0&new-version=4.4.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cli-go-mirror-image.yml | 4 ++-- .github/workflows/cli-go-pg-prove.yml | 4 ++-- .github/workflows/cli-go-publish-migra.yml | 4 ++-- .github/workflows/mirror-template-images.yml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cli-go-mirror-image.yml b/.github/workflows/cli-go-mirror-image.yml index 18921bf791..d82bc05002 100644 --- a/.github/workflows/cli-go-mirror-image.yml +++ b/.github/workflows/cli-go-mirror-image.yml @@ -38,10 +38,10 @@ jobs: with: role-to-assume: ${{ secrets.PROD_AWS_ROLE }} aws-region: us-east-1 - - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: public.ecr.aws - - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/cli-go-pg-prove.yml b/.github/workflows/cli-go-pg-prove.yml index 993e78bb0c..323758c1c0 100644 --- a/.github/workflows/cli-go-pg-prove.yml +++ b/.github/workflows/cli-go-pg-prove.yml @@ -45,7 +45,7 @@ jobs: - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 with: endpoint: builders - - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} @@ -67,7 +67,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/cli-go-publish-migra.yml b/.github/workflows/cli-go-publish-migra.yml index 189e6beea4..11b6437666 100644 --- a/.github/workflows/cli-go-publish-migra.yml +++ b/.github/workflows/cli-go-publish-migra.yml @@ -45,7 +45,7 @@ jobs: - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 with: endpoint: builders - - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} @@ -67,7 +67,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/mirror-template-images.yml b/.github/workflows/mirror-template-images.yml index ee7483d2e9..8f46d54288 100644 --- a/.github/workflows/mirror-template-images.yml +++ b/.github/workflows/mirror-template-images.yml @@ -50,7 +50,7 @@ jobs: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - name: Log in to ghcr.io - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} From 43a8798b5eb2653af2b2720be84ee839b4a20ca9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:36:24 +0200 Subject: [PATCH 41/49] fix(docker): bump the docker-minor group in /apps/cli-go/pkg/config/templates with 3 updates (#5851) Bumps the docker-minor group in /apps/cli-go/pkg/config/templates with 3 updates: supabase/realtime, supabase/storage-api and supabase/logflare. Updates `supabase/realtime` from v2.112.9 to v2.112.10 Updates `supabase/storage-api` from v1.64.1 to v1.66.2 Updates `supabase/logflare` from 1.46.0 to 1.47.0 Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 6 +++--- packages/stack/src/versions.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index f285fe5ead..6b9c671d1d 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -11,9 +11,9 @@ FROM supabase/edge-runtime:v1.74.2 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.7 AS supavisor FROM supabase/gotrue:v2.192.0 AS gotrue -FROM supabase/realtime:v2.112.9 AS realtime -FROM supabase/storage-api:v1.64.1 AS storage -FROM supabase/logflare:1.46.0 AS logflare +FROM supabase/realtime:v2.112.10 AS realtime +FROM supabase/storage-api:v1.66.2 AS storage +FROM supabase/logflare:1.47.0 AS logflare # Append to JobImages when adding new dependencies below FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ FROM supabase/migra:3.0.1663481299 AS migra diff --git a/packages/stack/src/versions.ts b/packages/stack/src/versions.ts index e9196cea68..be3d7f5687 100644 --- a/packages/stack/src/versions.ts +++ b/packages/stack/src/versions.ts @@ -50,13 +50,13 @@ export const DEFAULT_VERSIONS: VersionManifest = { postgrest: "14.14", auth: "2.192.0", "edge-runtime": "1.74.2", - realtime: "2.112.9", - storage: "1.64.1", + realtime: "2.112.10", + storage: "1.66.2", imgproxy: "v3.8.0", mailpit: "v1.30.2", pgmeta: "0.96.6", studio: "2026.07.07-sha-a6a04f2", - analytics: "1.46.0", + analytics: "1.47.0", vector: "0.53.0-alpine", pooler: "2.9.7", } as const; From 68344977e5aab57c28ed8f99b10d09be98418828 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 10 Jul 2026 10:39:29 +0100 Subject: [PATCH 42/49] fix(cli): suppress vendored effect CLI's stdout help-dump and duplicate error on parse failures (CLI-1901) (#5844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Bug fix (legacy shell shared CLI runtime). ## What is the current behavior? Fixes [CLI-1901](https://linear.app/supabase/issue/CLI-1901/legacy-cli-required-flagchoice-parse-errors-double-print-and-dump-help), originally surfaced as a side-finding during #5803's review (CLI-1859, `gen bearer-jwt --role required`). Any legacy command whose flag parsing fails with a `CliError.ShowHelp`-wrapped error carrying a non-empty `errors` array (missing required flag, invalid `Flag.choice` value, unrecognized flag, etc.) printed a broken, non-Go-parity error: ``` $ supabase sso add --project-ref DESCRIPTION Add and configure a new connection to a SSO identity provider... USAGE supabase sso add [flags] FLAGS ... [... 20-30 line help doc, printed to STDOUT ...] ERROR Missing required flag: --type Error: required flag(s) "type" not set Try rerunning the command with --debug to troubleshoot the error. ``` Root cause: the vendored `effect@4.0.0-beta.93` CLI library's `Command.runWith` (`.repos/effect/packages/effect/src/unstable/cli/Command.ts`) always dumps the full help doc via `Console.log` and, when `errors.length > 0`, also `Console.error`s the same errors — before this repo's own Go-parity renderer (`normalize-error.ts` + `run.ts`'s `handledProgram`) renders its own line for the same failure. This breaks stdout-piping scripts (e.g. `TOKEN=$(supabase gen bearer-jwt --role "$ROLE")` with an empty `$ROLE`) and violates this repo's own Go-parity contract (`apps/cli/CLAUDE.md`). ## What is the new behavior? `run.ts` gains `withoutParseErrorHelpDump`, which wraps `Command.runWith(rootCommand, ...)(args)` with a buffering `Console.Console` and, once the run's outcome is known, disposes of the buffered writes per `classifyParseErrorConsoleOutput` — a three-way split that mirrors Go cobra's *actual* behavior (verified directly against the built `apps/cli-go/supabase-go` binary, not just assumed): - **`drop`** — a missing required flag (`MissingOption`). Go's `PersistentPreRunE` sets `SilenceUsage = true` (`cmd/root.go:97`) *before* `ValidateRequiredFlags` runs, so this is a single clean stderr line, nothing on stdout. - **`flush-help-doc-to-stderr`** — every other genuine parse/validation failure (unrecognized flag, invalid `Flag.choice` value, missing positional argument, unknown subcommand). These are raised during `ParseFlags`/`ValidateArgs`, *before* `SilenceUsage` is set — Go still shows a usage block for these, always on stderr, never stdout. The library's help doc isn't byte-identical to cobra's shorter usage template (see judgement calls below), but it's now on the right stream with no duplicate. - **`flush-unchanged`** — success, `--help`, `--version`, `--completions`, and the bare-group-command help dump (`errors: []`, exit 0) — all untouched. In every genuine-failure case, the library's own duplicate `Console.error` render is dropped; this repo's own `handledProgram` + `normalizeCause` render the single Go-parity line instead. `normalize-error.ts`'s `ShowHelp`-envelope unwrap was also extended: since the library's duplicate render is now gone, a wrapped inner error with no Go-parity-specific mapping (`UnrecognizedOption`, `DuplicateOption`, `MissingArgument`, `UnknownSubcommand`, or a non-doubled-prefix `InvalidValue`) now falls back to that inner error's own `.message` instead of the useless generic "Help requested" envelope text — otherwise removing the duplicate would have silently regressed those tags from "informative, but printed twice" to "printed once, uselessly." Tests: pure-predicate unit tests for the three-way classifier (`run.unit.test.ts`), integration tests exercising the real buffering/flush wiring against `legacyBranchesCommand` and a synthetic required-flag command via a `Console.Console` test double (`run.integration.test.ts` — not `vi.spyOn`, which reliably breaks call detection when spying `console.log` and `console.error` in the same test under this repo's Bun+Vitest combination), and e2e tests proving the real subprocess stdout/stderr streams against `branches --bogus-flag` and `sso add` (missing/invalid `--type`, the issue's own named repro target). ### Judgement calls left open - **Wording, not structure**: some of this repo's existing error wording still differs from Go's exact text for these paths (e.g. Go's bare `required flag(s) "type" not set` vs this repo's `Error: required flag(s) "type" not set` with an extra `Error: ` prefix; Go's `invalid argument "bogus" for "-t, --type" flag: must be one of [ saml ]` vs this repo's `Invalid value for flag --type: "bogus". Expected "saml", got "bogus"`). Both pre-date this fix and are unrelated to the stdout-pollution/duplicate-print bug it targets — flagged by review as a possible follow-up, not fixed here to keep this PR's diff focused. - **Usage-block content, not stream**: the help/usage text now correctly lands on stderr (never stdout) for the `flush-help-doc-to-stderr` class, but its *content* is this library's own (longer) help doc, not cobra's shorter usage template. True byte-parity there would need a second, purpose-built "usage-only" formatter — out of scope for this fix. - **Pre-existing terminal-escape echo**: user-supplied argv tokens (e.g. an unrecognized flag name) are echoed verbatim into error messages with no control-character stripping. Unaffected in kind by this change (just relocated/de-duplicated); flagged by security review as low-priority and better suited to its own issue if the team wants to harden it. - The issue's secondary, explicitly-optional finding (`internal/command.ts:243` misreporting `Flag.optional` status in `--output-format json` help docs) is a separate, unrelated code path in the same vendored module — deliberately left unaddressed here to keep this PR scoped to the double-print/stdout-dump bug. --- apps/cli-e2e/src/tests/migrations.e2e.test.ts | 11 +- .../src/legacy/cli/agent-output.e2e.test.ts | 32 +- .../src/legacy/commands/sso/sso.e2e.test.ts | 49 +++ apps/cli/src/shared/cli/run.e2e.test.ts | 38 ++ .../src/shared/cli/run.integration.test.ts | 247 ++++++++++++- apps/cli/src/shared/cli/run.ts | 303 +++++++++++++++- apps/cli/src/shared/cli/run.unit.test.ts | 343 +++++++++++++++++- .../shared/cli/subcommand-flag-suggestions.ts | 65 ++++ apps/cli/src/shared/output/normalize-error.ts | 69 +++- .../output/normalize-error.unit.test.ts | 180 ++++++++- packages/config/src/io.ts | 15 +- 11 files changed, 1313 insertions(+), 39 deletions(-) diff --git a/apps/cli-e2e/src/tests/migrations.e2e.test.ts b/apps/cli-e2e/src/tests/migrations.e2e.test.ts index 0e3dbc932f..45b02ef3bc 100644 --- a/apps/cli-e2e/src/tests/migrations.e2e.test.ts +++ b/apps/cli-e2e/src/tests/migrations.e2e.test.ts @@ -45,7 +45,10 @@ describe("migrations", () => { testBehaviour("exits non-zero without name argument", async ({ run }) => { const result = await run(["migration", "new"]); expect(result.exitCode).not.toBe(0); - expect(result.stdout).toContain("migration name"); + // CLI-1901: a missing positional argument's usage block now prints to + // stderr (never stdout) instead of being duplicated across both. + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("migration name"); }); }); @@ -90,7 +93,11 @@ describe("migrations", () => { testBehaviour("exits non-zero when --status flag is missing", async ({ run }) => { const result = await run(["migration", "repair", "--local", "20230101000000"]); expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("--status"); + // CLI-1901: a missing required flag now drops the vendored library's + // duplicate usage dump entirely (Go's cobra suppresses usage for this + // case too), leaving only this repo's existing Go-parity error line, + // which spells the flag name without its `--` prefix. + expect(result.stderr).toContain('"status" not set'); }); testBehaviour("exits non-zero on connection refused", async ({ run }) => { diff --git a/apps/cli/src/legacy/cli/agent-output.e2e.test.ts b/apps/cli/src/legacy/cli/agent-output.e2e.test.ts index d6625c88f7..15c124caa2 100644 --- a/apps/cli/src/legacy/cli/agent-output.e2e.test.ts +++ b/apps/cli/src/legacy/cli/agent-output.e2e.test.ts @@ -38,19 +38,18 @@ describe("legacy CLI agent output", () => { }); expect(exitCode).toBe(1); + // CLI-1901: the vendored effect CLI library's own duplicate JSON render + // (the old `{_tag:"Help"}` + `{_tag:"Error", error:{code:"ShowHelp"}}` + // pair on stdout, `{_tag:"Errors"}` on stderr) is gone. stdout carries + // exactly this repo's single Go-parity error line; the library's help + // doc is redirected to stderr instead of being dropped or duplicated. expect(parseJsonLines(stdout)).toEqual([ - expect.objectContaining({ _tag: "Help" }), expect.objectContaining({ _tag: "Error", - error: expect.objectContaining({ code: "ShowHelp" }), - }), - ]); - expect(parseJsonLines(stderr)).toEqual([ - expect.objectContaining({ - _tag: "Errors", - errors: [expect.objectContaining({ code: "UnknownSubcommand" })], + error: expect.objectContaining({ code: "UnknownSubcommand" }), }), ]); + expect(parseJsonLines(stderr)).toEqual([expect.objectContaining({ _tag: "Help" })]); }); test("keeps parse errors in text mode when --output-format=text is explicit", async () => { @@ -63,7 +62,9 @@ describe("legacy CLI agent output", () => { ); expect(exitCode).toBe(1); - expect(stdout).toContain("DESCRIPTION"); + // CLI-1901: the help doc no longer prints to stdout at all. + expect(stdout).toBe(""); + expect(stderr).toContain("DESCRIPTION"); expect(stderr).toContain('Unknown subcommand "definitely-not-a-command"'); }); @@ -77,7 +78,8 @@ describe("legacy CLI agent output", () => { ); expect(exitCode).toBe(1); - expect(stdout).toContain("DESCRIPTION"); + expect(stdout).toBe(""); + expect(stderr).toContain("DESCRIPTION"); expect(stderr).toContain('Unknown subcommand "definitely-not-a-command"'); }); @@ -92,18 +94,12 @@ describe("legacy CLI agent output", () => { expect(exitCode).toBe(1); expect(parseJsonLines(stdout)).toEqual([ - expect.objectContaining({ _tag: "Help" }), expect.objectContaining({ _tag: "Error", - error: expect.objectContaining({ code: "ShowHelp" }), - }), - ]); - expect(parseJsonLines(stderr)).toEqual([ - expect.objectContaining({ - _tag: "Errors", - errors: [expect.objectContaining({ code: "UnknownSubcommand" })], + error: expect.objectContaining({ code: "UnknownSubcommand" }), }), ]); + expect(parseJsonLines(stderr)).toEqual([expect.objectContaining({ _tag: "Help" })]); }); test("keeps built-in version and help in text mode for detected coding agents", async () => { diff --git a/apps/cli/src/legacy/commands/sso/sso.e2e.test.ts b/apps/cli/src/legacy/commands/sso/sso.e2e.test.ts index 795b71200a..b2b1636c51 100644 --- a/apps/cli/src/legacy/commands/sso/sso.e2e.test.ts +++ b/apps/cli/src/legacy/commands/sso/sso.e2e.test.ts @@ -69,4 +69,53 @@ describe("supabase sso (legacy)", () => { expect(`${stdout}${stderr}`).toContain(`identity provider ID "not-a-uuid" is not a UUID`); }, ); + + // CLI-1901: `add`'s `--type` has no `Flag.optional` (see `add.command.ts`) + // — Go marks it required via `MarkFlagRequired("type")` (`cmd/sso.go:65`) + // — so a missing/invalid `--type` used to dump the full help doc to + // stdout AND print the error twice on stderr. No auth/network call ever + // happens for either case: flag parsing fails before the handler runs. + // + // A missing required flag and an invalid choice value get different + // treatment, matching the real `apps/cli-go/supabase-go` binary (verified + // directly against it): Go's `PersistentPreRunE` sets `SilenceUsage = true` + // (`cmd/root.go:97`) BEFORE `ValidateRequiredFlags` runs, so a missing + // `--type` is a single clean stderr line with no usage block — but + // `Flag.choice` validation happens during `ParseFlags`, BEFORE that point, + // so Go still shows a usage block for an invalid `--type` value, always on + // stderr, never stdout. + test( + "add without --type: stdout stays clean, stderr is a single Go-parity line (no usage block)", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout, stderr } = await runSupabase( + ["sso", "add", "--project-ref", TEST_PROJECT_REF], + { entrypoint: "legacy" }, + ); + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain(`required flag(s) "type" not set`); + expect(stderr).not.toContain("USAGE"); + expect(stderr.trim().split("\n")).toHaveLength(2); + }, + ); + + test( + "add with an invalid --type value: stdout stays clean, the usage content and the single error line land on stderr with no duplicate", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout, stderr } = await runSupabase( + ["sso", "add", "--type", "bogus", "--project-ref", TEST_PROJECT_REF], + { entrypoint: "legacy" }, + ); + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain("USAGE"); + const occurrences = stderr.split(`Invalid value for flag --type: "bogus"`).length - 1; + expect(occurrences).toBe(1); + expect( + stderr.trim().endsWith("Try rerunning the command with --debug to troubleshoot the error."), + ).toBe(true); + }, + ); }); diff --git a/apps/cli/src/shared/cli/run.e2e.test.ts b/apps/cli/src/shared/cli/run.e2e.test.ts index 1f399eb37e..3c90257fe2 100644 --- a/apps/cli/src/shared/cli/run.e2e.test.ts +++ b/apps/cli/src/shared/cli/run.e2e.test.ts @@ -22,3 +22,41 @@ describe("legacy CLI process exit codes (CLI-1906)", () => { expect(exitCode).toBe(1); }); }); + +/** + * CLI-1901: a required-flag/choice parse failure used to dump the full help + * doc to **stdout** and print the error **twice** on stderr. The buffering + * mechanism that fixes this (`withoutParseErrorHelpDump` in `run.ts`) is + * already covered against a real command definition, in-process, by + * `run.integration.test.ts` — this is the one minimal case that observes the + * real subprocess boundary: whether the actual `stdout`/`stderr` streams of + * the shipped binary stay separated, and whether the error text is + * de-duplicated, matching the real `apps/cli-go/supabase-go` binary (Go + * still shows a usage block for an unrecognized flag — raised during + * `ParseFlags`, before `PersistentPreRunE` sets `SilenceUsage` + * (`apps/cli-go/cmd/root.go:97`) — always on stderr, never stdout; + * verified directly against the built Go binary). + */ +describe("legacy CLI required-flag/choice parse errors (CLI-1901)", () => { + test("an unrecognized flag: stdout stays clean, the help/usage content and the single error line land on stderr with no duplicate", async () => { + const { exitCode, stdout, stderr } = await runSupabase( + ["branches", "--this-flag-does-not-exist"], + { entrypoint: "legacy" }, + ); + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + // Matches Go's still-shown usage block for this error class (see the + // describe-level comment) — this library's help doc isn't byte-identical + // to cobra's shorter usage template, but it's on the right stream now. + expect(stderr).toContain("USAGE"); + // The error text appears exactly once — before the fix, the library's own + // duplicate render put it on stderr a second time (on top of the stdout + // help dump this test doesn't even need to check for, since stdout is + // asserted empty above). + const occurrences = stderr.split("Unrecognized flag: --this-flag-does-not-exist").length - 1; + expect(occurrences).toBe(1); + expect( + stderr.trim().endsWith("Try rerunning the command with --debug to troubleshoot the error."), + ).toBe(true); + }); +}); diff --git a/apps/cli/src/shared/cli/run.integration.test.ts b/apps/cli/src/shared/cli/run.integration.test.ts index 9ff4f7fd4e..1b91a5567d 100644 --- a/apps/cli/src/shared/cli/run.integration.test.ts +++ b/apps/cli/src/shared/cli/run.integration.test.ts @@ -1,11 +1,54 @@ import { describe, expect, test } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { Effect, Exit, Layer } from "effect"; -import { CliOutput, Command } from "effect/unstable/cli"; +import { Console, Effect, Exit, Layer } from "effect"; +import { CliOutput, Command, Flag } from "effect/unstable/cli"; import { legacyBranchesCommand } from "../../legacy/commands/branches/branches.command.ts"; import { textCliOutputFormatter } from "../output/text-formatter.ts"; import { CliArgs } from "./cli-args.service.ts"; -import { exitCodeForFailure } from "./run.ts"; +import { exitCodeForFailure, withoutParseErrorHelpDump } from "./run.ts"; + +/** + * A `Console.Console` test double that records `log`/`error` calls into + * `calls` instead of writing anywhere, for asserting on writes through the + * `Console.Console` Effect service directly. Deliberately NOT `vi.spyOn`ing + * the global `console` object: under this repo's Bun + Vitest combination, + * spying on `console.log` and `console.error` in the SAME test reliably + * breaks call detection on both (reproduced in isolation, unrelated to this + * fix) — observing the `Console.Console` service `withoutParseErrorHelpDump` + * itself reads and overrides is both more precise and immune to that quirk. + */ +function fakeConsole(): { readonly console: Console.Console; readonly calls: Array } { + const calls: Array = []; + const unused = () => {}; + return { + calls, + console: { + assert: unused, + clear: unused, + count: unused, + countReset: unused, + debug: unused, + dir: unused, + dirxml: unused, + error: (...args: ReadonlyArray) => { + calls.push(`error:${args.join(" ")}`); + }, + group: unused, + groupCollapsed: unused, + groupEnd: unused, + info: unused, + log: (...args: ReadonlyArray) => { + calls.push(`log:${args.join(" ")}`); + }, + table: unused, + time: unused, + timeEnd: unused, + timeLog: unused, + trace: unused, + warn: unused, + }, + }; +} /** * CLI-1906: `supabase branches` (a legacy "group" command — subcommands, no @@ -64,3 +107,201 @@ describe("legacy group command exit codes (CLI-1906)", () => { expect(exitCodeForFailure(exit.cause)).toBe(1); }); }); + +/** + * CLI-1901: a required-flag/choice parse failure used to dump the full help + * doc to stdout (`console.log`) AND print the error twice — once from the + * vendored `effect` CLI library's own `showHelp()` (`console.error`), once + * from this repo's own Go-parity renderer (`handledProgram` + + * `normalizeCause` in `run.ts`, exercised downstream of this test, not + * here). These tests run real commands through `Command.runWith`, wrapped in + * `withoutParseErrorHelpDump`, and assert on the calls recorded by a fake + * `Console.Console` (see `fakeConsole` above) provided in place of the real + * one — that's the exact service `withoutParseErrorHelpDump` overrides and + * later replays through, so it's what actually matters here. + * + * Go only suppresses its own usage block for a MISSING REQUIRED FLAG + * (`ValidateRequiredFlags`, post-`PersistentPreRunE`) — verified against the + * real `apps/cli-go/supabase-go` binary. Every other parse-error tag + * (`UnrecognizedOption`, `InvalidValue`, `MissingArgument`, + * `UnknownSubcommand`) is raised during `ParseFlags`/`ValidateArgs`, BEFORE + * `PersistentPreRunE` sets `SilenceUsage` — Go still shows a usage block for + * those, always on stderr, never stdout. `classifyParseErrorConsoleOutput` + * (see `run.ts`) mirrors that split; these tests cover both branches. + * + * `legacyBranchesCommand` (no `Command.provide` of its own — see the + * sibling CLI-1906 suite above for why that matters) covers the + * `UnrecognizedOption` shape and proves the buffering/conditional-flush + * wiring end to end. `MissingOption`/`InvalidValue` specifically need a + * genuinely required flag / `Flag.choice`, which every shipped native + * command with one (e.g. `sso add`'s `--type`, see `add.command.ts`) also + * wraps in its own `Command.provide`d management-API runtime layer — + * exercising those tags against a real shipped command would mean + * providing or mocking that whole layer graph for services this parse-error + * path never consumes (same friction the CLI-1906 suite's own comment + * describes and avoids). A minimal `Command.make` with a required + * `Flag.string`/`Flag.choice` still runs through the exact same vendored + * `Command.runWith`/`showHelp()` machinery that produces the bug — the + * command is synthetic, but the `ShowHelp` cause shape it produces is not; a + * real subprocess run against `sso add` itself (the issue's own named + * repro target) is covered end to end by `sso.e2e.test.ts`. + */ +describe("withoutParseErrorHelpDump (CLI-1901)", () => { + const layerFor = (args: ReadonlyArray, console: Console.Console) => + Layer.mergeAll( + CliOutput.layer(textCliOutputFormatter()), + Layer.succeed(CliArgs, { args }), + Layer.succeed(Console.Console, console), + BunServices.layer, + ); + + const runBranches = (args: ReadonlyArray, console: Console.Console) => + withoutParseErrorHelpDump( + Command.runWith(legacyBranchesCommand, { version: "0.0.0-test" })(args), + { rootCommand: legacyBranchesCommand, args }, + ).pipe(Effect.provide(layerFor(args, console))); + + // `type`'s `-t` alias mirrors the real `sso add --type`/`-t` flag + // (`add.command.ts`) so the short-alias "present but missing its value" + // case below (Codex review finding, CLI-1901 follow-up) exercises the same + // shape end to end, without pulling in `sso add`'s own management-API + // runtime layer graph (see the suite-level comment above for why that's + // avoided here). + const requiredFlagCommand = Command.make("test-required-flag", { + type: Flag.choice("type", ["saml"] as const).pipe(Flag.withAlias("t")), + }); + + const runRequiredFlagCommand = (args: ReadonlyArray, console: Console.Console) => + withoutParseErrorHelpDump( + Command.runWith(requiredFlagCommand, { version: "0.0.0-test" })(args), + { rootCommand: requiredFlagCommand, args }, + ).pipe(Effect.provide(layerFor(args, console))); + + test("an unrecognized flag: replays the help dump to stderr (never stdout) and drops the duplicate error, but still fails with the original cause", async () => { + const { console, calls } = fakeConsole(); + const exit = await Effect.runPromiseExit(runBranches(["--this-flag-does-not-exist"], console)); + + // The library's own duplicate `Console.error` write is gone. Its help + // doc survives, but redirected to stderr (`error:`), never stdout + // (`log:`) — matching Go, which still shows usage for an unrecognized + // flag (raised during `ParseFlags`, before `SilenceUsage` is set), just + // on stderr. + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.startsWith("error:"))).toBe(true); + + // The original ShowHelp/UnrecognizedOption failure still propagates — + // the fix only suppresses the library's own console writes, it must not + // swallow or reshape the failure this repo's own `handledProgram` + + // `normalizeCause` still needs to render the single Go-parity line. + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + expect(exitCodeForFailure(exit.cause)).toBe(1); + }); + + test("`branches` bare (clean ShowHelp) still flushes its help dump to stdout and exits 0 (untouched)", async () => { + const { console, calls } = fakeConsole(); + const exit = await Effect.runPromiseExit(runBranches([], console)); + + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.startsWith("log:"))).toBe(true); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + expect(exitCodeForFailure(exit.cause)).toBe(0); + }); + + test("missing a required flag: drops the help dump entirely and the duplicate error, but still fails with the original cause", async () => { + const { console, calls } = fakeConsole(); + const exit = await Effect.runPromiseExit(runRequiredFlagCommand([], console)); + + // Go's `SilenceUsage` is already active for a missing required flag + // (post-`PersistentPreRunE`) — nothing survives, not even on stderr. + expect(calls).toEqual([]); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + expect(exitCodeForFailure(exit.cause)).toBe(1); + }); + + // CLI-1901 (Codex review finding): the vendored library can't tell "flag + // never given" apart from "flag given with no value following it" — both + // raise `MissingOption`. Go's pflag DOES distinguish these (a present-but- + // valueless flag is a `ParseFlags`-time error, before `SilenceUsage` is + // set) — verified against the real `apps/cli-go/supabase-go` binary, which + // still prints its usage block for this input. `--type` as the LAST token + // (no value token follows it) reproduces that "present but valueless" case. + test("a required flag present on argv but missing its value: replays the help dump to stderr instead of dropping it", async () => { + const { console, calls } = fakeConsole(); + const exit = await Effect.runPromiseExit(runRequiredFlagCommand(["--type"], console)); + + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.startsWith("error:"))).toBe(true); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + expect(exitCodeForFailure(exit.cause)).toBe(1); + }); + + // Codex review finding (CLI-1901 follow-up): the same "present but missing + // its value" case, but supplied via the flag's SHORT ALIAS (`-t`) instead of + // its canonical long form. Go's pflag treats a present-but-valueless + // shorthand exactly like the long form (`parseSingleShortArg` raises the + // same `ValueRequiredError` as `parseLongArg`, same pre-`SilenceUsage` + // timing) — verified against the real `apps/cli-go/supabase-go` binary + // (`sso add -t`: full usage block on stderr, byte-parallel to `sso add + // --type`). Before this fix, `isMissingFlagTokenPresent` only recognized + // the canonical `--type` token and misclassified `-t` as absent, silently + // dropping the help dump instead. + test("a required flag present on argv by its short alias but missing its value: replays the help dump to stderr instead of dropping it", async () => { + const { console, calls } = fakeConsole(); + const exit = await Effect.runPromiseExit(runRequiredFlagCommand(["-t"], console)); + + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.startsWith("error:"))).toBe(true); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + expect(exitCodeForFailure(exit.cause)).toBe(1); + }); + + test("an invalid Flag.choice value: replays the help dump to stderr (never stdout) and drops the duplicate error, but still fails with the original cause", async () => { + const { console, calls } = fakeConsole(); + const exit = await Effect.runPromiseExit(runRequiredFlagCommand(["--type", "bogus"], console)); + + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.startsWith("error:"))).toBe(true); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + expect(exitCodeForFailure(exit.cause)).toBe(1); + }); + + test("`--help` on a command with a required flag still prints the full help doc to stdout and exits 0 (untouched)", async () => { + const { console, calls } = fakeConsole(); + const exit = await Effect.runPromiseExit(runRequiredFlagCommand(["--help"], console)); + + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.startsWith("log:"))).toBe(true); + expect(Exit.isSuccess(exit)).toBe(true); + }); + + // Effect's default logger (`Effect.log*`) resolves through this same + // `Console.Console` reference (`Logger.withConsoleLog`/`withConsoleError` + // in the vendored library) — see the "buffering scope" note on + // `withoutParseErrorHelpDump` in `run.ts`. No command handler in this + // codebase uses `Effect.log*` today, but this pins the invariant the doc + // comment describes: on a successful run, buffered logger output still + // reaches the user (deferred to end-of-run, not dropped). + test("Effect.log* output during a successful run is still flushed, not lost", async () => { + const { console, calls } = fakeConsole(); + const program = Effect.gen(function* () { + yield* Effect.logInfo("hello from a handler"); + return "done" as const; + }); + + const result = await Effect.runPromise( + withoutParseErrorHelpDump(program, { rootCommand: requiredFlagCommand, args: [] }).pipe( + Effect.provide(Layer.succeed(Console.Console, console)), + ), + ); + + expect(result).toBe("done"); + expect(calls.length).toBeGreaterThan(0); + expect(calls.some((call) => call.includes("hello from a handler"))).toBe(true); + }); +}); diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index b0db350a35..2fd8f1ea1d 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -1,8 +1,8 @@ import { BunServices } from "@effect/platform-bun"; import { ProjectConfigStore } from "@supabase/config"; import { unixHttpClientLayer } from "@supabase/stack"; -import { Cause, Effect, Exit, Fiber, Layer, Runtime, Stdio } from "effect"; -import { CliOutput, Command } from "effect/unstable/cli"; +import { Cause, Console, Effect, Exit, Fiber, Layer, Runtime, Stdio } from "effect"; +import { CliError, CliOutput, Command } from "effect/unstable/cli"; import { CLI_VERSION } from "./version.ts"; import { Credentials } from "../../next/auth/credentials.service.ts"; import { jsonCliOutputFormatter } from "../output/json-formatter.ts"; @@ -29,6 +29,8 @@ import { telemetryRuntimeLayer } from "../telemetry/runtime.layer.ts"; import { tracingLayer } from "../telemetry/tracing.layer.ts"; import { CliArgs } from "./cli-args.service.ts"; import { resolveAgentOutputFormatFromArgs } from "./agent-output.ts"; +import type { CliErrorSuggestionContext } from "./subcommand-flag-suggestions.ts"; +import { flagAliasesFor, isValueTakingFlagTokenFor } from "./subcommand-flag-suggestions.ts"; // Global flags that consume the following argv token as their value. Keep this in // sync with the value-taking global flags defined in `shared/cli/global-flags.ts` @@ -137,6 +139,290 @@ export function shouldReportFailure(cause: Cause.Cause, exitCode: numbe return !(Cause.squash(cause) instanceof LegacyGoChildExitError); } +/** + * A single `Console.log`/`Console.error` call captured while + * `withoutParseErrorHelpDump` runs, so it can be replayed once the run's + * outcome is known instead of being written immediately. + */ +interface BufferedConsoleWrite { + readonly method: "log" | "error"; + readonly args: ReadonlyArray; +} + +/** + * A `Console.Console` that captures `log`/`error` calls into `sink` instead + * of writing them, and forwards every other method straight through to the + * real console. The vendored `effect` CLI library's parser only ever calls + * `log`/`error` (`showHelp()` and the `Help`/`Version`/`Completions` + * `GlobalFlag.Action`s in `Command.ts`) — the rest are implemented so this + * stays a faithful `Console.Console` rather than a partial stand-in. + */ +function bufferingConsole(sink: Array): Console.Console { + const real = globalThis.console; + return { + assert: real.assert.bind(real), + clear: real.clear.bind(real), + count: real.count.bind(real), + countReset: real.countReset.bind(real), + debug: real.debug.bind(real), + dir: real.dir.bind(real), + dirxml: real.dirxml.bind(real), + error: (...args: ReadonlyArray) => { + sink.push({ method: "error", args }); + }, + group: real.group.bind(real), + groupCollapsed: real.groupCollapsed.bind(real), + groupEnd: real.groupEnd.bind(real), + info: real.info.bind(real), + log: (...args: ReadonlyArray) => { + sink.push({ method: "log", args }); + }, + table: real.table.bind(real), + time: real.time.bind(real), + timeEnd: real.timeEnd.bind(real), + timeLog: real.timeLog.bind(real), + trace: real.trace.bind(real), + warn: real.warn.bind(real), + }; +} + +/** + * How `withoutParseErrorHelpDump` should dispose of its buffered + * `Console.log`/`Console.error` writes, given how the wrapped effect failed: + * + * - `"flush-unchanged"` — success, or a "clean" `ShowHelp` (`errors: []` — + * an explicit `--help` or a bare group command with no subcommand, both + * of which map to exit `0` per `exitCodeForFailure` above), or any other + * failure. Those buffered writes (if any, there normally are none outside + * the two `ShowHelp` cases) are the actual intended output. + * - `"drop"` — a genuine parse/validation failure Go cobra's + * `PersistentPreRunE` already suppresses usage for: `ValidateRequiredFlags` + * (cobra `command.go:1007`), which sets `cmd.SilenceUsage = true` + * (`apps/cli-go/cmd/root.go:97`) BEFORE it runs. This library's + * `MissingOption` is the one tag that maps to that stage — see CLI-1901 — + * but ONLY when the flag was never given at all. A required flag that IS + * present on argv but missing its value (e.g. `sso add --type` with + * nothing after it) also raises `MissingOption` in this library (it has no + * distinct "value required" tag), yet Go's own pflag raises a DIFFERENT, + * earlier `ParseFlags`-time error for that input (`flag needs an + * argument: --type`) which does NOT get `SilenceUsage` treatment — verified + * against the real binary (`apps/cli-go/supabase-go sso add --type`: full + * usage block on stderr, vs `sso add --project-ref x` with `--type` never + * mentioned at all: bare `required flag(s) "type" not set`, no usage). See + * `isMissingFlagTokenPresent` below for how this case is distinguished from + * a genuinely-absent flag. + * - `"flush-help-doc-to-stderr"` — every other genuine parse/validation + * failure (`UnrecognizedOption`, `InvalidValue`, `MissingArgument`, + * `UnknownSubcommand`; multiple simultaneous errors also lands here). + * These map to cobra's `ParseFlags`/`ValidateArgs` (`command.go:919,968`), + * which run BEFORE `PersistentPreRunE` — Go still shows a usage block for + * these, just on stderr, never stdout (verified against the real + * `apps/cli-go/supabase-go` binary, e.g. `branches --bogus-flag` and + * `sso add --type bogus`). The help doc this library renders isn't + * byte-identical to cobra's shorter usage template (that would need a + * second formatter, out of scope for CLI-1901), but showing SOME usage + * content on the RIGHT stream is closer to Go than showing none at all. + * + * In every "genuine failure" case, the buffered `Console.error` write (the + * library's own duplicate render of the errors) is always dropped — this + * repo's own `handledProgram` + `normalizeCause` already render the single + * Go-parity line for it (see `withoutParseErrorHelpDump` below). + */ +export type ParseErrorConsoleDisposition = "flush-unchanged" | "drop" | "flush-help-doc-to-stderr"; + +/** + * Whether `option`'s canonical long-form flag token (`--option` or + * `--option=...`), or one of its short/long `aliases` (e.g. `-t`), appears + * anywhere in the raw argv this run was invoked with BEFORE the `--` + * operand terminator (if any) — used to tell a genuinely-absent required + * flag (Go: `SilenceUsage`-suppressed) apart from one that's present but + * missing its value (Go: a `ParseFlags`-time error, usage still shown). See + * the `"drop"` case on `ParseErrorConsoleDisposition` above for the full + * rationale. `aliases` come from `flagAliasesFor` (see + * `classifyParseErrorConsoleOutput` below), already formatted with their + * leading dash(es). + * + * Tokens after a literal `--` are always positional operands, never a flag + * occurrence for ANY option — this mirrors the vendored `effect` CLI + * library's own lexer, which treats `--` the same way (`internal/lexer.ts`, + * `argv.indexOf("--")`). Without this cutoff, a command like + * `migration repair -- 20230101000000 --status` (a required `Flag.choice`, + * `legacy/commands/migration/repair/repair.command.ts`) would have its + * trailing `--status` positional argument misread as evidence the `--status` + * flag was given, flipping a genuinely-absent-flag failure (Go: no usage + * shown) into a "present but missing its value" one (Go: usage shown) — see + * CLI-1901. + * + * That `--` cutoff is only genuine when the scan reaches `--` as a LIVE + * token, not when it was itself consumed as the VALUE of an immediately + * preceding value-taking flag. Go/pflag's `parseArgs` (`flag.go`) only + * recognizes `--` as the terminator when it's at the FRONT of the remaining + * args on a fresh iteration; `parseLongArg`'s value branch pops the very + * next raw token with no shape check at all, so a literal `--` right after a + * value-taking flag gets swallowed as that flag's value and never reaches + * the terminator check — e.g. `sso add --project-ref -- --type` hands the + * literal string `--` to `--project-ref`, and parsing resumes normally on + * `--type` (which then fails with pflag's OWN `ValueRequiredError` — a + * `ParseFlags`-time error, usage still shown — since nothing follows it). + * The scan below therefore folds the terminator check into the very same + * loop that already skips consumed-value tokens, rather than precomputing + * `args.indexOf("--")` up front — a Codex review finding on CLI-1901. + * + * `isValueTakingToken` (from `isValueTakingFlagTokenFor`, OR'd with + * `globalFlagsWithValues` at the `classifyParseErrorConsoleOutput` call site + * below) lets the scan skip a token immediately consumed as the VALUE of a + * preceding value-taking flag — local OR global — instead of mistaking that + * consumed token (or a consumed literal `--`, per above) for `option`'s own + * occurrence. Go/pflag's `parseLongArg` (`flag.go`) unconditionally consumes + * the very next argv entry as a value-taking flag's value, even when that + * entry itself looks like another flag — e.g. `sso add --project-ref --type` + * hands the literal string `--type` to `--project-ref`, so `--type` is never + * seen as its own occurrence in Go, and its `MissingOption` failure keeps + * Go's `SilenceUsage` treatment (no usage shown). The vendored `effect` + * parser does NOT replicate that eager consumption (it only treats a + * following token as a value when the lexer tags it `Value`, never a + * flag-shaped token — `internal/parser.ts`'s `consumeFlagValueWithTokens`), + * so without this skip the raw scan would find the literal `--type` token + * and wrongly flush the help doc for an input Go shows no usage for — a + * Codex review finding on CLI-1901. + */ +function isMissingFlagTokenPresent( + option: string, + args: ReadonlyArray, + aliases: ReadonlyArray = [], + isValueTakingToken: (token: string) => boolean = () => false, +): boolean { + const tokens = [`--${option}`, ...aliases]; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === undefined) continue; + // A literal "--" only terminates flag parsing when reached as a LIVE + // token here — if the previous iteration already skipped it as a + // value-taking flag's consumed value (below), this line never runs for + // it, matching pflag's own ordering (see the doc comment above). + if (arg === "--") break; + if (tokens.some((token) => arg === token || arg.startsWith(`${token}=`))) return true; + const equalIndex = arg.indexOf("="); + const bareToken = equalIndex === -1 ? arg : arg.slice(0, equalIndex); + if (equalIndex === -1 && isValueTakingToken(bareToken)) { + // Go/pflag consumes the following argv entry as `bareToken`'s value + // unconditionally — even a literal "--" — so skip it here too, before + // the next iteration's terminator check ever sees it. + index++; + } + } + return false; +} + +export function classifyParseErrorConsoleOutput( + cause: Cause.Cause, + context: CliErrorSuggestionContext, +): ParseErrorConsoleDisposition { + const error = Cause.squash(cause); + if (!CliError.isCliError(error) || error._tag !== "ShowHelp" || error.errors.length === 0) { + return "flush-unchanged"; + } + // `isValueTakingFlagTokenFor` only inspects the resolved LEAF command's own + // flags, so it has no visibility into value-taking GLOBAL flags (`--network-id`, + // `--profile`, etc. — see `globalFlagsWithValues` above). Without OR-ing those + // in, a global value flag consuming the very next token (e.g. + // `migration repair --network-id --status --local ` handing the + // literal `--status` to `--network-id`, per pflag's `parseLongArg`) would + // leave the required `status` flag looking "present" to the scan below, even + // though Go/pflag never sees it as its own occurrence and suppresses usage + // for it (`SilenceUsage`) — a Codex review finding on CLI-1901. + const isLeafValueTakingToken = isValueTakingFlagTokenFor(context.rootCommand, error.commandPath); + const isValueTakingToken = (token: string) => + globalFlagsWithValues.has(token) || isLeafValueTakingToken(token); + const isSuppressedMissingFlag = (inner: (typeof error.errors)[number]) => + inner._tag === "MissingOption" && + !isMissingFlagTokenPresent( + inner.option, + context.args, + flagAliasesFor(context.rootCommand, error.commandPath, inner.option), + isValueTakingToken, + ); + return error.errors.every(isSuppressedMissingFlag) ? "drop" : "flush-help-doc-to-stderr"; +} + +/** + * Wraps `Command.runWith(rootCommand, ...)(args)` so the vendored `effect` + * CLI library's OWN `Console.log`/`Console.error` writes are captured + * instead of reaching the real console, then disposed of per + * `classifyParseErrorConsoleOutput` once the run's outcome is known: + * dropped entirely for a missing-required-flag failure, replayed to stderr + * (never stdout) for every other genuine parse/validation failure, and + * replayed unchanged for everything else. Either way, the library's own + * duplicate error render never survives — this repo's own `handledProgram` + * + `normalizeCause` already render the single Go-parity line for it. That + * fixes both halves of CLI-1901 (the stdout help dump and the duplicate + * error line) from this one call site, without patching the vendored + * library itself. + * + * TODO: remove this whole buffering/classification dance once upstream + * Effect-TS/effect#6313 is fixed — https://github.com/Effect-TS/effect/issues/6313. + * `runWith` has no supported way to opt out of, or redirect, its own + * `showHelp` console writes; everything below exists only to work around + * that gap from the outside. + * + * The "flush unchanged" outcome covers success, `--help`, `--version`, + * `--completions`, and the bare-group-command help dump, all of which stay + * untouched. + * + * Safe to wrap the entire `runWith` call — parsing AND the eventual command + * handler, not just the parse phase that can actually raise `ShowHelp`: no + * command handler in this codebase writes through `effect`'s `Console` + * service directly (they go through the `Output` service instead). One + * indirect exception is known — `@supabase/config`'s `loadProjectConfigFile` + * emits its deprecated-config-section warnings via `Console.error`, and is + * reachable from handlers through `ProjectConfigStore`/`loadProjectConfig` — + * so it pins itself to the real console (`Effect.provideService(Console.Console, + * globalThis.console)`) rather than relying on whatever `Console.Console` is + * ambient here; see CLI-1901 and that package's `io.ts` for why (a + * long-running command like `functions serve` would otherwise have the + * warning buffered for its entire session instead of shown at startup). + * Any other handler writing through `Console` directly would need the same + * treatment — buffering here never delays or drops real command output + * ONLY as long as that invariant holds. Note that Effect's OWN default + * logger (`Effect.log*`) DOES resolve through this same `Console` reference + * (`Logger.withConsoleLog`/`withConsoleError`) — this codebase has no + * `Effect.log*` call sites today, but if one is ever added to a handler, its + * output would be buffered too (deferred to end-of-run on the "flush + * unchanged" path, or dropped on a genuine parse failure — which never runs + * a handler in the first place, so that half is moot). + */ +export function withoutParseErrorHelpDump( + effect: Effect.Effect, + context: CliErrorSuggestionContext, +): Effect.Effect { + return Effect.gen(function* () { + const sink: Array = []; + const exit = yield* effect.pipe( + Effect.provideService(Console.Console, bufferingConsole(sink)), + Effect.exit, + ); + const disposition = Exit.isFailure(exit) + ? classifyParseErrorConsoleOutput(exit.cause, context) + : "flush-unchanged"; + if (disposition === "drop") { + return yield* exit; + } + for (const write of sink) { + // The library's own duplicate error render never survives a genuine + // parse failure — only its help-doc `log` write gets a second look, + // redirected to stderr instead of its original stdout-bound method. + if (disposition === "flush-help-doc-to-stderr" && write.method === "error") continue; + const method = disposition === "flush-help-doc-to-stderr" ? "error" : write.method; + yield* Console.consoleWith((console) => + Effect.sync(() => { + console[method](...write.args); + }), + ); + } + return yield* exit; + }); +} + function projectContextLayerFor(runtimeLayer: Layer.Layer) { return projectContextLayer.pipe(Layer.provide(runtimeLayer), Layer.provide(BunServices.layer)); } @@ -193,7 +479,10 @@ function cliProgramFor( }), ), ); - return Command.runWith(rootCommand, { version: CLI_VERSION })(args).pipe( + return withoutParseErrorHelpDump(Command.runWith(rootCommand, { version: CLI_VERSION })(args), { + rootCommand, + args, + }).pipe( Effect.provide(formatterLayerFor(rootCommand, args, outputFormat)), Effect.provide(options.analyticsLayer), Effect.provide(tracingLayer), @@ -218,6 +507,12 @@ export async function runCli(rootCommand: Command.Command.Any, options: RunCliOp }).pipe(Effect.provide(BunServices.layer)), ); + // Same `{ rootCommand, args }` shape `formatterLayerFor` builds below, so + // `normalizeCause`'s single-render fallback path (CLI-1901) can reuse + // `formatCliErrorsForDisplay` and surface the same subcommand-flag hint the + // text/json formatters would have shown before the vendored library's own + // duplicate render was suppressed. + const suggestionContext = { rootCommand, args }; const useGlobalSignalInterrupt = shouldUseGlobalSignalInterrupt(args); const outputFormat = await Effect.runPromise( Effect.gen(function* () { @@ -271,7 +566,7 @@ export async function runCli(rootCommand: Command.Command.Any, options: RunCliOp // below. See `exitCodeForFailure` for why a "clean" ShowHelp failure // (e.g. a bare group command with no subcommand) also maps to exit 0. if (shouldReportFailure(exit.cause, exitCode)) { - yield* output.fail(normalizeCause(exit.cause)); + yield* output.fail(normalizeCause(exit.cause, suggestionContext)); } return yield* processControl.exit(exitCode); } diff --git a/apps/cli/src/shared/cli/run.unit.test.ts b/apps/cli/src/shared/cli/run.unit.test.ts index 290c9e9557..687d556e7d 100644 --- a/apps/cli/src/shared/cli/run.unit.test.ts +++ b/apps/cli/src/shared/cli/run.unit.test.ts @@ -1,15 +1,26 @@ import { Cause } from "effect"; -import { CliError } from "effect/unstable/cli"; +import { CliError, Command } from "effect/unstable/cli"; import { describe, expect, it } from "vitest"; +import { legacyBranchesCommand } from "../../legacy/commands/branches/branches.command.ts"; +import { legacyMigrationCommand } from "../../legacy/commands/migration/migration.command.ts"; +import { legacySsoCommand } from "../../legacy/commands/sso/sso.command.ts"; import { LegacyGoChildExitError } from "../legacy/legacy-go-child-exit.error.ts"; import { + classifyParseErrorConsoleOutput, exitCodeForFailure, extractCommandPath, shouldReportFailure, shouldUseGlobalSignalInterrupt, } from "./run.ts"; +// Real command tree (not a hand-rolled stand-in) so `classifyParseErrorConsoleOutput`'s +// alias resolution (`flagAliasesFor`, via `context.rootCommand`) has real `Flag.withAlias` +// declarations to walk — e.g. `sso add`'s `type` flag aliases to `-t` (`add.command.ts`). +const testRoot = Command.make("supabase").pipe( + Command.withSubcommands([legacyBranchesCommand, legacyMigrationCommand, legacySsoCommand]), +); + describe("extractCommandPath", () => { it("returns positional command-path tokens", () => { expect(extractCommandPath(["functions", "serve"])).toEqual(["functions", "serve"]); @@ -145,3 +156,333 @@ describe("shouldReportFailure", () => { expect(shouldReportFailure(cause, 1)).toBe(true); }); }); + +// CLI-1901: a required-flag/choice parse failure used to dump the full help +// doc to stdout AND print the error twice (once from the vendored `effect` +// CLI library's own `showHelp()`, once from this repo's own Go-parity +// renderer). `withoutParseErrorHelpDump` fixes this by buffering the +// library's own `Console.log`/`Console.error` writes and disposing of them +// per this classifier's verdict — this suite covers the classifier; +// `run.integration.test.ts` covers the end-to-end buffering/flush behavior +// against real command definitions, and `run.e2e.test.ts` / +// `sso.e2e.test.ts` cover the real subprocess stdout/stderr streams. +describe("classifyParseErrorConsoleOutput", () => { + // Go cobra's `PersistentPreRunE` sets `SilenceUsage = true` + // (`apps/cli-go/cmd/root.go:97`) BEFORE `ValidateRequiredFlags` + // (`command.go:1007`) runs — verified against the real `supabase-go` + // binary (`sso add` without `--type`): a single clean stderr line, no + // usage block. `MissingOption` is the one tag that maps to that stage. + it("drops the help dump for a missing required flag", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ + commandPath: ["supabase", "sso", "add"], + errors: [new CliError.MissingOption({ option: "type" })], + }), + ); + // `--type` (nor its `-t` alias) never appears on argv at all — genuinely absent. + expect( + classifyParseErrorConsoleOutput(cause, { + rootCommand: testRoot, + args: ["sso", "add", "--project-ref", "x"], + }), + ).toBe("drop"); + }); + + // Multiple simultaneously-missing required flags: still `ValidateRequiredFlags`, + // still post-`PersistentPreRunE` in Go — must still drop, not just for a lone error. + it("drops the help dump when every error is a missing required flag", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ + commandPath: ["supabase", "sso", "add"], + errors: [ + new CliError.MissingOption({ option: "type" }), + new CliError.MissingOption({ option: "project-ref" }), + ], + }), + ); + expect( + classifyParseErrorConsoleOutput(cause, { rootCommand: testRoot, args: ["sso", "add"] }), + ).toBe("drop"); + }); + + // CLI-1901 (Codex review finding): the vendored library raises the SAME + // `MissingOption` tag whether a required flag was never given at all, or + // given with no value following it (e.g. `sso add --type` as the last + // token) — it has no distinct "value required" error. Go's own pflag does + // distinguish these: a present-but-valueless flag is a `ParseFlags`-time + // error (`flag needs an argument: --type`), raised BEFORE + // `PersistentPreRunE` sets `SilenceUsage` — verified against the real + // `apps/cli-go/supabase-go` binary, which still prints its full usage block + // to stderr for this input. `isMissingFlagTokenPresent` recovers this + // distinction from raw argv so this case flushes the help doc instead of + // silently dropping it like a genuinely-absent flag. + it("flushes the help dump to stderr for a required flag present on argv but missing its value", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ + commandPath: ["supabase", "migration", "repair"], + errors: [new CliError.MissingOption({ option: "status" })], + }), + ); + expect( + classifyParseErrorConsoleOutput(cause, { + rootCommand: testRoot, + args: ["migration", "repair", "20230101000000", "--status"], + }), + ).toBe("flush-help-doc-to-stderr"); + }); + + // Codex review finding (CLI-1901 follow-up): `--` is the standard operand + // terminator — everything after it is positional, never a flag occurrence, + // for ANY option (mirrors the vendored `effect` lexer's own + // `argv.indexOf("--")` cutoff, `internal/lexer.ts`). Verified against the + // real `apps/cli-go/supabase-go` binary: + // `migration repair --local -- 20230101000000 --status` prints a bare + // `required flag(s) "status" not set`, no usage block — Go correctly parses + // the literal `--status` string as a second positional `version`, leaving + // the real `--status` flag genuinely unset. Without the `--` cutoff, + // `isMissingFlagTokenPresent` would find that trailing `--status` string + // anywhere in argv and wrongly conclude the flag was given but missing its + // value. + it("drops the help dump for a missing required flag whose token only appears after the -- terminator", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ + commandPath: ["supabase", "migration", "repair"], + errors: [new CliError.MissingOption({ option: "status" })], + }), + ); + expect( + classifyParseErrorConsoleOutput(cause, { + rootCommand: testRoot, + args: ["migration", "repair", "--", "20230101000000", "--status"], + }), + ).toBe("drop"); + }); + + // Codex review finding (CLI-1901 follow-up): `sso add`'s `type` flag also has + // a short alias, `-t` (`add.command.ts`). Go's pflag treats a present-but- + // valueless SHORT flag exactly like the long form — `parseSingleShortArg` + // raises the same `ValueRequiredError` as `parseLongArg`, same timing, same + // "usage still shown" outcome — verified against the real + // `apps/cli-go/supabase-go` binary (`sso add -t`: full usage block on + // stderr, byte-parallel to `sso add --type`). `flagAliasesFor` resolves + // `type`'s aliases from the real command tree (via `context.rootCommand`) + // so `isMissingFlagTokenPresent` recognizes `-t` here too, instead of + // misclassifying it as a genuinely-absent flag. + it("flushes the help dump to stderr for a required flag present on argv by its short alias but missing its value", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ + commandPath: ["supabase", "sso", "add"], + errors: [new CliError.MissingOption({ option: "type" })], + }), + ); + expect( + classifyParseErrorConsoleOutput(cause, { rootCommand: testRoot, args: ["sso", "add", "-t"] }), + ).toBe("flush-help-doc-to-stderr"); + }); + + // Codex review finding (CLI-1901 follow-up): `sso add --project-ref --type` + // omits `--type`'s value, but `--type` immediately follows `--project-ref` + // (a value-taking `Flag.string`, `add.command.ts`). Verified against the + // real `apps/cli-go/supabase-go` binary: pflag's `parseLongArg` + // (`flag.go`) unconditionally consumes the very next argv entry as + // `--project-ref`'s value, even though it looks like another flag — so + // `--type` is never seen as its own occurrence, and Go shows no usage at + // all (only its own "type" required-flag error, `SilenceUsage`-suppressed). + // The vendored `effect` parser does NOT eagerly consume a flag-shaped + // token as a value (`internal/parser.ts`'s `consumeFlagValueWithTokens` + // only consumes a following `Value`-tagged token), so `--type` remains its + // own token and raises its own `MissingOption` here too — but the raw scan + // must still recognize that `--type` was effectively consumed as + // `--project-ref`'s value, matching Go, instead of concluding `--type` is + // present but missing its value (which would wrongly flush the help doc). + it("drops the help dump when a required flag's own token is consumed as a preceding value-taking flag's value", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ + commandPath: ["supabase", "sso", "add"], + errors: [new CliError.MissingOption({ option: "type" })], + }), + ); + expect( + classifyParseErrorConsoleOutput(cause, { + rootCommand: testRoot, + args: ["sso", "add", "--project-ref", "--type"], + }), + ).toBe("drop"); + }); + + // Codex review finding (CLI-1901 follow-up): `isValueTakingFlagTokenFor` + // only inspects the resolved LEAF command's own flags, so it doesn't know + // `--network-id` (a value-taking GLOBAL flag, `globalFlagsWithValues` in + // `run.ts`) consumes the very next argv entry. Verified against pflag's + // `parseLongArg` (`flag.go`): `--network-id --status` hands the literal + // string `--status` to `--network-id` as its value, so the required + // `status` flag (`migration repair`, `Flag.choice`) is never seen as its + // own occurrence and keeps Go's `SilenceUsage` treatment (no usage shown). + // Without OR-ing `globalFlagsWithValues` into the scan's value-taking + // predicate, the raw `--status` token would be found anyway and wrongly + // flush the help doc. + it("drops the help dump when a required flag's own token is consumed as a global value-taking flag's value", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ + commandPath: ["supabase", "migration", "repair"], + errors: [new CliError.MissingOption({ option: "status" })], + }), + ); + expect( + classifyParseErrorConsoleOutput(cause, { + rootCommand: testRoot, + args: ["migration", "repair", "--network-id", "--status", "--local", "20230101000000"], + }), + ).toBe("drop"); + }); + + // Codex review finding (CLI-1901 follow-up): a literal `--` immediately + // after a value-taking flag is NOT a genuine operand terminator in Go — + // pflag's `parseLongArg` (`flag.go`) pops the very next raw token as the + // flag's value with no shape check at all, so `--project-ref --` hands the + // literal string `--` to `--project-ref`. Parsing then resumes normally on + // `--type`, which (nothing follows it) raises pflag's own + // `ValueRequiredError` — a `ParseFlags`-time error, usage still shown, NOT + // `SilenceUsage`-suppressed. Precomputing `args.indexOf("--")` before the + // value-consumption scan would wrongly treat that consumed `--` as the + // terminator and drop `--type` from the scan entirely, misclassifying + // `type` as genuinely absent. + it("flushes the help dump to stderr for a required flag whose own token follows a -- consumed as a preceding value-taking flag's value", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ + commandPath: ["supabase", "sso", "add"], + errors: [new CliError.MissingOption({ option: "type" })], + }), + ); + expect( + classifyParseErrorConsoleOutput(cause, { + rootCommand: testRoot, + args: ["sso", "add", "--project-ref", "--", "--type"], + }), + ).toBe("flush-help-doc-to-stderr"); + }); + + it("still drops the help dump for a missing required flag even when an unrelated flag shares a substring of its name", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ + commandPath: ["supabase", "sso", "add"], + errors: [new CliError.MissingOption({ option: "type" })], + }), + ); + // `--type-hint` is a different flag token — must not false-positive-match `--type`. + expect( + classifyParseErrorConsoleOutput(cause, { + rootCommand: testRoot, + args: ["sso", "add", "--type-hint", "x"], + }), + ).toBe("drop"); + }); + + // Go's `ParseFlags` (`command.go:919`) validates `Flag.choice` values BEFORE + // `PersistentPreRunE` runs — verified against the real binary (`sso add --type + // bogus`): Go still shows its usage block, on stderr. Same for an unrecognized + // flag and a missing positional argument (both also pre-`PersistentPreRunE`). + it("flushes the help dump to stderr for an invalid Flag.choice value", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ + commandPath: ["supabase", "sso", "add"], + errors: [ + new CliError.InvalidValue({ + option: "type", + value: "bogus", + expected: 'Expected "saml", got "bogus"', + kind: "flag", + }), + ], + }), + ); + expect( + classifyParseErrorConsoleOutput(cause, { + rootCommand: testRoot, + args: ["sso", "add", "--type", "bogus"], + }), + ).toBe("flush-help-doc-to-stderr"); + }); + + it("flushes the help dump to stderr for an unrecognized flag", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ + commandPath: ["supabase", "branches"], + errors: [new CliError.UnrecognizedOption({ option: "--bogus", suggestions: [] })], + }), + ); + expect( + classifyParseErrorConsoleOutput(cause, { + rootCommand: testRoot, + args: ["branches", "--bogus"], + }), + ).toBe("flush-help-doc-to-stderr"); + }); + + it("flushes the help dump to stderr for a missing positional argument", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ + commandPath: ["supabase", "sso", "show"], + errors: [new CliError.MissingArgument({ argument: "id" })], + }), + ); + expect( + classifyParseErrorConsoleOutput(cause, { rootCommand: testRoot, args: ["sso", "show"] }), + ).toBe("flush-help-doc-to-stderr"); + }); + + // A mix (e.g. a missing required flag alongside an unrecognized flag) can't + // actually occur in practice — the library fails fast on the earlier + // `ParseFlags`-class error before `MissingOption` is ever checked — but the + // classifier must still not mistake a mix for the all-`MissingOption` case. + it("flushes the help dump to stderr for a mix of error tags", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ + commandPath: ["supabase", "sso", "add"], + errors: [ + new CliError.MissingOption({ option: "type" }), + new CliError.UnrecognizedOption({ option: "--bogus", suggestions: [] }), + ], + }), + ); + expect( + classifyParseErrorConsoleOutput(cause, { + rootCommand: testRoot, + args: ["sso", "add", "--bogus"], + }), + ).toBe("flush-help-doc-to-stderr"); + }); + + it("flushes unchanged for a clean ShowHelp failure (bare group command / explicit --help)", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ commandPath: ["supabase", "branches"], errors: [] }), + ); + expect( + classifyParseErrorConsoleOutput(cause, { rootCommand: testRoot, args: ["branches"] }), + ).toBe("flush-unchanged"); + }); + + it("flushes unchanged for a non-ShowHelp failure", () => { + expect( + classifyParseErrorConsoleOutput(Cause.fail(new Error("boom")), { + rootCommand: testRoot, + args: [], + }), + ).toBe("flush-unchanged"); + }); + + it("flushes unchanged for an interrupt", () => { + expect( + classifyParseErrorConsoleOutput(Cause.interrupt(), { rootCommand: testRoot, args: [] }), + ).toBe("flush-unchanged"); + }); + + it("flushes unchanged for a defect with no typed failure", () => { + expect( + classifyParseErrorConsoleOutput(Cause.die(new Error("unexpected crash")), { + rootCommand: testRoot, + args: [], + }), + ).toBe("flush-unchanged"); + }); +}); diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts index c6da39c2e1..e1865a5273 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts @@ -102,6 +102,71 @@ function flagMatchesOption(flag: HelpDoc.FlagDoc, option: string): boolean { return flag.aliases.includes(option); } +/** + * Every argv token (e.g. `-t`, alongside the canonical `--type`) that also + * resolves to `option` for the command at `commandPath`, by walking the + * command tree the same way `buildSubcommandFlagHint` does. Returns `[]` if + * `commandPath` doesn't resolve to a real command or that command has no + * flag named `option` — callers should treat that as "no aliases", not an + * error, since a synthetic/test command path is a legitimate input. + * + * Used by `run.ts`'s `isMissingFlagTokenPresent` to recognize a required + * flag supplied by its short alias but missing its value (Go/pflag still + * shows usage for that case — see CLI-1901) instead of misclassifying it as + * genuinely absent (Go: `SilenceUsage`-suppressed, no usage shown). + */ +export function flagAliasesFor( + rootCommand: Command.Command.Any, + commandPath: ReadonlyArray, + option: string, +): ReadonlyArray { + const command = findCommand(rootCommand, commandPath.slice(1)); + if (!command) return []; + const flag = helpDocFor(command, commandPath)?.flags.find( + (candidate) => candidate.name === option, + ); + return flag?.aliases ?? []; +} + +/** + * Builds a lookup for whether an argv token (a canonical `--name` or one of + * its aliases, e.g. `-t`) at `commandPath` belongs to a *value-taking* flag + * (any flag whose `HelpDoc.FlagDoc.type !== "boolean"`) on the command + * resolved the same way `flagAliasesFor` does. Returns a function that + * answers `false` for every token when `commandPath` doesn't resolve to a + * real command, mirroring `flagAliasesFor`'s "no aliases" fallback. + * + * Used by `run.ts`'s `isMissingFlagTokenPresent` to recognize when a + * DIFFERENT flag's value-consumption ate the very token being scanned for. + * Go/pflag's `parseLongArg` (`flag.go`) unconditionally consumes the next + * argv entry as a value-taking flag's value, even when that entry itself + * looks like another flag — e.g. `sso add --project-ref --type` hands the + * literal string `--type` to `--project-ref`, so `--type` is never seen as + * its own occurrence and its `MissingOption` failure gets Go's + * `SilenceUsage` treatment (no usage shown). The vendored `effect` CLI + * library's own parser does NOT replicate that (`internal/parser.ts`'s + * `consumeFlagValueWithTokens` only consumes a following token when it's + * lexed as a plain `Value`, never a flag-shaped token), so without this + * lookup the raw argv scan in `isMissingFlagTokenPresent` would find the + * literal `--type` token and wrongly conclude the flag is present but + * missing its value — reintroducing the usage dump CLI-1901 suppresses. + */ +export function isValueTakingFlagTokenFor( + rootCommand: Command.Command.Any, + commandPath: ReadonlyArray, +): (token: string) => boolean { + const command = findCommand(rootCommand, commandPath.slice(1)); + const flags = command && helpDocFor(command, commandPath)?.flags; + if (!flags) return () => false; + const valueTakingTokens = new Set(); + for (const flag of flags) { + if (flag.type === "boolean") continue; + valueTakingTokens.add(`--${flag.name}`); + for (const alias of flag.aliases) valueTakingTokens.add(alias); + } + return (token: string) => valueTakingTokens.has(token); +} + function findPathEndIndex( args: ReadonlyArray, pathWithoutRoot: ReadonlyArray, diff --git a/apps/cli/src/shared/output/normalize-error.ts b/apps/cli/src/shared/output/normalize-error.ts index 19cc375913..7d0976dd05 100644 --- a/apps/cli/src/shared/output/normalize-error.ts +++ b/apps/cli/src/shared/output/normalize-error.ts @@ -1,5 +1,8 @@ import { Cause, Option } from "effect"; +import { CliError } from "effect/unstable/cli"; import { formatInvalidValueMessage } from "../cli/invalid-value-message.ts"; +import type { CliErrorSuggestionContext } from "../cli/subcommand-flag-suggestions.ts"; +import { formatCliErrorsForDisplay } from "../cli/subcommand-flag-suggestions.ts"; type NormalizedCliError = { readonly code: string; @@ -28,7 +31,10 @@ const readRawString = (value: ErrorRecord, key: string): string | undefined => { return typeof field === "string" ? field : undefined; }; -const mappedError = (error: ErrorRecord): NormalizedCliError | undefined => { +const mappedError = ( + error: ErrorRecord, + context?: CliErrorSuggestionContext, +): NormalizedCliError | undefined => { const tag = readString(error, "_tag"); switch (tag) { case "NoRunningStackError": @@ -123,21 +129,64 @@ const mappedError = (error: ErrorRecord): NormalizedCliError | undefined => { // message — otherwise the user sees a useless top-line above the real // problem. const errors = error["errors"]; - if (Array.isArray(errors) && errors.length === 1) { + if (!Array.isArray(errors) || errors.length === 0) return undefined; + + if (errors.length === 1) { const inner = errors[0]; if (isErrorRecord(inner)) { - const innerMapped = mappedError(inner); + const innerMapped = mappedError(inner, context); if (innerMapped) return innerMapped; } } + + // No Go-parity-specific single-error mapping applies (either more than + // one simultaneous error, e.g. a child flag placed before its + // subcommand — `UnrecognizedOption` plus the `UnknownSubcommand` its + // misplaced value gets parsed as — or a lone error with no known + // mapping: UnrecognizedOption, DuplicateOption, MissingArgument, + // UnknownSubcommand, UserError, or an InvalidValue that doesn't hit + // CLI-1898's doubled-"Expected"-prefix bug). Surface every inner + // error's own message — reusing the same `formatCliErrorsForDisplay` + // the text/json formatters use, so a subcommand-flag hint (e.g. "Hint: + // --foo is available on `branches create`. Pass it after the + // subcommand") survives — rather than falling through to ShowHelp's + // useless "Help requested" envelope message: since CLI-1901 (`run.ts`'s + // `withoutParseErrorHelpDump`) stopped the vendored library from also + // `Console.error`-ing this same text, this is now the ONLY place any + // of it reaches the user, for one error or many. + if (errors.every(CliError.isCliError)) { + const formatted = formatCliErrorsForDisplay(errors, context); + if (formatted.errors.length > 0) { + const [only] = formatted.errors; + return { + code: formatted.errors.length === 1 && only ? only._tag : "ShowHelp", + message: formatted.errors.map((formattedError) => formattedError.message).join("\n\n"), + }; + } + } + + // Defensive fallback for a single inner value that carries a usable + // `_tag`/`message` pair but isn't a real `CliError` instance (e.g. a + // hand-rolled test double) — real `ShowHelp.errors` entries always are. + if (errors.length === 1) { + const inner = errors[0]; + if (isErrorRecord(inner)) { + const code = readString(inner, "_tag"); + const message = readString(inner, "message"); + if (code && message) return { code, message }; + } + } return undefined; } } }; -export function normalizeCliError(error: unknown): NormalizedCliError { +export function normalizeCliError( + error: unknown, + context?: CliErrorSuggestionContext, +): NormalizedCliError { if (isErrorRecord(error)) { - const mapped = mappedError(error); + const mapped = mappedError(error, context); if (mapped) { return mapped; } @@ -174,9 +223,15 @@ export function normalizeCliError(error: unknown): NormalizedCliError { }; } -export function normalizeCause(cause: Cause.Cause): NormalizedCliError { +export function normalizeCause( + cause: Cause.Cause, + context?: CliErrorSuggestionContext, +): NormalizedCliError { const errorOption = Cause.findErrorOption(cause); - return normalizeCliError(Option.getOrElse(errorOption, () => Cause.squash(cause))); + return normalizeCliError( + Option.getOrElse(errorOption, () => Cause.squash(cause)), + context, + ); } export function formatCliError(error: NormalizedCliError): string { diff --git a/apps/cli/src/shared/output/normalize-error.unit.test.ts b/apps/cli/src/shared/output/normalize-error.unit.test.ts index 107600ac13..5f7c122ae3 100644 --- a/apps/cli/src/shared/output/normalize-error.unit.test.ts +++ b/apps/cli/src/shared/output/normalize-error.unit.test.ts @@ -1,8 +1,14 @@ import { describe, expect, test } from "vitest"; import { Cause } from "effect"; -import { CliError } from "effect/unstable/cli"; +import { CliError, Command } from "effect/unstable/cli"; +import { legacyBranchesCommand } from "../../legacy/commands/branches/branches.command.ts"; +import { legacyNetworkRestrictionsCommand } from "../../legacy/commands/network-restrictions/network-restrictions.command.ts"; import { formatCliError, normalizeCause, normalizeCliError } from "./normalize-error.ts"; +const testRoot = Command.make("supabase").pipe( + Command.withSubcommands([legacyBranchesCommand, legacyNetworkRestrictionsCommand]), +); + describe("normalizeCliError", () => { test("maps NoRunningStackError to a user-facing message", () => { const error = { @@ -157,6 +163,178 @@ describe("normalizeCliError", () => { }); }); + // CLI-1901: before this fix, the vendored `effect` CLI library's own + // `showHelp()` also printed this same message (via `Console.error`) as a + // duplicate of whatever `normalizeCause` rendered here — so a tag with no + // Go-parity-specific mapping (e.g. UnrecognizedOption) still had SOME + // informative text visible, just twice. Now that CLI-1901's `run.ts` fix + // suppresses the library's own duplicate print entirely, this generic + // fallback is the ONLY place the message reaches the user — it must not + // regress to the useless "Help requested" envelope message. + test("ShowHelp envelope unwraps a single UnrecognizedOption to its own message (no Go-parity mapping exists yet)", () => { + const error = { + _tag: "ShowHelp", + commandPath: ["branches"], + errors: [ + new CliError.UnrecognizedOption({ + option: "--bogus", + command: ["branches"], + suggestions: [], + }), + ], + }; + expect(normalizeCliError(error)).toEqual({ + code: "UnrecognizedOption", + message: "Unrecognized flag: --bogus in command branches", + }); + }); + + // Regression test for a Codex review finding on CLI-1901: `run.ts`'s + // `withoutParseErrorHelpDump` suppresses the vendored library's own + // `Console.error` render, which used to be the only place a subcommand-flag + // placement hint (`buildSubcommandFlagHint` / + // `subcommand-flag-suggestions.ts`) reached the user. When a + // `CliErrorSuggestionContext` is supplied, this fallback must reuse + // `formatCliErrorsForDisplay` — the same helper the text/json formatters + // use — so that hint still survives through the single-render path. + test("ShowHelp envelope unwraps a single UnrecognizedOption and preserves its subcommand-flag hint when a suggestion context is supplied", () => { + const error = { + _tag: "ShowHelp", + commandPath: ["branches"], + errors: [ + new CliError.UnrecognizedOption({ + option: "--persistent", + command: ["supabase", "branches"], + suggestions: [], + }), + ], + }; + + const result = normalizeCliError(error, { + rootCommand: testRoot, + args: ["branches", "--persistent", "create"], + }); + + expect(result.code).toBe("UnrecognizedOption"); + expect(result.message).toContain( + "Unrecognized flag: --persistent in command supabase branches", + ); + expect(result.message).toContain( + "Hint: --persistent is available on `supabase branches create` and `supabase branches update`. Pass it after the subcommand", + ); + }); + + // Regression test for a second Codex review finding on CLI-1901: a child + // flag placed before its subcommand, WITH a value (e.g. `network-restrictions + // --project-ref get`), makes Effect raise TWO simultaneous errors — + // `UnrecognizedOption` for the flag, plus `UnknownSubcommand` for the + // flag's own value being misread as the subcommand name. Before this fix, + // `mappedError`'s ShowHelp unwrap only ever looked at `errors.length === 1`, + // so a real two-error ShowHelp fell straight through to the generic + // "Help requested" envelope message — losing the hint (and the specific + // error) entirely now that CLI-1901 also suppresses the vendored library's + // own duplicate render for this case. + test("ShowHelp envelope unwraps multiple simultaneous errors and preserves the subcommand-flag hint (child flag with a value, before its subcommand)", () => { + const error = { + _tag: "ShowHelp", + commandPath: ["network-restrictions"], + errors: [ + new CliError.UnrecognizedOption({ + option: "--project-ref", + command: ["supabase", "network-restrictions"], + suggestions: [], + }), + new CliError.UnknownSubcommand({ + subcommand: "jacraenyzrorgjhsdvvf", + parent: ["supabase", "network-restrictions"], + suggestions: [], + }), + ], + }; + + const result = normalizeCliError(error, { + rootCommand: testRoot, + args: ["network-restrictions", "--project-ref", "jacraenyzrorgjhsdvvf", "get"], + }); + + expect(result.code).toBe("UnrecognizedOption"); + expect(result.message).toContain( + "Unrecognized flag: --project-ref in command supabase network-restrictions", + ); + expect(result.message).toContain( + "Hint: --project-ref is available on `supabase network-restrictions get` and `supabase network-restrictions update`.", + ); + expect(result.message).not.toContain("Help requested"); + }); + + // A genuine, unrelated multi-error case (no shared hint to collapse them + // into one) must still surface every error's own message, not just the + // first — and must not crash trying to pick a single `code`. + test("ShowHelp envelope unwraps multiple unrelated simultaneous errors into a joined message", () => { + const error = { + _tag: "ShowHelp", + commandPath: ["branches"], + errors: [ + new CliError.UnrecognizedOption({ + option: "--bogus-one", + command: ["supabase", "branches"], + suggestions: [], + }), + new CliError.UnrecognizedOption({ + option: "--bogus-two", + command: ["supabase", "branches"], + suggestions: [], + }), + ], + }; + + const result = normalizeCliError(error, { rootCommand: testRoot, args: ["branches"] }); + + expect(result.code).toBe("ShowHelp"); + expect(result.message).toContain("Unrecognized flag: --bogus-one in command supabase branches"); + expect(result.message).toContain("Unrecognized flag: --bogus-two in command supabase branches"); + }); + + // The same fallback also covers an InvalidValue that does NOT have the + // CLI-1898 doubled-"Expected"-prefix bug (e.g. a custom `Flag.mapTryCatch` + // validator like `sso add --domains`, whose `expected` text is already + // clean) — `mappedError`'s own InvalidValue case returns `undefined` for + // those (nothing to fix), so this generic fallback is what surfaces the + // message, not CLI-1898's specific rebuild. + test("ShowHelp envelope unwraps a single InvalidValue with an already-clean expected message (not the CLI-1898 doubled-prefix bug)", () => { + const error = { + _tag: "ShowHelp", + commandPath: ["sso", "add"], + errors: [ + new CliError.InvalidValue({ + option: "domains", + value: "unterminated-quote.com", + expected: "a comma-separated list (unterminated quote)", + kind: "flag", + }), + ], + }; + expect(normalizeCliError(error)).toEqual({ + code: "InvalidValue", + message: + 'Invalid value for flag --domains: "unterminated-quote.com". Expected: a comma-separated list (unterminated quote)', + }); + }); + + // If the single inner error has a `_tag` but no usable `message` (neither a + // string via its own getter nor otherwise), the fallback must not surface a + // blank/garbage message — it should fall through to ShowHelp's own generic + // handling, same as the pre-existing multiple-errors case below. + test("ShowHelp envelope with a single inner error carrying no message falls back to generic", () => { + const error = { + _tag: "ShowHelp", + commandPath: ["branches"], + errors: [{ _tag: "SomeUnmappedTag" }], + }; + const result = normalizeCliError(error); + expect(result.code).toBe("ShowHelp"); + }); + test("ShowHelp with multiple errors does not unwrap (falls back to generic)", () => { const error = { _tag: "ShowHelp", diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index 8052399091..ae427d5f67 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -694,12 +694,19 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( }); const { document: normalized, deprecatedSections } = normalizeDeprecatedSMTPSections(document); // Warn on stderr (matching Go's normalizeDeprecatedSMTPConfig) so the notice - // never pollutes machine-readable stdout payloads. + // never pollutes machine-readable stdout payloads. Pinned to the real + // console (bypassing whatever `Console.Console` is ambient) so this always + // writes immediately, matching Go's synchronous `fmt.Fprintln(os.Stderr, ...)` + // (config.go:618,630) — a caller wrapping this in a deferred/buffered + // `Console.Console` (e.g. `apps/cli/src/shared/cli/run.ts`'s + // `withoutParseErrorHelpDump`, which only buffers the CLI parser's own + // duplicate-render writes and must never delay a handler's real output; see + // CLI-1901) must not silently swallow or delay it. for (const section of deprecatedSections) { const replacement = section.replace(/inbucket$/, "local_smtp"); yield* Console.error( `WARN: config section [${section}] is deprecated. Please use [${replacement}] instead.`, - ); + ).pipe(Effect.provideService(Console.Console, globalThis.console)); } // Substitute `env(VAR)` references against `.env`/`.env.local`/ambient env @@ -780,11 +787,13 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( // artifact, not the parity-relevant part, same call already made for // `LegacyInvalidPortEnvOverrideError` in the legacy shell. Not reproduced // byte-for-byte; `Console.error` supplies a normal trailing newline instead. + // Pinned to the real console for the same reason as the `[inbucket]` + // warning above — see that comment. if (goViperCompat) { for (const ext of deprecatedProviders) { yield* Console.error( `WARN: disabling deprecated "${ext}" provider. Please use [auth.external.${ext}_oidc] instead`, - ); + ).pipe(Effect.provideService(Console.Console, globalThis.console)); } } From 7c047c59ac1e8e42b14b996b2a54d4f9ca1557d4 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 10 Jul 2026 13:43:53 +0100 Subject: [PATCH 43/49] fix(cli): exempt global choice flags from telemetry redaction (Go isEnumFlag parity) (#5855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed Go's `isEnumFlag` (`apps/cli-go/cmd/root_analytics.go:110-116`) unconditionally reports the value of any `*utils.EnumFlag`-backed pflag verbatim in `cli_command_executed` telemetry — no per-flag annotation needed. `--output`, `--dns-resolver`, and `--agent` are all registered as `*utils.EnumFlag` on `rootCmd.PersistentFlags()` (`cmd/root.go:330,331,333`), so Go always sends their real value. The TS port's `withLegacyCommandInstrumentation` already auto-detects `Flag.choice` flags declared in a command's own `config` (CLI-1866), and already resolves global/persistent flag values as a fallback when a command doesn't declare that CLI name locally (CLI-1896). But CLI-1896 deliberately scoped out the 3 global *choice* flags — nothing taught the global-fallback path that a resolved value might itself be a safe enum, so `--output`/`--dns-resolver`/`--agent` always fell through to `""`. This adds `GLOBAL_CHOICE_FLAG_NAMES` (`legacy-command-instrumentation.ts`), derived from `LEGACY_GLOBAL_FLAGS` by reusing the existing `getChoiceFlagNames` helper (so the choice-detection predicate has exactly one home), and consults it only on the global-fallback path — i.e. only when the invoking command's own `flags` record doesn't already declare that CLI name. A command that registers its own differently-typed local flag under the same CLI name (`db diff`'s local string `output: Flag.string("output")`, `cmd/db.go:622`) still shadows the global and stays redacted, matching Go's `isEnumFlag` type-asserting that command's own non-enum flag object instead of root's persistent `EnumFlag`. Verified this is the real wiring, not a hypothetical: `db diff` passes `output` in its own `flags` record with no matching `config` entry, so `isFromHandler` is structurally `true` for it and the new global set is never consulted. Also verified Go's separate `output_format` telemetry property (`PropOutputFormat`) is independent of the generic `flags.output` entry — both already fire in Go too, so no double-counting concern from un-redacting `flags.output`. Fixes CLI-1904. ## Why Found during CLI-1866's review as a pre-existing gap in the same problem class, tracked separately since it was out of scope for that PR's diff, then explicitly deferred again by CLI-1896 ("the 3 global choice flags ... remain redacted — that gap is the already-tracked CLI-1904, not this ticket"). ## Reviewer-relevant context - All 4 `review-changes` agents (architect, engineer, security, DX) reviewed this diff and approved with no blocking findings. Two converging nits were fixed in a follow-up commit: deduped the choice-detection predicate (`GLOBAL_CHOICE_FLAG_NAMES` now derives from `getChoiceFlagNames` instead of re-implementing it) and clarified the two `AGENTS.md` telemetry bullets. - One deliberately-deferred judgement call from engineer-reviewer: no command's own `*.integration.test.ts` today exercises `withLegacyCommandInstrumentation`'s PostHog-capture behavior for *any* flag (that coverage is entirely the job of `legacy-command-instrumentation.unit.test.ts`'s own scenario suite) — this is a pre-existing, repo-wide pattern, not something this PR narrows or regresses. Adding integration-tier telemetry assertions to `db diff`/`db query` specifically would set a new cross-cutting test precedent well beyond this fix's scope, so left as a possible future hardening rather than folded in here. --- apps/cli/AGENTS.md | 9 ++- .../legacy-command-instrumentation.ts | 48 ++++++++++-- ...egacy-command-instrumentation.unit.test.ts | 73 +++++++++++++++++-- 3 files changed, 117 insertions(+), 13 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index da6b1bb60b..d4830ccc9e 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -285,8 +285,13 @@ The legacy shell sends the same PostHog events to the same product analytics pip - **Native legacy commands wrap with `withLegacyCommandInstrumentation`** (from `legacy/telemetry/legacy-command-instrumentation.ts`) — _not_ the shared `withCommandInstrumentation`. The legacy variant emits Go-shape properties: a single `flags` map (vs `flags_used`/`flag_values`), `is_agent: boolean` (vs `ai_tool: string`), and `env_signals`. - **Pass `flags` to the wrapper** so boolean flag values can be detected and logged verbatim: `handler(flags).pipe(withLegacyCommandInstrumentation({ flags }), ...)`. Sensitive values become the literal string `""` to match Go. - **Use `safeFlags: ["flag-name"]`** to whitelist flags that Go marks with `markFlagTelemetrySafe` (grep `apps/cli-go/cmd/*.go`). Today these are `--project-ref` (sso, branches, link, functions, projects/api-keys), `--project-id` (gen/types), `--org-id` (projects/create), and `--version` (migration/squash). -- **Pass `config` (the command's own flag config record) to the wrapper** if it has any `Flag.choice`/`Flag.choiceWithValue` flags: `withLegacyCommandInstrumentation({ flags, config })`. Every choice flag declared in that command's own `config` is auto-detected and treated as safe, mirroring Go's `isEnumFlag` (`cmd/root_analytics.go:110-116`), which checks `flag.Value.(*utils.EnumFlag)` unconditionally — no per-flag `safeFlags` entry needed, and it stays correct as choices are added or removed. This does NOT cover global/root flags (`--output`, `--dns-resolver`, `--agent` in `shared/legacy/global-flags.ts`) even though Go's equivalents are also `EnumFlag` — see CLI-1904. -- **Global/persistent flags (`shared/legacy/global-flags.ts`) resolve automatically** — the wrapper reads `legacyGlobalFlagValues` (via `Effect.serviceOption`, so it's a no-op outside the real CLI tree) and falls back to it whenever a changed flag name isn't in the handler's own `flags` record, mirroring Go's `changedFlags()` walking `cmd.Parent()`'s `PersistentFlags()` (`cmd/root_analytics.go:53-76`). No per-command wiring needed. Boolean globals (`--debug`, `--yes`, `--experimental`, `--create-ticket`) therefore already report their real value through the existing boolean-is-safe rule — but ONLY when a command's own `flags` record doesn't already declare that CLI name (a command's own flag always wins, e.g. `db diff`'s local `--output`); the three global choice flags (`--output`, `--dns-resolver`, `--agent`) still redact until CLI-1904 teaches the safety pipeline about global `EnumFlag`s. +- **Pass `config` (the command's own flag config record) to the wrapper** if it has any `Flag.choice`/`Flag.choiceWithValue` flags: `withLegacyCommandInstrumentation({ flags, config })`. Every choice flag declared in that command's own `config` is auto-detected and treated as safe, mirroring Go's `isEnumFlag` (`cmd/root_analytics.go:110-116`), which checks `flag.Value.(*utils.EnumFlag)` unconditionally — no per-flag `safeFlags` entry needed, and it stays correct as choices are added or removed. A command's own `config` only ever contains its own locally-declared flags, so this cannot cover the 3 global choice flags (`--output`, `--dns-resolver`, `--agent` in `shared/legacy/global-flags.ts`) — those are handled separately, see below. +- **Global/persistent flags (`shared/legacy/global-flags.ts`) resolve automatically** — the wrapper reads `legacyGlobalFlagValues` (via `Effect.serviceOption`, so it's a no-op outside the real CLI tree) and falls back to it whenever a changed flag name isn't in the handler's own `flags` record, mirroring Go's `changedFlags()` walking `cmd.Parent()`'s `PersistentFlags()` (`cmd/root_analytics.go:53-76`). No per-command wiring needed. This gives two flag families their real value automatically, via the existing boolean-is-safe rule and a new choice-is-safe rule (`GLOBAL_CHOICE_FLAG_NAMES` — CLI-1904) respectively: + - Boolean globals: `--debug`, `--yes`, `--experimental`, `--create-ticket`. + - Choice globals: `--output`, `--dns-resolver`, `--agent`. + + Both rules apply ONLY when a command's own `flags` record doesn't already declare that CLI name — a command's own flag always wins. Example: `db diff` declares its own local `output: Flag.string("output")` (a file path, not a choice) in its `flags` record, so `db diff --output diff.sql` stays redacted — matching Go, where `isEnumFlag` type-asserts `db diff`'s own non-enum local flag object instead of root's persistent `*utils.EnumFlag`. + - **Proxy handlers (`LegacyGoProxy.exec`) must NOT wrap with any instrumentation.** The Go subprocess fires its own telemetry; a TS wrapper would double-count `cli_command_executed`. - **When promoting a command from proxy to native, reproduce every `phtelemetry.*` call in the Go counterpart.** Grep `apps/cli-go/internal//` for `service.Capture`, `service.Alias`, `service.Identify`, `service.GroupIdentify`, and `TrackUpgradeSuggested`. The current Go custom events that legacy ports must reproduce when natively ported: diff --git a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts index 8b38786463..ceae9c91be 100644 --- a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts +++ b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts @@ -45,7 +45,10 @@ interface LegacyCommandInstrumentationOptions; // The `-o`/`--output` values this command accepts, mirroring Go's per-command // `--output` enum (`internal/utils/enum.go`). Defaults to the resource-command @@ -295,6 +298,33 @@ const GLOBAL_SHORT_ALIASES: Readonly> = (() => { return aliases; })(); +// CLI-name set for every global/persistent flag that is itself a +// `Flag.choice`/`Flag.choiceWithValue` — today `output`, `dns-resolver`, and +// `agent` (`shared/legacy/global-flags.ts`). Reuses `getChoiceFlagNames` +// itself (keyed by `.id` rather than CLI name — `getChoiceFlagNames` only +// ever reads `single.name` off the unwrapped param, so the record key is +// irrelevant) rather than re-implementing the choice-detection predicate, so +// the two can never silently drift apart. Derived from `LEGACY_GLOBAL_FLAGS` +// the same way `GLOBAL_SHORT_ALIASES` is, so this never drifts from that +// single source of truth either. Mirrors Go's `isEnumFlag` +// (`cmd/root_analytics.go:110-116`) checked against the actual +// `*utils.EnumFlag`-backed persistent flag object registered on root +// (`cmd/root.go:330,331,333`). Applied ONLY to the global-fallback path in +// `buildFlagsMap` below (`!isFromHandler`): when a command registers its OWN +// differently-typed local flag under the same CLI name (e.g. `db diff`'s +// local string `--output`, `cmd/db.go:622`), Go's own `isEnumFlag` check runs +// against THAT command's local flag object instead — which fails the type +// assertion, so it stays redacted — exactly mirrored by +// `choiceFlagNames`/`isFromHandler` continuing to govern the handler-owned +// path. Invariant this depends on: any command whose Go counterpart locally +// shadows one of these three with a NON-enum flag (only `db diff`'s `output` +// today) must pass that flag in its own `flags` record, so `isFromHandler` +// is true and this global set is never consulted for it — otherwise the +// fallback would report verbatim here even though Go redacts it. +const GLOBAL_CHOICE_FLAG_NAMES: ReadonlySet = getChoiceFlagNames( + Object.fromEntries(LEGACY_GLOBAL_FLAGS.map((globalFlag) => [globalFlag.id, globalFlag.flag])), +); + function buildFlagsMap>(options: { readonly flags: Flags | undefined; // Live global/persistent flag values (`legacyGlobalFlagValues`), keyed by @@ -330,13 +360,19 @@ function buildFlagsMap>(options: { // `safeFlagSet`/`choiceFlagNames` classify a flag as safe by CLI NAME, // sourced from this command's own `safeFlags`/`config` options — they may // only vouch for a value that actually came from this command's own - // `flags` record. A value resolved from the global-flag fallback is safe - // solely because it's boolean (Go's `isBooleanFlag` branch applies - // unconditionally); it must never inherit a *different* command's - // per-flag safe/choice annotation just because the CLI name matches. + // `flags` record; a value resolved from the global-flag fallback must + // never inherit a *different* command's per-flag safe/choice annotation + // just because the CLI name matches. The global-fallback path instead + // consults `GLOBAL_CHOICE_FLAG_NAMES` (CLI-1904) — the global flag's OWN + // choice-ness, exactly mirroring Go's `isEnumFlag` type-asserting the + // actual persistent flag object rather than any per-command annotation. + // Boolean values are safe unconditionally regardless of source (Go's + // `isBooleanFlag` branch applies unconditionally too). const isSafe = typeof value === "boolean" || - (isFromHandler && (safeFlagSet.has(cliName) || choiceFlagNames.has(cliName))); + (isFromHandler + ? safeFlagSet.has(cliName) || choiceFlagNames.has(cliName) + : GLOBAL_CHOICE_FLAG_NAMES.has(cliName)); result[cliName] = isSafe ? (value ?? REDACTED_VALUE) : REDACTED_VALUE; } diff --git a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.unit.test.ts b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.unit.test.ts index f546c5e536..eb5be95e3d 100644 --- a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.unit.test.ts +++ b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.unit.test.ts @@ -3,6 +3,7 @@ import { Effect, Layer, Option, Stdio } from "effect"; import { Flag } from "effect/unstable/cli"; import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; import { + LegacyAgentFlag, LegacyDebugFlag, LegacyDnsResolverFlag, LegacyOutputFlag, @@ -1072,7 +1073,7 @@ describe("withLegacyCommandInstrumentation", () => { ); it.live( - "still redacts a changed global choice flag like --dns-resolver (global EnumFlag safety is CLI-1904's scope, not this fix's)", + "passes a changed global choice flag like --dns-resolver through verbatim (Go parity: isEnumFlag, CLI-1904)", () => { const analytics = mockContextualAnalytics(); @@ -1091,7 +1092,68 @@ describe("withLegacyCommandInstrumentation", () => { Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; - expect(event?.properties.flags).toEqual({ "dns-resolver": "" }); + expect(event?.properties.flags).toEqual({ "dns-resolver": "https" }); + }), + ), + ); + }, + ); + + it.live( + "passes a changed global choice flag like --agent through verbatim (Go parity: isEnumFlag, CLI-1904)", + () => { + const analytics = mockContextualAnalytics(); + + return Effect.void.pipe( + withLegacyCommandInstrumentation({ flags: {} }), + Effect.provide(analytics.layer), + Effect.provide(mockProcessControl().layer), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide( + Stdio.layerTest({ + args: Effect.succeed(["backups", "list", "--agent", "yes"]), + }), + ), + Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(Layer.succeed(LegacyAgentFlag, "yes" as const)), + Effect.tap(() => + Effect.sync(() => { + const event = analytics.captured[0]; + expect(event?.properties.flags).toEqual({ agent: "yes" }); + }), + ), + ); + }, + ); + + it.live( + "still redacts a global choice flag shadowed by a command's own differently-typed local flag (db diff's local string --output, Go parity)", + () => { + // `db diff` declares its own local `output: Flag.string("output")` (a + // file path, `cmd/db.go:622`) rather than a `Flag.choice` — mirroring + // Go, where that command's own non-enum flag object governs + // `isEnumFlag`, not root's persistent `*utils.EnumFlag`. Simulate that + // shape here: `output` is declared in the handler's own `flags` record + // (so `isFromHandler` is true) but absent from `config`, so it must NOT + // inherit safety from `GLOBAL_CHOICE_FLAG_NAMES` just because the CLI + // name collides with the global `--output` choice flag. + const analytics = mockContextualAnalytics(); + + return Effect.void.pipe( + withLegacyCommandInstrumentation({ flags: { output: "diff.sql" } }), + Effect.provide(analytics.layer), + Effect.provide(mockProcessControl().layer), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide( + Stdio.layerTest({ + args: Effect.succeed(["db", "diff", "--output", "diff.sql"]), + }), + ), + Effect.provide(commandRuntimeLayer(["db", "diff"])), + Effect.tap(() => + Effect.sync(() => { + const event = analytics.captured[0]; + expect(event?.properties.flags).toEqual({ output: "" }); }), ), ); @@ -1170,9 +1232,10 @@ describe("withLegacyCommandInstrumentation", () => { Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; - // `output` is a global choice flag — still redacted (CLI-1904's - // scope), but it must be PRESENT, not silently dropped. - expect(event?.properties.flags).toEqual({ output: "" }); + // `output` is a global choice flag — passed through verbatim + // (Go parity: isEnumFlag, CLI-1904), and it must be PRESENT, not + // silently dropped. + expect(event?.properties.flags).toEqual({ output: "json" }); }), ), ); From 3fa4bb094ca75c867d59e880b500b3869c014b6b Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 10 Jul 2026 15:23:22 +0100 Subject: [PATCH 44/49] fix(cli): match Go path.Match character-class negation in legacy seed globbing (#5856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Current Behavior `legacyMatchPattern` (the glob matcher backing `[db.seed].sql_paths` and `db reset --version`) treated a leading `!` inside a `[...]` bracket class the same as `^` — shell/fnmatch-style negation. Go's `path.Match` (which `apps/cli-go`'s `fs.Glob`/`afero.Glob` actually use for this) only negates on `^`; `!` is an ordinary literal class member. So a pattern like `[!a].sql` matched `b.sql` in the TS port where the Go CLI would not, meaning `db push --include-seed` / `db reset` could read, hash, and execute seed SQL outside the set the user's Go-compatible config intended. Verified directly against the real Go stdlib source (`$GOROOT/src/path/match.go`) available on this machine, and against `apps/cli-go/pkg/config/config.go` (`fs.Glob`) and `apps/cli-go/internal/migration/repair/repair.go` (`GetMigrationFile` / `afero.Glob`) — both go through the same `path.Match` grammar. ## New Behavior Rather than patching the bug in place, this fixes it by deleting the duplicate: `apps/cli/src/legacy/shared/legacy-path-match.ts` (`legacyPathMatch`) is already a byte-faithful, already-tested port of Go's `path.Match` — introduced earlier for the sibling `db new`/migration seed pipeline (`legacy-seed.ts`) — with correct `^`-only negation, escapes, and malformed-pattern (`ErrBadPattern`) detection. `legacyMatchPattern` and its `matchClass`/`match` helpers are removed, and both call sites are rewired onto `legacyPathMatch`: - `legacy-seed-ops.ts`'s own seed glob (`globOne`) - `reset.handler.ts`'s `--version` migration-file glob (behavior-identical here — `v` is validated as digits-only before the glob ever runs, so no bracket syntax is reachable on this path) A follow-up commit closes a related gap all four `review-changes` reviewers independently flagged: `legacyGlobSeedFiles` was discarding `legacyPathMatch`'s `badPattern` signal, so a malformed pattern (e.g. an unterminated `[` class) fell through to the generic `no files matched pattern` warning instead of Go's `failed to glob files: syntax error in pattern` (`fs.Glob`'s up-front `Match(pattern, "")` validation). The sibling pipeline (`legacy-seed.ts:resolveSeedFiles`) already did this correctly — mirrored it here now that both pipelines share `legacyPathMatch`. **Behavior change worth calling out for release notes:** any existing `[db.seed].sql_paths` config relying on `[!x]` as shell-style negation will now select a different (Go-correct) set of seed files with no error — e.g. `[!0-9]` now means "the literal characters `!`, `0`-`9`", not "not a digit". Use `[^x]` for negation, matching Go/the real Go CLI. This is a correctness fix (the legacy shell's contract is strict Go parity), but it is a silent behavior change for anyone who had unknowingly depended on the old, non-Go-compatible negation. ## Related Issue(s) Fixes https://linear.app/supabase/issue/CLI-1880 ## Notes for reviewers `review-changes` (architect/engineer/security/DX, run against the first commit) all returned APPROVE/SHIP IT. Judgement calls deliberately left open rather than expanded into this PR: - The two seed pipelines (`legacy-seed-ops.ts` and `legacy-seed.ts`) still duplicate the outer `fs.Glob`-style directory-walk/dedup logic (both now delegate the leaf matcher to `legacyPathMatch`, but the surrounding glob-resolution code is still two separate ports). Flagged by the architect reviewer as a good candidate for a follow-up consolidation, out of scope for this fix. - `sql_paths`' glob dialect (Go `path.Match` semantics, `^`-only negation, no POSIX classes) isn't documented anywhere user-facing (`packages/config/src/db.ts`). Flagged by the DX reviewer as worth a docs follow-up, not blocking. --- .../legacy/commands/db/reset/reset.handler.ts | 11 ++- .../commands/db/shared/legacy-seed-ops.ts | 84 +++--------------- .../db/shared/legacy-seed-ops.unit.test.ts | 85 +++++++++++-------- 3 files changed, 69 insertions(+), 111 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts index f1637df50a..4b5fc92782 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -30,11 +30,8 @@ import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state. import { legacyDropUserSchemas } from "../shared/legacy-drop-schemas.ts"; import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; import { legacyListLocalMigrations } from "../shared/legacy-pgdelta.cache.ts"; -import { - legacyGetPendingSeeds, - legacyMatchPattern, - legacySeedData, -} from "../shared/legacy-seed-ops.ts"; +import { legacyGetPendingSeeds, legacySeedData } from "../shared/legacy-seed-ops.ts"; +import { legacyPathMatch } from "../../../shared/legacy-path-match.ts"; import { legacyUpsertVaultSecrets } from "../../../shared/legacy-vault.ts"; import { legacySeedBucketsRun } from "../../../shared/legacy-seed-buckets.ts"; import type { LegacyDbResetFlags } from "./reset.command.ts"; @@ -206,7 +203,9 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega const entries = yield* fs .readDirectory(migrationsDir) .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); - const found = entries.some((name) => legacyMatchPattern(`${v}_*.sql`, path.basename(name))); + const found = entries.some( + (name) => legacyPathMatch(`${v}_*.sql`, path.basename(name)).matched, + ); if (!found) { return yield* Effect.fail( new LegacyDbResetMigrationFileError({ diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts index 5caba63a87..2617aee0c3 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts @@ -5,6 +5,7 @@ import { Output } from "../../../../shared/output/output.service.ts"; import type { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; import { legacyCreateSeedTable } from "../../../shared/legacy-migration-history.ts"; +import { LEGACY_BAD_PATTERN_MESSAGE, legacyPathMatch } from "../../../shared/legacy-path-match.ts"; import { legacySplitAndTrim } from "../../../shared/legacy-sql-split.ts"; /** @@ -27,74 +28,6 @@ export interface LegacySeedFile { const META_CHARS = /[*?[\\]/u; -/** - * Go's `path.Match` for a single filename (no `/`). Supports `*` (any run of - * non-separator chars), `?` (one char), `[...]` classes with ranges and a - * leading `^`/`!` negation, and `\` escapes. Filenames never contain `/`, so the - * separator subtlety in Go's matcher does not apply here. - */ -export function legacyMatchPattern(pattern: string, name: string): boolean { - const matchClass = (cls: string, ch: string): boolean => { - let negated = false; - let body = cls; - if (body.startsWith("^") || body.startsWith("!")) { - negated = true; - body = body.slice(1); - } - let matched = false; - for (let k = 0; k < body.length; k++) { - if (body[k + 1] === "-" && k + 2 < body.length) { - if (ch >= body[k]! && ch <= body[k + 2]!) matched = true; - k += 2; - } else if (body[k] === ch) { - matched = true; - } - } - return matched !== negated; - }; - - const match = (p: number, n: number): boolean => { - while (p < pattern.length) { - const pc = pattern[p]!; - if (pc === "*") { - // Collapse consecutive stars, then try to match the rest at every offset. - while (pattern[p] === "*") p++; - if (p === pattern.length) return true; - for (let k = n; k <= name.length; k++) { - if (match(p, k)) return true; - } - return false; - } - if (n >= name.length) return false; - if (pc === "?") { - p++; - n++; - continue; - } - if (pc === "[") { - const end = pattern.indexOf("]", p + 1); - if (end === -1) return false; - if (!matchClass(pattern.slice(p + 1, end), name[n]!)) return false; - p = end + 1; - n++; - continue; - } - if (pc === "\\" && p + 1 < pattern.length) { - if (pattern[p + 1] !== name[n]) return false; - p += 2; - n++; - continue; - } - if (pc !== name[n]) return false; - p++; - n++; - } - return n === name.length; - }; - - return match(0, 0); -} - /** Result of resolving `[db.seed].sql_paths` against the workspace. */ interface LegacyGlobResult { /** Workdir-relative, forward-slashed matches, deduplicated in pattern order. */ @@ -108,8 +41,10 @@ interface LegacyGlobResult { * over `fs.Glob` (`pkg/config/config.go:102-124`). Each pattern is first joined * under the `supabase/` directory (Go resolves `sql_paths` at config load, * `config.go:884`). Matches per pattern are sorted; the overall result preserves - * first-seen order across patterns. A pattern that matches nothing contributes a - * `no files matched pattern: ` warning but is not fatal. + * first-seen order across patterns. A pattern that matches nothing, or is malformed + * (Go's `path.ErrBadPattern`, e.g. an unterminated `[` class), contributes a warning + * but is not fatal — mirroring `fs.Glob`'s up-front `Match(pattern, "")` validation + * (`io/fs/glob.go`) and the sibling seed pipeline's `legacy-seed.ts:resolveSeedFiles`. */ const legacyGlobSeedFiles = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, @@ -128,6 +63,13 @@ const legacyGlobSeedFiles = Effect.fnUntraced(function* ( // globs those resolved paths without re-prefixing (`config.go:102-124`), so only // normalize separators here; re-joining `supabase/` would double-prefix. const pattern = toSlash(rawPattern); + // Go's `fs.Glob` validates the whole pattern up front (`Match(pattern, "")`); a + // malformed glob is reported as `failed to glob files: ` and + // contributes no matches, rather than the misleading "no files matched" below. + if (legacyPathMatch(pattern, "").badPattern) { + errors.push(`failed to glob files: ${LEGACY_BAD_PATTERN_MESSAGE}`); + continue; + } const matches = yield* globOne(fs, path, workdir, pattern); if (matches.length === 0) { errors.push(`no files matched pattern: ${pattern}`); @@ -202,7 +144,7 @@ const globOne = ( .readDirectory(absDir) .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); for (const name of names) { - if (legacyMatchPattern(file, name)) { + if (legacyPathMatch(file, name).matched) { result.push(d === "" ? name : `${d}/${name}`); } } diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts index 3c50f914c8..6f0a1dcb3f 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts @@ -7,7 +7,7 @@ import { Data, Effect, Exit, FileSystem, Path } from "effect"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; -import { legacyMatchPattern, legacySeedData } from "./legacy-seed-ops.ts"; +import { legacyGetPendingSeeds, legacySeedData } from "./legacy-seed-ops.ts"; class TestError extends Data.TaggedError("TestError")<{ readonly message: string }> {} @@ -29,40 +29,57 @@ function fakeSeedSession() { return { session, calls }; } -describe("legacyMatchPattern", () => { - it("matches a literal filename", () => { - expect(legacyMatchPattern("seed.sql", "seed.sql")).toBe(true); - expect(legacyMatchPattern("seed.sql", "other.sql")).toBe(false); - }); - - it("matches `*` against any run of characters", () => { - expect(legacyMatchPattern("*.sql", "seed.sql")).toBe(true); - expect(legacyMatchPattern("*.sql", "0001_init.sql")).toBe(true); - expect(legacyMatchPattern("*.sql", "seed.txt")).toBe(false); - expect(legacyMatchPattern("seed.*", "seed.sql")).toBe(true); - }); - - it("matches `?` against exactly one character", () => { - expect(legacyMatchPattern("seed?.sql", "seed1.sql")).toBe(true); - expect(legacyMatchPattern("seed?.sql", "seed12.sql")).toBe(false); - expect(legacyMatchPattern("seed?.sql", "seed.sql")).toBe(false); - }); - - it("matches character classes with ranges and negation", () => { - expect(legacyMatchPattern("seed[0-9].sql", "seed5.sql")).toBe(true); - expect(legacyMatchPattern("seed[0-9].sql", "seedx.sql")).toBe(false); - expect(legacyMatchPattern("seed[!0-9].sql", "seedx.sql")).toBe(true); - expect(legacyMatchPattern("seed[!0-9].sql", "seed5.sql")).toBe(false); - }); - - it("honors backslash escapes", () => { - expect(legacyMatchPattern("seed\\*.sql", "seed*.sql")).toBe(true); - expect(legacyMatchPattern("seed\\*.sql", "seedx.sql")).toBe(false); - }); +// Glob matching itself is `legacyPathMatch` (`../../../shared/legacy-path-match.ts`), +// a faithful port of Go's `path.Match` already covered by +// `legacy-path-match.unit.test.ts` (including the `^`-only negation / `!`-is-literal +// rule this file used to duplicate — and get wrong — in a local `legacyMatchPattern`). +// This exercises that the seed pipeline's own glob resolution (`legacyGetPendingSeeds`) +// actually uses it end to end, per Go's `config.Glob.Files` → `fs.Glob` → `path.Match`. +describe("legacyGetPendingSeeds (glob character classes)", () => { + it.effect( + "treats a leading `!` in a bracket class as literal, not negation (Go path.Match parity)", + () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-glob-")); + writeFileSync(join(dir, "a.sql"), "select 1;"); + writeFileSync(join(dir, "b.sql"), "select 2;"); + const { session } = fakeSeedSession(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Go's `[!a]` is a positive class of the literal members `!` and `a` — only a + // leading `^` negates. So this pattern matches `a.sql`, not `b.sql` (the old + // shell-style bug negated on `!` too, and would have matched `b.sql` instead). + const pending = yield* legacyGetPendingSeeds(session, fs, path, ["[!a].sql"], dir); + expect(pending.map((seed) => seed.path)).toEqual(["a.sql"]); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(BunServices.layer), + ); + }, + ); - it("collapses consecutive stars", () => { - expect(legacyMatchPattern("**.sql", "seed.sql")).toBe(true); - }); + it.effect( + "warns Go's bad-pattern message for an unterminated bracket class, not a bogus no-match", + () => { + // An unclosed `[` is malformed per Go's `path.Match` grammar (`ErrBadPattern`), which + // `fs.Glob` reports as `failed to glob files: syntax error in pattern` — not the + // generic `no files matched pattern` a same-shaped but well-formed glob would get. + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-glob-")); + const { session } = fakeSeedSession(); + const out = mockOutput({ format: "text" }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const pending = yield* legacyGetPendingSeeds(session, fs, path, ["seed[.sql"], dir); + expect(pending).toEqual([]); + expect(out.rawChunks.map((c) => c.text).join("")).toContain( + "failed to glob files: syntax error in pattern", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe(Effect.provide(out.layer), Effect.provide(BunServices.layer)); + }, + ); }); const runSeed = ( From bba5486d66f45f4bdf292bce127dedb768a1ab00 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:10:25 +0000 Subject: [PATCH 45/49] fix(deps): bump github.com/stripe/pg-schema-diff from 1.0.5 to 1.0.7 in /apps/cli-go in the go-minor group across 1 directory (#5858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the go-minor group with 1 update in the /apps/cli-go directory: [github.com/stripe/pg-schema-diff](https://github.com/stripe/pg-schema-diff). Updates `github.com/stripe/pg-schema-diff` from 1.0.5 to 1.0.7
Release notes

Sourced from github.com/stripe/pg-schema-diff's releases.

v1.0.7

Security fixes

Fixes two additional SQL injection sinks identified via the same root cause as v1.0.6 (enum labels, #295). Schema-derived values were re-emitted into generated DDL without proper escaping.

Policy role names (policy_sql_generator.go)

AppliesTo role names (sourced from pg_roles.rolname) were interpolated raw into CREATE POLICY ... TO and ALTER POLICY ... TO statements. A user with CREATEROLE privilege could plant a role whose name contains an embedded double-quote to inject arbitrary SQL during plan execution.

Fix: Added escapeRoleNames() helper that applies EscapeIdentifier to each role name, preserving PUBLIC as an unquoted SQL keyword.

Function/procedure names (schema.go buildProcName)

The function used hand-rolled quoting (fmt.Sprintf("\"%s\"(%s)", name, ...)) that did not double embedded double-quotes. A user with CREATE FUNCTION privilege could create a function with " in its name to inject SQL when DROP FUNCTION/DROP PROCEDURE statements are generated.

Fix: Replaced the hand-rolled quoting with EscapeIdentifier(name).

What's Changed

  • fix: escape policy role names and function/procedure names in generated SQL (#296)

Full Changelog: https://github.com/stripe/pg-schema-diff/compare/v1.0.6...v1.0.7

v1.0.6

Security fix

Escapes enum labels in generated SQL to prevent a second-order SQL injection.

Enum labels were interpolated into generated DDL using raw fmt.Sprintf("'%s'", val). A label containing a single quote could break out of the string literal and inject arbitrary SQL, which then executes with the plan runner's (often superuser) privileges when pg-schema-diff generates migration SQL — enabling RCE via COPY ... TO PROGRAM.

All three enum sinks (CREATE TYPE ... AS ENUM, ALTER TYPE ... ADD VALUE, and the BEFORE ordering clause) now route through a new EscapeLiteral helper that doubles single quotes and strips null bytes.

See #295 for details and test evidence.

Recommendation: upgrade to v1.0.6, especially if you run pg-schema-diff against databases where lower-privileged users can create enum types.

Commits
  • 6208f8f fix: escape policy role names and function names in generated SQL (#296)
  • 4ff84db fix: escape enum labels in generated SQL to prevent SQL injection (#295)
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/stripe/pg-schema-diff&package-manager=go_modules&previous-version=1.0.5&new-version=1.0.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli-go/go.mod | 2 +- apps/cli-go/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli-go/go.mod b/apps/cli-go/go.mod index 00a84266a6..875cf3c268 100644 --- a/apps/cli-go/go.mod +++ b/apps/cli-go/go.mod @@ -48,7 +48,7 @@ require ( github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 - github.com/stripe/pg-schema-diff v1.0.5 + github.com/stripe/pg-schema-diff v1.0.7 github.com/supabase/cli/pkg v1.0.0 github.com/tidwall/jsonc v0.3.3 github.com/withfig/autocomplete-tools/packages/cobra v1.2.0 diff --git a/apps/cli-go/go.sum b/apps/cli-go/go.sum index 9d22c07d69..676a7cb287 100644 --- a/apps/cli-go/go.sum +++ b/apps/cli-go/go.sum @@ -1111,8 +1111,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/stripe/pg-schema-diff v1.0.5 h1:TNHkiRNMn7ttiBd+YBypAbx9v0SfVls+NQZFtamy1K4= -github.com/stripe/pg-schema-diff v1.0.5/go.mod h1:3IctPaAqm+0LtWw/GiwyRoRlU1/N/+00+eXVk0KZIHs= +github.com/stripe/pg-schema-diff v1.0.7 h1:aVMFqjsnPSeh46hlJnexGPm28nZJUvZK0bEyk6rdFVE= +github.com/stripe/pg-schema-diff v1.0.7/go.mod h1:3IctPaAqm+0LtWw/GiwyRoRlU1/N/+00+eXVk0KZIHs= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= From 6201e4fac7bbcfc0ef29c80720457e7d7eb8b09c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:12:55 +0000 Subject: [PATCH 46/49] fix(deps): bump the npm-major group with 3 updates (#5859) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the npm-major group with 3 updates: [@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript), [@clack/prompts](https://github.com/bombshell-dev/clack/tree/HEAD/packages/prompts) and [@typescript/native-preview](https://github.com/microsoft/typescript-go). Updates `@anthropic-ai/claude-agent-sdk` from 0.3.199 to 0.3.201
Release notes

Sourced from @​anthropic-ai/claude-agent-sdk's releases.

v0.3.201

What's changed

  • Updated to parity with Claude Code v2.1.201

Update

npm install @anthropic-ai/claude-agent-sdk@0.3.201
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.201
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.201
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.201

v0.3.200

What's changed

  • Added 'manual' as an accepted alias for the 'default' permission mode in SDK inputs
  • Fixed onSetPermissionMode callback not firing for SDK-hosted Remote Control sessions
  • Fixed set_model control request accepting unrecognized model strings; invalid models are now rejected before latching

Update

npm install @anthropic-ai/claude-agent-sdk@0.3.200
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.200
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.200
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.200
Changelog

Sourced from @​anthropic-ai/claude-agent-sdk's changelog.

0.3.201

  • Updated to parity with Claude Code v2.1.201

0.3.200

  • Added 'manual' as an accepted alias for the 'default' permission mode in SDK inputs
  • Fixed onSetPermissionMode callback not firing for SDK-hosted Remote Control sessions
  • Fixed set_model control request accepting unrecognized model strings; invalid models are now rejected before latching
Commits

Updates `@clack/prompts` from 1.6.0 to 1.7.0
Release notes

Sourced from @​clack/prompts's releases.

@​clack/prompts@​1.7.0

Minor Changes

  • #574 8f1c380 Thanks @​dreyfus92! - Add showInstructions option to select, multiselect, and groupMultiselect. Keyboard hints remain shown by default; pass showInstructions: false to hide them.

Patch Changes

Changelog

Sourced from @​clack/prompts's changelog.

1.7.0

Minor Changes

  • #574 8f1c380 Thanks @​dreyfus92! - Add showInstructions option to select, multiselect, and groupMultiselect. Keyboard hints remain shown by default; pass showInstructions: false to hide them.

Patch Changes

Commits

Updates `@typescript/native-preview` from 7.0.0-dev.20260702.3 to 7.0.0-dev.20260703.1
Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli/package.json | 4 +- pnpm-lock.yaml | 506 +++++++++++++++++++++++++++++++----------- pnpm-workspace.yaml | 2 +- 3 files changed, 378 insertions(+), 134 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index e7dd1a8292..4d458e4441 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -43,9 +43,9 @@ "jose": "^6.2.3" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.199", + "@anthropic-ai/claude-agent-sdk": "^0.3.201", "@anthropic-ai/sdk": "^0.110.0", - "@clack/prompts": "^1.6.0", + "@clack/prompts": "^1.7.0", "@effect/atom-react": "catalog:", "@effect/platform-bun": "catalog:", "@effect/sql-pg": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00f08c3538..fb2200064f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,8 +37,8 @@ catalogs: specifier: ^1.3.14 version: 1.3.14 '@typescript/native-preview': - specifier: 7.0.0-dev.20260702.3 - version: 7.0.0-dev.20260702.3 + specifier: 7.0.0-dev.20260703.1 + version: 7.0.0-dev.20260703.1 '@vitest/coverage-istanbul': specifier: ^4.1.9 version: 4.1.9 @@ -97,14 +97,14 @@ importers: version: 6.2.3 devDependencies: '@anthropic-ai/claude-agent-sdk': - specifier: ^0.3.199 - version: 0.3.199(@anthropic-ai/sdk@0.110.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + specifier: ^0.3.201 + version: 0.3.201(@anthropic-ai/sdk@0.110.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@anthropic-ai/sdk': specifier: ^0.110.0 version: 0.110.0(zod@4.4.3) '@clack/prompts': - specifier: ^1.6.0 - version: 1.6.0 + specifier: ^1.7.0 + version: 1.7.0 '@effect/atom-react': specifier: 'catalog:' version: 4.0.0-beta.93(effect@4.0.0-beta.93)(react@19.2.7)(scheduler@0.27.0) @@ -155,7 +155,7 @@ importers: version: 19.2.17 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260702.3 + version: 7.0.0-dev.20260703.1 '@vercel/detect-agent': specifier: ^1.2.3 version: 1.2.3 @@ -259,7 +259,7 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260702.3 + version: 7.0.0-dev.20260703.1 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -339,7 +339,7 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260702.3 + version: 7.0.0-dev.20260703.1 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -381,7 +381,7 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260702.3 + version: 7.0.0-dev.20260703.1 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -431,7 +431,7 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260702.3 + version: 7.0.0-dev.20260703.1 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -471,7 +471,7 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260702.3 + version: 7.0.0-dev.20260703.1 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -523,7 +523,7 @@ importers: version: 1.3.14 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260702.3 + version: 7.0.0-dev.20260703.1 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.9(vitest@4.1.9) @@ -547,7 +547,7 @@ importers: dependencies: '@nx/devkit': specifier: 'catalog:' - version: 23.0.1(nx@23.0.1(@swc-node/register@1.11.1(@swc/core@1.15.43)(@swc/types@0.1.27)(typescript@6.0.3))(@swc/core@1.15.43)) + version: 23.0.1(nx@23.0.2(@swc-node/register@1.11.1(@swc/core@1.15.43)(@swc/types@0.1.27)(typescript@6.0.3))(@swc/core@1.15.43)) vitest: specifier: 'catalog:' version: 4.1.9(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -570,52 +570,52 @@ packages: resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} engines: {node: '>=18'} - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.199': - resolution: {integrity: sha512-0813IEsPlA3GQZ86CGmRPh/2DY/iEuBkXV7CRAj55sQ30oIm+N/BbXzI/jH7WiWsHh3pCsRx65dbswf6jA5Uuw==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.201': + resolution: {integrity: sha512-8Mcb3BDyKUGfJWFFTWwt+at37lbDH3ZwVtUNPWGG1toZ75RDCJry5U4kXRvQ2xokvJQlA0E+eNp6keWe5ZH22Q==} cpu: [arm64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.199': - resolution: {integrity: sha512-iUenoE7UbWFfYjdfaeJkPl4qpXajPcRw8SzMYn0PK2LsBE5SJTfEdZyvtypNaKiWzsCCaU4MV1yzR8S7i/wZQg==} + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.201': + resolution: {integrity: sha512-TFR2bu0+ml3RHoMrtsgD0qDK5Oknw8kYGBV7qpQHn+IWmE96gnHhogG1LpJwpHtni08XkJIjfWk1DdlsUYtRkQ==} cpu: [x64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.199': - resolution: {integrity: sha512-mcqHuNHsA2V589rt0JpCsweS8kZ8LkKZi0qmMLqN3oZz+ar4DsuDjLDaNI1C5f+W/nz5G/st+2W3ZCJubeOqVQ==} + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.201': + resolution: {integrity: sha512-EiqbpfJIpChfkn+8Uj061Qjyw0eaRcOXtdrvVuHANyj8ZErVOr8HlH6op9PSeIUa9TX0m2+tNgKPQvOGseQckA==} cpu: [arm64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.199': - resolution: {integrity: sha512-wJ4GJCwrdVv4TWTiEwh2gUjrddIg+oMhoUcfXhtZgZeqXfGzxVWJa3HudX5xsVq/wktvM65CfdYly2blVBVG8Q==} + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.201': + resolution: {integrity: sha512-mShTo3MwF0gkN4dDw78wWJiB6aBDVRkl81cnApvoBofpdyUBYgm9Gw16CCjDTgelMKeBFqN6ErJpwjI3wbP00A==} cpu: [arm64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.199': - resolution: {integrity: sha512-wr7IIQ9d9H7Tt79Mn4ga8iNLzsvoPnAbBtsnUVBjgcAxKWrJ+QV2wCsowhhSc7DWNueiurdxNsLIwd07BzhfCA==} + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.201': + resolution: {integrity: sha512-IbxnzO5UCbqbm2TnzCHkSyJorAFw2isdKdIsFCTxJJjSs3ZC+v3LC1QSUiVCx0qi+CV6w3MKx6mLI11mrvhbbQ==} cpu: [x64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.199': - resolution: {integrity: sha512-nNLk8KcM33AYQ0P1cNK72kE14iTIuM5RTr0CrQm40FwdrPuVZ/se6KggpE3eje8hFYJu/1WrJqcZxtJSL6qnPg==} + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.201': + resolution: {integrity: sha512-jrJBrRWrSuoFKIgjyqxHqmfd6Pb3Bs5Bvakg0knXCTC4fbUXGnC9Q6u7gdDwgXohUNP6/DD+s8U7bivvvVv0dg==} cpu: [x64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.199': - resolution: {integrity: sha512-EQFMATdpp6XXYXw6Ih83BFCDj2PXqMYkM6nq3OrOVUF19xTe6o5dWhGzZ6TQfauvWf2BHFIfBDzZz82m8ffV8w==} + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.201': + resolution: {integrity: sha512-UsoytRJ/037uHpb3ATrIoe+AgwTf+PwKuFLGjddHAV/11wERJs0hlrnSmcnp43kf0PFxoSNinngme96YYASmQg==} cpu: [arm64] os: [win32] - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.199': - resolution: {integrity: sha512-6W2djU/NnOCY4NEdDjtZ3LIzFFQMT2OH9m5y5Hc4bko3KIXr0WHLrCJdgGiVycOWEd03OEsC4uYIIYIeueBAHw==} + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.201': + resolution: {integrity: sha512-PhalN/0cWcqDfbx7iwoLNR2gurjTiqhBk1G6K+NRScxEcQjWuu5xKXCcdbX8ePVpT+nbEMmFEFpn2y+8V8hIdA==} cpu: [x64] os: [win32] - '@anthropic-ai/claude-agent-sdk@0.3.199': - resolution: {integrity: sha512-fET9rfYR2DgjVG4Yri02Q6YL+SWBlcrsd3Dx96CpZSM50W4Jqh1sEC06hXIEHL0uDZ/kS//Y2uPpCQ9GQqh8Bg==} + '@anthropic-ai/claude-agent-sdk@0.3.201': + resolution: {integrity: sha512-InT1XLmf2QpldWdtznKDWEoGJT4p+sXh24yxbeBQ++lMJCzMrI0W27MEmmmDWx0otpa+ubdHCF5YQ6oiNt7cmg==} engines: {node: '>=18.0.0'} peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' @@ -702,12 +702,12 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@clack/core@1.4.2': - resolution: {integrity: sha512-0Ty/1Gfm+Kb07sXcuESjyKfwEhSy4Ns1AgeEisHb/bDY5fWme0tTeTkU14T1Gmcs17YIjB/teiDe4uaCghbYqQ==} + '@clack/core@1.4.3': + resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} engines: {node: '>= 20.12.0'} - '@clack/prompts@1.6.0': - resolution: {integrity: sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==} + '@clack/prompts@1.7.0': + resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} engines: {node: '>= 20.12.0'} '@colors/colors@1.5.0': @@ -1396,55 +1396,109 @@ packages: cpu: [arm64] os: [darwin] + '@nx/nx-darwin-arm64@23.0.2': + resolution: {integrity: sha512-9sqhZMVFpF+qM7hq6y2xA4gVK+6RdxRioAwHxorhOZRSXdW7Y7NESs5fm8vOmdddlG07QB7sMefOKLrqCV3zGg==} + cpu: [arm64] + os: [darwin] + '@nx/nx-darwin-x64@23.0.1': resolution: {integrity: sha512-e/lvzHKN6gpuD7MqEtfH1fOfnR75E55ytYNt8jaRxKI6EvpCq+Q3MunDuh9GQYAkqDrUqE7AhHrHc+eKATVEHw==} cpu: [x64] os: [darwin] + '@nx/nx-darwin-x64@23.0.2': + resolution: {integrity: sha512-p6L3AvRhRRaR8Bl3jr76/9H04RdWUQbSgB7agK7GB7vqaLI8RifP2lqeaXcAngzjDAjw2EAf0TjOBP+T67hhcg==} + cpu: [x64] + os: [darwin] + '@nx/nx-freebsd-x64@23.0.1': resolution: {integrity: sha512-f582OhSYN9qHpA9Ox9qnr3kZSZ7gQHs7crmBUutmbXmZQB2TDS/TlhvYSNnxudpwHR/tuWGi2IOQqa7zGOZj1Q==} cpu: [x64] os: [freebsd] + '@nx/nx-freebsd-x64@23.0.2': + resolution: {integrity: sha512-/py4I8Rp2UURses9H/+SQmgPVnHVSJgPimJLhXIfsRavKGu4RS7Ddu1OyNqSkCT3Otic6ImMTtkufURW22KiEQ==} + cpu: [x64] + os: [freebsd] + '@nx/nx-linux-arm-gnueabihf@23.0.1': resolution: {integrity: sha512-VjhqPc6E7aiI0e+lowrkVbdyulsmP9fgMdcX1mCzXCEu/XZDcUbZ5qveR964cMhvm5qKn0ILJtJOUqZgmOT3Xg==} cpu: [arm] os: [linux] + '@nx/nx-linux-arm-gnueabihf@23.0.2': + resolution: {integrity: sha512-xv2IzeiWJFWi4WjK0ocMkP+ze1lDeoPVCg0xOTqVs40gM66V1wVw3EK077gTqU4m0Bq1wUxe6/I8WaIGlkLgug==} + cpu: [arm] + os: [linux] + '@nx/nx-linux-arm64-gnu@23.0.1': resolution: {integrity: sha512-zX2JdHQejZWB3DRgNsh77qOVYaSSjSLuBP2qIqc7EWVlCUnR7Aj3e65PTIps4LxMMmUp4twZA2ezS0rtyK2A4w==} cpu: [arm64] os: [linux] libc: [glibc] + '@nx/nx-linux-arm64-gnu@23.0.2': + resolution: {integrity: sha512-ckA6hTXST+agxt/HzPGqMss9qFCZhO9b07o8usygb7QFBYQRXFgcYzhTblq4yiTL5ibJmXAGGh98011fLA2MVw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@nx/nx-linux-arm64-musl@23.0.1': resolution: {integrity: sha512-9lyhxRNBgNYwHt6paq0OLzoKNoEGF5LnNW2YYrgFY8Cjtsg/Q4pcfZ1vB5o9FX9OmUgUQs3t2d4tU8YDukRUWg==} cpu: [arm64] os: [linux] libc: [musl] + '@nx/nx-linux-arm64-musl@23.0.2': + resolution: {integrity: sha512-PAxBxy7m//cKxUeIb6Sk2X5MJ/wjcJcqCx7/L0p8omTt/y/+q1TGpVy6qmJMPUWzNgAULXtsVRSOK4rmiBcrQQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@nx/nx-linux-x64-gnu@23.0.1': resolution: {integrity: sha512-kVszY2xRyyrCXgdCdM1qG1WUhDjNPZxtdWq86a0TyIRJjfJTP9NHqpyhmvj9c2RdZxKVWHotx6fBJzY6Vn2ZrA==} cpu: [x64] os: [linux] libc: [glibc] + '@nx/nx-linux-x64-gnu@23.0.2': + resolution: {integrity: sha512-gR146Mo+BjhrIU2fNOJeVjX8HEAHrtmc7IyrD0qD9yqyq0l9Mdx92JnMW3yVQaYIMpPJIqsOvHTDIUbTLFrmTA==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@nx/nx-linux-x64-musl@23.0.1': resolution: {integrity: sha512-co71K2n4zcS1SYR8EBRlCvIko7M1YycO2tZL0nrCrga87AF5dzCwx+wEclpyCR/4tNOY3FrACk4gIkVskh3CdA==} cpu: [x64] os: [linux] libc: [musl] + '@nx/nx-linux-x64-musl@23.0.2': + resolution: {integrity: sha512-L7JkaoAI0p+DpAi2CNCqPq8nHWkApQw2td0Cyt+stOx/OJlmEteyc/MXyTHBoMPoopNj1wWANJJQm/G1anOLhQ==} + cpu: [x64] + os: [linux] + libc: [musl] + '@nx/nx-win32-arm64-msvc@23.0.1': resolution: {integrity: sha512-7oma7iy5fbnn+x5AP7SFGMuleAA2R5RZm26dn+faikyQ4PXjoRAikWJJNiOWAeCA0BaMAeVedI6fJeAsVeDUKg==} cpu: [arm64] os: [win32] + '@nx/nx-win32-arm64-msvc@23.0.2': + resolution: {integrity: sha512-B3ePaYeu31ivP3i72Vou5RBYFVrDKCMZgz7Jsax+RvEJa8dNfI+Ynh/PSoXzHudN0YsrekkKbjlxNvp/D6fWFA==} + cpu: [arm64] + os: [win32] + '@nx/nx-win32-x64-msvc@23.0.1': resolution: {integrity: sha512-TE/wvBa2cpkVXmk/AXUQAneong4JReS2hyNpAUONKG1yXU7TDKe0wvn1xQXxAbyspudT9NuCnVtpVuEkRz8S+Q==} cpu: [x64] os: [win32] + '@nx/nx-win32-x64-msvc@23.0.2': + resolution: {integrity: sha512-/NiB9w8nYrw7LUkcmwAJ9wis5O+kh3ahSZXMDHVYgFnD8yN7kLql39MNJD+/DGstjNSDvWh45ftqr7mZMi6wpQ==} + cpu: [x64] + os: [win32] + '@octokit/auth-token@6.0.0': resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} engines: {node: '>= 20'} @@ -2867,50 +2921,50 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260702.3': - resolution: {integrity: sha512-sswJ7XRSH17/jDv6eqlRyNzjQev4w+TH+16RgeoZkKbQfZnVuLchF4ORCecEuia15X2yE8pbiQGY596LVbGKdA==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260703.1': + resolution: {integrity: sha512-EwJEd6hfaHrrbSLat6+xX8fZiAvG+/Ae1gqvJEayTNdDbkrZxmeLS23YdjUmDMIOKI2wa7zXQDid8WtrvDfMTQ==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260702.3': - resolution: {integrity: sha512-j8ZYT/chWtx3QT1Bghp0/+2g6aKLaxRVpXEHt6Fjdj4OXQTpH4pljOE0+LeAqaET88PUo/XM9pi0TJ5TJwHLMg==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260703.1': + resolution: {integrity: sha512-tcS3gpivMq+BAiaupFqM5vuERykogJlfD4CjoxkSCksmmsKgWV96S2U/LjrKgll8R6/OEkg2VL2ycRG9+tIuHw==} engines: {node: '>=16.20.0'} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260702.3': - resolution: {integrity: sha512-FEBt9UER27yvn3o4quXZ9fQ/Hd39hxFPljJe8dxstCvyYLh0A9TwDuuwghWCisp5U9TDWWtl8m1GLjvqIKc6IA==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260703.1': + resolution: {integrity: sha512-m8IJTOneLXRtq6prLz8uuhp463kEt+AHV5Ceqp7G0o3eAvbKP059OwzK6WXCS5J0B3ZxX+BwBf9wDXSNidWJCw==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260702.3': - resolution: {integrity: sha512-SM73Mp3oYUWkyHmym0MVfPnMeLGM6oo1vcPZTxVoL7BOKWkJKK+iM7D1tcqDSZNPwURSkWMxP17Dh+4JPqzikg==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260703.1': + resolution: {integrity: sha512-nluwnKcdGo6laWWYtWF3zFUDfi7nNrcD5S/T5382fVmtKmNqIdzFm9ANgSSGAavuzfdu9YJLaVWpx72Oe40AEQ==} engines: {node: '>=16.20.0'} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260702.3': - resolution: {integrity: sha512-CY/r7gHjDnf3xWptpQVRQnagMdUDbh+zL01SF+cTJsg1WVY3TtVNrCo48dwAnJL+U97qx1SKaGVsi/8cSPwrZA==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260703.1': + resolution: {integrity: sha512-5ga94rso68kaxJUzaufDVhka1zPaSbPgNXaemQO8faPW0crvRIkbv0g8bpG4pdWMV0tTylmluDles4ZcAzOprg==} engines: {node: '>=16.20.0'} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260702.3': - resolution: {integrity: sha512-9U/ahdJAWZdYxPdNry3VAhB9avaQCiqsEk2PfheeKrKSKCtfs5FoPXy/QXAaIGIBK/Lw4a7OqyVyEbd0i77TkQ==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260703.1': + resolution: {integrity: sha512-YeRqOUuSyhbtryBSUE073OLpReIQXWJVyjP45gPLJ+0KAGvkd1VFz2FY1JEo++LyHoqXsGD2+qvdPpnTfSAIJg==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260702.3': - resolution: {integrity: sha512-kPqfivHuzzEK2NFEl2QeCCiWJ4qkGMJYFX9TmJ4ylh784XICgQLX3PJe7OCqZcUicfeMIIDu6m3flcCi+4tW4Q==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260703.1': + resolution: {integrity: sha512-WYNcbTaxCPgiDLrL2aPJ3BJA05nJU8DK0fUFGkGAER0oM+KcJcQUBeUkXg/7A4HBTNYcj7zIxqNgkJU1TRa/Kw==} engines: {node: '>=16.20.0'} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260702.3': - resolution: {integrity: sha512-j21laxUja23ex6qYGjwiSiQ6YpS/p5E7lCMDYKDBAdBv93Z1NzV96BZV3gdRwl+buH09EPuBAiqxKQqRO1grhw==} + '@typescript/native-preview@7.0.0-dev.20260703.1': + resolution: {integrity: sha512-qyEHkEeRWCSGLa6a8oArnMPdn3Vcl1AZj8YHLO7lK0VjEEF/YJfz1FvxmpEeuhy0ZDfvJ7GtgXbvbjcU3zpPjQ==} engines: {node: '>=16.20.0'} hasBin: true @@ -3179,6 +3233,9 @@ packages: axios@1.16.0: resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + axios@1.16.1: + resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} + b4a@1.8.1: resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} peerDependencies: @@ -5313,6 +5370,18 @@ packages: '@swc/core': optional: true + nx@23.0.2: + resolution: {integrity: sha512-e5H6ceqj0Z8ovAmtsiHXswwpiNPrr1DRhvJMTpc2AW8G1za9PKxk3bP5josShsIrmGEsOlBNZZsxszXA2+Q2dw==} + hasBin: true + peerDependencies: + '@swc-node/register': ^1.11.1 + '@swc/core': ^1.15.8 + peerDependenciesMeta: + '@swc-node/register': + optional: true + '@swc/core': + optional: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -6852,44 +6921,44 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.199': + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.201': optional: true - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.199': + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.201': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.199': + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.201': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.199': + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.201': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.199': + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.201': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.199': + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.201': optional: true - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.199': + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.201': optional: true - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.199': + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.201': optional: true - '@anthropic-ai/claude-agent-sdk@0.3.199(@anthropic-ai/sdk@0.110.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.201(@anthropic-ai/sdk@0.110.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.110.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) zod: 4.4.3 optionalDependencies: - '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.199 - '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.199 - '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.199 - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.199 - '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.199 - '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.199 - '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.199 - '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.199 + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.201 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.201 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.201 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.201 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.201 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.201 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.201 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.201 '@anthropic-ai/sdk@0.110.0(zod@4.4.3)': dependencies: @@ -6919,7 +6988,7 @@ snapshots: '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -6991,7 +7060,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/template': 7.29.7 '@babel/types': 7.29.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -7000,14 +7069,14 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@clack/core@1.4.2': + '@clack/core@1.4.3': dependencies: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clack/prompts@1.6.0': + '@clack/prompts@1.7.0': dependencies: - '@clack/core': 1.4.2 + '@clack/core': 1.4.3 fast-string-width: 3.0.2 fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 @@ -7572,13 +7641,13 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@nx/devkit@23.0.1(nx@23.0.1(@swc-node/register@1.11.1(@swc/core@1.15.43)(@swc/types@0.1.27)(typescript@6.0.3))(@swc/core@1.15.43))': + '@nx/devkit@23.0.1(nx@23.0.2(@swc-node/register@1.11.1(@swc/core@1.15.43)(@swc/types@0.1.27)(typescript@6.0.3))(@swc/core@1.15.43))': dependencies: '@zkochan/js-yaml': 0.0.7 ejs: 5.0.1 enquirer: 2.3.6 minimatch: 10.2.5 - nx: 23.0.1(@swc-node/register@1.11.1(@swc/core@1.15.43)(@swc/types@0.1.27)(typescript@6.0.3))(@swc/core@1.15.43) + nx: 23.0.2(@swc-node/register@1.11.1(@swc/core@1.15.43)(@swc/types@0.1.27)(typescript@6.0.3))(@swc/core@1.15.43) semver: 7.8.5 tslib: 2.8.1 yargs-parser: 21.1.1 @@ -7586,33 +7655,63 @@ snapshots: '@nx/nx-darwin-arm64@23.0.1': optional: true + '@nx/nx-darwin-arm64@23.0.2': + optional: true + '@nx/nx-darwin-x64@23.0.1': optional: true + '@nx/nx-darwin-x64@23.0.2': + optional: true + '@nx/nx-freebsd-x64@23.0.1': optional: true + '@nx/nx-freebsd-x64@23.0.2': + optional: true + '@nx/nx-linux-arm-gnueabihf@23.0.1': optional: true + '@nx/nx-linux-arm-gnueabihf@23.0.2': + optional: true + '@nx/nx-linux-arm64-gnu@23.0.1': optional: true + '@nx/nx-linux-arm64-gnu@23.0.2': + optional: true + '@nx/nx-linux-arm64-musl@23.0.1': optional: true + '@nx/nx-linux-arm64-musl@23.0.2': + optional: true + '@nx/nx-linux-x64-gnu@23.0.1': optional: true + '@nx/nx-linux-x64-gnu@23.0.2': + optional: true + '@nx/nx-linux-x64-musl@23.0.1': optional: true + '@nx/nx-linux-x64-musl@23.0.2': + optional: true + '@nx/nx-win32-arm64-msvc@23.0.1': optional: true + '@nx/nx-win32-arm64-msvc@23.0.2': + optional: true + '@nx/nx-win32-x64-msvc@23.0.1': optional: true + '@nx/nx-win32-x64-msvc@23.0.2': + optional: true + '@octokit/auth-token@6.0.0': {} '@octokit/core@7.0.6': @@ -8424,7 +8523,7 @@ snapshots: conventional-changelog-writer: 8.4.0 conventional-commits-filter: 5.0.0 conventional-commits-parser: 6.4.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) import-from-esm: 2.0.0 lodash-es: 4.18.1 micromatch: 4.0.8 @@ -8442,7 +8541,7 @@ snapshots: '@octokit/plugin-throttling': 11.0.3(@octokit/core@7.0.6) '@semantic-release/error': 4.0.0 aggregate-error: 5.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) dir-glob: 3.0.1 http-proxy-agent: 9.1.0 https-proxy-agent: 9.1.0 @@ -8483,7 +8582,7 @@ snapshots: conventional-changelog-writer: 8.4.0 conventional-commits-filter: 5.0.0 conventional-commits-parser: 6.4.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) import-from-esm: 2.0.0 lodash-es: 4.18.1 read-package-up: 11.0.0 @@ -8584,7 +8683,7 @@ snapshots: '@swc-node/sourcemap-support': 0.6.1 '@swc/core': 1.15.43 colorette: 2.0.20 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) oxc-resolver: 11.21.3 pirates: 4.0.7 tslib: 2.8.1 @@ -8751,36 +8850,36 @@ snapshots: dependencies: '@types/node': 26.1.1 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260702.3': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260703.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260702.3': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260703.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260702.3': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260703.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260702.3': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260703.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260702.3': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260703.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260702.3': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260703.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260702.3': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260703.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260702.3': + '@typescript/native-preview@7.0.0-dev.20260703.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260702.3 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260702.3 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260702.3 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260702.3 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260702.3 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260702.3 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260702.3 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260703.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260703.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260703.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260703.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260703.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260703.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260703.1 '@ungap/structured-clone@1.3.2': {} @@ -8792,7 +8891,7 @@ snapshots: '@verdaccio/core': 8.1.2 '@verdaccio/loaders': 8.0.3 '@verdaccio/signature': 8.0.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) lodash: 4.18.1 verdaccio-htpasswd: 13.0.3 transitivePeerDependencies: @@ -8801,7 +8900,7 @@ snapshots: '@verdaccio/config@8.1.2': dependencies: '@verdaccio/core': 8.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) js-yaml: 4.1.1 lodash: 4.18.1 transitivePeerDependencies: @@ -8824,7 +8923,7 @@ snapshots: dependencies: '@verdaccio/core': 8.1.2 '@verdaccio/logger': 8.0.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) got-cjs: 12.5.4 handlebars: 4.7.9 transitivePeerDependencies: @@ -8833,7 +8932,7 @@ snapshots: '@verdaccio/loaders@8.0.3': dependencies: '@verdaccio/core': 8.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) lodash: 4.18.1 transitivePeerDependencies: - supports-color @@ -8843,7 +8942,7 @@ snapshots: '@verdaccio/core': 8.1.2 '@verdaccio/file-locking': 13.0.1 '@verdaccio/streams': 10.2.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) globby: 11.1.0 lodash: 4.18.1 lowdb: 1.0.0 @@ -8857,7 +8956,7 @@ snapshots: '@verdaccio/core': 8.1.2 '@verdaccio/logger-prettify': 8.0.1 colorette: 2.0.20 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -8882,7 +8981,7 @@ snapshots: '@verdaccio/config': 8.1.2 '@verdaccio/core': 8.1.2 '@verdaccio/url': 13.0.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) express: 4.22.1 express-rate-limit: 5.5.1 lodash: 4.18.1 @@ -8893,14 +8992,14 @@ snapshots: '@verdaccio/package-filter@13.0.3': dependencies: '@verdaccio/core': 8.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) semver: 7.7.4 transitivePeerDependencies: - supports-color '@verdaccio/search-indexer@8.0.2': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) fuse.js: 7.3.0 transitivePeerDependencies: - supports-color @@ -8909,7 +9008,7 @@ snapshots: dependencies: '@verdaccio/config': 8.1.2 '@verdaccio/core': 8.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) jsonwebtoken: 9.0.3 transitivePeerDependencies: - supports-color @@ -8920,7 +9019,7 @@ snapshots: dependencies: '@verdaccio/core': 8.1.2 '@verdaccio/url': 13.0.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) gunzip-maybe: 1.4.2 tar-stream: 3.1.7 transitivePeerDependencies: @@ -8930,14 +9029,14 @@ snapshots: '@verdaccio/ui-theme@9.0.0-next-9.20': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color '@verdaccio/url@13.0.3': dependencies: '@verdaccio/core': 8.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) validator: 13.15.26 transitivePeerDependencies: - supports-color @@ -9036,9 +9135,9 @@ snapshots: acorn@8.17.0: {} - agent-base@6.0.2: + agent-base@6.0.2(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -9122,12 +9221,22 @@ snapshots: axios@1.16.0: dependencies: - follow-redirects: 1.16.0 + follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0)) form-data: 4.0.6 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + axios@1.16.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0)) + form-data: 4.0.6 + https-proxy-agent: 5.0.1(supports-color@7.2.0) + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + b4a@1.8.1: {} bail@2.0.2: {} @@ -9177,7 +9286,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -9505,9 +9614,11 @@ snapshots: dependencies: ms: 2.0.0 - debug@4.4.3: + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 decode-named-character-reference@1.3.0: dependencies: @@ -9891,7 +10002,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -9992,7 +10103,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -10016,7 +10127,9 @@ snapshots: flat@5.0.2: {} - follow-redirects@1.16.0: {} + follow-redirects@1.16.0(debug@4.4.3(supports-color@7.2.0)): + optionalDependencies: + debug: 4.4.3(supports-color@7.2.0) forever-agent@0.6.1: {} @@ -10429,7 +10542,7 @@ snapshots: http-proxy-agent@9.1.0: dependencies: agent-base: 9.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) proxy-agent-negotiate: 1.1.0 transitivePeerDependencies: - kerberos @@ -10448,17 +10561,17 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 - https-proxy-agent@5.0.1: + https-proxy-agent@5.0.1(supports-color@7.2.0): dependencies: - agent-base: 6.0.2 - debug: 4.4.3 + agent-base: 6.0.2(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color https-proxy-agent@9.1.0: dependencies: agent-base: 9.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) proxy-agent-negotiate: 1.1.0 transitivePeerDependencies: - kerberos @@ -10491,7 +10604,7 @@ snapshots: import-from-esm@2.0.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) import-meta-resolve: 4.2.0 transitivePeerDependencies: - supports-color @@ -10555,7 +10668,7 @@ snapshots: dependencies: '@ioredis/commands': 1.10.0 cluster-key-slot: 1.1.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) denque: 2.1.0 redis-errors: 1.2.0 redis-parser: 3.0.0 @@ -11350,7 +11463,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.13 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -11594,7 +11707,7 @@ snapshots: escape-string-regexp: 1.0.5 figures: 3.2.0 flat: 5.0.2 - follow-redirects: 1.16.0 + follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0)) form-data: 4.0.6 fs-constants: 1.0.0 function-bind: 1.1.2 @@ -11675,6 +11788,137 @@ snapshots: transitivePeerDependencies: - debug + nx@23.0.2(@swc-node/register@1.11.1(@swc/core@1.15.43)(@swc/types@0.1.27)(typescript@6.0.3))(@swc/core@1.15.43): + dependencies: + '@emnapi/core': 1.4.5 + '@emnapi/runtime': 1.4.5 + '@emnapi/wasi-threads': 1.0.4 + '@jest/diff-sequences': 30.0.1 + '@napi-rs/wasm-runtime': 0.2.4 + '@tybys/wasm-util': 0.9.0 + '@yarnpkg/lockfile': 1.1.0 + '@zkochan/js-yaml': 0.0.7 + agent-base: 6.0.2(supports-color@7.2.0) + ansi-colors: 4.1.3 + ansi-regex: 5.0.1 + ansi-styles: 4.3.0 + argparse: 2.0.1 + asynckit: 0.4.0 + axios: 1.16.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0) + balanced-match: 4.0.3 + base64-js: 1.5.1 + bl: 4.1.0 + brace-expansion: 5.0.6 + buffer: 5.7.1 + call-bind-apply-helpers: 1.0.2 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.6.1 + cliui: 8.0.1 + clone: 1.0.4 + color-convert: 2.0.1 + color-name: 1.1.4 + combined-stream: 1.0.8 + debug: 4.4.3(supports-color@7.2.0) + defaults: 1.0.4 + define-lazy-prop: 2.0.0 + delayed-stream: 1.0.0 + dotenv: 16.4.7 + dotenv-expand: 12.0.3 + dunder-proto: 1.0.1 + ejs: 5.0.1 + emoji-regex: 8.0.0 + end-of-stream: 1.4.5 + enquirer: 2.3.6 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + escalade: 3.2.0 + escape-string-regexp: 1.0.5 + figures: 3.2.0 + flat: 5.0.2 + follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0)) + form-data: 4.0.6 + fs-constants: 1.0.0 + function-bind: 1.1.2 + get-caller-file: 2.0.5 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + has-flag: 4.0.0 + has-symbols: 1.1.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + https-proxy-agent: 5.0.1(supports-color@7.2.0) + ieee754: 1.2.1 + ignore: 7.0.5 + inherits: 2.0.4 + is-docker: 2.2.1 + is-fullwidth-code-point: 3.0.0 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + is-wsl: 2.2.0 + isexe: 2.0.0 + json5: 2.2.3 + jsonc-parser: 3.3.1 + lines-and-columns: 2.0.3 + log-symbols: 4.1.0 + math-intrinsics: 1.1.0 + mime-db: 1.52.0 + mime-types: 2.1.35 + mimic-fn: 2.1.0 + minimatch: 10.2.5 + minimist: 1.2.8 + ms: 2.1.3 + npm-run-path: 4.0.1 + once: 1.4.0 + onetime: 5.1.2 + open: 8.4.2 + ora: 5.4.1 + path-key: 3.1.1 + picocolors: 1.1.1 + proxy-from-env: 2.1.0 + readable-stream: 3.6.2 + require-directory: 2.1.1 + resolve.exports: 2.0.3 + restore-cursor: 3.1.0 + safe-buffer: 5.2.1 + semver: 7.7.4 + signal-exit: 3.0.7 + smol-toml: 1.6.1 + string-width: 4.2.3 + string_decoder: 1.3.0 + strip-ansi: 6.0.1 + strip-bom: 3.0.0 + supports-color: 7.2.0 + tar-stream: 2.2.0 + tmp: 0.2.7 + tsconfig-paths: 4.2.0 + tslib: 2.8.1 + util-deprecate: 1.0.2 + wcwidth: 1.0.1 + which: 3.0.1 + wrap-ansi: 7.0.0 + wrappy: 1.0.2 + y18n: 5.0.8 + yaml: 2.9.0 + yargs: 17.7.2 + yargs-parser: 21.1.1 + optionalDependencies: + '@nx/nx-darwin-arm64': 23.0.2 + '@nx/nx-darwin-x64': 23.0.2 + '@nx/nx-freebsd-x64': 23.0.2 + '@nx/nx-linux-arm-gnueabihf': 23.0.2 + '@nx/nx-linux-arm64-gnu': 23.0.2 + '@nx/nx-linux-arm64-musl': 23.0.2 + '@nx/nx-linux-x64-gnu': 23.0.2 + '@nx/nx-linux-x64-musl': 23.0.2 + '@nx/nx-win32-arm64-msvc': 23.0.2 + '@nx/nx-win32-x64-msvc': 23.0.2 + '@swc-node/register': 1.11.1(@swc/core@1.15.43)(@swc/types@0.1.27)(typescript@6.0.3) + '@swc/core': 1.15.43 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -12425,7 +12669,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -12464,7 +12708,7 @@ snapshots: '@semantic-release/release-notes-generator': 14.1.1(semantic-release@25.0.5(typescript@6.0.3)) aggregate-error: 5.0.0 cosmiconfig: 9.0.2(typescript@6.0.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) env-ci: 11.2.0 execa: 9.6.1 figures: 6.1.0 @@ -12522,7 +12766,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -13113,7 +13357,7 @@ snapshots: '@verdaccio/config': 8.1.2 '@verdaccio/core': 8.1.2 express: 4.22.1 - https-proxy-agent: 5.0.1 + https-proxy-agent: 5.0.1(supports-color@7.2.0) node-fetch: 2.6.7 transitivePeerDependencies: - encoding @@ -13125,7 +13369,7 @@ snapshots: '@verdaccio/file-locking': 13.0.1 apache-md5: 1.1.8 bcryptjs: 2.4.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) http-errors: 2.0.1 unix-crypt-td-js: 1.1.4 transitivePeerDependencies: @@ -13155,7 +13399,7 @@ snapshots: clipanion: 4.0.0-rc.4(typanion@3.14.0) compression: 1.8.1 cors: 2.8.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) envinfo: 7.21.0 express: 4.22.2 lodash: 4.18.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e5ca91b394..1c109e8892 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,7 +22,7 @@ catalog: "@swc/core": "^1.15.43" "@tsconfig/bun": "^1.0.10" "@types/bun": "^1.3.14" - "@typescript/native-preview": "7.0.0-dev.20260702.3" + "@typescript/native-preview": "7.0.0-dev.20260703.1" "@vitest/coverage-istanbul": "^4.1.9" "effect": "4.0.0-beta.93" "knip": "^6.24.0" From 79379d9a68740d939ab131ccb3fca28a80d600e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:15:10 +0000 Subject: [PATCH 47/49] fix(deps): bump github.com/posthog/posthog-go from 1.17.4 to 1.17.5 in /apps/cli-go in the go-minor group across 1 directory (#5865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the go-minor group with 1 update in the /apps/cli-go directory: [github.com/posthog/posthog-go](https://github.com/posthog/posthog-go). Updates `github.com/posthog/posthog-go` from 1.17.4 to 1.17.5
Release notes

Sourced from github.com/posthog/posthog-go's releases.

1.17.5

Unreleased

Changelog

Sourced from github.com/posthog/posthog-go's changelog.

1.17.5

Patch Changes

  • fe66557: Change the default capture delivery budget from 10 attempts to 4 (DefaultMaxAttempts) when Config.MaxRetries is unset, aligning with the cross-SDK Capture V1 parity standard (posthog-rs uses the same envelope). This affects both the v0 (/batch/) and v1 send paths, since they share the attempt budget. Callers that set MaxRetries explicitly are unaffected.
  • 3d8404a: Unify the capture retry backoff ceiling at 30s. DefaultBackoff's cap changes from 10s to 30s (default only — override via Config.RetryAfter), and the Capture V1 send now clamps a server Retry-After to the same 30s so a hostile or buggy header cannot park a batch goroutine. Retry-After still acts as a minimum; the configured backoff is never truncated. This aligns the default retry behavior with posthog-rs and posthog-python.
Commits
  • d7f8a22 chore: release v1.17.5 [version bump] [skip ci]
  • fe66557 feat(capture): default to 4 delivery attempts (down from 10) (#256)
  • 3d8404a fix(capture): unify retry backoff ceiling at 30s (#255)
  • 6affc15 ci: Standardize SDK release failure telemetry (#252)
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/posthog/posthog-go&package-manager=go_modules&previous-version=1.17.4&new-version=1.17.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli-go/go.mod | 2 +- apps/cli-go/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli-go/go.mod b/apps/cli-go/go.mod index 875cf3c268..ede19cb805 100644 --- a/apps/cli-go/go.mod +++ b/apps/cli-go/go.mod @@ -42,7 +42,7 @@ require ( github.com/multigres/multigres v0.0.0-20260126223308-f5a52171bbc4 github.com/oapi-codegen/nullable v1.2.0 github.com/olekukonko/tablewriter v1.1.4 - github.com/posthog/posthog-go v1.17.4 + github.com/posthog/posthog-go v1.17.5 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/apps/cli-go/go.sum b/apps/cli-go/go.sum index 676a7cb287..a9cb686621 100644 --- a/apps/cli-go/go.sum +++ b/apps/cli-go/go.sum @@ -970,8 +970,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posthog/posthog-go v1.17.4 h1:7olsPzTGzuQvXJtH9n55u/Vgy58nB7V8T85AIpRjd8Q= -github.com/posthog/posthog-go v1.17.4/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU= +github.com/posthog/posthog-go v1.17.5 h1:mrLiAdyiQpl8Yeyg23iShyAztJMaUrYzsBjKeH52Aak= +github.com/posthog/posthog-go v1.17.5/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU= github.com/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= From 249b9ec577f178350c686e1a581469d680e054ba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:14:31 +0000 Subject: [PATCH 48/49] chore(ci): bump aws-actions/configure-aws-credentials from 6.2.1 to 6.2.2 in the actions-major group (#5871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the actions-major group with 1 update: [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials). Updates `aws-actions/configure-aws-credentials` from 6.2.1 to 6.2.2
Release notes

Sourced from aws-actions/configure-aws-credentials's releases.

v6.2.2

6.2.2 (2026-07-07)

Miscellaneous Chores

Changelog

Sourced from aws-actions/configure-aws-credentials's changelog.

Changelog

All notable changes to this project will be documented in this file. See standard-version for commit guidelines.

6.2.2 (2026-07-07)

Miscellaneous Chores

6.2.1 (2026-06-26)

Bug Fixes

  • enforce allowed-account-ids on all auth paths (#1847) (4d281fb)

6.2.0 (2026-06-01)

Features

Bug Fixes

  • skip credential check on output-env-credentials: false (#1778) (58e7c47)
  • assumeRole failing from session tag size too large (#1808) (d6f5dc3)

6.1.3 (2026-05-28)

Bug Fixes

  • fix: allow kubelet token symlink in #1805

6.1.2 (2026-05-26)

Bug Fixes

6.1.1 (2026-05-05)

Miscellaneous Chores

... (truncated)

Commits
  • 517a711 chore(main): release 6.2.2 (#1876)
  • d01d678 chore: release 6.2.2
  • 8efa52b chore(deps-dev): bump vitest dependencies (#1874)
  • 8e1eed5 chore(deps-dev): bump @​smithy/property-provider from 4.4.4 to 4.4.6 (#1869)
  • 112421a chore(deps-dev): bump @​biomejs/biome from 2.5.1 to 2.5.2 (#1868)
  • fbc01c6 chore(deps-dev): bump @​types/node from 26.0.1 to 26.1.0 (#1871)
  • b12ca87 chore(deps-dev): bump memfs from 4.57.8 to 4.58.0 (#1873)
  • d314f7f chore: Update dist
  • a53b65b chore(deps): bump @​aws-sdk/client-sts from 3.1076.0 to 3.1080.0 (#1867)
  • 338d2c1 chore(deps-dev): bump sigstore from 4.1.0 to 4.1.1 (#1864)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=aws-actions/configure-aws-credentials&package-manager=github_actions&previous-version=6.2.1&new-version=6.2.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cli-go-mirror-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cli-go-mirror-image.yml b/.github/workflows/cli-go-mirror-image.yml index d82bc05002..2844079487 100644 --- a/.github/workflows/cli-go-mirror-image.yml +++ b/.github/workflows/cli-go-mirror-image.yml @@ -34,7 +34,7 @@ jobs: run: | echo "image=${TAG##*/}" >> $GITHUB_OUTPUT - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1 + uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 with: role-to-assume: ${{ secrets.PROD_AWS_ROLE }} aws-region: us-east-1 From dac162506e8a3a760745698268eb44f926c26623 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:16:24 +0000 Subject: [PATCH 49/49] chore(ci): bump the actions-major group with 2 updates (#5877) Bumps the actions-major group with 2 updates: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action). Updates `github/codeql-action/init` from 4.36.3 to 4.37.0
Release notes

Sourced from github/codeql-action/init's releases.

v4.37.0

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973
Changelog

Sourced from github/codeql-action/init's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973

4.36.3 - 01 Jul 2026

No user facing changes.

4.36.2 - 04 Jun 2026

  • Cache CodeQL CLI version information across Actions steps. #3943
  • Reduce requests while waiting for analysis processing by using exponential backoff when polling SARIF processing status. #3937
  • Update default CodeQL bundle version to 2.25.6. #3948

4.36.1 - 02 Jun 2026

No user facing changes.

4.36.0 - 22 May 2026

  • Breaking change: Bump the minimum required CodeQL bundle version to 2.19.4. #3894
  • Add support for SHA-256 Git object IDs. #3893
  • Update default CodeQL bundle version to 2.25.5. #3926

4.35.5 - 15 May 2026

  • We have improved how the JavaScript bundles for the CodeQL Action are generated to avoid duplication across bundles and reduce the size of the repository by around 70%. This should have no effect on the runtime behaviour of the CodeQL Action. #3899
  • For performance and accuracy reasons, improved incremental analysis will now only be enabled on a pull request when diff-informed analysis is also enabled for that run. If diff-informed analysis is unavailable (for example, because the PR diff ranges could not be computed), the action will fall back to a full analysis. #3791
  • If multiple inputs are provided for the GitHub-internal analysis-kinds input, only code-scanning will be enabled. The analysis-kinds input is experimental, for GitHub-internal use only, and may change without notice at any time. #3892
  • Added an experimental change which, when running a Code Scanning analysis for a PR with improved incremental analysis enabled, prefers CodeQL CLI versions that have a cached overlay-base database for the configured languages. This speeds up analysis for a repository when there is not yet a cached overlay-base database for the latest CLI version. We expect to roll this change out to everyone in May. #3880

4.35.4 - 07 May 2026

  • Update default CodeQL bundle version to 2.25.4. #3881

4.35.3 - 01 May 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.19.3 and earlier. These versions of CodeQL were discontinued on 9 April 2026 alongside GitHub Enterprise Server 3.15, and will be unsupported by the next minor release of the CodeQL Action. #3837
  • Configurations for private registries that use Cloudsmith or GCP OIDC are now accepted. #3850
  • Best-effort connection tests for private registries now use GET requests instead of HEAD for better compatibility with various registry implementations. For NuGet feeds, the test is now always performed against the service index. #3853
  • Fixed a bug where two diagnostics produced within the same millisecond could overwrite each other on disk, causing one of them to be lost. #3852

... (truncated)

Commits
  • 99df26d Merge pull request #3996 from github/update-v4.37.0-c7c896d71
  • 31c2707 Add changenote for #3973
  • 72df218 Update changelog for v4.37.0
  • c7c896d Merge pull request #3995 from github/update-bundle/codeql-bundle-v2.26.0
  • 3f34ff0 Add changelog note
  • 43bec09 Update default bundle to codeql-bundle-v2.26.0
  • f58f0d1 Merge pull request #3973 from github/mbg/repo-props/config-file-shorthands
  • 7dc37cb Merge remote-tracking branch 'origin/main' into mbg/repo-props/config-file-sh...
  • 8e22350 Thread ActionState to initConfig
  • 69c9e8c Mark some status-report imports as type-only to avoid circular dependencies
  • Additional commits viewable in compare view

Updates `github/codeql-action/analyze` from 4.36.3 to 4.37.0
Release notes

Sourced from github/codeql-action/analyze's releases.

v4.37.0

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973
Changelog

Sourced from github/codeql-action/analyze's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973

4.36.3 - 01 Jul 2026

No user facing changes.

4.36.2 - 04 Jun 2026

  • Cache CodeQL CLI version information across Actions steps. #3943
  • Reduce requests while waiting for analysis processing by using exponential backoff when polling SARIF processing status. #3937
  • Update default CodeQL bundle version to 2.25.6. #3948

4.36.1 - 02 Jun 2026

No user facing changes.

4.36.0 - 22 May 2026

  • Breaking change: Bump the minimum required CodeQL bundle version to 2.19.4. #3894
  • Add support for SHA-256 Git object IDs. #3893
  • Update default CodeQL bundle version to 2.25.5. #3926

4.35.5 - 15 May 2026

  • We have improved how the JavaScript bundles for the CodeQL Action are generated to avoid duplication across bundles and reduce the size of the repository by around 70%. This should have no effect on the runtime behaviour of the CodeQL Action. #3899
  • For performance and accuracy reasons, improved incremental analysis will now only be enabled on a pull request when diff-informed analysis is also enabled for that run. If diff-informed analysis is unavailable (for example, because the PR diff ranges could not be computed), the action will fall back to a full analysis. #3791
  • If multiple inputs are provided for the GitHub-internal analysis-kinds input, only code-scanning will be enabled. The analysis-kinds input is experimental, for GitHub-internal use only, and may change without notice at any time. #3892
  • Added an experimental change which, when running a Code Scanning analysis for a PR with improved incremental analysis enabled, prefers CodeQL CLI versions that have a cached overlay-base database for the configured languages. This speeds up analysis for a repository when there is not yet a cached overlay-base database for the latest CLI version. We expect to roll this change out to everyone in May. #3880

4.35.4 - 07 May 2026

  • Update default CodeQL bundle version to 2.25.4. #3881

4.35.3 - 01 May 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.19.3 and earlier. These versions of CodeQL were discontinued on 9 April 2026 alongside GitHub Enterprise Server 3.15, and will be unsupported by the next minor release of the CodeQL Action. #3837
  • Configurations for private registries that use Cloudsmith or GCP OIDC are now accepted. #3850
  • Best-effort connection tests for private registries now use GET requests instead of HEAD for better compatibility with various registry implementations. For NuGet feeds, the test is now always performed against the service index. #3853
  • Fixed a bug where two diagnostics produced within the same millisecond could overwrite each other on disk, causing one of them to be lost. #3852

... (truncated)

Commits
  • 99df26d Merge pull request #3996 from github/update-v4.37.0-c7c896d71
  • 31c2707 Add changenote for #3973
  • 72df218 Update changelog for v4.37.0
  • c7c896d Merge pull request #3995 from github/update-bundle/codeql-bundle-v2.26.0
  • 3f34ff0 Add changelog note
  • 43bec09 Update default bundle to codeql-bundle-v2.26.0
  • f58f0d1 Merge pull request #3973 from github/mbg/repo-props/config-file-shorthands
  • 7dc37cb Merge remote-tracking branch 'origin/main' into mbg/repo-props/config-file-sh...
  • 8e22350 Thread ActionState to initConfig
  • 69c9e8c Mark some status-report imports as type-only to avoid circular dependencies
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cli-go-codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cli-go-codeql.yml b/.github/workflows/cli-go-codeql.yml index 9759f2f7e6..e55cfffdfd 100644 --- a/.github/workflows/cli-go-codeql.yml +++ b/.github/workflows/cli-go-codeql.yml @@ -67,7 +67,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -95,7 +95,7 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: category: "/language:${{matrix.language}}" defaults: