From 1b781f591931f01a91d13928ab3751dcb58fc392 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 12 Nov 2026 09:36:07 +0100 Subject: [PATCH 1/3] fix(cli): name the stack opt-in when a stack-only flag is rejected --- apps/cli/src/cli/main.ts | 14 ++- ...stack-flag-opt-in-hint.integration.test.ts | 116 ++++++++++++++++++ apps/cli/src/commands/start/SIDE_EFFECTS.md | 1 + apps/cli/src/commands/status/SIDE_EFFECTS.md | 1 + apps/cli/src/commands/stop/SIDE_EFFECTS.md | 25 ++-- apps/cli/src/shared/cli/run.ts | 22 ++-- .../shared/cli/subcommand-flag-suggestions.ts | 26 ++++ 7 files changed, 181 insertions(+), 24 deletions(-) create mode 100644 apps/cli/src/cli/stack-flag-opt-in-hint.integration.test.ts diff --git a/apps/cli/src/cli/main.ts b/apps/cli/src/cli/main.ts index 4198aeed5a..4e7a099358 100644 --- a/apps/cli/src/cli/main.ts +++ b/apps/cli/src/cli/main.ts @@ -9,7 +9,7 @@ import { defaultCompleteDeps, tryComplete } from "./complete.ts"; import { resolveProjectStateSelection } from "../command-internal/stack-backend.ts"; import { resolveComputeEnabled } from "../commands/experimental/compute/compute-backend.ts"; import { cliConfigProviderLayer } from "../shared/config/cli-config-provider.layer.ts"; -import { cliEntrypointForFeatures } from "./root.ts"; +import { cliEntrypointForFeatures, rootCommandForFeatures } from "./root.ts"; const args = await Effect.runPromise( Effect.gen(function* () { @@ -31,12 +31,17 @@ const selectionExit = await Effect.runPromiseExit( return { projectState, stackBackend, computeEnabled }; }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer))), ); +const selectedFeatures = Exit.isSuccess(selectionExit) + ? selectionExit.value + : ({ stackBackend: "legacy", computeEnabled: false } as const); const { rootCommand: selectedRoot, agentDefaultOutputFormat } = cliEntrypointForFeatures( - Exit.isSuccess(selectionExit) - ? selectionExit.value - : { stackBackend: "legacy", computeEnabled: false }, + selectedFeatures, args, ); +const stackRootCommand = + selectedFeatures.stackBackend === "stack" + ? undefined + : rootCommandForFeatures({ ...selectedFeatures, stackBackend: "stack" }); const selectionCause = Exit.isFailure(selectionExit) ? selectionExit.cause : undefined; const selectedProjectState = Exit.isSuccess(selectionExit) ? selectionExit.value.projectState @@ -63,6 +68,7 @@ if ( : "legacy", ).pipe(Layer.provide(FetchHttpClient.layer)), agentDefaultOutputFormat, + ...(stackRootCommand ? { stackRootCommand } : {}), afterSuccess: upgradeNoticeHook, ...(selectionCause ? { beforeParse: Effect.failCause(selectionCause) } : {}), }), diff --git a/apps/cli/src/cli/stack-flag-opt-in-hint.integration.test.ts b/apps/cli/src/cli/stack-flag-opt-in-hint.integration.test.ts new file mode 100644 index 0000000000..6b75348beb --- /dev/null +++ b/apps/cli/src/cli/stack-flag-opt-in-hint.integration.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Console, Effect, Exit, Layer } from "effect"; +import { CliError, CliOutput, Command } from "effect/cli"; +import { emptyEnv, fakeConsole, mockOutput } from "../../tests/helpers/mocks.ts"; +import { CliArgs } from "../shared/cli/cli-args.service.ts"; +import { withoutParseErrorHelpDump } from "../shared/cli/run.ts"; +import { jsonCliOutputFormatter } from "../shared/output/json-formatter.ts"; +import { normalizeCause } from "../shared/output/normalize-error.ts"; +import { textCliOutputFormatter } from "../shared/output/text-formatter.ts"; +import { rootCommandForFeatures } from "./root.ts"; + +const legacyRoot = rootCommandForFeatures({ stackBackend: "legacy" }); +const stackRoot = rootCommandForFeatures({ stackBackend: "stack" }); + +type Root = typeof legacyRoot; + +const optInAdvice = + "Set SUPABASE_EXPERIMENTAL_STACK=1, or add stack = true under [experimental] in supabase/config.toml."; + +/** + * Parses `args` against `root` and returns what `runCli` reports for the failure: the normalized + * message for text output and the formatter's payload for JSON output. + */ +const parse = ( + root: Root, + args: ReadonlyArray, + options: { readonly stackRoot?: Root } = {}, +) => + Effect.gen(function* () { + const context = { + rootCommand: root, + args, + ...(options.stackRoot ? { stackRootCommand: options.stackRoot } : {}), + }; + const exit = yield* withoutParseErrorHelpDump( + Command.runWith(root, { version: "0.0.0-test" })(args), + context, + ).pipe( + Effect.provide( + Layer.mergeAll( + emptyEnv(), + CliOutput.layer(textCliOutputFormatter(context)), + Layer.succeed(CliArgs, { args }), + Layer.succeed(Console.Console, fakeConsole().console), + mockOutput({ format: "text" }).layer, + ), + ), + Effect.exit, + ); + if (Exit.isSuccess(exit)) return { failed: false, message: "", json: "" }; + const failure = Cause.squash(exit.cause); + const errors = + CliError.isCliError(failure) && failure._tag === "ShowHelp" ? failure.errors : []; + return { + failed: true, + message: normalizeCause(exit.cause, context).message, + json: jsonCliOutputFormatter(context).formatErrors(errors), + }; + }); + +describe("stack-only flags rejected by the legacy backend", () => { + const stackOnlyInvocations: ReadonlyArray> = [ + ["start", "--runtime", "native"], + ["start", "--stack", "feature-a"], + ["start", "--stack-id", "feature-a"], + ["start", "--preparation", "frozen"], + ["start", "--frozen"], + ["start", "--eager"], + ["stop", "--stack", "feature-a"], + ["status", "--stack", "feature-a"], + ]; + + for (const args of stackOnlyInvocations) { + const [command, flag] = args; + it.effect(`\`${command} ${flag}\` names the stack opt-in`, () => + Effect.gen(function* () { + const result = yield* parse(legacyRoot, args, { stackRoot }); + expect(result.failed).toBe(true); + expect(result.message).toContain(`Unrecognized flag: ${flag}`); + expect(result.message).toContain(`Hint: ${flag} needs the stack backend. ${optInAdvice}`); + }), + ); + } + + it.effect("names the opt-in in JSON output", () => + Effect.gen(function* () { + const { json } = yield* parse(legacyRoot, ["start", "--runtime", "native"], { stackRoot }); + expect(json).toContain('"code":"UnrecognizedOption"'); + expect(json).toContain(`Hint: --runtime needs the stack backend. ${optInAdvice}`); + }), + ); + + it.effect("adds no hint for a flag neither backend accepts", () => + Effect.gen(function* () { + const { message, json } = yield* parse(legacyRoot, ["start", "--bogus"], { stackRoot }); + expect(message).toContain("Unrecognized flag: --bogus"); + expect(message).not.toContain("Hint:"); + expect(json).not.toContain("Hint:"); + }), + ); + + it.effect("adds no hint when the stack opt-in is not offered", () => + Effect.gen(function* () { + const { message } = yield* parse(legacyRoot, ["start", "--runtime", "native"]); + expect(message).toContain("Unrecognized flag: --runtime"); + expect(message).not.toContain("Hint:"); + }), + ); + + it.effect("the stack backend's own start accepts the flag", () => + Effect.gen(function* () { + const { message } = yield* parse(stackRoot, ["start", "--runtime", "native", "--help"]); + expect(message).not.toContain("Unrecognized flag"); + }), + ); +}); diff --git a/apps/cli/src/commands/start/SIDE_EFFECTS.md b/apps/cli/src/commands/start/SIDE_EFFECTS.md index 2c4c72505a..45a3206304 100644 --- a/apps/cli/src/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/start/SIDE_EFFECTS.md @@ -315,6 +315,7 @@ code is surfaced on failure. | `0` | `--ignore-health-check` set and one or more containers timed out — the failure is printed and swallowed, no rollback | | `1` | `--ignore-health-check` set, the fresh-volume/Storage-healthy recheck-and-seed path ran (see "Storage bucket seeding"), and that seed itself failed — rolls back despite the flag | | `1` | malformed CSV in an `--exclude`/`-x` value — fails during flag parsing, before the handler and telemetry, with the exact diagnostic text on stderr; the shorthand frames it with both spellings (e.g. `invalid argument "a\"b" for "-x, --exclude" flag: parse error on line 1, column 2: bare " in non-quoted-field`; a blank-only value fails with `EOF`) — CLI-2005 | +| `1` | a stack-only flag (`--runtime`, `--stack`, `--stack-id`, `--preparation`, `--frozen`, `--eager`) is given while the legacy backend is active — fails during flag parsing with `Unrecognized flag: ` plus a `Hint:` naming the opt-in (`SUPABASE_EXPERIMENTAL_STACK=1`, or `stack = true` under `[experimental]`); other unrecognized flags get no hint | | `1` | malformed `config.toml` / `Config.Validate` failure, including an `auth.email.*.content_path` that resolves outside the project root, or that resolves in-root but is missing/unreadable (checked eagerly, before any Docker work, regardless of `auth.enabled` — see Notes) | | `1` | an enabled function has `import_map.json` but no `deno.json`/`deno.jsonc` and no `functions..import_map` — `import_map.json in a function directory is no longer supported: `; checked in the preflight whenever Edge Runtime or Studio is enabled, so it fails before any container is created | | `1` | an unparsable `SUPABASE_*` config override (e.g. a non-boolean `SUPABASE_AUTH_ENABLED`) | diff --git a/apps/cli/src/commands/status/SIDE_EFFECTS.md b/apps/cli/src/commands/status/SIDE_EFFECTS.md index 8d7b2bda3a..9015bd0ca6 100644 --- a/apps/cli/src/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/status/SIDE_EFFECTS.md @@ -86,6 +86,7 @@ convention used elsewhere in config loading, and take precedence over the corres | `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` | malformed CSV in an `--override-name`/`--exclude` value — fails during flag parsing, before the handler and telemetry, with the exact diagnostic text on stderr (e.g. `invalid argument "\"api.url=FOO" for "--override-name" flag: parse error on line 1, column 13: extraneous or missing " in quoted-field`; a blank-only value fails with `EOF`) — CLI-2005 | +| `1` | a stack-only flag (e.g. `--stack`) is given while the legacy backend is active — fails during flag parsing with `Unrecognized flag: ` plus a `Hint:` naming the opt-in (`SUPABASE_EXPERIMENTAL_STACK=1`, or `stack = true` under `[experimental]`); other unrecognized flags get no hint | | `1` | a malformed `--override-name` entry, or the removed `inbucket.url` key (the message names `mailpit.url`) | | `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 | diff --git a/apps/cli/src/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/stop/SIDE_EFFECTS.md index 333bbea781..d91356b984 100644 --- a/apps/cli/src/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/stop/SIDE_EFFECTS.md @@ -90,18 +90,19 @@ and ensure it is on PATH") rather than a generic "failed to ..." string. ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — containers/volumes/networks pruned | -| `1` | `--project-id` and `--all` both set (`StopMutuallyExclusiveError`) | -| `1` | another lifecycle command still holds the lock after 10 minutes (`ProjectLifecycleLockBusyError`); `--all` waits for every other lifecycle command | -| `1` | `config.toml` present but malformed (`StopConfigLoadError`), or a config value is invalid (`CliConfigValueError`, naming its source) — an **absent** file is not an error | -| `1` | listing containers failed (`StopListError`) | -| `1` | stopping one or more containers failed (`StopContainerError`) | -| `1` | `docker container prune` failed (`StopContainerPruneError`) | -| `1` | `docker volume prune` failed, only reached when volumes are being deleted (`StopVolumePruneError`) | -| `1` | `docker network prune` failed (`StopNetworkPruneError`) | -| `1` | `docker`/`podman` both absent from `PATH` (surfaces as one of the errors above) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — containers/volumes/networks pruned | +| `1` | `--project-id` and `--all` both set (`StopMutuallyExclusiveError`) | +| `1` | a stack-only flag (e.g. `--stack`) is given while the legacy backend is active — fails during flag parsing with `Unrecognized flag: ` plus a `Hint:` naming the opt-in (`SUPABASE_EXPERIMENTAL_STACK=1`, or `stack = true` under `[experimental]`); other unrecognized flags get no hint | +| `1` | another lifecycle command still holds the lock after 10 minutes (`ProjectLifecycleLockBusyError`); `--all` waits for every other lifecycle command | +| `1` | `config.toml` present but malformed (`StopConfigLoadError`), or a config value is invalid (`CliConfigValueError`, naming its source) — an **absent** file is not an error | +| `1` | listing containers failed (`StopListError`) | +| `1` | stopping one or more containers failed (`StopContainerError`) | +| `1` | `docker container prune` failed (`StopContainerPruneError`) | +| `1` | `docker volume prune` failed, only reached when volumes are being deleted (`StopVolumePruneError`) | +| `1` | `docker network prune` failed (`StopNetworkPruneError`) | +| `1` | `docker`/`podman` both absent from `PATH` (surfaces as one of the errors above) | ## Telemetry Events Fired diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index b7f61b0fea..74a648b875 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -392,12 +392,15 @@ export function shouldUseGlobalSignalInterrupt(args: ReadonlyArray): boo ); } -function formatterLayerFor( +function suggestionContextFor( rootCommand: Command.Command.Any, args: ReadonlyArray, - format: OutputFormat, -) { - const context = { rootCommand, args }; + stackRootCommand: Command.Command.Any | undefined, +): CliErrorSuggestionContext { + return { rootCommand, args, ...(stackRootCommand ? { stackRootCommand } : {}) }; +} + +function formatterLayerFor(context: CliErrorSuggestionContext, format: OutputFormat) { return format === "json" || format === "stream-json" ? CliOutput.layer(jsonCliOutputFormatter(context)) : CliOutput.layer(textCliOutputFormatter(context)); @@ -612,6 +615,8 @@ export interface RunCliOptions { readonly beforeParse?: Effect.Effect; /** The selected root's agent default, applied to failures `runCli` renders itself. */ readonly agentDefaultOutputFormat: OutputFormat; + /** The root the stack opt-in would select, so a rejected stack-only flag can name the opt-in. */ + readonly stackRootCommand?: Command.Command.Any; readonly analyticsLayer: Layer.Layer< Analytics, Config.ConfigError | PlatformError.PlatformError, @@ -665,9 +670,10 @@ export function cliProgramFor< ), ); const commandProgram = options.beforeParse ?? Effect.void; - const commandLayer = formatterLayerFor(rootCommand, args, outputFormat).pipe( - Layer.provideMerge(options.analyticsLayer), - ); + const commandLayer = formatterLayerFor( + suggestionContextFor(rootCommand, args, options.stackRootCommand), + outputFormat, + ).pipe(Layer.provideMerge(options.analyticsLayer)); const cliProgramLayer = telemetryRuntimeLayer.pipe( Layer.provideMerge(cliSettingsLayerFor(runtimeLayer)), Layer.provideMerge(cliProjectHomeLayerFor(runtimeLayer)), @@ -723,7 +729,7 @@ export const runCli = Effect.fnUntraced(function* < // Same shape `formatterLayerFor` builds below, so `normalizeCause`'s fallback path can reuse // `formatCliErrorsForDisplay` and surface the same subcommand-flag hint the formatters would. - const suggestionContext = { rootCommand, args }; + const suggestionContext = suggestionContextFor(rootCommand, args, options.stackRootCommand); const useGlobalSignalInterrupt = shouldUseGlobalSignalInterrupt(args); const outputFormat = yield* Effect.gen(function* () { const aiTool = yield* AiTool; diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts index 83c4c6455a..c3050f4b7f 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts @@ -10,6 +10,8 @@ import { formatInvalidValueMessage } from "./invalid-value-message.ts"; export interface CliErrorSuggestionContext { readonly rootCommand: Command.Command.Any; readonly args: ReadonlyArray; + /** The root the stack opt-in would select; set only while the legacy backend is active. */ + readonly stackRootCommand?: Command.Command.Any; } export interface FormattedCliError { @@ -319,6 +321,19 @@ export function isRemoteFlagMisuse( ); } +/** Names the opt-in when the stack backend's version of the command accepts the rejected flag. */ +function buildStackOptInHint( + error: CliError.UnrecognizedOption, + context: CliErrorSuggestionContext, +): string | undefined { + if (!context.stackRootCommand || !error.command) return undefined; + const command = findCommand(context.stackRootCommand, error.command.slice(1)); + if (!command || !commandFlagsFor(command).some((flag) => flagMatchesOption(flag, error.option))) { + return undefined; + } + return `${optionToken(error.option)} needs the stack backend. Set SUPABASE_EXPERIMENTAL_STACK=1, or add stack = true under [experimental] in supabase/config.toml.`; +} + function buildSubcommandFlagHint( error: CliError.UnrecognizedOption, context: CliErrorSuggestionContext, @@ -386,6 +401,17 @@ export function formatCliErrorsForDisplay( }); continue; } + const stackHint = buildStackOptInHint(error, context); + if (stackHint) { + changed = true; + formatted.push({ + _tag: error._tag, + message: `${error.message}\n\n Hint: ${stackHint}`, + source: error, + changed: true, + }); + continue; + } } if ( From 88494d7f78c94bf94bef6d7a477f8fd8f62acfd7 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 12 Nov 2026 10:07:04 +0100 Subject: [PATCH 2/3] test(cli): prove stack flag acceptance and cover the opt-in hint end to end --- apps/cli/src/cli/main.e2e.test.ts | 36 ++++++++++++++++- ...stack-flag-opt-in-hint.integration.test.ts | 39 +++++-------------- 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/apps/cli/src/cli/main.e2e.test.ts b/apps/cli/src/cli/main.e2e.test.ts index 545f989f98..44bdf54a19 100644 --- a/apps/cli/src/cli/main.e2e.test.ts +++ b/apps/cli/src/cli/main.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Schema } from "effect"; import { runSupabaseEffect, withTempCliProject, withTempHome } from "../../tests/helpers/cli.ts"; describe("CLI feature routing", () => { @@ -25,4 +25,38 @@ describe("CLI feature routing", () => { ), ), ); + + it.live("names the stack opt-in when the legacy backend rejects a stack-only flag", () => + withTempHome((home) => + withTempCliProject("supabase-main-routing-e2e-", (project) => + Effect.gen(function* () { + const options = { + cwd: project.dir, + home: home.dir, + env: { SUPABASE_EXPERIMENTAL_STACK: undefined, SUPABASE_WORKDIR: undefined }, + }; + const hint = + "Hint: --runtime needs the stack backend. Set SUPABASE_EXPERIMENTAL_STACK=1, or add stack = true under [experimental] in supabase/config.toml."; + + const text = yield* runSupabaseEffect(["start", "--runtime", "native"], options); + expect(text.exitCode).toBe(1); + expect(text.stderr).toContain("Unrecognized flag: --runtime"); + expect(text.stderr).toContain(hint); + + const json = yield* runSupabaseEffect( + ["start", "--runtime", "native", "--output-format", "json"], + options, + ); + expect(json.exitCode).toBe(1); + const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + json.stdout, + ); + expect(envelope).toMatchObject({ + _tag: "Error", + error: { code: "UnrecognizedOption", message: expect.stringContaining(hint) }, + }); + }), + ), + ), + ); }); diff --git a/apps/cli/src/cli/stack-flag-opt-in-hint.integration.test.ts b/apps/cli/src/cli/stack-flag-opt-in-hint.integration.test.ts index 6b75348beb..d564dc0917 100644 --- a/apps/cli/src/cli/stack-flag-opt-in-hint.integration.test.ts +++ b/apps/cli/src/cli/stack-flag-opt-in-hint.integration.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Console, Effect, Exit, Layer } from "effect"; -import { CliError, CliOutput, Command } from "effect/cli"; +import { Console, Effect, Exit, Layer } from "effect"; +import { CliOutput, Command } from "effect/cli"; import { emptyEnv, fakeConsole, mockOutput } from "../../tests/helpers/mocks.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { withoutParseErrorHelpDump } from "../shared/cli/run.ts"; -import { jsonCliOutputFormatter } from "../shared/output/json-formatter.ts"; import { normalizeCause } from "../shared/output/normalize-error.ts"; import { textCliOutputFormatter } from "../shared/output/text-formatter.ts"; import { rootCommandForFeatures } from "./root.ts"; @@ -17,10 +16,7 @@ type Root = typeof legacyRoot; const optInAdvice = "Set SUPABASE_EXPERIMENTAL_STACK=1, or add stack = true under [experimental] in supabase/config.toml."; -/** - * Parses `args` against `root` and returns what `runCli` reports for the failure: the normalized - * message for text output and the formatter's payload for JSON output. - */ +/** Parses `args` against `root` and returns the normalized failure message `runCli` reports. */ const parse = ( root: Root, args: ReadonlyArray, @@ -47,15 +43,8 @@ const parse = ( ), Effect.exit, ); - if (Exit.isSuccess(exit)) return { failed: false, message: "", json: "" }; - const failure = Cause.squash(exit.cause); - const errors = - CliError.isCliError(failure) && failure._tag === "ShowHelp" ? failure.errors : []; - return { - failed: true, - message: normalizeCause(exit.cause, context).message, - json: jsonCliOutputFormatter(context).formatErrors(errors), - }; + if (Exit.isSuccess(exit)) return { failed: false, message: "" }; + return { failed: true, message: normalizeCause(exit.cause, context).message }; }); describe("stack-only flags rejected by the legacy backend", () => { @@ -82,20 +71,11 @@ describe("stack-only flags rejected by the legacy backend", () => { ); } - it.effect("names the opt-in in JSON output", () => - Effect.gen(function* () { - const { json } = yield* parse(legacyRoot, ["start", "--runtime", "native"], { stackRoot }); - expect(json).toContain('"code":"UnrecognizedOption"'); - expect(json).toContain(`Hint: --runtime needs the stack backend. ${optInAdvice}`); - }), - ); - it.effect("adds no hint for a flag neither backend accepts", () => Effect.gen(function* () { - const { message, json } = yield* parse(legacyRoot, ["start", "--bogus"], { stackRoot }); + const { message } = yield* parse(legacyRoot, ["start", "--bogus"], { stackRoot }); expect(message).toContain("Unrecognized flag: --bogus"); expect(message).not.toContain("Hint:"); - expect(json).not.toContain("Hint:"); }), ); @@ -107,10 +87,11 @@ describe("stack-only flags rejected by the legacy backend", () => { }), ); - it.effect("the stack backend's own start accepts the flag", () => + it.effect("the stack backend's own start recognizes the flag", () => Effect.gen(function* () { - const { message } = yield* parse(stackRoot, ["start", "--runtime", "native", "--help"]); - expect(message).not.toContain("Unrecognized flag"); + const result = yield* parse(stackRoot, ["start", "--runtime", "bogus"]); + expect(result.failed).toBe(true); + expect(result.message).toContain('Invalid value for flag --runtime: "bogus"'); }), ); }); From 98080906037e094dcede15244b79f82c3b098516 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 12 Nov 2026 11:53:43 +0100 Subject: [PATCH 3/3] fix(cli): say the config opt-in needs SUPABASE_EXPERIMENTAL_STACK unset --- apps/cli/src/cli/main.e2e.test.ts | 2 +- ...stack-flag-opt-in-hint.integration.test.ts | 2 +- apps/cli/src/commands/start/SIDE_EFFECTS.md | 48 +++++++++---------- apps/cli/src/commands/status/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/stop/SIDE_EFFECTS.md | 26 +++++----- .../shared/cli/subcommand-flag-suggestions.ts | 2 +- 6 files changed, 41 insertions(+), 41 deletions(-) diff --git a/apps/cli/src/cli/main.e2e.test.ts b/apps/cli/src/cli/main.e2e.test.ts index 44bdf54a19..761778ef43 100644 --- a/apps/cli/src/cli/main.e2e.test.ts +++ b/apps/cli/src/cli/main.e2e.test.ts @@ -36,7 +36,7 @@ describe("CLI feature routing", () => { env: { SUPABASE_EXPERIMENTAL_STACK: undefined, SUPABASE_WORKDIR: undefined }, }; const hint = - "Hint: --runtime needs the stack backend. Set SUPABASE_EXPERIMENTAL_STACK=1, or add stack = true under [experimental] in supabase/config.toml."; + "Hint: --runtime needs the stack backend. Set SUPABASE_EXPERIMENTAL_STACK=1, or set stack = true under [experimental] in your project config and leave SUPABASE_EXPERIMENTAL_STACK unset."; const text = yield* runSupabaseEffect(["start", "--runtime", "native"], options); expect(text.exitCode).toBe(1); diff --git a/apps/cli/src/cli/stack-flag-opt-in-hint.integration.test.ts b/apps/cli/src/cli/stack-flag-opt-in-hint.integration.test.ts index d564dc0917..047cb31bd6 100644 --- a/apps/cli/src/cli/stack-flag-opt-in-hint.integration.test.ts +++ b/apps/cli/src/cli/stack-flag-opt-in-hint.integration.test.ts @@ -14,7 +14,7 @@ const stackRoot = rootCommandForFeatures({ stackBackend: "stack" }); type Root = typeof legacyRoot; const optInAdvice = - "Set SUPABASE_EXPERIMENTAL_STACK=1, or add stack = true under [experimental] in supabase/config.toml."; + "Set SUPABASE_EXPERIMENTAL_STACK=1, or set stack = true under [experimental] in your project config and leave SUPABASE_EXPERIMENTAL_STACK unset."; /** Parses `args` against `root` and returns the normalized failure message `runCli` reports. */ const parse = ( diff --git a/apps/cli/src/commands/start/SIDE_EFFECTS.md b/apps/cli/src/commands/start/SIDE_EFFECTS.md index 45a3206304..00fe7fdf4e 100644 --- a/apps/cli/src/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/start/SIDE_EFFECTS.md @@ -308,30 +308,30 @@ code is surfaced on failure. ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — every started container passed its health check, or was accepted through the exec-failure fallback with a stderr `WARNING:` (see "Readiness gating") | -| `0` | the stack was already running — shows status instead of restarting | -| `0` | `--ignore-health-check` set and one or more containers timed out — the failure is printed and swallowed, no rollback | -| `1` | `--ignore-health-check` set, the fresh-volume/Storage-healthy recheck-and-seed path ran (see "Storage bucket seeding"), and that seed itself failed — rolls back despite the flag | -| `1` | malformed CSV in an `--exclude`/`-x` value — fails during flag parsing, before the handler and telemetry, with the exact diagnostic text on stderr; the shorthand frames it with both spellings (e.g. `invalid argument "a\"b" for "-x, --exclude" flag: parse error on line 1, column 2: bare " in non-quoted-field`; a blank-only value fails with `EOF`) — CLI-2005 | -| `1` | a stack-only flag (`--runtime`, `--stack`, `--stack-id`, `--preparation`, `--frozen`, `--eager`) is given while the legacy backend is active — fails during flag parsing with `Unrecognized flag: ` plus a `Hint:` naming the opt-in (`SUPABASE_EXPERIMENTAL_STACK=1`, or `stack = true` under `[experimental]`); other unrecognized flags get no hint | -| `1` | malformed `config.toml` / `Config.Validate` failure, including an `auth.email.*.content_path` that resolves outside the project root, or that resolves in-root but is missing/unreadable (checked eagerly, before any Docker work, regardless of `auth.enabled` — see Notes) | -| `1` | an enabled function has `import_map.json` but no `deno.json`/`deno.jsonc` and no `functions..import_map` — `import_map.json in a function directory is no longer supported: `; checked in the preflight whenever Edge Runtime or Studio is enabled, so it fails before any container is created | -| `1` | an unparsable `SUPABASE_*` config override (e.g. a non-boolean `SUPABASE_AUTH_ENABLED`) | -| `1` | stopped Postgres detected but the project id sanitizes to empty — aborts before recovery removes any containers | -| `1` | Postgres container exists but is not running and is not recovered (Bitbucket Pipelines, or Docker reported no state) — ` container is not running: ` (`unknown` for an empty status) with a `supabase stop` then `supabase start` suggestion; the "already running" banner is never printed | -| `1` | another lifecycle command still holds this project's lock after 10 minutes — prints `Waiting for another Supabase CLI command to finish with project ...` (or `...with local Supabase projects...` behind `stop --all`) to stderr first (`ProjectLifecycleLockBusyError`) | -| `1` | `docker`/`podman` not spawnable, or the daemon is unreachable | -| `1` | stopped-stack recovery cannot list, stop, or prune current-project containers, or prune matching networks — aborts before startup; named volumes are preserved | -| `1` | image pull exhausted across every registry candidate, or the Docker daemon becomes unreachable during the pre-pull — even with `--ignore-health-check` (never swallowed into exit 0; see the `--ignore-health-check` note under "Notes") | -| `1` | Studio is enabled and `/supabase/snippets/` cannot be created (a file is in the way, or the workdir is not writable) — fails before any network or container is created | -| `1` | network, volume, container create, or container start failure (including a port conflict) — rolls back everything created so far | -| `1` | health check timeout **without** `--ignore-health-check` (including a container whose exec-failure fallback request keeps failing) — rolls back | -| `1` | Postgres itself fails to start or its own readiness wait (inspect + host connect) times out, **without** `--ignore-health-check` — rolls back | -| `0` | `--ignore-health-check` set and Postgres's own health wait times out — the failure is printed and swallowed, no rollback; no OTHER service is ever created (Postgres's failure is returned before any other bring-up step runs), but the command still prints "Started..." + the (config-derived) status table | -| `1` | fresh-volume DB setup failure (schema SQL / one-shot migrate job / vault upsert / roles seed / migration-apply) — rolls back | -| `1` | fresh-volume bucket-seeding failure — rolls back | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — every started container passed its health check, or was accepted through the exec-failure fallback with a stderr `WARNING:` (see "Readiness gating") | +| `0` | the stack was already running — shows status instead of restarting | +| `0` | `--ignore-health-check` set and one or more containers timed out — the failure is printed and swallowed, no rollback | +| `1` | `--ignore-health-check` set, the fresh-volume/Storage-healthy recheck-and-seed path ran (see "Storage bucket seeding"), and that seed itself failed — rolls back despite the flag | +| `1` | malformed CSV in an `--exclude`/`-x` value — fails during flag parsing, before the handler and telemetry, with the exact diagnostic text on stderr; the shorthand frames it with both spellings (e.g. `invalid argument "a\"b" for "-x, --exclude" flag: parse error on line 1, column 2: bare " in non-quoted-field`; a blank-only value fails with `EOF`) — CLI-2005 | +| `1` | a stack-only flag (`--runtime`, `--stack`, `--stack-id`, `--preparation`, `--frozen`, `--eager`) is given while the legacy backend is active — fails during flag parsing with `Unrecognized flag: ` plus a `Hint:` naming the opt-in (`SUPABASE_EXPERIMENTAL_STACK=1`, or `stack = true` under `[experimental]` with that variable unset); other unrecognized flags get no hint | +| `1` | malformed `config.toml` / `Config.Validate` failure, including an `auth.email.*.content_path` that resolves outside the project root, or that resolves in-root but is missing/unreadable (checked eagerly, before any Docker work, regardless of `auth.enabled` — see Notes) | +| `1` | an enabled function has `import_map.json` but no `deno.json`/`deno.jsonc` and no `functions..import_map` — `import_map.json in a function directory is no longer supported: `; checked in the preflight whenever Edge Runtime or Studio is enabled, so it fails before any container is created | +| `1` | an unparsable `SUPABASE_*` config override (e.g. a non-boolean `SUPABASE_AUTH_ENABLED`) | +| `1` | stopped Postgres detected but the project id sanitizes to empty — aborts before recovery removes any containers | +| `1` | Postgres container exists but is not running and is not recovered (Bitbucket Pipelines, or Docker reported no state) — ` container is not running: ` (`unknown` for an empty status) with a `supabase stop` then `supabase start` suggestion; the "already running" banner is never printed | +| `1` | another lifecycle command still holds this project's lock after 10 minutes — prints `Waiting for another Supabase CLI command to finish with project ...` (or `...with local Supabase projects...` behind `stop --all`) to stderr first (`ProjectLifecycleLockBusyError`) | +| `1` | `docker`/`podman` not spawnable, or the daemon is unreachable | +| `1` | stopped-stack recovery cannot list, stop, or prune current-project containers, or prune matching networks — aborts before startup; named volumes are preserved | +| `1` | image pull exhausted across every registry candidate, or the Docker daemon becomes unreachable during the pre-pull — even with `--ignore-health-check` (never swallowed into exit 0; see the `--ignore-health-check` note under "Notes") | +| `1` | Studio is enabled and `/supabase/snippets/` cannot be created (a file is in the way, or the workdir is not writable) — fails before any network or container is created | +| `1` | network, volume, container create, or container start failure (including a port conflict) — rolls back everything created so far | +| `1` | health check timeout **without** `--ignore-health-check` (including a container whose exec-failure fallback request keeps failing) — rolls back | +| `1` | Postgres itself fails to start or its own readiness wait (inspect + host connect) times out, **without** `--ignore-health-check` — rolls back | +| `0` | `--ignore-health-check` set and Postgres's own health wait times out — the failure is printed and swallowed, no rollback; no OTHER service is ever created (Postgres's failure is returned before any other bring-up step runs), but the command still prints "Started..." + the (config-derived) status table | +| `1` | fresh-volume DB setup failure (schema SQL / one-shot migrate job / vault upsert / roles seed / migration-apply) — rolls back | +| `1` | fresh-volume bucket-seeding failure — rolls back | Rollback (`rollbackStart`) tears down everything created so far by Docker label, and never masks the original failure — a rollback error diff --git a/apps/cli/src/commands/status/SIDE_EFFECTS.md b/apps/cli/src/commands/status/SIDE_EFFECTS.md index 9015bd0ca6..b858903655 100644 --- a/apps/cli/src/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/status/SIDE_EFFECTS.md @@ -86,7 +86,7 @@ convention used elsewhere in config loading, and take precedence over the corres | `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` | malformed CSV in an `--override-name`/`--exclude` value — fails during flag parsing, before the handler and telemetry, with the exact diagnostic text on stderr (e.g. `invalid argument "\"api.url=FOO" for "--override-name" flag: parse error on line 1, column 13: extraneous or missing " in quoted-field`; a blank-only value fails with `EOF`) — CLI-2005 | -| `1` | a stack-only flag (e.g. `--stack`) is given while the legacy backend is active — fails during flag parsing with `Unrecognized flag: ` plus a `Hint:` naming the opt-in (`SUPABASE_EXPERIMENTAL_STACK=1`, or `stack = true` under `[experimental]`); other unrecognized flags get no hint | +| `1` | a stack-only flag (e.g. `--stack`) is given while the legacy backend is active — fails during flag parsing with `Unrecognized flag: ` plus a `Hint:` naming the opt-in (`SUPABASE_EXPERIMENTAL_STACK=1`, or `stack = true` under `[experimental]` with that variable unset); other unrecognized flags get no hint | | `1` | a malformed `--override-name` entry, or the removed `inbucket.url` key (the message names `mailpit.url`) | | `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 | diff --git a/apps/cli/src/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/stop/SIDE_EFFECTS.md index d91356b984..69372de140 100644 --- a/apps/cli/src/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/stop/SIDE_EFFECTS.md @@ -90,19 +90,19 @@ and ensure it is on PATH") rather than a generic "failed to ..." string. ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — containers/volumes/networks pruned | -| `1` | `--project-id` and `--all` both set (`StopMutuallyExclusiveError`) | -| `1` | a stack-only flag (e.g. `--stack`) is given while the legacy backend is active — fails during flag parsing with `Unrecognized flag: ` plus a `Hint:` naming the opt-in (`SUPABASE_EXPERIMENTAL_STACK=1`, or `stack = true` under `[experimental]`); other unrecognized flags get no hint | -| `1` | another lifecycle command still holds the lock after 10 minutes (`ProjectLifecycleLockBusyError`); `--all` waits for every other lifecycle command | -| `1` | `config.toml` present but malformed (`StopConfigLoadError`), or a config value is invalid (`CliConfigValueError`, naming its source) — an **absent** file is not an error | -| `1` | listing containers failed (`StopListError`) | -| `1` | stopping one or more containers failed (`StopContainerError`) | -| `1` | `docker container prune` failed (`StopContainerPruneError`) | -| `1` | `docker volume prune` failed, only reached when volumes are being deleted (`StopVolumePruneError`) | -| `1` | `docker network prune` failed (`StopNetworkPruneError`) | -| `1` | `docker`/`podman` both absent from `PATH` (surfaces as one of the errors above) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — containers/volumes/networks pruned | +| `1` | `--project-id` and `--all` both set (`StopMutuallyExclusiveError`) | +| `1` | a stack-only flag (e.g. `--stack`) is given while the legacy backend is active — fails during flag parsing with `Unrecognized flag: ` plus a `Hint:` naming the opt-in (`SUPABASE_EXPERIMENTAL_STACK=1`, or `stack = true` under `[experimental]` with that variable unset); other unrecognized flags get no hint | +| `1` | another lifecycle command still holds the lock after 10 minutes (`ProjectLifecycleLockBusyError`); `--all` waits for every other lifecycle command | +| `1` | `config.toml` present but malformed (`StopConfigLoadError`), or a config value is invalid (`CliConfigValueError`, naming its source) — an **absent** file is not an error | +| `1` | listing containers failed (`StopListError`) | +| `1` | stopping one or more containers failed (`StopContainerError`) | +| `1` | `docker container prune` failed (`StopContainerPruneError`) | +| `1` | `docker volume prune` failed, only reached when volumes are being deleted (`StopVolumePruneError`) | +| `1` | `docker network prune` failed (`StopNetworkPruneError`) | +| `1` | `docker`/`podman` both absent from `PATH` (surfaces as one of the errors above) | ## Telemetry Events Fired diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts index c3050f4b7f..2670340179 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts @@ -331,7 +331,7 @@ function buildStackOptInHint( if (!command || !commandFlagsFor(command).some((flag) => flagMatchesOption(flag, error.option))) { return undefined; } - return `${optionToken(error.option)} needs the stack backend. Set SUPABASE_EXPERIMENTAL_STACK=1, or add stack = true under [experimental] in supabase/config.toml.`; + return `${optionToken(error.option)} needs the stack backend. Set SUPABASE_EXPERIMENTAL_STACK=1, or set stack = true under [experimental] in your project config and leave SUPABASE_EXPERIMENTAL_STACK unset.`; } function buildSubcommandFlagHint(