From 9ad66ca050db862fe0e299a8bcdd36397afb0745 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 9 Sep 2026 14:05:01 +0200 Subject: [PATCH 1/3] feat(cli): select stack start and stop with feature flag --- AGENTS.md | 2 +- apps/cli/docs/stack-commands.md | 50 ++++ apps/cli/src/cli/complete.ts | 7 +- apps/cli/src/cli/main.ts | 27 ++- apps/cli/src/cli/root.ts | 228 ++++++++++-------- .../command-internal/experimental-feature.ts | 36 +++ .../experimental/experimental.command.ts | 3 +- .../stack/stack-backend.integration.test.ts | 172 +++++++++++++ .../experimental/stack/stack-backend.ts | 153 ++++++++++++ ...tack-command-telemetry.integration.test.ts | 84 +++++++ .../experimental/stack/stack.command.ts | 28 ++- .../experimental/stack/start/SIDE_EFFECTS.md | 2 +- .../experimental/stack/start/start.command.ts | 6 +- .../stack/start/start.e2e.test.ts | 17 +- .../stack/start/start.integration.test.ts | 16 +- .../experimental/stack/stop/SIDE_EFFECTS.md | 2 +- .../experimental/stack/stop/stop.command.ts | 4 +- apps/cli/src/commands/start/SIDE_EFFECTS.md | 5 + apps/cli/src/commands/stop/SIDE_EFFECTS.md | 5 + apps/cli/src/config/command-settings.layer.ts | 2 +- apps/cli/src/docs/docs-spec.tables.ts | 3 + apps/cli/src/shared/cli/agent-output.ts | 4 +- apps/cli/src/shared/cli/run.ts | 20 +- apps/docs/public/cli/config.schema.json | 118 +++++++++ .../public/cli/project-config.schema.json | 54 +++++ package.json | 4 +- packages/config/src/experimental.ts | 6 + packages/config/src/io.unit.test.ts | 12 + .../src/project-config/project-config.ts | 2 + .../project-config.unit.test.ts | 3 + 30 files changed, 915 insertions(+), 160 deletions(-) create mode 100644 apps/cli/docs/stack-commands.md create mode 100644 apps/cli/src/command-internal/experimental-feature.ts create mode 100644 apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts create mode 100644 apps/cli/src/commands/experimental/stack/stack-backend.ts create mode 100644 apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts diff --git a/AGENTS.md b/AGENTS.md index 9b3f5e64ee..46c109352f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ These workspaces should generally follow this structure: - Standard scripts: `test`, `types:check` - Standard devDependencies: `@tsconfig/bun`, `@types/bun`, `typescript` -Generic linting (`oxlint`), formatting (`oxfmt`), and unused-code analysis (`knip`) are repo-wide, not per-package: the tools are root devDependencies configured by `.oxlintrc.json`, `.oxfmtrc.json`, and `knip.json` at the repo root (knip's config maps each workspace under its `workspaces` key). Effect-specific linting covers `packages/stack` and all files under `apps/cli/src/commands/experimental/stack` through `.oxlintrc.effect.json`; run it with the root `lint:effect:check` or `lint:effect:fix` scripts. The root `check:all`/`fix:all` scripts are the sole repo-wide quality entrypoints and use Turbo to orchestrate the root-owned generic `lint:*`/`fmt:*`/`knip:*` scripts and package `types:check` targets; `fix:all` runs the Effect lint fix after those generic fixes complete. Package-local work can run `pnpm types:check` and the package's test scripts; `pnpm exec oxlint`, `pnpm exec oxfmt`, and `pnpm exec knip-bun` from the repo root also work directly. +Generic linting (`oxlint`), formatting (`oxfmt`), and unused-code analysis (`knip`) are repo-wide, not per-package: the tools are root devDependencies configured by `.oxlintrc.json`, `.oxfmtrc.json`, and `knip.json` at the repo root (knip's config maps each workspace under its `workspaces` key). Effect-specific linting covers `packages/stack`, all files under `apps/cli/src/commands/experimental/stack`, and the shared `apps/cli/src/command-internal/experimental-feature.ts` helper through `.oxlintrc.effect.json`; run it with the root `lint:effect:check` or `lint:effect:fix` scripts. The root `check:all`/`fix:all` scripts are the sole repo-wide quality entrypoints and use Turbo to orchestrate the root-owned generic `lint:*`/`fmt:*`/`knip:*` scripts and package `types:check` targets; `fix:all` runs the Effect lint fix after those generic fixes complete. Package-local work can run `pnpm types:check` and the package's test scripts; `pnpm exec oxlint`, `pnpm exec oxfmt`, and `pnpm exec knip-bun` from the repo root also work directly. Expected exceptions: diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md new file mode 100644 index 0000000000..4c22dc2309 --- /dev/null +++ b/apps/cli/docs/stack-commands.md @@ -0,0 +1,50 @@ +# Local stack commands + +`supabase stack` manages local stacks with the new runtime. It is available regardless of the +project's backend setting and supports both Docker and native runtimes. + +| Command | Purpose | +| ---------------------- | -------------------------------------- | +| `supabase stack start` | Create or resume the project's stack. | +| `supabase stack stop` | Stop a stack while retaining its data. | + +The previous `supabase experimental stack` command path has been removed. Use each command's +`--help` for its available targeting and runtime options. + +## Selecting the top-level commands + +The top-level `supabase start` and `supabase stop` commands use the legacy backend by default. +To make them aliases of the corresponding `supabase stack` commands, add this to +`supabase/config.toml`: + +```toml +[experimental] +stack = true +``` + +The selected backend determines accepted flags, help, and completion before the command is parsed. +Set the flag to `false`, or remove it, to restore the legacy top-level commands. Explicit +`supabase stack` commands always use the new backend. `supabase status` always uses its existing +command implementation and is unaffected by this flag. + +Root help and root completion do not read project configuration, so they remain available without +a project directory. Help and completion for `start` and `stop` resolve the same backend as the +command itself. An invalid configuration produces a routing error instead of silently selecting a +backend; set `SUPABASE_EXPERIMENTAL_STACK=0` to select the legacy top-level command explicitly, or +use the explicit `supabase stack start` or `supabase stack stop` command. + +For temporary selection, set `SUPABASE_EXPERIMENTAL_STACK=1` to select the new backend or +`SUPABASE_EXPERIMENTAL_STACK=0` to select the legacy backend. This environment variable takes +precedence over `experimental.stack`; an unset or empty value falls back to the file setting. +Other values are rejected. The override affects only the top-level lifecycle aliases and is +applied before reading the project configuration. + +## Data and configuration + +The backends own separate state and databases. Enabling the flag does not import, copy, seed from, +or reuse the legacy database, and does not stop a running legacy stack. Normal project migrations +and seed configuration are separate from importing legacy database data. + +The flag is local CLI configuration in `supabase/config.toml` and is excluded from hosted project +configuration. Routing reads that exact file after applying the CLI's working-directory rules, +including `--workdir` and `SUPABASE_WORKDIR`; a JSON-only project does not enable the flag. diff --git a/apps/cli/src/cli/complete.ts b/apps/cli/src/cli/complete.ts index cf982118cf..f2c2cd8873 100644 --- a/apps/cli/src/cli/complete.ts +++ b/apps/cli/src/cli/complete.ts @@ -107,7 +107,7 @@ export interface ClassifyCompletionInput { } export interface CompleteDeps { - readonly root: Command.Command.Any; + readonly root: Command.Command.Any | undefined; readonly argv: ReadonlyArray; readonly env: Readonly>; readonly stdoutWrite: (message: string) => void; @@ -1525,10 +1525,11 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe * (see the module doc comment for why that case isn't otherwise reproduced). */ export function respondToComplete( - root: Command.Command.Any, + root: Command.Command.Any | undefined, argv: ReadonlyArray, ): CompletionResult | undefined { if (argv[0] !== "__complete" && argv[0] !== "__completeNoDesc") return undefined; + if (root === undefined) return undefined; const args = argv.slice(1); if (args.length === 0) return undefined; @@ -1745,7 +1746,7 @@ export async function tryComplete(deps: CompleteDeps): Promise { return true; } -export function defaultCompleteDeps(root: Command.Command.Any): CompleteDeps { +export function defaultCompleteDeps(root?: Command.Command.Any): CompleteDeps { return { root, argv: process.argv.slice(2), diff --git a/apps/cli/src/cli/main.ts b/apps/cli/src/cli/main.ts index 5758dc7f9f..224f5df833 100644 --- a/apps/cli/src/cli/main.ts +++ b/apps/cli/src/cli/main.ts @@ -1,13 +1,34 @@ #!/usr/bin/env bun +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, Stdio } from "effect"; import { runCli } from "../shared/cli/run.ts"; import { upgradeNoticeHook } from "../command-internal/upgrade-notice.ts"; import { analyticsLayer } from "../telemetry/analytics.layer.ts"; import { defaultCompleteDeps, tryComplete } from "./complete.ts"; -import { rootCommand } from "./root.ts"; +import { resolveStackBackend } from "../commands/experimental/stack/stack-backend.ts"; +import { rootCommand, rootCommandForBackend } from "./root.ts"; -if (!(await tryComplete(defaultCompleteDeps(rootCommand)))) { - await runCli(rootCommand, { +const args = await Effect.runPromise( + Effect.gen(function* () { + const stdio = yield* Stdio.Stdio; + return yield* stdio.args; + }).pipe(Effect.provide(BunServices.layer)), +); + +const backendExit = await Effect.runPromiseExit( + resolveStackBackend({ args, cwd: process.cwd(), env: process.env }).pipe( + Effect.provide(BunServices.layer), + ), +); +const selectedRoot = Exit.isSuccess(backendExit) + ? rootCommandForBackend(backendExit.value) + : rootCommand; +const completionRoot = Exit.isSuccess(backendExit) ? selectedRoot : undefined; + +if (!(await tryComplete(defaultCompleteDeps(completionRoot)))) { + await runCli(selectedRoot, { analyticsLayer: analyticsLayer, afterSuccess: upgradeNoticeHook, + ...(Exit.isFailure(backendExit) ? { beforeParse: Effect.failCause(backendExit.cause) } : {}), }); } diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index 570d8cb316..9bb7dd7a64 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -9,6 +9,13 @@ import { dbCommand } from "../commands/db/db.command.ts"; import { domainsCommand } from "../commands/domains/domains.command.ts"; import { encryptionCommand } from "../commands/encryption/encryption.command.ts"; import { experimentalCommand } from "../commands/experimental/experimental.command.ts"; +import { + experimentalStackRuntimeLayer, + stackCommand, +} from "../commands/experimental/stack/stack.command.ts"; +import { experimentalStackStartCommand } from "../commands/experimental/stack/start/start.command.ts"; +import { experimentalStackStopCommand } from "../commands/experimental/stack/stop/stop.command.ts"; +import type { StackBackend } from "../commands/experimental/stack/stack-backend.ts"; import { functionsCommand } from "../commands/functions/functions.command.ts"; import { genCommand } from "../commands/gen/gen.command.ts"; import { initCommand } from "../commands/init/init.command.ts"; @@ -44,6 +51,8 @@ import { makeGoProxyLayer } from "../command-internal/go-proxy.layer.ts"; import { AiTool } from "../shared/telemetry/ai-tool.service.ts"; import { aiToolLayer } from "../shared/telemetry/ai-tool.layer.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; +import { commandRuntimeLayer } from "../shared/runtime/command-runtime.layer.ts"; +import type { CliRootCommand } from "../shared/cli/run.ts"; import { isBuiltInTextRequest, resolveAgentOutputFormat } from "../shared/cli/agent-output.ts"; import { GLOBAL_FLAGS, @@ -59,111 +68,124 @@ import { YesFlag, } from "../command-internal/global-flags.ts"; -export const rootCommand = Command.make("supabase").pipe( - Command.withDescription("Supabase CLI (stable channel)."), - Command.withSubcommands([ - backupsCommand, - bootstrapCommand, - branchesCommand, - completionCommand, - configCommand, - dbCommand, - domainsCommand, - encryptionCommand, - experimentalCommand, - functionsCommand, - genCommand, - initCommand, - inspectCommand, - issueCommand, - linkCommand, - loginCommand, - logoutCommand, - migrationCommand, - networkBansCommand, - networkRestrictionsCommand, - orgsCommand, - postgresConfigCommand, - projectsCommand, - secretsCommand, - seedCommand, - servicesCommand, - snippetsCommand, - sslEnforcementCommand, - ssoCommand, - startCommand, - statusCommand, - stopCommand, - storageCommand, - telemetryCommand, - testCommand, - unlinkCommand, - vanitySubdomainsCommand, - ]), - Command.provide( - Layer.unwrap( - Effect.gen(function* () { - const explicitOutputFormat = yield* OutputFormatFlag; - const goOutput = yield* OutputFlag; - const profile = yield* ProfileFlag; - const debug = yield* DebugFlag; - const workdir = yield* WorkdirFlag; - const experimental = yield* ExperimentalFlag; - const networkId = yield* NetworkIdFlag; - const yes = yield* YesFlag; - const dnsResolver = yield* DnsResolverFlag; - const createTicket = yield* CreateTicketFlag; - const agent = yield* AgentFlag; - const cliArgs = yield* CliArgs; +const stackStartAliasCommand = experimentalStackStartCommand.pipe( + Command.provide(commandRuntimeLayer(["start"])), + Command.provide(experimentalStackRuntimeLayer), +); +export const stackStopAliasCommand = experimentalStackStopCommand.pipe( + Command.provide(commandRuntimeLayer(["stop"])), + Command.provide(experimentalStackRuntimeLayer), +); + +export const rootCommandForBackend = (backend: StackBackend = "legacy"): CliRootCommand => + Command.make("supabase").pipe( + Command.withDescription("Supabase CLI (stable channel)."), + Command.withSubcommands([ + backupsCommand, + bootstrapCommand, + branchesCommand, + completionCommand, + configCommand, + dbCommand, + domainsCommand, + encryptionCommand, + experimentalCommand, + stackCommand, + functionsCommand, + genCommand, + initCommand, + inspectCommand, + issueCommand, + linkCommand, + loginCommand, + logoutCommand, + migrationCommand, + networkBansCommand, + networkRestrictionsCommand, + orgsCommand, + postgresConfigCommand, + projectsCommand, + secretsCommand, + seedCommand, + servicesCommand, + snippetsCommand, + sslEnforcementCommand, + ssoCommand, + backend === "stack" ? stackStartAliasCommand : startCommand, + statusCommand, + backend === "stack" ? stackStopAliasCommand : stopCommand, + storageCommand, + telemetryCommand, + testCommand, + unlinkCommand, + vanitySubdomainsCommand, + ]), + Command.provide( + Layer.unwrap( + Effect.gen(function* () { + const explicitOutputFormat = yield* OutputFormatFlag; + const goOutput = yield* OutputFlag; + const profile = yield* ProfileFlag; + const debug = yield* DebugFlag; + const workdir = yield* WorkdirFlag; + const experimental = yield* ExperimentalFlag; + const networkId = yield* NetworkIdFlag; + const yes = yield* YesFlag; + const dnsResolver = yield* DnsResolverFlag; + const createTicket = yield* CreateTicketFlag; + const agent = yield* AgentFlag; + const cliArgs = yield* CliArgs; - const aiTool = yield* AiTool.pipe(Effect.provide(aiToolLayer)); - // An explicit Go --output is a complete format choice (even `-o pretty` - // must keep its human table), so the agent JSON default only applies - // when that flag is absent. - const outputFormat = resolveAgentOutputFormat({ - explicitOutputFormat, - goOutputFormat: goOutput, - agentOverride: agent, - detectedAgentName: aiTool.name, - isBuiltInTextRequest: isBuiltInTextRequest(cliArgs.args), - }); + const aiTool = yield* AiTool.pipe(Effect.provide(aiToolLayer)); + // An explicit Go --output is a complete format choice (even `-o pretty` + // must keep its human table), so the agent JSON default only applies + // when that flag is absent. + const outputFormat = resolveAgentOutputFormat({ + explicitOutputFormat, + goOutputFormat: goOutput, + agentOverride: agent, + detectedAgentName: aiTool.name, + isBuiltInTextRequest: isBuiltInTextRequest(cliArgs.args), + }); - // Build args to prepend to every proxy exec call. - // --output: use explicit --output if set, otherwise map from --output-format. - const globalArgs: string[] = []; - if (Option.isSome(goOutput)) { - globalArgs.push("--output", goOutput.value); - } else if (outputFormat !== "text") { - globalArgs.push("--output", "json"); - } - if (profile !== "supabase") globalArgs.push("--profile", profile); - if (debug) globalArgs.push("--debug"); - if (Option.isSome(workdir)) globalArgs.push("--workdir", workdir.value); - if (experimental) globalArgs.push("--experimental"); - if (Option.isSome(networkId)) globalArgs.push("--network-id", networkId.value); - if (yes) globalArgs.push("--yes"); - if (dnsResolver !== "native") globalArgs.push("--dns-resolver", dnsResolver); - if (createTicket) globalArgs.push("--create-ticket"); - if (agent !== "auto") globalArgs.push("--agent", agent); + // Build args to prepend to every proxy exec call. + // --output: use explicit --output if set, otherwise map from --output-format. + const globalArgs: string[] = []; + if (Option.isSome(goOutput)) { + globalArgs.push("--output", goOutput.value); + } else if (outputFormat !== "text") { + globalArgs.push("--output", "json"); + } + if (profile !== "supabase") globalArgs.push("--profile", profile); + if (debug) globalArgs.push("--debug"); + if (Option.isSome(workdir)) globalArgs.push("--workdir", workdir.value); + if (experimental) globalArgs.push("--experimental"); + if (Option.isSome(networkId)) globalArgs.push("--network-id", networkId.value); + if (yes) globalArgs.push("--yes"); + if (dnsResolver !== "native") globalArgs.push("--dns-resolver", dnsResolver); + if (createTicket) globalArgs.push("--create-ticket"); + if (agent !== "auto") globalArgs.push("--agent", agent); - // Go's `-o {json,yaml,toml,env,csv}` selects a machine encoder the - // handler writes via `output.raw`. Keep the text layer (so errors still - // render as red text on stderr, matching Go), but suppress its progress - // spinner — otherwise clack writes ANSI to stdout and corrupts the - // payload (CLI-1546). `-o pretty` / `-o table` (`db query`'s human - // default) / no `-o` keep the normal text/json layers. - const goFmt = Option.getOrUndefined(goOutput); - const isGoMachineFormat = goFmt !== undefined && goFmt !== "pretty" && goFmt !== "table"; - const outputLayer = isGoMachineFormat - ? quietProgressTextOutputLayer - : outputLayerFor(outputFormat); + // Go's `-o {json,yaml,toml,env,csv}` selects a machine encoder the + // handler writes via `output.raw`. Keep the text layer (so errors still + // render as red text on stderr, matching Go), but suppress its progress + // spinner — otherwise clack writes ANSI to stdout and corrupts the + // payload (CLI-1546). `-o pretty` / `-o table` (`db query`'s human + // default) / no `-o` keep the normal text/json layers. + const goFmt = Option.getOrUndefined(goOutput); + const isGoMachineFormat = goFmt !== undefined && goFmt !== "pretty" && goFmt !== "table"; + const outputLayer = isGoMachineFormat + ? quietProgressTextOutputLayer + : outputLayerFor(outputFormat); - return Layer.mergeAll( - outputLayer, - makeGoProxyLayer({ globalArgs, parentOwnsCapturedSuccessTail: true }), - ); - }), + return Layer.mergeAll( + outputLayer, + makeGoProxyLayer({ globalArgs, parentOwnsCapturedSuccessTail: true }), + ); + }), + ), ), - ), - Command.withGlobalFlags([OutputFormatFlag, ...GLOBAL_FLAGS]), -); + Command.withGlobalFlags([OutputFormatFlag, ...GLOBAL_FLAGS]), + ); + +export const rootCommand: CliRootCommand = rootCommandForBackend(); diff --git a/apps/cli/src/command-internal/experimental-feature.ts b/apps/cli/src/command-internal/experimental-feature.ts new file mode 100644 index 0000000000..312b7cf7e8 --- /dev/null +++ b/apps/cli/src/command-internal/experimental-feature.ts @@ -0,0 +1,36 @@ +import { Data, Effect } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../shared/telemetry/error-actionability.ts"; + +export class ExperimentalFeatureFlagError extends Data.TaggedError("ExperimentalFeatureFlagError")<{ + readonly envName: string; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +/** Resolves one experimental boolean from its environment override and config fallback. */ +export const resolveExperimentalFeature = (input: { + readonly feature: string; + readonly configValue: Effect.Effect; + readonly env: Readonly>; +}): Effect.Effect => { + const envName = `SUPABASE_EXPERIMENTAL_${input.feature.toUpperCase()}`; + const override = input.env[envName]; + if (override === undefined || override === "") { + return input.configValue.pipe(Effect.map((value) => value === true)); + } + if (override === "1") return Effect.succeed(true); + if (override === "0") return Effect.succeed(false); + return Effect.fail( + new ExperimentalFeatureFlagError({ + envName, + message: `${envName} must be 0 or 1 when set`, + }), + ); +}; diff --git a/apps/cli/src/commands/experimental/experimental.command.ts b/apps/cli/src/commands/experimental/experimental.command.ts index 9024183d92..d080b85f52 100644 --- a/apps/cli/src/commands/experimental/experimental.command.ts +++ b/apps/cli/src/commands/experimental/experimental.command.ts @@ -1,6 +1,5 @@ import { Command } from "effect/unstable/cli"; import { workersCommand } from "./workers/workers.command.ts"; -import { experimentalStackCommand } from "./stack/stack.command.ts"; /** * `supabase experimental` — the parent for command families that are not yet @@ -18,6 +17,6 @@ export const experimentalCommand = Command.make("experimental").pipe( "Experimental commands. These are unstable: their flags, output, and invocation path can change or be removed in any release, and they are excluded from the CLI's compatibility promise.", ), Command.withShortDescription("Experimental, unstable commands"), - Command.withSubcommands([workersCommand, experimentalStackCommand]), + Command.withSubcommands([workersCommand]), Command.unlisted, ); diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts new file mode 100644 index 0000000000..29562c27a8 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts @@ -0,0 +1,172 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- temporary project fixture +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- temporary project fixture +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Option } from "effect"; +import { respondToComplete } from "../../../cli/complete.ts"; +import { rootCommandForBackend } from "../../../cli/root.ts"; +import { StackRoutingError, resolveStackBackend } from "./stack-backend.ts"; + +const resolve = (input: Parameters[0]) => + resolveStackBackend(input).pipe(Effect.provide(BunServices.layer)); + +const project = (config: string) => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-routing-")); + mkdirSync(join(root, "supabase"), { recursive: true }); + writeFileSync(join(root, "supabase", "config.toml"), config); + return root; +}; + +const completionFlags = (backend: "legacy" | "stack", command: string) => + respondToComplete(rootCommandForBackend(backend), ["__complete", command, "--"])?.candidates.map( + ({ name }) => name, + ); + +describe("resolveStackBackend", () => { + it.effect("selects the explicit stack namespace without config or env", () => + Effect.gen(function* () { + expect(yield* resolve({ args: ["stack", "start"], cwd: "/missing", env: {} })).toBe("stack"); + }), + ); + + it.effect("selects the configured backend for top-level start and stop", () => { + const root = project("[experimental]\nstack = true\n"); + return Effect.gen(function* () { + expect(yield* resolve({ args: ["start"], cwd: join(root, "nested"), env: {} })).toBe("stack"); + expect(yield* resolve({ args: ["stop"], cwd: root, env: {} })).toBe("stack"); + expect(yield* resolve({ args: ["status"], cwd: root, env: {} })).toBe("legacy"); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); + }); + + it.effect("uses the environment override before reading config", () => { + const root = project("[experimental]\nstack = true\n"); + return Effect.gen(function* () { + expect( + yield* resolve({ + args: ["start"], + cwd: root, + env: { SUPABASE_EXPERIMENTAL_STACK: "0" }, + }), + ).toBe("legacy"); + expect( + yield* resolve({ + args: ["start"], + cwd: root, + env: { SUPABASE_EXPERIMENTAL_STACK: "" }, + }), + ).toBe("stack"); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); + }); + + it.effect("rejects invalid overrides and bypasses malformed config", () => { + const root = project("[experimental\nstack = true\n"); + return Effect.gen(function* () { + const invalid = yield* resolve({ + args: ["start"], + cwd: "/missing", + env: { SUPABASE_EXPERIMENTAL_STACK: "yes" }, + }).pipe(Effect.exit); + expect(Exit.isFailure(invalid)).toBe(true); + if (Exit.isFailure(invalid)) { + const error = Cause.findErrorOption(invalid.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(StackRoutingError); + expect(String(error.value)).toContain("0 or 1"); + } + } + expect( + yield* resolve({ + args: ["start"], + cwd: root, + env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, + }), + ).toBe("stack"); + expect( + yield* resolve({ + args: ["stack", "start"], + cwd: root, + env: { SUPABASE_EXPERIMENTAL_STACK: "invalid" }, + }), + ).toBe("stack"); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); + }); + + it.effect("honors explicit workdir and separated global boolean values", () => { + const stackRoot = project("[experimental]\nstack = true\n"); + const legacyRoot = project("[experimental]\nstack = false\n"); + return Effect.gen(function* () { + expect( + yield* resolve({ + args: ["--workdir", legacyRoot, "start"], + cwd: stackRoot, + env: { SUPABASE_WORKDIR: stackRoot }, + }), + ).toBe("legacy"); + expect( + yield* resolve({ + args: [`--workdir=${legacyRoot}`, "start"], + cwd: stackRoot, + env: { SUPABASE_WORKDIR: stackRoot }, + }), + ).toBe("legacy"); + expect(yield* resolve({ args: ["--debug", "false", "start"], cwd: stackRoot, env: {} })).toBe( + "stack", + ); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + rmSync(stackRoot, { recursive: true, force: true }); + rmSync(legacyRoot, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("reports malformed routing config as a typed error", () => { + const root = project('[experimental]\nstack = "yes"\n'); + return Effect.gen(function* () { + const exit = yield* resolve({ args: ["start"], cwd: root, env: {} }).pipe(Effect.exit); + 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).toBeInstanceOf(StackRoutingError); + } + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); + }); + + it("selects matching command trees for completion", () => { + expect(completionFlags("stack", "start")).toEqual( + expect.arrayContaining(["--stack", "--runtime", "--preparation", "--eager"]), + ); + expect(completionFlags("legacy", "start")).toEqual( + expect.arrayContaining(["--exclude", "--ignore-health-check"]), + ); + expect(completionFlags("stack", "start")).not.toContain("--ignore-health-check"); + }); + + it("keeps status and workers on their existing command trees", () => { + for (const backend of ["legacy", "stack"] as const) { + const stackCommands = respondToComplete(rootCommandForBackend(backend), [ + "__complete", + "stack", + "", + ])?.candidates.map(({ name }) => name); + expect(stackCommands).toEqual(["start", "stop"]); + + const experimentalCommands = respondToComplete(rootCommandForBackend(backend), [ + "__complete", + "experimental", + "", + ])?.candidates.map(({ name }) => name); + expect(experimentalCommands).toContain("workers"); + expect(experimentalCommands).not.toContain("stack"); + + expect(completionFlags(backend, "status")).toContain("--override-name"); + } + }); +}); diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.ts b/apps/cli/src/commands/experimental/stack/stack-backend.ts new file mode 100644 index 0000000000..a06977b25b --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-backend.ts @@ -0,0 +1,153 @@ +import { CliConfigSchema } from "@supabase/config/effect"; +import { Data, Effect, FileSystem, Option, Path, Schema } from "effect"; +import * as SmolToml from "smol-toml"; +import { resolveWorkdir } from "../../../config/command-settings.layer.ts"; +import { resolveExperimentalFeature } from "../../../command-internal/experimental-feature.ts"; +import { BOOLEAN_FLAG_VALUES, ROOT_BOOLEAN_FLAGS } from "../../../shared/cli/agent-output.ts"; +import { GLOBAL_VALUE_FLAG_TOKENS } from "../../../shared/cli/cobra-flag-groups.ts"; +import { hasRootVersionFlag, rootFlagTokens } from "../../../shared/cli/run.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + +export type StackBackend = "legacy" | "stack"; + +export class StackRoutingError extends Data.TaggedError("StackRoutingError")<{ + readonly message: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +const stackRoutingSchema = Schema.Struct({ + experimental: Schema.optionalKey( + Schema.Struct({ stack: CliConfigSchema.fields.experimental.to.fields.stack }), + ), +}); + +const firstExplicitLongFlagValue = ( + args: ReadonlyArray, + flagName: string, +): string | undefined => { + for (const { token, index } of rootFlagTokens(args)) { + if (token === `--${flagName}`) return args[index + 1]; + if (token.startsWith(`--${flagName}=`)) return token.slice(flagName.length + 3); + } + return undefined; +}; + +/** Extracts command path tokens while honoring optional separated boolean values. */ +const extractRoutingCommandPath = (args: ReadonlyArray): ReadonlyArray => { + const commandPath: Array = []; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === undefined || arg === "--") break; + if (!arg.startsWith("-")) { + commandPath.push(arg); + continue; + } + const [flag] = arg.split("=", 1); + if (!arg.includes("=") && flag !== undefined && GLOBAL_VALUE_FLAG_TOKENS.has(flag)) { + index += 1; + continue; + } + if ( + !arg.includes("=") && + flag !== undefined && + ROOT_BOOLEAN_FLAGS.includes(flag) && + BOOLEAN_FLAG_VALUES.has(args[index + 1] ?? "") + ) + index += 1; + } + return commandPath; +}; + +const parseConfig = (path: string, content: string): Effect.Effect => + Effect.try({ + try: () => SmolToml.parse(content), + catch: (cause) => + new StackRoutingError({ + message: `Unable to read ${path}: ${String(cause)}`, + cause, + }), + }); + +const stackSettingFrom = ( + path: string, + document: unknown, +): Effect.Effect => + Schema.decodeUnknownEffect(stackRoutingSchema)(document).pipe( + Effect.map(({ experimental }) => experimental?.stack), + Effect.mapError( + (cause) => + new StackRoutingError({ + message: `Invalid experimental.stack in ${path}: expected a boolean value`, + cause, + }), + ), + ); + +export const resolveStackBackend = (input: { + readonly args: ReadonlyArray; + readonly cwd: string; + readonly env: Readonly>; +}): Effect.Effect => + Effect.gen(function* () { + if (hasRootVersionFlag(input.args)) return "legacy"; + + const commandPath = extractRoutingCommandPath(input.args); + const completePath = + commandPath[0] === "__complete" || commandPath[0] === "__completeNoDesc" + ? commandPath.slice(1) + : commandPath; + const command = completePath[0]; + + // The explicit namespace is always backed by the stack runtime and does + // not need a project config or environment lookup to select it. + if (command === "stack") return "stack"; + if (command !== "start" && command !== "stop") return "legacy"; + + const configValue = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const explicitWorkdir = firstExplicitLongFlagValue(input.args, "workdir"); + const resolvedWorkdir = yield* resolveWorkdir( + explicitWorkdir === undefined ? Option.none() : Option.some(explicitWorkdir), + input.env["SUPABASE_WORKDIR"], + input.cwd, + (filePath) => fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)), + path, + ); + const configPath = path.join(resolvedWorkdir.workdir, "supabase", "config.toml"); + const configExists = yield* fs.exists(configPath).pipe(Effect.orElseSucceed(() => false)); + if (!configExists) return undefined; + const content = yield* fs.readFileString(configPath).pipe( + Effect.mapError( + (cause) => + new StackRoutingError({ + message: `Unable to read ${configPath}: ${String(cause)}`, + cause, + }), + ), + ); + return yield* parseConfig(configPath, content).pipe( + Effect.flatMap((document) => stackSettingFrom(configPath, document)), + ); + }); + const enabled = yield* resolveExperimentalFeature({ + feature: "stack", + configValue, + env: input.env, + }).pipe( + Effect.mapError((error) => + error instanceof StackRoutingError + ? error + : new StackRoutingError({ message: error.message, cause: error }), + ), + ); + return enabled ? "stack" : "legacy"; + }); diff --git a/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts new file mode 100644 index 0000000000..a2e6a04998 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts @@ -0,0 +1,84 @@ +import { BunServices } from "@effect/platform-bun"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- temporary project fixture +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- temporary project fixture +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { Effect, Layer, Option } from "effect"; +import { + mockContextualAnalytics, + mockOutput, + mockProcessControl, + mockRuntimeInfo, + mockTelemetryRuntime, + processEnvLayer, +} from "../../../../tests/helpers/mocks.ts"; +import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { DebugFlag, ProfileFlag, WorkdirFlag } from "../../../command-internal/global-flags.ts"; +import { + EventCommandExecuted, + PropCommand, + PropCommandRunId, +} from "../../../shared/telemetry/event-catalog.ts"; +import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; +import { stackCommand } from "../../../commands/experimental/stack/stack.command.ts"; +import { stackStopAliasCommand } from "../../../cli/root.ts"; + +function setup() { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-telemetry-")); + mkdirSync(join(root, "supabase")); + const output = mockOutput(); + const analytics = mockContextualAnalytics(); + const processControl = mockProcessControl(); + return { + analytics, + output, + layer: Layer.mergeAll( + BunServices.layer, + CliOutput.layer(textCliOutputFormatter()), + output.layer, + analytics.layer, + processControl.layer, + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(DebugFlag, false), + Layer.succeed(ProfileFlag, "supabase"), + Layer.succeed(WorkdirFlag, Option.none()), + mockRuntimeInfo({ cwd: root, homeDir: root }), + mockTelemetryRuntime({ + configDir: join(root, ".supabase"), + tracesDir: join(root, ".supabase", "traces"), + }), + processEnvLayer({ SUPABASE_HOME: join(root, ".supabase") }), + ), + root, + }; +} + +describe("stack command telemetry", () => { + it.live("records canonical and top-level stop paths with distinct run ids", () => { + const fixture = setup(); + const canonical = stackCommand.pipe(Command.provide(fixture.layer)); + const alias = stackStopAliasCommand.pipe(Command.provide(fixture.layer)); + return Effect.gen(function* () { + yield* Command.runWith(canonical, { version: "0.0.0-test" })(["stop"]); + yield* Command.runWith(alias, { version: "0.0.0-test" })([]); + const events = fixture.analytics.captured.filter( + (event) => event.event === EventCommandExecuted, + ); + expect(events.map((event) => event.properties[PropCommand])).toEqual(["stack stop", "stop"]); + const runIds = events.map((event) => event.properties[PropCommandRunId]); + expect(runIds.every((runId) => typeof runId === "string")).toBe(true); + expect(new Set(runIds).size).toBe(2); + expect(fixture.output.messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "success", data: { found: false } }), + ]), + ); + }).pipe( + Effect.provide(fixture.layer), + Effect.ensuring(Effect.sync(() => rmSync(fixture.root, { recursive: true, force: true }))), + ); + }); +}); diff --git a/apps/cli/src/commands/experimental/stack/stack.command.ts b/apps/cli/src/commands/experimental/stack/stack.command.ts index 75367c3fc2..6a072488fa 100644 --- a/apps/cli/src/commands/experimental/stack/stack.command.ts +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -1,5 +1,6 @@ import { Layer } from "effect"; import { Command } from "effect/unstable/cli"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { commandSettingsLayer } from "../../../config/command-settings.layer.ts"; import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.ts"; import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; @@ -7,12 +8,23 @@ import { experimentalStackStartCommand } from "./start/start.command.ts"; import { experimentalStackStopCommand } from "./stop/stop.command.ts"; import { experimentalStackApiLayer, experimentalStackTargetResolverLayer } from "./stack.shared.ts"; -export const experimentalStackCommand = Command.make("stack").pipe( - Command.withDescription("Manage an experimental managed local Supabase stack."), - Command.withShortDescription("Manage a managed local stack"), - Command.withSubcommands([experimentalStackStartCommand, experimentalStackStopCommand]), - Command.provide(experimentalStackTargetResolverLayer), - Command.provide(experimentalStackApiLayer), - Command.provide(commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer))), - Command.provide(telemetryStateLayer), +export const experimentalStackRuntimeLayer = Layer.mergeAll( + experimentalStackTargetResolverLayer, + experimentalStackApiLayer, + commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)), + telemetryStateLayer, +); + +const stackStartCommand = experimentalStackStartCommand.pipe( + Command.provide(commandRuntimeLayer(["stack", "start"])), +); +const stackStopCommand = experimentalStackStopCommand.pipe( + Command.provide(commandRuntimeLayer(["stack", "stop"])), +); + +export const stackCommand = Command.make("stack").pipe( + Command.withDescription("Manage a local Supabase stack with the new backend."), + Command.withShortDescription("Manage local stacks"), + Command.withSubcommands([stackStartCommand, stackStopCommand]), + Command.provide(experimentalStackRuntimeLayer), ); diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index 828d8d5822..0778376839 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -1,4 +1,4 @@ -# `supabase experimental stack start` +# `supabase stack start` This command creates or resumes the managed stack identified by the current project and optional `--stack`, or opens an existing stack with `--stack-id`. diff --git a/apps/cli/src/commands/experimental/stack/start/start.command.ts b/apps/cli/src/commands/experimental/stack/start/start.command.ts index 62f80728bb..f66b60caa3 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.command.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.command.ts @@ -1,7 +1,6 @@ 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 { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { experimentalStackStart } from "./start.handler.ts"; @@ -35,11 +34,11 @@ export const experimentalStackStartCommand = Command.make("start", config).pipe( Command.withShortDescription("Start a managed local stack"), Command.withExamples([ { - command: "supabase experimental stack start", + command: "supabase stack start", description: "Start the current project stack", }, { - command: "supabase experimental stack start --stack feature-a --runtime docker", + command: "supabase stack start --stack feature-a --runtime docker", description: "Start a named Docker stack", }, ]), @@ -49,5 +48,4 @@ export const experimentalStackStartCommand = Command.make("start", config).pipe( withJsonErrorHandling, ), ), - Command.provide(commandRuntimeLayer(["experimental", "stack", "start"])), ); diff --git a/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts b/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts index dee45a541e..5097fd1a37 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts @@ -106,7 +106,7 @@ async function destroyStack(home: string, stackId: string) { }); } -describe("experimental stack start (compiled e2e)", () => { +describe("stack start (compiled e2e)", () => { let home: ReturnType | undefined; let projectDir: string | undefined; let stackId: string | undefined; @@ -149,14 +149,11 @@ describe("experimental stack start (compiled e2e)", () => { await mkdir(path.join(projectDir, "supabase"), { recursive: true }); await writeFile(path.join(projectDir, "supabase", "config.toml"), minimalConfig); - const result = await runSupabase( - ["experimental", "stack", "start", "--runtime", "native", "--eager"], - { - cwd: projectDir, - home: home.dir, - exitTimeoutMs: START_TIMEOUT_MS, - }, - ); + const result = await runSupabase(["stack", "start", "--runtime", "native", "--eager"], { + cwd: projectDir, + home: home.dir, + exitTimeoutMs: START_TIMEOUT_MS, + }); expect(result.exitCode, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); const idMatch = result.stdout.match(/Stack ([0-9a-f]{64})/u); expect(idMatch, `stdout:\n${result.stdout}`).not.toBeNull(); @@ -177,7 +174,7 @@ describe("experimental stack start (compiled e2e)", () => { await access(path.join(databasePath, "PG_VERSION")); await rm(path.join(projectRoot, "supabase", "config.toml")); - const stop = await runSupabase(["experimental", "stack", "stop", "--stack-id", idText], { + const stop = await runSupabase(["stack", "stop", "--stack-id", idText], { cwd: projectRoot, home: homeDir.dir, exitTimeoutMs: CLEANUP_TIMEOUT_MS, diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index a2eb959e8a..fe074250c3 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -32,7 +32,7 @@ import { experimentalStackStart } from "./start.handler.ts"; import { ExperimentalStackStartError } from "./start.errors.ts"; import { experimentalStackStartCommand } from "./start.command.ts"; import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; -import { CommandRuntime } from "../../../../shared/runtime/command-runtime.service.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { OutputFlag } from "../../../../command-internal/global-flags.ts"; import { actionability, @@ -539,27 +539,21 @@ describe("experimental stack start parser", () => { const stack = fakeStack("e".repeat(64), () => Effect.succeed(status("e".repeat(64)))); const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); const command = experimentalStackStartCommand.pipe( + Command.provide(commandRuntimeLayer(["stack", "start"])), Command.provide( Layer.mergeAll(setup.layer, output.layer, analytics.layer, processControl.layer), ), ); const run = Command.runWith(command, { version: "0.0.0-test" })([]); - const runtime = Layer.mergeAll( - BunServices.layer, - CliOutput.layer(textCliOutputFormatter()), - Layer.succeed( - CommandRuntime, - CommandRuntime.of({ commandPath: ["root"], commandRunId: "root-command-run-id" }), - ), - ); + const runtime = Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter())); return Effect.gen(function* () { yield* run.pipe(Effect.provide(runtime)); yield* run.pipe(Effect.provide(runtime)); const events = analytics.captured.filter((event) => event.event === "cli_command_executed"); expect(events).toHaveLength(2); - expect(events[0]?.properties.command).toBe("experimental stack start"); - expect(events[1]?.properties.command).toBe("experimental stack start"); + expect(events[0]?.properties.command).toBe("stack start"); + expect(events[1]?.properties.command).toBe("stack start"); expect(events[0]?.properties.command_run_id).toBeDefined(); expect(events[1]?.properties.command_run_id).toBeDefined(); expect(events[0]?.properties.command_run_id).not.toBe(events[1]?.properties.command_run_id); diff --git a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md index 72e2893f85..f8baa63c19 100644 --- a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md @@ -1,4 +1,4 @@ -# `supabase experimental stack stop` +# `supabase stack stop` This command stops the managed stack identified by the current project, an optional `--stack` name, or `--stack-id`. It uses the public `@supabase/stack` API to stop the owner while diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts index a041463e59..41db465c47 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts @@ -1,7 +1,6 @@ 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 { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { experimentalStackStop } from "./stop.handler.ts"; @@ -23,7 +22,7 @@ export const experimentalStackStopCommand = Command.make("stop", config).pipe( Command.withShortDescription("Stop a managed local stack"), Command.withExamples([ { - command: "supabase experimental stack stop --stack feature-a", + command: "supabase stack stop --stack feature-a", description: "Stop the existing feature-a stack", }, ]), @@ -33,5 +32,4 @@ export const experimentalStackStopCommand = Command.make("stop", config).pipe( withJsonErrorHandling, ), ), - Command.provide(commandRuntimeLayer(["experimental", "stack", "stop"])), ); diff --git a/apps/cli/src/commands/start/SIDE_EFFECTS.md b/apps/cli/src/commands/start/SIDE_EFFECTS.md index 75030a7d35..31ee4ef66a 100644 --- a/apps/cli/src/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/start/SIDE_EFFECTS.md @@ -1,5 +1,10 @@ # `supabase start` +This document describes the legacy backend. With `SUPABASE_EXPERIMENTAL_STACK=1`, or +`[experimental] stack = true` when the environment override is unset or empty, `supabase start` +uses the new [`supabase stack start` implementation](../experimental/stack/start/SIDE_EFFECTS.md). +`SUPABASE_EXPERIMENTAL_STACK=0` forces the legacy backend. See [backend selection](../../../docs/stack-commands.md). + This command talks directly to Docker via subprocess (`docker`/`podman`) to bring up the local dev stack sequentially, one container at a time — it does not use Docker Compose, and it does not go through `@supabase/stack/effect`'s orchestration model diff --git a/apps/cli/src/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/stop/SIDE_EFFECTS.md index 2cd1e6d5ec..a5d149fa36 100644 --- a/apps/cli/src/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/stop/SIDE_EFFECTS.md @@ -1,5 +1,10 @@ # `supabase stop` +This document describes the legacy backend. With `SUPABASE_EXPERIMENTAL_STACK=1`, or +`[experimental] stack = true` when the environment override is unset or empty, `supabase stop` +uses the new [`supabase stack stop` implementation](../experimental/stack/stop/SIDE_EFFECTS.md). +`SUPABASE_EXPERIMENTAL_STACK=0` forces the legacy backend. See [backend selection](../../../docs/stack-commands.md). + Talks directly to Docker via subprocess (`docker`/`podman`), replicating the old Go CLI's label-filtering and container-naming scheme byte-for-byte — it does not go through `@supabase/stack/effect`'s orchestration diff --git a/apps/cli/src/config/command-settings.layer.ts b/apps/cli/src/config/command-settings.layer.ts index 357c761409..d669a366b3 100644 --- a/apps/cli/src/config/command-settings.layer.ts +++ b/apps/cli/src/config/command-settings.layer.ts @@ -99,7 +99,7 @@ function resolveProfile( * `--workdir=` differently (treats it as explicit-but-falls-through-to-walk-up, * never to env) — the two are intentionally NOT unified. */ -function resolveWorkdir( +export function resolveWorkdir( flagValue: Option.Option, envValue: string | undefined, cwd: string, diff --git a/apps/cli/src/docs/docs-spec.tables.ts b/apps/cli/src/docs/docs-spec.tables.ts index 44a41a173a..b86a83ab86 100644 --- a/apps/cli/src/docs/docs-spec.tables.ts +++ b/apps/cli/src/docs/docs-spec.tables.ts @@ -67,6 +67,7 @@ export const DOCS_TAGS: Readonly>> = { "supabase-snippets": ["management-api"], "supabase-ssl-enforcement": ["management-api"], "supabase-sso": ["management-api"], + "supabase-stack": ["local-dev"], "supabase-start": ["local-dev"], "supabase-status": ["local-dev"], "supabase-stop": ["local-dev"], @@ -139,6 +140,8 @@ export const DOCS_DEFAULT_OVERRIDES: Readonly> = { "supabase output": "pretty", "supabase output-format": "text", "supabase profile": "supabase", + "supabase-stack-start runtime": "auto", + "supabase-stack-start preparation": "background", "supabase-db-advisors fail-on": "none", "supabase-db-advisors level": "warn", "supabase-db-advisors local": "true", diff --git a/apps/cli/src/shared/cli/agent-output.ts b/apps/cli/src/shared/cli/agent-output.ts index a35af5c9a8..49ac6a1d11 100644 --- a/apps/cli/src/shared/cli/agent-output.ts +++ b/apps/cli/src/shared/cli/agent-output.ts @@ -111,7 +111,7 @@ function isRootValueFlagWithInlineValue(arg: string): boolean { return false; } -const ROOT_BOOLEAN_FLAGS: ReadonlyArray = [ +export const ROOT_BOOLEAN_FLAGS: ReadonlyArray = [ "--debug", "--experimental", "--yes", @@ -130,7 +130,7 @@ function isFlagOccurrence(arg: string, name: string): boolean { * `run.ts`'s `PFLAG_BOOL_TRUE` answers a DIFFERENT question (ParseBool * truthiness, for the pflag-modeled upgrade-notice scans); do not merge them. */ -const BOOLEAN_FLAG_VALUES: ReadonlySet = new Set([ +export const BOOLEAN_FLAG_VALUES: ReadonlySet = new Set([ "true", "false", "1", diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 8a060c2ed5..7f3af5bd62 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -69,7 +69,7 @@ import { * makes an accidentally unprovided service fail at the shell boundary instead * of becoming a runtime missing-service defect. */ -type AllowedRunCliServices = +export type AllowedRunCliServices = | Analytics | ChildProcessSpawner.ChildProcessSpawner | CliArgs @@ -91,6 +91,8 @@ type AllowedRunCliServices = | "effect/unstable/cli/GlobalFlag/linked" | "effect/unstable/cli/GlobalFlag/local"; +export type CliRootCommand = Command.Command<"supabase", {}, {}, unknown, AllowedRunCliServices>; + // Global flags that consume the following argv token as their value — a value // flag missing here would make `extractCommandPath` mistake its value for a // command-path segment, and would leave the flag's following token unconsumed @@ -727,6 +729,8 @@ function cliProjectHomeLayerFor(runtimeLayer: Layer.Layer) { type AnyAnalyticsLayer = Layer.Layer; export interface RunCliOptions { + /** Runs after runtime services are installed and before command parsing. */ + readonly beforeParse?: Effect.Effect; readonly analyticsLayer: AnyAnalyticsLayer; /** * Runs just before the process exits on any invocation that exits 0 — the @@ -783,10 +787,16 @@ function cliProgramFor< }), ), ); - return withoutParseErrorHelpDump(Command.runWith(rootCommand, { version: CLI_VERSION })(args), { - rootCommand, - args, - }).pipe( + const commandProgram = options.beforeParse ?? Effect.void; + return withoutParseErrorHelpDump( + commandProgram.pipe( + Effect.andThen(Command.runWith(rootCommand, { version: CLI_VERSION })(args)), + ), + { + rootCommand, + args, + }, + ).pipe( Effect.provide(formatterLayerFor(rootCommand, args, outputFormat)), Effect.provide(options.analyticsLayer), Effect.provide(tracingLayer), diff --git a/apps/docs/public/cli/config.schema.json b/apps/docs/public/cli/config.schema.json index 41bfb54b32..867359c202 100644 --- a/apps/docs/public/cli/config.schema.json +++ b/apps/docs/public/cli/config.schema.json @@ -651,6 +651,16 @@ "description": "Controls the minimum amount of time that must pass before sending another sms otp.", "default": "5s" }, + "otp_length": { + "type": "number", + "description": "Number of characters used in the SMS OTP.", + "default": 6 + }, + "otp_expiry": { + "type": "number", + "description": "Number of seconds before the SMS OTP expires.", + "default": 60 + }, "twilio": { "type": "object", "properties": { @@ -669,6 +679,10 @@ "description": "The message service SID for the Twilio API.", "default": "" }, + "content_sid": { + "type": "string", + "description": "The content SID of a WhatsApp/Messaging Content Template for the Twilio API." + }, "auth_token": { "type": "string", "description": "The auth token for the Twilio API.", @@ -979,6 +993,47 @@ }, "additionalProperties": false }, + "figma": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Figma OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Figma OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Figma OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_FIGMA_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Figma OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, "github": { "type": "object", "properties": { @@ -2345,6 +2400,10 @@ "experimental": { "type": "object", "properties": { + "stack": { + "type": "boolean", + "description": "Use the new local stack backend for top-level start and stop commands." + }, "orioledb_version": { "type": "string", "description": "Postgres storage engine version for OrioleDB." @@ -3095,6 +3154,16 @@ "description": "Controls the minimum amount of time that must pass before sending another sms otp.", "default": "5s" }, + "otp_length": { + "type": "number", + "description": "Number of characters used in the SMS OTP.", + "default": 6 + }, + "otp_expiry": { + "type": "number", + "description": "Number of seconds before the SMS OTP expires.", + "default": 60 + }, "twilio": { "type": "object", "properties": { @@ -3113,6 +3182,10 @@ "description": "The message service SID for the Twilio API.", "default": "" }, + "content_sid": { + "type": "string", + "description": "The content SID of a WhatsApp/Messaging Content Template for the Twilio API." + }, "auth_token": { "type": "string", "description": "The auth token for the Twilio API.", @@ -3423,6 +3496,47 @@ }, "additionalProperties": false }, + "figma": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Figma OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Figma OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Figma OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_FIGMA_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Figma OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, "github": { "type": "object", "properties": { @@ -4789,6 +4903,10 @@ "experimental": { "type": "object", "properties": { + "stack": { + "type": "boolean", + "description": "Use the new local stack backend for top-level start and stop commands." + }, "orioledb_version": { "type": "string", "description": "Postgres storage engine version for OrioleDB." diff --git a/apps/docs/public/cli/project-config.schema.json b/apps/docs/public/cli/project-config.schema.json index 73b6747a72..8c4bc32ef9 100644 --- a/apps/docs/public/cli/project-config.schema.json +++ b/apps/docs/public/cli/project-config.schema.json @@ -529,6 +529,16 @@ "description": "Controls the minimum amount of time that must pass before sending another sms otp.", "default": "5s" }, + "otp_length": { + "type": "number", + "description": "Number of characters used in the SMS OTP.", + "default": 6 + }, + "otp_expiry": { + "type": "number", + "description": "Number of seconds before the SMS OTP expires.", + "default": 60 + }, "twilio": { "type": "object", "properties": { @@ -546,6 +556,10 @@ "type": "string", "description": "The message service SID for the Twilio API.", "default": "" + }, + "content_sid": { + "type": "string", + "description": "The content SID of a WhatsApp/Messaging Content Template for the Twilio API." } }, "additionalProperties": true @@ -811,6 +825,42 @@ }, "additionalProperties": true }, + "figma": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Figma OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Figma OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Figma OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + }, "github": { "type": "object", "properties": { @@ -1888,6 +1938,10 @@ "experimental": { "type": "object", "properties": { + "stack": { + "type": "boolean", + "description": "Use the new local stack backend for top-level start and stop commands." + }, "orioledb_version": { "type": "string", "description": "Postgres storage engine version for OrioleDB." diff --git a/package.json b/package.json index 6732d997c9..3fe8b0d582 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,8 @@ "fix:all": "pnpm exec turbo run lint:fix fmt:fix knip:fix && pnpm run lint:effect:fix", "lint:check": "oxlint --config .oxlintrc.json", "lint:fix": "oxlint --config .oxlintrc.json --fix", - "lint:effect:check": "oxlint --config .oxlintrc.effect.json packages/stack apps/cli/src/commands/experimental/stack", - "lint:effect:fix": "oxlint --config .oxlintrc.effect.json --fix --fix-suggestions packages/stack apps/cli/src/commands/experimental/stack", + "lint:effect:check": "oxlint --config .oxlintrc.effect.json packages/stack apps/cli/src/commands/experimental/stack apps/cli/src/command-internal/experimental-feature.ts", + "lint:effect:fix": "oxlint --config .oxlintrc.effect.json --fix --fix-suggestions packages/stack apps/cli/src/commands/experimental/stack apps/cli/src/command-internal/experimental-feature.ts", "fmt:check": "oxfmt --config .oxfmtrc.json --check", "fmt:fix": "oxfmt --config .oxfmtrc.json", "knip:check": "knip-bun", diff --git a/packages/config/src/experimental.ts b/packages/config/src/experimental.ts index 7a04fec9ab..17a9ec3d65 100644 --- a/packages/config/src/experimental.ts +++ b/packages/config/src/experimental.ts @@ -32,6 +32,12 @@ const inspectRule = Schema.Struct({ }).pipe(Schema.withDecodingDefaultKey(Effect.succeed({}))); export const experimental = Schema.Struct({ + stack: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Use the new local stack backend for top-level start and stop commands.", + tags, + }), + ), orioledb_version: Schema.optionalKey( Schema.String.annotate({ description: "Postgres storage engine version for OrioleDB.", diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 46775c45a6..5034eeb148 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -84,6 +84,7 @@ describe("config io", () => { db: { major_version: 16, }, + experimental: { stack: true }, }), ); @@ -91,6 +92,7 @@ describe("config io", () => { expect(loaded.format).toBe("json"); expect(loaded.config.project_id).toBe("abc123"); expect(loaded.config.db.major_version).toBe(16); + expect(loaded.config.experimental.stack).toBe(true); expect(loaded.config.api.enabled).toBe(true); } finally { await rm(cwd, { recursive: true, force: true }); @@ -433,6 +435,9 @@ describe("config io", () => { [db] major_version = 16 + +[experimental] +stack = true `, ); @@ -463,6 +468,9 @@ major_version = 16 [db] major_version = 16 + +[experimental] +stack = true `, ); @@ -501,6 +509,9 @@ major_version = 16 [db] major_version = 16 + +[experimental] +stack = true `, ); @@ -508,6 +519,7 @@ major_version = 16 expect(loaded?.format).toBe("toml"); expect(loaded?.config.project_id).toBe("toml-ref"); expect(loaded?.config.db.major_version).toBe(16); + expect(loaded?.config.experimental.stack).toBe(true); } finally { await rm(cwd, { recursive: true, force: true }); } diff --git a/packages/config/src/project-config/project-config.ts b/packages/config/src/project-config/project-config.ts index 00305bc031..10f4d94069 100644 --- a/packages/config/src/project-config/project-config.ts +++ b/packages/config/src/project-config/project-config.ts @@ -327,6 +327,7 @@ function copyHostedValueForDocument(value: unknown, path: ReadonlyArray) * same as any other section: pruning only fires on a container this * function's OWN exclusion emptied, never one that started empty), matching * `fromApiProjectConfig` already never carrying a populated one either. + * - `experimental.stack` — selects the local CLI stack backend. * - `experimental.orioledb_version`, `experimental.s3_host`, * `experimental.s3_region` — local OrioleDB-with-S3 storage engine config * (`experimental.s3_access_key`/`s3_secret_key` need no entry: both are @@ -387,6 +388,7 @@ export const DOCUMENT_ONLY_LOCAL_PATHS: ReadonlyArray> = [ ["realtime", "enabled"], ["realtime", "ip_version"], ["realtime", "max_header_length"], + ["experimental", "stack"], ["experimental", "orioledb_version"], ["experimental", "s3_host"], ["experimental", "s3_region"], diff --git a/packages/config/src/project-config/project-config.unit.test.ts b/packages/config/src/project-config/project-config.unit.test.ts index d77ecea89b..f5c1bae8d5 100644 --- a/packages/config/src/project-config/project-config.unit.test.ts +++ b/packages/config/src/project-config/project-config.unit.test.ts @@ -538,6 +538,7 @@ describe("fromConfigDocument — CLI-only field exclusion (CLI-2316)", () => { test("excludes local-only experimental fields while experimental.webhooks (genuinely pushed) survives", () => { const document = decodeCliConfig({ experimental: { + stack: true, orioledb_version: "1.0", s3_host: "bucket.s3.example.com", s3_region: "us-east-1", @@ -547,6 +548,7 @@ describe("fromConfigDocument — CLI-only field exclusion (CLI-2316)", () => { }, }); const projected = fromConfigDocument(document); + expect(Object.hasOwn(projected.experimental ?? {}, "stack")).toBe(false); expect(Object.hasOwn(projected.experimental ?? {}, "orioledb_version")).toBe(false); expect(Object.hasOwn(projected.experimental ?? {}, "s3_host")).toBe(false); expect(Object.hasOwn(projected.experimental ?? {}, "s3_region")).toBe(false); @@ -655,6 +657,7 @@ describe("fromConfigDocument — CLI-only field exclusion (CLI-2316)", () => { }, realtime: { enabled: false, ip_version: "IPv6", max_header_length: 1 }, experimental: { + stack: true, orioledb_version: "1.0", s3_host: "host", s3_region: "region", From 76e83b480d6f2f1aedd257b213957591a38e9dcb Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 9 Sep 2026 15:35:54 +0200 Subject: [PATCH 2/3] fix(cli): handle stack flags in Go config and completion --- apps/cli-go/pkg/config/config.go | 1 + apps/cli-go/pkg/config/config_test.go | 88 +++++++++++++++++-- apps/cli-go/pkg/config/db_test.go | 16 ++-- .../local_disabled_remote_enabled.diff | 2 +- .../local_enabled_remote_disabled.diff | 2 +- .../local_disabled_remote_enabled.diff | 4 +- .../local_enabled_remote_disabled.diff | 2 +- .../local_enabled_and_disabled.diff | 10 +-- .../local_disabled_remote_enabled.diff | 2 +- .../local_enabled_remote_disabled.diff | 2 +- .../local_enabled_and_disabled.diff | 2 +- .../local_and_remote_rate_limits_differ.diff | 2 +- .../enable_sign_up_without_provider.diff | 2 +- .../local_disabled_remote_enabled.diff | 4 +- .../local_enabled_remote_disabled.diff | 6 +- apps/cli-go/pkg/config/updater_test.go | 12 ++- apps/cli/src/cli/complete.e2e.test.ts | 74 +++++++++++++++- apps/cli/src/cli/complete.integration.test.ts | 1 + apps/cli/src/cli/complete.ts | 22 ++++- apps/cli/src/cli/complete.unit.test.ts | 34 ++++++- apps/cli/src/cli/main.ts | 16 +++- apps/cli/src/cli/root.ts | 2 +- .../stack/stack-backend.integration.test.ts | 55 +++++++++++- .../experimental/stack/stack-backend.ts | 19 +++- ...tack-command-telemetry.integration.test.ts | 2 +- apps/cli/src/docs/docs-spec.tables.ts | 4 +- 26 files changed, 328 insertions(+), 58 deletions(-) diff --git a/apps/cli-go/pkg/config/config.go b/apps/cli-go/pkg/config/config.go index 04eae99289..684be94796 100644 --- a/apps/cli-go/pkg/config/config.go +++ b/apps/cli-go/pkg/config/config.go @@ -342,6 +342,7 @@ type ( S3Region string `toml:"s3_region" json:"s3_region"` S3AccessKey string `toml:"s3_access_key" json:"s3_access_key"` S3SecretKey string `toml:"s3_secret_key" json:"s3_secret_key"` + Stack bool `toml:"-" json:"stack"` Webhooks *webhooks `toml:"webhooks" json:"webhooks"` PgDelta *PgDeltaConfig `toml:"pgdelta" json:"pgdelta"` Inspect inspect `toml:"inspect" json:"inspect"` diff --git a/apps/cli-go/pkg/config/config_test.go b/apps/cli-go/pkg/config/config_test.go index 4190d71371..552b04f338 100644 --- a/apps/cli-go/pkg/config/config_test.go +++ b/apps/cli-go/pkg/config/config_test.go @@ -309,6 +309,70 @@ instances = 3 assert.NoError(t, config.Load("", fsys)) }) + + for _, tt := range []struct { + name string + configData string + projectID string + want bool + }{ + { + name: "base true", + configData: "[experimental]\nstack = true\n", + want: true, + }, + { + name: "base false", + configData: "[experimental]\nstack = false\n", + want: false, + }, + { + name: "remote true", + configData: "[remotes.prod]\nproject_id = \"abcdefghijklmnopqrst\"\n[remotes.prod.experimental]\nstack = true\n", + projectID: "abcdefghijklmnopqrst", + want: true, + }, + { + name: "remote false", + configData: "[remotes.prod]\nproject_id = \"abcdefghijklmnopqrst\"\n[remotes.prod.experimental]\nstack = false\n", + projectID: "abcdefghijklmnopqrst", + want: false, + }, + } { + t.Run("accepts experimental stack "+tt.name, func(t *testing.T) { + t.Setenv("SUPABASE_EXPERIMENTAL_STACK", "") + config := NewConfig() + config.ProjectId = tt.projectID + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(tt.configData)}, + } + + require.NoError(t, config.Load("", fsys)) + assert.Equal(t, tt.want, config.Experimental.Stack) + }) + } + + t.Run("does not emit experimental stack", func(t *testing.T) { + config := NewConfig() + config.Experimental.Stack = true + + encodedToml, err := ToTomlBytes(config.Experimental) + require.NoError(t, err) + var encoded map[string]any + _, err = toml.Decode(string(encodedToml), &encoded) + require.NoError(t, err) + assert.NotContains(t, encoded, "stack") + + var buf bytes.Buffer + require.NoError(t, config.Eject(&buf)) + var rendered map[string]any + _, err = toml.Decode(buf.String(), &rendered) + require.NoError(t, err) + experimental, ok := rendered["experimental"].(map[string]any) + if assert.True(t, ok) { + assert.NotContains(t, experimental, "stack") + } + }) } func TestRemoteOverride(t *testing.T) { @@ -317,8 +381,12 @@ func TestRemoteOverride(t *testing.T) { config.ProjectId = "bvikqvbczudanvggcord" // Setup in-memory fs fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: testInitConfigEmbed}, - "supabase/templates/invite.html": &fs.MapFile{}, + "supabase/config.toml": &fs.MapFile{Data: testInitConfigEmbed}, + "supabase/templates/invite.html": &fs.MapFile{}, + "supabase/templates/password_changed_notification.html": &fs.MapFile{}, + "certs/my-cert.pem": &fs.MapFile{}, + "certs/my-key.pem": &fs.MapFile{}, + "supabase/signing_keys.json": &fs.MapFile{Data: []byte("[]")}, } // Run test t.Setenv("SUPABASE_AUTH_SITE_URL", "http://preview.com") @@ -335,8 +403,12 @@ func TestRemoteOverride(t *testing.T) { config.ProjectId = "vpefcjyosynxeiebfscx" // Setup in-memory fs fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: testInitConfigEmbed}, - "supabase/templates/invite.html": &fs.MapFile{}, + "supabase/config.toml": &fs.MapFile{Data: testInitConfigEmbed}, + "supabase/templates/invite.html": &fs.MapFile{}, + "supabase/templates/password_changed_notification.html": &fs.MapFile{}, + "certs/my-cert.pem": &fs.MapFile{}, + "certs/my-key.pem": &fs.MapFile{}, + "supabase/signing_keys.json": &fs.MapFile{Data: []byte("[]")}, } // Run test t.Setenv("SUPABASE_AUTH_SITE_URL", "http://preview.com") @@ -353,8 +425,12 @@ func TestRemoteOverride(t *testing.T) { config := NewConfig() // Setup in-memory fs fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: testInitConfigEmbed}, - "supabase/templates/invite.html": &fs.MapFile{}, + "supabase/config.toml": &fs.MapFile{Data: testInitConfigEmbed}, + "supabase/templates/invite.html": &fs.MapFile{}, + "supabase/templates/password_changed_notification.html": &fs.MapFile{}, + "certs/my-cert.pem": &fs.MapFile{}, + "certs/my-key.pem": &fs.MapFile{}, + "supabase/signing_keys.json": &fs.MapFile{Data: []byte("[]")}, } // Run test t.Setenv("TWILIO_AUTH_TOKEN", "token") diff --git a/apps/cli-go/pkg/config/db_test.go b/apps/cli-go/pkg/config/db_test.go index 7c9607f332..2e23ec9563 100644 --- a/apps/cli-go/pkg/config/db_test.go +++ b/apps/cli-go/pkg/config/db_test.go @@ -232,10 +232,10 @@ func TestNetworkRestrictionsDiff(t *testing.T) { remoteConfig.Config.DbAllowedCidrsV6 = &[]string{"fd00::/8"} diff, err := local.DiffWithRemote(remoteConfig) assert.NoError(t, err) - assert.Contains(t, string(diff), "-db_allowed_cidrs = [\"10.0.0.0/8\"]") - assert.Contains(t, string(diff), "+db_allowed_cidrs = [\"192.168.1.0/24\"]") - assert.Contains(t, string(diff), "-db_allowed_cidrs_v6 = [\"2001:db8::/32\"]") - assert.Contains(t, string(diff), "+db_allowed_cidrs_v6 = [\"fd00::/8\"]") + assert.Contains(t, string(diff), "-allowed_cidrs = [\"10.0.0.0/8\"]") + assert.Contains(t, string(diff), "+allowed_cidrs = [\"192.168.1.0/24\"]") + assert.Contains(t, string(diff), "-allowed_cidrs_v6 = [\"fd00::/8\"]") + assert.Contains(t, string(diff), "+allowed_cidrs_v6 = [\"2001:db8::/32\"]") }) t.Run("no differences", func(t *testing.T) { @@ -273,9 +273,9 @@ func TestNetworkRestrictionsDiff(t *testing.T) { remoteConfig.Config.DbAllowedCidrsV6 = &[]string{"::/0"} diff, err := local.DiffWithRemote(remoteConfig) assert.NoError(t, err) - assert.Contains(t, string(diff), "-db_allowed_cidrs = [\"0.0.0.0/0\"]") - assert.Contains(t, string(diff), "+db_allowed_cidrs = []") - assert.Contains(t, string(diff), "-db_allowed_cidrs_v6 = [\"::/0\"]") - assert.Contains(t, string(diff), "+db_allowed_cidrs_v6 = []") + assert.Contains(t, string(diff), "-allowed_cidrs = [\"0.0.0.0/0\"]") + assert.Contains(t, string(diff), "+allowed_cidrs = []") + assert.Contains(t, string(diff), "-allowed_cidrs_v6 = [\"::/0\"]") + assert.Contains(t, string(diff), "+allowed_cidrs_v6 = []") }) } diff --git a/apps/cli-go/pkg/config/testdata/TestCaptchaDiff/local_disabled_remote_enabled.diff b/apps/cli-go/pkg/config/testdata/TestCaptchaDiff/local_disabled_remote_enabled.diff index 87790083c2..2df3d18a24 100644 --- a/apps/cli-go/pkg/config/testdata/TestCaptchaDiff/local_disabled_remote_enabled.diff +++ b/apps/cli-go/pkg/config/testdata/TestCaptchaDiff/local_disabled_remote_enabled.diff @@ -1,7 +1,7 @@ diff remote[auth] local[auth] --- remote[auth] +++ local[auth] -@@ -23,7 +23,7 @@ +@@ -28,7 +28,7 @@ web3 = 0 [captcha] diff --git a/apps/cli-go/pkg/config/testdata/TestCaptchaDiff/local_enabled_remote_disabled.diff b/apps/cli-go/pkg/config/testdata/TestCaptchaDiff/local_enabled_remote_disabled.diff index 2865d6d711..ea98e88092 100644 --- a/apps/cli-go/pkg/config/testdata/TestCaptchaDiff/local_enabled_remote_disabled.diff +++ b/apps/cli-go/pkg/config/testdata/TestCaptchaDiff/local_enabled_remote_disabled.diff @@ -1,7 +1,7 @@ diff remote[auth] local[auth] --- remote[auth] +++ local[auth] -@@ -23,9 +23,9 @@ +@@ -28,9 +28,9 @@ web3 = 0 [captcha] diff --git a/apps/cli-go/pkg/config/testdata/TestEmailDiff/local_disabled_remote_enabled.diff b/apps/cli-go/pkg/config/testdata/TestEmailDiff/local_disabled_remote_enabled.diff index 4307592475..d341bab3f4 100644 --- a/apps/cli-go/pkg/config/testdata/TestEmailDiff/local_disabled_remote_enabled.diff +++ b/apps/cli-go/pkg/config/testdata/TestEmailDiff/local_disabled_remote_enabled.diff @@ -1,7 +1,7 @@ diff remote[auth] local[auth] --- remote[auth] +++ local[auth] -@@ -47,13 +47,13 @@ +@@ -49,13 +49,13 @@ inactivity_timeout = "0s" [email] @@ -22,7 +22,7 @@ diff remote[auth] local[auth] [email.template] [email.template.confirmation] content_path = "" -@@ -69,25 +69,25 @@ +@@ -71,25 +71,25 @@ content_path = "" [email.notification] [email.notification.email_changed] diff --git a/apps/cli-go/pkg/config/testdata/TestEmailDiff/local_enabled_remote_disabled.diff b/apps/cli-go/pkg/config/testdata/TestEmailDiff/local_enabled_remote_disabled.diff index 388c75ed3b..c3a49ddf02 100644 --- a/apps/cli-go/pkg/config/testdata/TestEmailDiff/local_enabled_remote_disabled.diff +++ b/apps/cli-go/pkg/config/testdata/TestEmailDiff/local_enabled_remote_disabled.diff @@ -1,7 +1,7 @@ diff remote[auth] local[auth] --- remote[auth] +++ local[auth] -@@ -47,62 +47,74 @@ +@@ -49,62 +49,74 @@ inactivity_timeout = "0s" [email] diff --git a/apps/cli-go/pkg/config/testdata/TestExternalDiff/local_enabled_and_disabled.diff b/apps/cli-go/pkg/config/testdata/TestExternalDiff/local_enabled_and_disabled.diff index 5c885eeffb..54ca03e506 100644 --- a/apps/cli-go/pkg/config/testdata/TestExternalDiff/local_enabled_and_disabled.diff +++ b/apps/cli-go/pkg/config/testdata/TestExternalDiff/local_enabled_and_disabled.diff @@ -1,7 +1,7 @@ diff remote[auth] local[auth] --- remote[auth] +++ local[auth] -@@ -84,7 +84,7 @@ +@@ -89,7 +89,7 @@ [external] [external.apple] @@ -10,9 +10,9 @@ diff remote[auth] local[auth] client_id = "test-client-1,test-client-2" secret = "hash:ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252" url = "" -@@ -91,9 +91,9 @@ - redirect_uri = "" +@@ -97,9 +97,9 @@ skip_nonce_check = false + email_optional = false [external.azure] -enabled = false -client_id = "" @@ -23,9 +23,9 @@ diff remote[auth] local[auth] url = "" redirect_uri = "" skip_nonce_check = false -@@ -140,7 +140,7 @@ - redirect_uri = "" +@@ -153,7 +153,7 @@ skip_nonce_check = false + email_optional = false [external.google] -enabled = true +enabled = false diff --git a/apps/cli-go/pkg/config/testdata/TestHookDiff/local_disabled_remote_enabled.diff b/apps/cli-go/pkg/config/testdata/TestHookDiff/local_disabled_remote_enabled.diff index 43bab9e109..d0b4dcc30e 100644 --- a/apps/cli-go/pkg/config/testdata/TestHookDiff/local_disabled_remote_enabled.diff +++ b/apps/cli-go/pkg/config/testdata/TestHookDiff/local_disabled_remote_enabled.diff @@ -1,7 +1,7 @@ diff remote[auth] local[auth] --- remote[auth] +++ local[auth] -@@ -24,23 +24,23 @@ +@@ -29,23 +29,23 @@ [hook] [hook.mfa_verification_attempt] diff --git a/apps/cli-go/pkg/config/testdata/TestHookDiff/local_enabled_remote_disabled.diff b/apps/cli-go/pkg/config/testdata/TestHookDiff/local_enabled_remote_disabled.diff index ed61a4cdb8..3c3364d17b 100644 --- a/apps/cli-go/pkg/config/testdata/TestHookDiff/local_enabled_remote_disabled.diff +++ b/apps/cli-go/pkg/config/testdata/TestHookDiff/local_enabled_remote_disabled.diff @@ -1,7 +1,7 @@ diff remote[auth] local[auth] --- remote[auth] +++ local[auth] -@@ -24,25 +24,25 @@ +@@ -29,25 +29,25 @@ [hook] [hook.mfa_verification_attempt] diff --git a/apps/cli-go/pkg/config/testdata/TestMfaDiff/local_enabled_and_disabled.diff b/apps/cli-go/pkg/config/testdata/TestMfaDiff/local_enabled_and_disabled.diff index ae67613cbe..ec1f6ebe90 100644 --- a/apps/cli-go/pkg/config/testdata/TestMfaDiff/local_enabled_and_disabled.diff +++ b/apps/cli-go/pkg/config/testdata/TestMfaDiff/local_enabled_and_disabled.diff @@ -1,7 +1,7 @@ diff remote[auth] local[auth] --- remote[auth] +++ local[auth] -@@ -25,16 +25,16 @@ +@@ -30,16 +30,16 @@ [hook] [mfa] diff --git a/apps/cli-go/pkg/config/testdata/TestRateLimitsDiff/local_and_remote_rate_limits_differ.diff b/apps/cli-go/pkg/config/testdata/TestRateLimitsDiff/local_and_remote_rate_limits_differ.diff index 26f0fe484e..9663c1438b 100644 --- a/apps/cli-go/pkg/config/testdata/TestRateLimitsDiff/local_and_remote_rate_limits_differ.diff +++ b/apps/cli-go/pkg/config/testdata/TestRateLimitsDiff/local_and_remote_rate_limits_differ.diff @@ -1,7 +1,7 @@ diff remote[auth] local[auth] --- remote[auth] +++ local[auth] -@@ -14,12 +14,12 @@ +@@ -19,12 +19,12 @@ service_role_key = "" [rate_limit] diff --git a/apps/cli-go/pkg/config/testdata/TestSmsDiff/enable_sign_up_without_provider.diff b/apps/cli-go/pkg/config/testdata/TestSmsDiff/enable_sign_up_without_provider.diff index 66250c8c79..2e44496fd3 100644 --- a/apps/cli-go/pkg/config/testdata/TestSmsDiff/enable_sign_up_without_provider.diff +++ b/apps/cli-go/pkg/config/testdata/TestSmsDiff/enable_sign_up_without_provider.diff @@ -1,7 +1,7 @@ diff remote[auth] local[auth] --- remote[auth] +++ local[auth] -@@ -53,7 +53,7 @@ +@@ -58,7 +58,7 @@ otp_expiry = 0 [sms] diff --git a/apps/cli-go/pkg/config/testdata/TestSmsDiff/local_disabled_remote_enabled.diff b/apps/cli-go/pkg/config/testdata/TestSmsDiff/local_disabled_remote_enabled.diff index a59f66f56a..385f49fb83 100644 --- a/apps/cli-go/pkg/config/testdata/TestSmsDiff/local_disabled_remote_enabled.diff +++ b/apps/cli-go/pkg/config/testdata/TestSmsDiff/local_disabled_remote_enabled.diff @@ -1,7 +1,7 @@ diff remote[auth] local[auth] --- remote[auth] +++ local[auth] -@@ -53,12 +53,12 @@ +@@ -58,12 +58,12 @@ otp_expiry = 0 [sms] @@ -19,7 +19,7 @@ diff remote[auth] local[auth] account_sid = "" message_service_sid = "" auth_token = "" -@@ -81,8 +81,6 @@ +@@ -86,8 +86,6 @@ api_key = "" api_secret = "" [sms.test_otp] diff --git a/apps/cli-go/pkg/config/testdata/TestSmsDiff/local_enabled_remote_disabled.diff b/apps/cli-go/pkg/config/testdata/TestSmsDiff/local_enabled_remote_disabled.diff index 0c52717190..3db44eabab 100644 --- a/apps/cli-go/pkg/config/testdata/TestSmsDiff/local_enabled_remote_disabled.diff +++ b/apps/cli-go/pkg/config/testdata/TestSmsDiff/local_enabled_remote_disabled.diff @@ -1,7 +1,7 @@ diff remote[auth] local[auth] --- remote[auth] +++ local[auth] -@@ -53,12 +53,12 @@ +@@ -58,12 +58,12 @@ otp_expiry = 0 [sms] @@ -19,7 +19,7 @@ diff remote[auth] local[auth] account_sid = "" message_service_sid = "" auth_token = "" -@@ -68,9 +68,9 @@ +@@ -73,9 +73,9 @@ message_service_sid = "" auth_token = "" [sms.messagebird] @@ -32,7 +32,7 @@ diff remote[auth] local[auth] [sms.textlocal] enabled = false sender = "" -@@ -81,6 +81,7 @@ +@@ -86,6 +86,7 @@ api_key = "" api_secret = "" [sms.test_otp] diff --git a/apps/cli-go/pkg/config/updater_test.go b/apps/cli-go/pkg/config/updater_test.go index 5ddba85f89..0f00aba3b8 100644 --- a/apps/cli-go/pkg/config/updater_test.go +++ b/apps/cli-go/pkg/config/updater_test.go @@ -205,7 +205,8 @@ func TestUpdateAuthConfig(t *testing.T) { Get("/v1/projects/test-project/config/auth"). Reply(http.StatusOK). JSON(v1API.AuthConfigResponseOutput{ - SiteUrl: nullable.NewNullableWithValue("http://localhost:3000"), + SiteUrl: nullable.NewNullableWithValue("http://localhost:3000"), + SmtpAdminEmail: nullable.NewNullableWithValue(openapi_types.Email("abc@example.com")), }) gock.New(server). Patch("/v1/projects/test-project/config/auth"). @@ -224,7 +225,9 @@ func TestUpdateAuthConfig(t *testing.T) { gock.New(server). Get("/v1/projects/test-project/config/auth"). Reply(http.StatusOK). - JSON(v1API.AuthConfigResponseOutput{}) + JSON(v1API.AuthConfigResponseOutput{ + SmtpAdminEmail: nullable.NewNullableWithValue(openapi_types.Email("abc@example.com")), + }) // Run test err := updater.UpdateAuthConfig(context.Background(), "test-project", auth{ Enabled: true, @@ -331,11 +334,6 @@ func TestUpdateRemoteConfig(t *testing.T) { JSON(v1API.PostgresConfigResponseOutput{ MaxConnections: cast.Ptr(cast.UintToInt(100)), }) - // Network config - gock.New(server). - Get("/v1/projects/test-project/network-restrictions"). - Reply(http.StatusOK). - JSON(v1API.V1GetNetworkRestrictionsResponse{}) // Auth config gock.New(server). Get("/v1/projects/test-project/config/auth"). diff --git a/apps/cli/src/cli/complete.e2e.test.ts b/apps/cli/src/cli/complete.e2e.test.ts index 8c2b28ee62..1c3d264563 100644 --- a/apps/cli/src/cli/complete.e2e.test.ts +++ b/apps/cli/src/cli/complete.e2e.test.ts @@ -1,5 +1,7 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; import { describe, expect, test } from "vitest"; -import { runSupabase } from "../../tests/helpers/cli.ts"; +import { makeTempCliProject, makeTempHome, runSupabase } from "../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; @@ -33,4 +35,74 @@ describe("supabase __complete", () => { expect(exitCode).toBe(0); expect(stdout).toContain("--debug\toutput debug logs to stderr"); }); + + test( + "routes complete command paths before reading config and reports routing errors on stderr", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const project = await makeTempCliProject("supabase-completion-routing-e2e-"); + const home = makeTempHome(); + try { + await mkdir(path.join(project.dir, "supabase"), { recursive: true }); + await writeFile(path.join(project.dir, "supabase", "config.toml"), "[experimental\n"); + + const prefix = await runSupabase(["__complete", "start"], { + cwd: project.dir, + home: home.dir, + env: { + SUPABASE_EXPERIMENTAL_STACK: undefined, + SUPABASE_WORKDIR: undefined, + }, + }); + expect(prefix.exitCode).toBe(0); + expect(prefix.stdout).toContain("start"); + expect(prefix.stderr).toBe(""); + + const failure = await runSupabase(["__complete", "--output-format=json", "start", "--"], { + cwd: project.dir, + home: home.dir, + env: { + SUPABASE_EXPERIMENTAL_STACK: undefined, + SUPABASE_WORKDIR: undefined, + }, + }); + expect(failure.exitCode).toBe(1); + expect(failure.stdout).toBe(""); + expect(failure.stderr).toContain("Unable to parse"); + + const malformedConfig = await runSupabase(["start", "--output-format=json"], { + cwd: project.dir, + home: home.dir, + env: { + SUPABASE_EXPERIMENTAL_STACK: undefined, + SUPABASE_WORKDIR: undefined, + }, + }); + expect(malformedConfig.exitCode).toBe(1); + expect(malformedConfig.stderr).toBe(""); + expect(JSON.parse(malformedConfig.stdout)).toMatchObject({ + _tag: "Error", + error: { code: "StackRoutingError" }, + }); + + const invalidEnv = await runSupabase(["start", "--output-format=json"], { + cwd: project.dir, + home: home.dir, + env: { + SUPABASE_EXPERIMENTAL_STACK: "invalid", + SUPABASE_WORKDIR: undefined, + }, + }); + expect(invalidEnv.exitCode).toBe(1); + expect(invalidEnv.stderr).toBe(""); + expect(JSON.parse(invalidEnv.stdout)).toMatchObject({ + _tag: "Error", + error: { code: "StackRoutingError" }, + }); + } finally { + await project.cleanup(); + home[Symbol.dispose](); + } + }, + ); }); diff --git a/apps/cli/src/cli/complete.integration.test.ts b/apps/cli/src/cli/complete.integration.test.ts index 5e45e38c82..502f4b3fc4 100644 --- a/apps/cli/src/cli/complete.integration.test.ts +++ b/apps/cli/src/cli/complete.integration.test.ts @@ -56,6 +56,7 @@ function makeDeps(argv: ReadonlyArray, captureTelemetry: CompleteDeps["c stdoutWrite: (message) => { stdoutWrites.push(message); }, + stderrWrite: () => {}, exit: (code) => { exits.push(code); }, diff --git a/apps/cli/src/cli/complete.ts b/apps/cli/src/cli/complete.ts index f2c2cd8873..c6d806a94e 100644 --- a/apps/cli/src/cli/complete.ts +++ b/apps/cli/src/cli/complete.ts @@ -1,5 +1,5 @@ import { BunServices } from "@effect/platform-bun"; -import { Effect, Layer, Option } from "effect"; +import { Cause, Effect, Layer, Option } from "effect"; import { GlobalFlag } from "effect/unstable/cli"; import type { Command, Param, Primitive } from "effect/unstable/cli"; import process from "node:process"; @@ -20,6 +20,7 @@ import { } from "../shared/telemetry/event-catalog.ts"; import { standaloneAnalyticsConfigLayer } from "../shared/telemetry/standalone-analytics-config.layer.ts"; import { analyticsLayer } from "../telemetry/analytics.layer.ts"; +import { formatCliError, normalizeCliError } from "../shared/output/normalize-error.ts"; /** * Native TypeScript reimplementation of cobra's dynamic-completion protocol @@ -108,9 +109,12 @@ export interface ClassifyCompletionInput { export interface CompleteDeps { readonly root: Command.Command.Any | undefined; + /** The routing failure that prevented selecting a command tree, if any. */ + readonly routingFailure?: Cause.Cause; readonly argv: ReadonlyArray; readonly env: Readonly>; readonly stdoutWrite: (message: string) => void; + readonly stderrWrite: (message: string) => void; readonly exit: (code: number) => void; /** * Fires the `cli_command_executed` telemetry capture for this request — @@ -1734,6 +1738,13 @@ export async function tryComplete(deps: CompleteDeps): Promise { const startedAt = Date.now(); const response = respondToComplete(deps.root, deps.argv); if (response === undefined) { + if (deps.routingFailure !== undefined) { + const error = Cause.findErrorOption(deps.routingFailure); + const message = Option.isSome(error) + ? formatCliError(normalizeCliError(error.value)) + : Cause.pretty(deps.routingFailure); + deps.stderrWrite(`${message}\n`); + } await deps.captureTelemetry(1, Date.now() - startedAt); deps.exit(1); return true; @@ -1746,14 +1757,21 @@ export async function tryComplete(deps: CompleteDeps): Promise { return true; } -export function defaultCompleteDeps(root?: Command.Command.Any): CompleteDeps { +export function defaultCompleteDeps( + root?: Command.Command.Any, + routingFailure?: Cause.Cause, +): CompleteDeps { return { root, + routingFailure, argv: process.argv.slice(2), env: process.env, stdoutWrite: (message) => { process.stdout.write(message); }, + stderrWrite: (message) => { + process.stderr.write(message); + }, exit: (code) => { process.exit(code); }, diff --git a/apps/cli/src/cli/complete.unit.test.ts b/apps/cli/src/cli/complete.unit.test.ts index be6df55821..f2871f5a4e 100644 --- a/apps/cli/src/cli/complete.unit.test.ts +++ b/apps/cli/src/cli/complete.unit.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest"; +import { Cause } from "effect"; import { rootCommand } from "./root.ts"; +import { StackRoutingError } from "../commands/experimental/stack/stack-backend.ts"; import { CompletionDirective, type ClassifyCompletionInput, @@ -1474,6 +1476,7 @@ describe("formatCompletionResponse", () => { describe("tryComplete", () => { function makeDeps(overrides: Partial = {}) { const stdoutWrites: Array = []; + const stderrWrites: Array = []; const exits: Array = []; const deps: CompleteDeps = { root: rootCommand, @@ -1482,6 +1485,9 @@ describe("tryComplete", () => { stdoutWrite: (message) => { stdoutWrites.push(message); }, + stderrWrite: (message) => { + stderrWrites.push(message); + }, exit: (code) => { exits.push(code); }, @@ -1493,7 +1499,7 @@ describe("tryComplete", () => { captureTelemetry: async () => {}, ...overrides, }; - return { deps, stdoutWrites, exits }; + return { deps, stdoutWrites, stderrWrites, exits }; } // `tryComplete` returns `Promise` — it awaits @@ -1526,6 +1532,32 @@ describe("tryComplete", () => { expect(stdoutWrites).toEqual([]); expect(exits).toEqual([1]); }); + + it("writes routing failures to stderr without emitting completion stdout", async () => { + const { deps, stdoutWrites, stderrWrites, exits } = makeDeps({ + root: undefined, + routingFailure: Cause.fail(new StackRoutingError({ message: "Unable to parse config.toml" })), + }); + expect(await tryComplete(deps)).toBe(true); + expect(stdoutWrites).toEqual([]); + expect(stderrWrites).toHaveLength(1); + expect(stderrWrites[0]).toContain("Unable to parse config.toml"); + expect(stderrWrites[0]).toContain( + "Suggestion: Set SUPABASE_EXPERIMENTAL_STACK=0 to use legacy start/stop, or use `supabase stack`.", + ); + expect(exits).toEqual([1]); + }); + + it("preserves a defect diagnostic when routing fails with a defect cause", async () => { + const { deps, stdoutWrites, stderrWrites, exits } = makeDeps({ + root: undefined, + routingFailure: Cause.die(new Error("completion routing defect")), + }); + expect(await tryComplete(deps)).toBe(true); + expect(stdoutWrites).toEqual([]); + expect(stderrWrites[0]).toContain("completion routing defect"); + expect(exits).toEqual([1]); + }); }); describe("defaultCompleteDeps", () => { diff --git a/apps/cli/src/cli/main.ts b/apps/cli/src/cli/main.ts index 224f5df833..f432585ebf 100644 --- a/apps/cli/src/cli/main.ts +++ b/apps/cli/src/cli/main.ts @@ -20,12 +20,20 @@ const backendExit = await Effect.runPromiseExit( Effect.provide(BunServices.layer), ), ); -const selectedRoot = Exit.isSuccess(backendExit) - ? rootCommandForBackend(backendExit.value) - : rootCommand; +const selectedRoot = + Exit.isSuccess(backendExit) && backendExit.value === "stack" + ? rootCommandForBackend("stack") + : rootCommand; const completionRoot = Exit.isSuccess(backendExit) ? selectedRoot : undefined; -if (!(await tryComplete(defaultCompleteDeps(completionRoot)))) { +if ( + !(await tryComplete( + defaultCompleteDeps( + completionRoot, + Exit.isFailure(backendExit) ? backendExit.cause : undefined, + ), + )) +) { await runCli(selectedRoot, { analyticsLayer: analyticsLayer, afterSuccess: upgradeNoticeHook, diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index 9bb7dd7a64..4c5d2903cd 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -90,7 +90,6 @@ export const rootCommandForBackend = (backend: StackBackend = "legacy"): CliRoot domainsCommand, encryptionCommand, experimentalCommand, - stackCommand, functionsCommand, genCommand, initCommand, @@ -111,6 +110,7 @@ export const rootCommandForBackend = (backend: StackBackend = "legacy"): CliRoot snippetsCommand, sslEnforcementCommand, ssoCommand, + stackCommand, backend === "stack" ? stackStartAliasCommand : startCommand, statusCommand, backend === "stack" ? stackStopAliasCommand : stopCommand, diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts index 29562c27a8..c2ff59fef3 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts @@ -41,6 +41,22 @@ describe("resolveStackBackend", () => { }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); }); + it.effect("routes command-specific start completion through the selected backend", () => { + const root = project("[experimental]\nstack = true\n"); + return Effect.gen(function* () { + const backend = yield* resolve({ + args: ["__complete", "start", "--"], + cwd: root, + env: {}, + }); + expect(backend).toBe("stack"); + expect(completionFlags(backend, "start")).toEqual( + expect.arrayContaining(["--stack", "--runtime"]), + ); + expect(completionFlags(backend, "start")).not.toContain("--ignore-health-check"); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); + }); + it.effect("uses the environment override before reading config", () => { const root = project("[experimental]\nstack = true\n"); return Effect.gen(function* () { @@ -134,7 +150,44 @@ describe("resolveStackBackend", () => { if (Exit.isFailure(exit)) { const error = Cause.findErrorOption(exit.cause); expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) expect(error.value).toBeInstanceOf(StackRoutingError); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(StackRoutingError); + } + } + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); + }); + + it.effect("ignores the completion cursor until a command path is complete", () => { + const root = project("[experimental\nstack = true\n"); + return Effect.gen(function* () { + expect( + yield* resolve({ + args: ["__complete", "sta"], + cwd: root, + env: { SUPABASE_EXPERIMENTAL_STACK: "invalid" }, + }), + ).toBe("legacy"); + expect( + yield* resolve({ + args: ["__completeNoDesc", "start"], + cwd: root, + env: { SUPABASE_EXPERIMENTAL_STACK: "invalid" }, + }), + ).toBe("legacy"); + + const invalid = yield* resolve({ + args: ["__complete", "start", "--"], + cwd: root, + env: {}, + }).pipe(Effect.exit); + expect(Exit.isFailure(invalid)).toBe(true); + if (Exit.isFailure(invalid)) { + const error = Cause.findErrorOption(invalid.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(StackRoutingError); + expect(String(error.value)).toContain("Unable to parse"); + } } }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); }); diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.ts b/apps/cli/src/commands/experimental/stack/stack-backend.ts index a06977b25b..c03da3ae7b 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.ts +++ b/apps/cli/src/commands/experimental/stack/stack-backend.ts @@ -18,6 +18,10 @@ export class StackRoutingError extends Data.TaggedError("StackRoutingError")<{ readonly message: string; readonly cause?: unknown; }> { + get suggestion(): string { + return "Set SUPABASE_EXPERIMENTAL_STACK=0 to use legacy start/stop, or use `supabase stack`."; + } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.invalidConfig; } @@ -71,7 +75,7 @@ const parseConfig = (path: string, content: string): Effect.Effect SmolToml.parse(content), catch: (cause) => new StackRoutingError({ - message: `Unable to read ${path}: ${String(cause)}`, + message: `Unable to parse ${path}: ${String(cause)}`, cause, }), }); @@ -97,9 +101,16 @@ export const resolveStackBackend = (input: { readonly env: Readonly>; }): Effect.Effect => Effect.gen(function* () { - if (hasRootVersionFlag(input.args)) return "legacy"; + // Completion passes the final token as the cursor word, even when it is a + // command-shaped token such as `start`. It must not select a backend or + // trigger config I/O until the user has supplied a complete command path. + const routingArgs = + input.args[0] === "__complete" || input.args[0] === "__completeNoDesc" + ? input.args.slice(0, -1) + : input.args; + if (hasRootVersionFlag(routingArgs)) return "legacy"; - const commandPath = extractRoutingCommandPath(input.args); + const commandPath = extractRoutingCommandPath(routingArgs); const completePath = commandPath[0] === "__complete" || commandPath[0] === "__completeNoDesc" ? commandPath.slice(1) @@ -114,7 +125,7 @@ export const resolveStackBackend = (input: { const configValue = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const explicitWorkdir = firstExplicitLongFlagValue(input.args, "workdir"); + const explicitWorkdir = firstExplicitLongFlagValue(routingArgs, "workdir"); const resolvedWorkdir = yield* resolveWorkdir( explicitWorkdir === undefined ? Option.none() : Option.some(explicitWorkdir), input.env["SUPABASE_WORKDIR"], diff --git a/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts index a2e6a04998..0cf1acf955 100644 --- a/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts @@ -23,7 +23,7 @@ import { PropCommandRunId, } from "../../../shared/telemetry/event-catalog.ts"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; -import { stackCommand } from "../../../commands/experimental/stack/stack.command.ts"; +import { stackCommand } from "./stack.command.ts"; import { stackStopAliasCommand } from "../../../cli/root.ts"; function setup() { diff --git a/apps/cli/src/docs/docs-spec.tables.ts b/apps/cli/src/docs/docs-spec.tables.ts index b86a83ab86..f69f09028f 100644 --- a/apps/cli/src/docs/docs-spec.tables.ts +++ b/apps/cli/src/docs/docs-spec.tables.ts @@ -140,8 +140,6 @@ export const DOCS_DEFAULT_OVERRIDES: Readonly> = { "supabase output": "pretty", "supabase output-format": "text", "supabase profile": "supabase", - "supabase-stack-start runtime": "auto", - "supabase-stack-start preparation": "background", "supabase-db-advisors fail-on": "none", "supabase-db-advisors level": "warn", "supabase-db-advisors local": "true", @@ -191,6 +189,8 @@ export const DOCS_DEFAULT_OVERRIDES: Readonly> = { "supabase-migration-squash local": "true", "supabase-migration-up local": "true", "supabase-seed-buckets local": "true", + "supabase-stack-start preparation": "background", + "supabase-stack-start runtime": "auto", "supabase-storage-cp cache-control": "max-age=3600", "supabase-storage-cp content-type": "auto-detect", "supabase-storage-cp jobs": "1", From 529b25422b2a9d3602b1167cd0dcb1bbf968e514 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 9 Sep 2026 17:52:25 +0200 Subject: [PATCH 3/3] fix(cli): correct stack routing and legacy fallback --- apps/cli/docs/stack-commands.md | 16 +++--- apps/cli/src/cli/complete.e2e.test.ts | 36 ++++++++---- apps/cli/src/cli/complete.unit.test.ts | 10 +++- .../stack/stack-backend.integration.test.ts | 57 +++++++++++-------- .../experimental/stack/stack-backend.ts | 40 ++----------- .../experimental/stack/stack.command.ts | 6 +- apps/cli/src/commands/start/SIDE_EFFECTS.md | 3 + apps/cli/src/commands/stop/SIDE_EFFECTS.md | 15 +++-- apps/cli/src/shared/cli/run.ts | 32 +++++++---- apps/cli/src/shared/cli/run.unit.test.ts | 9 +++ 10 files changed, 126 insertions(+), 98 deletions(-) diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 4c22dc2309..971b9f047c 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -1,15 +1,15 @@ # Local stack commands -`supabase stack` manages local stacks with the new runtime. It is available regardless of the -project's backend setting and supports both Docker and native runtimes. +`supabase stack` manages local stacks with the new experimental runtime. It is unstable, its +command interface may change, and it is excluded from the CLI compatibility promise. It is +available regardless of the project's backend setting and supports both Docker and native runtimes. | Command | Purpose | | ---------------------- | -------------------------------------- | | `supabase stack start` | Create or resume the project's stack. | | `supabase stack stop` | Stop a stack while retaining its data. | -The previous `supabase experimental stack` command path has been removed. Use each command's -`--help` for its available targeting and runtime options. +Use each command's `--help` for its available targeting and runtime options. ## Selecting the top-level commands @@ -29,9 +29,11 @@ command implementation and is unaffected by this flag. Root help and root completion do not read project configuration, so they remain available without a project directory. Help and completion for `start` and `stop` resolve the same backend as the -command itself. An invalid configuration produces a routing error instead of silently selecting a -backend; set `SUPABASE_EXPERIMENTAL_STACK=0` to select the legacy top-level command explicitly, or -use the explicit `supabase stack start` or `supabase stack stop` command. +command itself. If the project configuration cannot be read or parsed, or if +`experimental.stack` has an invalid value, routing falls back to the legacy backend. An invalid +`SUPABASE_EXPERIMENTAL_STACK` value is still an error; set it to `0` to select the legacy +top-level command explicitly, or use the explicit `supabase stack start` or `supabase stack stop` +command. For temporary selection, set `SUPABASE_EXPERIMENTAL_STACK=1` to select the new backend or `SUPABASE_EXPERIMENTAL_STACK=0` to select the legacy backend. This environment variable takes diff --git a/apps/cli/src/cli/complete.e2e.test.ts b/apps/cli/src/cli/complete.e2e.test.ts index 1c3d264563..3943c8dbe7 100644 --- a/apps/cli/src/cli/complete.e2e.test.ts +++ b/apps/cli/src/cli/complete.e2e.test.ts @@ -37,7 +37,7 @@ describe("supabase __complete", () => { }); test( - "routes complete command paths before reading config and reports routing errors on stderr", + "routes complete command paths and keeps malformed config on the legacy tree", { timeout: E2E_TIMEOUT_MS }, async () => { const project = await makeTempCliProject("supabase-completion-routing-e2e-"); @@ -58,7 +58,7 @@ describe("supabase __complete", () => { expect(prefix.stdout).toContain("start"); expect(prefix.stderr).toBe(""); - const failure = await runSupabase(["__complete", "--output-format=json", "start", "--"], { + const fallback = await runSupabase(["__complete", "--output-format=json", "start", "--"], { cwd: project.dir, home: home.dir, env: { @@ -66,11 +66,11 @@ describe("supabase __complete", () => { SUPABASE_WORKDIR: undefined, }, }); - expect(failure.exitCode).toBe(1); - expect(failure.stdout).toBe(""); - expect(failure.stderr).toContain("Unable to parse"); + expect(fallback.exitCode).toBe(0); + expect(fallback.stdout).toContain("--ignore-health-check"); + expect(fallback.stderr).toBe(""); - const malformedConfig = await runSupabase(["start", "--output-format=json"], { + const help = await runSupabase(["start", "--help"], { cwd: project.dir, home: home.dir, env: { @@ -78,12 +78,24 @@ describe("supabase __complete", () => { SUPABASE_WORKDIR: undefined, }, }); - expect(malformedConfig.exitCode).toBe(1); - expect(malformedConfig.stderr).toBe(""); - expect(JSON.parse(malformedConfig.stdout)).toMatchObject({ - _tag: "Error", - error: { code: "StackRoutingError" }, - }); + expect(help.exitCode).toBe(0); + expect(help.stdout).toContain("--ignore-health-check"); + expect(help.stderr).toBe(""); + + const completionFailure = await runSupabase( + ["__complete", "--output-format=json", "start", "--"], + { + cwd: project.dir, + home: home.dir, + env: { + SUPABASE_EXPERIMENTAL_STACK: "invalid", + SUPABASE_WORKDIR: undefined, + }, + }, + ); + expect(completionFailure.exitCode).toBe(1); + expect(completionFailure.stdout).toBe(""); + expect(completionFailure.stderr).toContain("must be 0 or 1"); const invalidEnv = await runSupabase(["start", "--output-format=json"], { cwd: project.dir, diff --git a/apps/cli/src/cli/complete.unit.test.ts b/apps/cli/src/cli/complete.unit.test.ts index f2871f5a4e..11a4b7efc4 100644 --- a/apps/cli/src/cli/complete.unit.test.ts +++ b/apps/cli/src/cli/complete.unit.test.ts @@ -1533,15 +1533,19 @@ describe("tryComplete", () => { expect(exits).toEqual([1]); }); - it("writes routing failures to stderr without emitting completion stdout", async () => { + it("writes invalid environment routing failures to stderr without emitting completion stdout", async () => { const { deps, stdoutWrites, stderrWrites, exits } = makeDeps({ root: undefined, - routingFailure: Cause.fail(new StackRoutingError({ message: "Unable to parse config.toml" })), + routingFailure: Cause.fail( + new StackRoutingError({ + message: "SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set", + }), + ), }); expect(await tryComplete(deps)).toBe(true); expect(stdoutWrites).toEqual([]); expect(stderrWrites).toHaveLength(1); - expect(stderrWrites[0]).toContain("Unable to parse config.toml"); + expect(stderrWrites[0]).toContain("SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set"); expect(stderrWrites[0]).toContain( "Suggestion: Set SUPABASE_EXPERIMENTAL_STACK=0 to use legacy start/stop, or use `supabase stack`.", ); diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts index c2ff59fef3..881032da48 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts @@ -33,7 +33,18 @@ describe("resolveStackBackend", () => { ); it.effect("selects the configured backend for top-level start and stop", () => { - const root = project("[experimental]\nstack = true\n"); + const root = project(`project_id = "stack-routing-test" +[api] +port = 55421 +[db] +port = 55422 +[auth] +enabled = true +[experimental.webhooks] +enabled = true +[experimental] +stack = true +`); return Effect.gen(function* () { expect(yield* resolve({ args: ["start"], cwd: join(root, "nested"), env: {} })).toBe("stack"); expect(yield* resolve({ args: ["stop"], cwd: root, env: {} })).toBe("stack"); @@ -132,6 +143,12 @@ describe("resolveStackBackend", () => { expect(yield* resolve({ args: ["--debug", "false", "start"], cwd: stackRoot, env: {} })).toBe( "stack", ); + expect(yield* resolve({ args: ["-yo", "json", "start"], cwd: stackRoot, env: {} })).toBe( + "stack", + ); + expect(yield* resolve({ args: ["-ho", "json", "start"], cwd: stackRoot, env: {} })).toBe( + "stack", + ); }).pipe( Effect.ensuring( Effect.sync(() => { @@ -142,19 +159,21 @@ describe("resolveStackBackend", () => { ); }); - it.effect("reports malformed routing config as a typed error", () => { + it.effect("falls back to legacy routing when the config cannot be read or decoded", () => { const root = project('[experimental]\nstack = "yes"\n'); + const unreadableRoot = mkdtempSync(join(tmpdir(), "supabase-stack-routing-unreadable-")); + mkdirSync(join(unreadableRoot, "supabase", "config.toml"), { recursive: true }); return Effect.gen(function* () { - const exit = yield* resolve({ args: ["start"], cwd: root, env: {} }).pipe(Effect.exit); - 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).toBeInstanceOf(StackRoutingError); - } - } - }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); + expect(yield* resolve({ args: ["start"], cwd: root, env: {} })).toBe("legacy"); + expect(yield* resolve({ args: ["start"], cwd: unreadableRoot, env: {} })).toBe("legacy"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + rmSync(root, { recursive: true, force: true }); + rmSync(unreadableRoot, { recursive: true, force: true }); + }), + ), + ); }); it.effect("ignores the completion cursor until a command path is complete", () => { @@ -175,20 +194,12 @@ describe("resolveStackBackend", () => { }), ).toBe("legacy"); - const invalid = yield* resolve({ + const backend = yield* resolve({ args: ["__complete", "start", "--"], cwd: root, env: {}, - }).pipe(Effect.exit); - expect(Exit.isFailure(invalid)).toBe(true); - if (Exit.isFailure(invalid)) { - const error = Cause.findErrorOption(invalid.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) { - expect(error.value).toBeInstanceOf(StackRoutingError); - expect(String(error.value)).toContain("Unable to parse"); - } - } + }); + expect(backend).toBe("legacy"); }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); }); diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.ts b/apps/cli/src/commands/experimental/stack/stack-backend.ts index c03da3ae7b..82a7dd2beb 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.ts +++ b/apps/cli/src/commands/experimental/stack/stack-backend.ts @@ -3,9 +3,7 @@ import { Data, Effect, FileSystem, Option, Path, Schema } from "effect"; import * as SmolToml from "smol-toml"; import { resolveWorkdir } from "../../../config/command-settings.layer.ts"; import { resolveExperimentalFeature } from "../../../command-internal/experimental-feature.ts"; -import { BOOLEAN_FLAG_VALUES, ROOT_BOOLEAN_FLAGS } from "../../../shared/cli/agent-output.ts"; -import { GLOBAL_VALUE_FLAG_TOKENS } from "../../../shared/cli/cobra-flag-groups.ts"; -import { hasRootVersionFlag, rootFlagTokens } from "../../../shared/cli/run.ts"; +import { extractCommandPath, hasRootVersionFlag, rootFlagTokens } from "../../../shared/cli/run.ts"; import { actionability, type CliErrorActionabilityDeclaration, @@ -44,32 +42,6 @@ const firstExplicitLongFlagValue = ( return undefined; }; -/** Extracts command path tokens while honoring optional separated boolean values. */ -const extractRoutingCommandPath = (args: ReadonlyArray): ReadonlyArray => { - const commandPath: Array = []; - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (arg === undefined || arg === "--") break; - if (!arg.startsWith("-")) { - commandPath.push(arg); - continue; - } - const [flag] = arg.split("=", 1); - if (!arg.includes("=") && flag !== undefined && GLOBAL_VALUE_FLAG_TOKENS.has(flag)) { - index += 1; - continue; - } - if ( - !arg.includes("=") && - flag !== undefined && - ROOT_BOOLEAN_FLAGS.includes(flag) && - BOOLEAN_FLAG_VALUES.has(args[index + 1] ?? "") - ) - index += 1; - } - return commandPath; -}; - const parseConfig = (path: string, content: string): Effect.Effect => Effect.try({ try: () => SmolToml.parse(content), @@ -110,7 +82,7 @@ export const resolveStackBackend = (input: { : input.args; if (hasRootVersionFlag(routingArgs)) return "legacy"; - const commandPath = extractRoutingCommandPath(routingArgs); + const commandPath = extractCommandPath(routingArgs); const completePath = commandPath[0] === "__complete" || commandPath[0] === "__completeNoDesc" ? commandPath.slice(1) @@ -148,17 +120,13 @@ export const resolveStackBackend = (input: { return yield* parseConfig(configPath, content).pipe( Effect.flatMap((document) => stackSettingFrom(configPath, document)), ); - }); + }).pipe(Effect.catchTag("StackRoutingError", () => Effect.succeed(false))); const enabled = yield* resolveExperimentalFeature({ feature: "stack", configValue, env: input.env, }).pipe( - Effect.mapError((error) => - error instanceof StackRoutingError - ? error - : new StackRoutingError({ message: error.message, cause: error }), - ), + Effect.mapError((error) => new StackRoutingError({ message: error.message, cause: error })), ); return enabled ? "stack" : "legacy"; }); diff --git a/apps/cli/src/commands/experimental/stack/stack.command.ts b/apps/cli/src/commands/experimental/stack/stack.command.ts index 6a072488fa..488b6dc6c4 100644 --- a/apps/cli/src/commands/experimental/stack/stack.command.ts +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -23,8 +23,10 @@ const stackStopCommand = experimentalStackStopCommand.pipe( ); export const stackCommand = Command.make("stack").pipe( - Command.withDescription("Manage a local Supabase stack with the new backend."), - Command.withShortDescription("Manage local stacks"), + Command.withDescription( + "Manage an experimental, unstable local Supabase stack with the new backend. This command is excluded from the CLI compatibility promise.", + ), + Command.withShortDescription("Manage experimental local stacks"), Command.withSubcommands([stackStartCommand, stackStopCommand]), Command.provide(experimentalStackRuntimeLayer), ); diff --git a/apps/cli/src/commands/start/SIDE_EFFECTS.md b/apps/cli/src/commands/start/SIDE_EFFECTS.md index 31ee4ef66a..8f81f15623 100644 --- a/apps/cli/src/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/start/SIDE_EFFECTS.md @@ -4,6 +4,9 @@ This document describes the legacy backend. With `SUPABASE_EXPERIMENTAL_STACK=1` `[experimental] stack = true` when the environment override is unset or empty, `supabase start` uses the new [`supabase stack start` implementation](../experimental/stack/start/SIDE_EFFECTS.md). `SUPABASE_EXPERIMENTAL_STACK=0` forces the legacy backend. See [backend selection](../../../docs/stack-commands.md). +Backend selection happens before command parsing. When the environment override is unset or empty, +an unreadable, malformed, or invalid project configuration falls back to the legacy backend; an +invalid environment override remains an error. This command talks directly to Docker via subprocess (`docker`/`podman`) to bring up the local dev stack sequentially, one container at a time — it does not use Docker diff --git a/apps/cli/src/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/stop/SIDE_EFFECTS.md index a5d149fa36..0a40f26be4 100644 --- a/apps/cli/src/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/stop/SIDE_EFFECTS.md @@ -4,6 +4,9 @@ This document describes the legacy backend. With `SUPABASE_EXPERIMENTAL_STACK=1` `[experimental] stack = true` when the environment override is unset or empty, `supabase stop` uses the new [`supabase stack stop` implementation](../experimental/stack/stop/SIDE_EFFECTS.md). `SUPABASE_EXPERIMENTAL_STACK=0` forces the legacy backend. See [backend selection](../../../docs/stack-commands.md). +Backend selection happens before command parsing. When the environment override is unset or empty, +an unreadable, malformed, or invalid project configuration falls back to the legacy backend; an +invalid environment override remains an error. Talks directly to Docker via subprocess (`docker`/`podman`), replicating the old Go CLI's label-filtering and container-naming @@ -14,7 +17,7 @@ model (see the CLI-1324 plan's "Critical architectural finding" for why). | Path | Format | When | | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | default path only — skipped entirely when `--project-id` or `--all` is set | +| `/supabase/config.toml` | TOML | read by backend routing before dispatch when `SUPABASE_EXPERIMENTAL_STACK` is unset or empty; the legacy handler reads it on the default path only, and skips it when `--project-id` or `--all` is set | | `auth.email.template.*` / `auth.email.notification.*` `content_path` (config-relative or absolute) | text (existence/readability only — bytes discarded, used only to validate the config) | default path only, only when `auth.enabled`, for every configured template and every notification with `enabled = true`, as part of `resolveLocalConfigValues`'s own `Config.Validate` pass; the resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) — a path resolving outside it aborts before the read | ## Files Written @@ -134,9 +137,11 @@ Same payload as `json`, delivered as a `result` NDJSON event. ## Notes -- `--project-id` and `--all` are **directory-independent** pure Docker-label filters — - neither reads `config.toml`, so neither is subject to the `content_path` containment - check below; only the no-flags default path resolves the project id +- `--project-id` and `--all` are **directory-independent** pure Docker-label filters in the + legacy handler — the handler does not read `config.toml` for either, so neither is subject to + the `content_path` containment check below. Backend routing may still read the file first when + `SUPABASE_EXPERIMENTAL_STACK` is unset or empty; if that read fails, routing falls back to this + legacy handler. Only the no-flags default path resolves the project id from `CommandSettings.workdir` (env → config.toml `project_id` → workdir basename). - **The default path VALIDATES config, including the `content_path` containment check above, BEFORE any Docker teardown call.** `resolveSearchProjectIdFilter` @@ -145,7 +150,7 @@ Same payload as `json`, delivered as a `result` NDJSON event. invoked — so a config-validation failure here (a malformed config, or an `auth.email.*.content_path` that resolves outside the project root) fails the command and the running stack is **not** torn down. `--all`/`--project-id` bypass config - loading entirely (see the bullet above) and so are unaffected by this failure mode. + loading entirely (see the bullet above) and so are unaffected by this handler failure mode. - The hidden `--backup` flag exists only for CLI surface parity with the old Go CLI — it has **no effect**. The old Go CLI declared it but never wired its value into anything, so it always deleted volumes based on `!noBackup` regardless of `--backup`. The TS port diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 7f3af5bd62..c2844cf37f 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -54,7 +54,11 @@ import type { TelemetryRuntime } from "../telemetry/runtime.service.ts"; import { tracingLayer } from "../telemetry/tracing.layer.ts"; import { CliArgs } from "./cli-args.service.ts"; import { GLOBAL_VALUE_FLAG_TOKENS } from "./cobra-flag-groups.ts"; -import { resolveAgentOutputFormatFromArgs } from "./agent-output.ts"; +import { + BOOLEAN_FLAG_VALUES, + resolveAgentOutputFormatFromArgs, + ROOT_BOOLEAN_FLAGS, +} from "./agent-output.ts"; import { SuccessTrailer, successTrailerLayer } from "./success-trailer.ts"; import type { CliErrorSuggestionContext } from "./subcommand-flag-suggestions.ts"; import { @@ -102,15 +106,13 @@ export type CliRootCommand = Command.Command<"supabase", {}, {}, unknown, Allowe // `GLOBAL_VALUE_FLAG_TOKENS`) so the registries cannot drift apart again // (issue #6482). // -// DELIBERATE MODEL SPLIT: the scanners below keep pflag-style semantics for -// BOOLEAN globals — a bare `--debug` never consumes a following token here — -// while the shipped parser also consumes a space-separated boolean literal -// (`--debug false`), which `agent-output.ts`'s format walk mirrors. The -// residual divergence only steers the upgrade-notice base-dir/force-fetch -// choice and the signal-wrapper selection for spellings like -// `--debug false --version`, predates the issue #6482 fixes, and is -// deliberately left with the walk-consolidation follow-up rather than -// widened into this scanner family piecemeal. +// DELIBERATE MODEL SPLIT: `extractCommandPath` consumes a recognized +// space-separated boolean literal (`--debug false`) because it selects the +// command tree and signal-wrapper behavior. `rootFlagTokens` and +// `firstPositionalIndex` retain pflag-style semantics for their version and +// flag-walk checks: a bare boolean never consumes a following token there. +// The residual divergence predates the issue #6482 fixes and is deliberately +// left with the walk-consolidation follow-up rather than widened further. const globalFlagsWithValues: ReadonlySet = GLOBAL_VALUE_FLAG_TOKENS; // Commands that run their own foreground signal loop (serve/start daemons) and must @@ -156,10 +158,20 @@ export function extractCommandPath(args: ReadonlyArray): ReadonlyArray = []; for (let index = 0; index < args.length; index += 1) { const arg = args[index]!; + if (arg === "--") return commandArgs; if (arg.startsWith("-")) { const [flag] = arg.split("=", 1); if (!arg.includes("=") && flag !== undefined && globalFlagsWithValues.has(flag)) { index += 1; + } else if (shortClusterConsumesNextToken(arg, isGlobalValueFlagToken)) { + index += 1; + } else if ( + !arg.includes("=") && + flag !== undefined && + ROOT_BOOLEAN_FLAGS.includes(flag) && + BOOLEAN_FLAG_VALUES.has(args[index + 1] ?? "") + ) { + index += 1; } continue; } diff --git a/apps/cli/src/shared/cli/run.unit.test.ts b/apps/cli/src/shared/cli/run.unit.test.ts index e4195cecee..4f2755db53 100644 --- a/apps/cli/src/shared/cli/run.unit.test.ts +++ b/apps/cli/src/shared/cli/run.unit.test.ts @@ -56,6 +56,15 @@ describe("extractCommandPath", () => { "serve", ]); }); + + it("skips short-cluster and separated boolean values", () => { + expect(extractCommandPath(["-yo", "json", "--debug", "false", "start"])).toEqual(["start"]); + expect(extractCommandPath(["-ho", "json", "start"])).toEqual(["start"]); + }); + + it("stops at the positional argument boundary", () => { + expect(extractCommandPath(["--", "start"])).toEqual([]); + }); }); describe("local shorthand clusters", () => {