From cbe21474773eb6083dfadba744a76308cc600ce5 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 09:53:02 +0200 Subject: [PATCH 1/3] feat(cli): select stack lifecycle commands by project config --- apps/cli/docs/stack-commands.md | 44 ++++ apps/cli/src/cli/main.ts | 28 ++- apps/cli/src/cli/root.ts | 229 ++++++++++-------- .../experimental/experimental.command.ts | 3 +- .../experimental/stack/list/SIDE_EFFECTS.md | 2 +- .../experimental/stack/logs/SIDE_EFFECTS.md | 2 +- .../experimental/stack/logs/logs.command.ts | 4 +- .../experimental/stack/logs/logs.handler.ts | 4 +- .../stack/prepare/SIDE_EFFECTS.md | 2 +- .../stack/prepare/prepare.command.ts | 4 +- .../stack/restart/SIDE_EFFECTS.md | 2 +- .../stack/restart/restart.handler.ts | 4 +- .../stack/restart/restart.integration.test.ts | 2 +- .../stack/stack-backend.integration.test.ts | 132 ++++++++++ .../experimental/stack/stack-backend.ts | 112 +++++++++ .../stack-command-routing.integration.test.ts | 56 +++++ .../experimental/stack/stack.command.ts | 15 +- .../experimental/stack/start/SIDE_EFFECTS.md | 2 +- .../experimental/stack/start/start.command.ts | 4 +- .../stack/start/start.e2e.test.ts | 40 ++- .../experimental/stack/status/SIDE_EFFECTS.md | 2 +- .../stack/status/status.handler.ts | 4 +- .../stack/status/status.integration.test.ts | 2 +- .../experimental/stack/stop/SIDE_EFFECTS.md | 2 +- .../experimental/stack/stop/stop.command.ts | 2 +- apps/cli/src/commands/start/SIDE_EFFECTS.md | 2 + apps/cli/src/commands/status/SIDE_EFFECTS.md | 2 + apps/cli/src/commands/stop/SIDE_EFFECTS.md | 2 + apps/cli/src/docs/legacy-docs-spec.tables.ts | 5 + apps/cli/src/shared/cli/run.ts | 16 +- packages/config/src/experimental.ts | 7 + packages/config/src/io.unit.test.ts | 12 + .../src/project-config/project-config.ts | 2 + .../project-config.unit.test.ts | 3 + 34 files changed, 606 insertions(+), 148 deletions(-) create mode 100644 apps/cli/docs/stack-commands.md 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-routing.integration.test.ts diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md new file mode 100644 index 0000000000..0b8f23f961 --- /dev/null +++ b/apps/cli/docs/stack-commands.md @@ -0,0 +1,44 @@ +# 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. | +| `supabase stack status` | Inspect stack state and endpoints. | +| `supabase stack list` | List registered stacks. | +| `supabase stack logs` | Read or follow service logs. | +| `supabase stack prepare` | Prepare artifacts before starting services. | +| `supabase stack restart` | Restart an existing stack. | + +Use each command’s `--help` for its available targeting and runtime options. The previous `supabase experimental stack` command path has been removed. + +## Selecting the top-level commands + +The top-level `supabase start`, `supabase stop`, and `supabase status` 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 equivalent in `supabase/config.json` is: + +```json +{ + "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 continue to use the new backend. + +This flag currently selects only the `start`, `stop`, and `status` aliases. It does not switch the database, migration, functions, or storage command families to the new backend. + +## 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 and is excluded from hosted project configuration. Project selection follows the CLI’s working-directory rules, including `--workdir` and `SUPABASE_WORKDIR`. diff --git a/apps/cli/src/cli/main.ts b/apps/cli/src/cli/main.ts index 3ec93180c2..458d3188ce 100644 --- a/apps/cli/src/cli/main.ts +++ b/apps/cli/src/cli/main.ts @@ -1,13 +1,37 @@ #!/usr/bin/env bun +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, Stdio } from "effect"; import { runCli } from "../shared/cli/run.ts"; import { legacyUpgradeNoticeHook } from "../command-internal/legacy-upgrade-notice.ts"; import { legacyAnalyticsLayer } from "../telemetry/legacy-analytics.layer.ts"; import { legacyDefaultCompleteDeps, legacyTryComplete } from "./legacy-complete.ts"; -import { legacyRoot } from "./root.ts"; +import { legacyResolveExperimentalStackBackend } from "../commands/experimental/stack/stack-backend.ts"; +import { legacyRoot, legacyRootForBackend } from "./root.ts"; -if (!(await legacyTryComplete(legacyDefaultCompleteDeps(legacyRoot)))) { +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( + legacyResolveExperimentalStackBackend({ args, cwd: process.cwd(), env: process.env }).pipe( + Effect.provide(BunServices.layer), + ), +); +if (Exit.isFailure(backendExit)) { await runCli(legacyRoot, { analyticsLayer: legacyAnalyticsLayer, afterSuccess: legacyUpgradeNoticeHook, + beforeParse: Effect.failCause(backendExit.cause), }); +} else { + const root = legacyRootForBackend(backendExit.value); + if (!(await legacyTryComplete(legacyDefaultCompleteDeps(root)))) { + await runCli(root, { + analyticsLayer: legacyAnalyticsLayer, + afterSuccess: legacyUpgradeNoticeHook, + }); + } } diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index c1e1adfa6a..4b03d1536c 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -9,6 +9,15 @@ import { legacyDbCommand } from "../commands/db/db.command.ts"; import { legacyDomainsCommand } from "../commands/domains/domains.command.ts"; import { legacyEncryptionCommand } from "../commands/encryption/encryption.command.ts"; import { legacyExperimentalCommand } from "../commands/experimental/experimental.command.ts"; +import { + legacyExperimentalStackCommand, + legacyExperimentalStackRuntimeLayer, +} from "../commands/experimental/stack/stack.command.ts"; +import { legacyExperimentalStackStartCommand } from "../commands/experimental/stack/start/start.command.ts"; +import { legacyExperimentalStackStopCommand } from "../commands/experimental/stack/stop/stop.command.ts"; +import { legacyExperimentalStackStatusCommand } from "../commands/experimental/stack/status/status.command.ts"; +import type { LegacyExperimentalStackBackend } from "../commands/experimental/stack/stack-backend.ts"; + import { legacyFunctionsCommand } from "../commands/functions/functions.command.ts"; import { legacyGenCommand } from "../commands/gen/gen.command.ts"; import { legacyInitCommand } from "../commands/init/init.command.ts"; @@ -59,111 +68,125 @@ import { LegacyYesFlag, } from "../shared/legacy/global-flags.ts"; -export const legacyRoot = Command.make("supabase").pipe( - Command.withDescription("Supabase CLI (stable channel)."), - Command.withSubcommands([ - legacyBackupsCommand, - legacyBootstrapCommand, - legacyBranchesCommand, - legacyCompletionCommand, - legacyConfigCommand, - legacyDbCommand, - legacyDomainsCommand, - legacyEncryptionCommand, - legacyExperimentalCommand, - legacyFunctionsCommand, - legacyGenCommand, - legacyInitCommand, - legacyInspectCommand, - legacyIssueCommand, - legacyLinkCommand, - legacyLoginCommand, - legacyLogoutCommand, - legacyMigrationCommand, - legacyNetworkBansCommand, - legacyNetworkRestrictionsCommand, - legacyOrgsCommand, - legacyPostgresConfigCommand, - legacyProjectsCommand, - legacySecretsCommand, - legacySeedCommand, - legacyServicesCommand, - legacySnippetsCommand, - legacySslEnforcementCommand, - legacySsoCommand, - legacyStartCommand, - legacyStatusCommand, - legacyStopCommand, - legacyStorageCommand, - legacyTelemetryCommand, - legacyTestCommand, - legacyUnlinkCommand, - legacyVanitySubdomainsCommand, - ]), - Command.provide( - Layer.unwrap( - Effect.gen(function* () { - const explicitOutputFormat = yield* OutputFormatFlag; - const goOutput = yield* LegacyOutputFlag; - const profile = yield* LegacyProfileFlag; - const debug = yield* LegacyDebugFlag; - const workdir = yield* LegacyWorkdirFlag; - const experimental = yield* LegacyExperimentalFlag; - const networkId = yield* LegacyNetworkIdFlag; - const yes = yield* LegacyYesFlag; - const dnsResolver = yield* LegacyDnsResolverFlag; - const createTicket = yield* LegacyCreateTicketFlag; - const agent = yield* LegacyAgentFlag; - const cliArgs = yield* CliArgs; +const stackStart = legacyExperimentalStackStartCommand.pipe( + Command.provide(legacyExperimentalStackRuntimeLayer), +); +const stackStop = legacyExperimentalStackStopCommand.pipe( + Command.provide(legacyExperimentalStackRuntimeLayer), +); +const stackStatus = legacyExperimentalStackStatusCommand.pipe( + Command.provide(legacyExperimentalStackRuntimeLayer), +); + +export const legacyRootForBackend = (backend: LegacyExperimentalStackBackend = "legacy") => + Command.make("supabase").pipe( + Command.withDescription("Supabase CLI (stable channel)."), + Command.withSubcommands([ + legacyBackupsCommand, + legacyBootstrapCommand, + legacyBranchesCommand, + legacyCompletionCommand, + legacyConfigCommand, + legacyDbCommand, + legacyDomainsCommand, + legacyEncryptionCommand, + legacyExperimentalCommand, + legacyExperimentalStackCommand, + legacyFunctionsCommand, + legacyGenCommand, + legacyInitCommand, + legacyInspectCommand, + legacyIssueCommand, + legacyLinkCommand, + legacyLoginCommand, + legacyLogoutCommand, + legacyMigrationCommand, + legacyNetworkBansCommand, + legacyNetworkRestrictionsCommand, + legacyOrgsCommand, + legacyPostgresConfigCommand, + legacyProjectsCommand, + legacySecretsCommand, + legacySeedCommand, + legacyServicesCommand, + legacySnippetsCommand, + legacySslEnforcementCommand, + legacySsoCommand, + backend === "stack" ? stackStart : legacyStartCommand, + backend === "stack" ? stackStatus : legacyStatusCommand, + backend === "stack" ? stackStop : legacyStopCommand, + legacyStorageCommand, + legacyTelemetryCommand, + legacyTestCommand, + legacyUnlinkCommand, + legacyVanitySubdomainsCommand, + ]), + Command.provide( + Layer.unwrap( + Effect.gen(function* () { + const explicitOutputFormat = yield* OutputFormatFlag; + const goOutput = yield* LegacyOutputFlag; + const profile = yield* LegacyProfileFlag; + const debug = yield* LegacyDebugFlag; + const workdir = yield* LegacyWorkdirFlag; + const experimental = yield* LegacyExperimentalFlag; + const networkId = yield* LegacyNetworkIdFlag; + const yes = yield* LegacyYesFlag; + const dnsResolver = yield* LegacyDnsResolverFlag; + const createTicket = yield* LegacyCreateTicketFlag; + const agent = yield* LegacyAgentFlag; + 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, - legacyOutputFormat: 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, + legacyOutputFormat: 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 - ? legacyQuietProgressTextOutputLayer - : 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 + ? legacyQuietProgressTextOutputLayer + : outputLayerFor(outputFormat); - return Layer.mergeAll( - outputLayer, - makeGoProxyLayer({ globalArgs, parentOwnsCapturedSuccessTail: true }), - ); - }), + return Layer.mergeAll( + outputLayer, + makeGoProxyLayer({ globalArgs, parentOwnsCapturedSuccessTail: true }), + ); + }), + ), ), - ), - Command.withGlobalFlags([OutputFormatFlag, ...LEGACY_GLOBAL_FLAGS]), -); + Command.withGlobalFlags([OutputFormatFlag, ...LEGACY_GLOBAL_FLAGS]), + ); + +export const legacyRoot = legacyRootForBackend(); diff --git a/apps/cli/src/commands/experimental/experimental.command.ts b/apps/cli/src/commands/experimental/experimental.command.ts index 14c18a42b8..7a1762121d 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 { legacyWorkersCommand } from "./workers/workers.command.ts"; -import { legacyExperimentalStackCommand } from "./stack/stack.command.ts"; /** * `supabase experimental` — the parent for command families that are not yet @@ -18,6 +17,6 @@ export const legacyExperimentalCommand = 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([legacyWorkersCommand, legacyExperimentalStackCommand]), + Command.withSubcommands([legacyWorkersCommand]), Command.unlisted, ); diff --git a/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md index ad3ecf6e88..7377531d05 100644 --- a/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md @@ -1,4 +1,4 @@ -# `supabase experimental stack list` +# `supabase stack list` Lists persisted managed local stacks discovered in the global stack registry. The command includes stopped stacks and performs no config loading, owner RPC, diff --git a/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md index 10d8a3ad51..31e2025769 100644 --- a/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md @@ -1,4 +1,4 @@ -# `supabase experimental stack logs` +# `supabase stack logs` This command reads retained logs from the managed stack identified by the current project, an optional `--stack` name, or `--stack-id`. It calls the public `@supabase/stack` logs diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts index 2104b99aad..ea1ebe16c4 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts @@ -43,11 +43,11 @@ export const legacyExperimentalStackLogsCommand = Command.make("logs", config).p Command.withShortDescription("Read managed local stack logs"), Command.withExamples([ { - command: "supabase experimental stack logs --service database --tail 50", + command: "supabase stack logs --service database --tail 50", description: "Print the latest database logs", }, { - command: "supabase experimental stack logs --follow --output-format stream-json", + command: "supabase stack logs --follow --output-format stream-json", description: "Stream new stack logs as structured events", }, ]), diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts index a8e4036873..4b238269a9 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts @@ -32,12 +32,12 @@ const logsError = (error: unknown): LegacyExperimentalStackLogsError => { Match.tag("InvalidLogCursorError", () => ({ reason: "impossible-state" as const })), Match.tag("StackNotRunningError", () => ({ reason: "lifecycle" as const, - suggestion: "Run supabase experimental stack start before reading logs.", + suggestion: "Run supabase stack start before reading logs.", })), Match.tag("StackOwnershipConflictError", () => ({ reason: "lifecycle" as const, suggestion: - "Run supabase experimental stack status to inspect ownership; retry if the stack is shutting down.", + "Run supabase stack status to inspect ownership; retry if the stack is shutting down.", })), Match.tag("StackLifecycleConflictError", () => ({ reason: "lifecycle" as const, diff --git a/apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md index d9e2274142..264f34cf4f 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md @@ -1,4 +1,4 @@ -# `experimental stack prepare` +# `supabase stack prepare` ## Reads diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts index a93150a12d..75d6bd79a6 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts @@ -28,11 +28,11 @@ export const legacyExperimentalStackPrepareCommand = Command.make("prepare", con Command.withShortDescription("Prepare a managed local stack"), Command.withExamples([ { - command: "supabase experimental stack prepare", + command: "supabase stack prepare", description: "Prepare all enabled stack capabilities", }, { - command: "supabase experimental stack prepare --stack feature-a --capability rest", + command: "supabase stack prepare --stack feature-a --capability rest", description: "Prepare one capability in a named stack", }, ]), diff --git a/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md index 24858a1580..bb9ed310cc 100644 --- a/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md @@ -1,4 +1,4 @@ -# `supabase experimental stack restart` +# `supabase stack restart` ## Files Read diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts b/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts index 7f25d9d705..a974654867 100644 --- a/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts +++ b/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts @@ -56,7 +56,7 @@ const mapStackError = (error: StackError) => { "StackCleanupError", () => ({ reason: "lifecycle" as const, - suggestion: "Run supabase experimental stack status to inspect the stack state.", + suggestion: "Run supabase stack status to inspect the stack state.", }), ), Match.tag("ContainerEngineError", () => ({ @@ -125,7 +125,7 @@ export const legacyExperimentalStackRestart = Effect.fn("legacy.experimental.sta : `No managed stack named "${stackName}" was found for this project.`, suggestion: stackName === undefined - ? "Run supabase experimental stack start first." + ? "Run supabase stack start first." : "Choose an existing --stack name or omit --stack for the current project.", }); return { id: found.value.id, projectRoot: found.value.projectRoot }; diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts b/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts index a166019c7d..55018e13fc 100644 --- a/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts @@ -249,7 +249,7 @@ describe("experimental stack restart", () => { const failure = yield* start.effect.pipe(Effect.flip); expect(failure.reason).toBe("lifecycle"); expect(failure[ErrorActionabilityId]).toEqual(actionability.invalidConfig); - expect(failure.suggestion).toContain("experimental stack status"); + expect(failure.suggestion).toContain("stack status"); expect(stop.calls).toEqual(["open", "prepare", "stop"]); expect(start.calls).toEqual(["open", "prepare", "stop", "start"]); expect(start.calls).not.toContain("destroy"); 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..419dea44e4 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts @@ -0,0 +1,132 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- temporary project fixture +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } 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 { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Option } from "effect"; +import { + LegacyExperimentalStackRoutingError, + legacyResolveExperimentalStackBackend, +} from "./stack-backend.ts"; + +const resolve = (input: Parameters[0]) => + legacyResolveExperimentalStackBackend(input).pipe(Effect.provide(BunServices.layer)); + +const project = (config: string, format: "toml" | "json" = "toml") => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-routing-")); + mkdirSync(join(root, "supabase"), { recursive: true }); + writeFileSync(join(root, "supabase", `config.${format}`), config); + return root; +}; + +describe("legacyResolveExperimentalStackBackend", () => { + it.effect("selects the stack backend for the canonical namespace without config", () => + Effect.gen(function* () { + expect(yield* resolve({ args: ["stack", "--help"], cwd: "/missing", env: {} })).toBe("stack"); + }), + ); + + it.effect("selects the configured backend for top-level lifecycle aliases", () => { + 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: ["status"], cwd: root, env: {} })).toBe("stack"); + expect(yield* resolve({ args: ["start", "--workdir", "nested"], cwd: root, env: {} })).toBe( + "legacy", + ); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); + }); + + it.effect("reads the same routing key from JSON and completion argv", () => { + const root = project('{"experimental":{"stack":true}}', "json"); + return Effect.gen(function* () { + expect(yield* resolve({ args: ["__complete", "start"], cwd: root, env: {} })).toBe("stack"); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); + }); + + it.effect("keeps the legacy backend when the setting is false", () => { + const root = project("[experimental]\nstack = false\n"); + return resolve({ args: ["stop"], cwd: root, env: {} }).pipe( + Effect.tap((backend) => Effect.sync(() => expect(backend).toBe("legacy"))), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("keeps lifecycle commands legacy without an opt-in", () => { + const root = project('project_id = "routing"\n'); + return Effect.gen(function* () { + expect(yield* resolve({ args: ["start"], cwd: root, env: {} })).toBe("legacy"); + expect(yield* resolve({ args: ["stop"], cwd: join(root, "nested"), env: {} })).toBe("legacy"); + expect( + yield* resolve({ args: ["status", "--workdir", join(root, "nested")], cwd: root, env: {} }), + ).toBe("legacy"); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); + }); + + it.effect("bypasses config for unrelated commands and the canonical stack command", () => { + const root = project("[experimental\nstack = true\n"); + return Effect.gen(function* () { + expect(yield* resolve({ args: ["login"], cwd: root, env: {} })).toBe("legacy"); + expect(yield* resolve({ args: ["--version", "start"], cwd: root, env: {} })).toBe("legacy"); + expect(yield* resolve({ args: ["stack", "start"], cwd: root, env: {} })).toBe("stack"); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); + }); + + it.effect("honors explicit workdir over env and lets an empty flag use env", () => { + const legacyRoot = project("[experimental]\nstack = false\n"); + const stackRoot = project("[experimental]\nstack = true\n"); + return Effect.gen(function* () { + expect( + yield* resolve({ + args: ["start", "--workdir", legacyRoot], + cwd: stackRoot, + env: { SUPABASE_WORKDIR: stackRoot }, + }), + ).toBe("legacy"); + expect( + yield* resolve({ + args: ["--workdir=", "start"], + cwd: legacyRoot, + env: { SUPABASE_WORKDIR: stackRoot }, + }), + ).toBe("stack"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + rmSync(legacyRoot, { recursive: true, force: true }); + rmSync(stackRoot, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("reports an invalid routing value as a typed configuration 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(LegacyExperimentalStackRoutingError); + } + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); + }); + + it.effect("reports malformed config syntax as a typed configuration error", () => { + const root = project("[experimental\nstack = true\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(LegacyExperimentalStackRoutingError); + } + }).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 new file mode 100644 index 0000000000..94fc7e6e7b --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-backend.ts @@ -0,0 +1,112 @@ +import { CliConfigSchema, findCliProjectPaths } from "@supabase/config/effect"; +import { Data, Effect, FileSystem, Path, Schema } from "effect"; +import * as SmolToml from "smol-toml"; +import { extractCommandPath, hasRootVersionFlag } from "../../../shared/cli/run.ts"; +import { lastExplicitLongFlagValue } from "../../../shared/cli/cobra-flag-groups.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + +export type LegacyExperimentalStackBackend = "legacy" | "stack"; + +export class LegacyExperimentalStackRoutingError extends Data.TaggedError( + "LegacyExperimentalStackRoutingError", +)<{ 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 parseConfig = ( + path: string, + content: string, +): Effect.Effect => + path.endsWith(".json") + ? Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(content).pipe( + Effect.mapError( + (cause) => + new LegacyExperimentalStackRoutingError({ + message: `Unable to read ${path}: ${String(cause)}`, + cause, + }), + ), + ) + : Effect.try({ + try: () => SmolToml.parse(content), + catch: (cause) => + new LegacyExperimentalStackRoutingError({ + message: `Unable to read ${path}: ${String(cause)}`, + cause, + }), + }); + +const stackSettingFrom = ( + path: string, + document: unknown, +): Effect.Effect => { + return Schema.decodeUnknownEffect(stackRoutingSchema)(document).pipe( + Effect.map(({ experimental }) => (experimental?.stack === true ? "stack" : "legacy")), + Effect.mapError( + (cause) => + new LegacyExperimentalStackRoutingError({ + message: `Invalid experimental.stack in ${path}: expected a boolean value`, + cause, + }), + ), + ); +}; + +export const legacyResolveExperimentalStackBackend = (input: { + readonly args: ReadonlyArray; + readonly cwd: string; + readonly env: Readonly>; +}): Effect.Effect< + LegacyExperimentalStackBackend, + LegacyExperimentalStackRoutingError, + FileSystem.FileSystem | Path.Path +> => + Effect.gen(function* () { + if (hasRootVersionFlag(input.args)) return "legacy"; + const commandPath = extractCommandPath(input.args); + const completePath = commandPath[0] === "__complete" ? commandPath.slice(1) : commandPath; + const command = completePath[0]; + if (command === "stack") return "stack"; + if (command !== "start" && command !== "stop" && command !== "status") { + return "legacy"; + } + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const explicitWorkdir = lastExplicitLongFlagValue(input.args, [], "workdir"); + const configuredWorkdir = + explicitWorkdir === undefined || explicitWorkdir.length === 0 + ? input.env["SUPABASE_WORKDIR"] + : explicitWorkdir; + const start = + configuredWorkdir === undefined || configuredWorkdir.length === 0 + ? input.cwd + : path.resolve(input.cwd, configuredWorkdir); + const project = yield* findCliProjectPaths(start, { + search: configuredWorkdir === undefined || configuredWorkdir.length === 0, + }); + if (project === null) return "legacy"; + const content = yield* fs.readFileString(project.configPath).pipe( + Effect.mapError( + (cause) => + new LegacyExperimentalStackRoutingError({ + message: `Unable to read ${project.configPath}: ${String(cause)}`, + cause, + }), + ), + ); + return yield* parseConfig(project.configPath, content).pipe( + Effect.flatMap((document) => stackSettingFrom(project.configPath, document)), + ); + }); diff --git a/apps/cli/src/commands/experimental/stack/stack-command-routing.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-command-routing.integration.test.ts new file mode 100644 index 0000000000..9468cea7b0 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-command-routing.integration.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "@effect/vitest"; +import { legacyRespondToComplete } from "../../../cli/legacy-complete.ts"; +import { legacyRootForBackend } from "../../../cli/root.ts"; + +const canonicalStackCommands = [ + "start", + "stop", + "status", + "list", + "logs", + "prepare", + "restart", +] as const; + +const complete = (backend: "legacy" | "stack", args: ReadonlyArray) => { + const response = legacyRespondToComplete(legacyRootForBackend(backend), ["__complete", ...args]); + if (response === undefined) throw new Error(`No completion response for ${args.join(" ")}`); + return response.candidates.map((candidate) => candidate.name); +}; + +describe("experimental stack command routing", () => { + it("exposes the same seven canonical stack paths from both backend roots", () => { + for (const backend of ["legacy", "stack"] as const) { + expect(complete(backend, ["stack", ""])).toEqual( + expect.arrayContaining([...canonicalStackCommands]), + ); + expect(complete(backend, ["stack", ""])).toHaveLength(canonicalStackCommands.length); + } + }); + + it("keeps stack out of the unlisted experimental namespace", () => { + for (const backend of ["legacy", "stack"] as const) { + expect(complete(backend, ["experimental", ""])).not.toContain("stack"); + } + }); + + it("routes top-level lifecycle aliases to the matching flag sets", () => { + for (const flag of ["--stack", "--stack-id"] as const) { + expect(complete("stack", ["start", "--"])).toContain(flag); + expect(complete("stack", ["stop", "--"])).toContain(flag); + expect(complete("stack", ["status", "--"])).toContain(flag); + } + + expect(complete("stack", ["start", "--"])).toEqual( + expect.arrayContaining(["--runtime", "--preparation", "--eager"]), + ); + expect(complete("legacy", ["start", "--"])).toEqual( + expect.arrayContaining(["--exclude", "--ignore-health-check"]), + ); + expect(complete("legacy", ["status", "--"])).toEqual( + expect.arrayContaining(["--override-name"]), + ); + expect(complete("stack", ["status", "--"])).not.toContain("--override-name"); + expect(complete("stack", ["stop", "--"])).not.toContain("--no-backup"); + }); +}); diff --git a/apps/cli/src/commands/experimental/stack/stack.command.ts b/apps/cli/src/commands/experimental/stack/stack.command.ts index ce6370529f..617c143105 100644 --- a/apps/cli/src/commands/experimental/stack/stack.command.ts +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -14,9 +14,16 @@ import { legacyExperimentalStackTargetResolverLayer, } from "./stack.shared.ts"; +/** Shared by the explicit stack commands and config-selected top-level aliases. */ +export const legacyExperimentalStackRuntimeLayer = Layer.mergeAll( + legacyExperimentalStackTargetResolverLayer, + legacyExperimentalStackApiLayer, + legacyCliSettingsLayer.pipe(Layer.provide(legacyDebugLoggerLayer)), +); + export const legacyExperimentalStackCommand = Command.make("stack").pipe( - Command.withDescription("Manage an experimental managed local Supabase stack."), - Command.withShortDescription("Manage a managed local stack"), + Command.withDescription("Manage a local Supabase stack with the new backend."), + Command.withShortDescription("Manage local stacks"), Command.withSubcommands([ legacyExperimentalStackStartCommand, legacyExperimentalStackStopCommand, @@ -26,7 +33,5 @@ export const legacyExperimentalStackCommand = Command.make("stack").pipe( legacyExperimentalStackPrepareCommand, legacyExperimentalStackRestartCommand, ]), - Command.provide(legacyExperimentalStackTargetResolverLayer), - Command.provide(legacyExperimentalStackApiLayer), - Command.provide(legacyCliSettingsLayer.pipe(Layer.provide(legacyDebugLoggerLayer))), + Command.provide(legacyExperimentalStackRuntimeLayer), ); 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 745a94675f..55b347e5ea 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 9d8cf59924..36d26b993d 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.command.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.command.ts @@ -31,11 +31,11 @@ export const legacyExperimentalStackStartCommand = Command.make("start", config) 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", }, ]), 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 dd0d3b8c17..90cc100154 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 @@ -122,7 +122,7 @@ describe("experimental stack start (compiled e2e)", () => { }, CLEANUP_TIMEOUT_MS); test.skipIf(!nativeSupported)( - "starts a detached native owner and leaves a ready database after CLI exit", + "starts a native stack and manages it through config-selected aliases", { timeout: START_TIMEOUT_MS + CLEANUP_TIMEOUT_MS }, // oxlint-disable-next-line effecttsgo/async-function -- compiled CLI e2e callback is a Promise boundary async () => { @@ -131,15 +131,12 @@ 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"], - { - entrypoint: "legacy", - cwd: projectDir, - home: home.dir, - exitTimeoutMs: START_TIMEOUT_MS, - }, - ); + const result = await runSupabase(["stack", "start", "--runtime", "native", "--eager"], { + entrypoint: "legacy", + 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(); @@ -150,6 +147,29 @@ describe("experimental stack start (compiled e2e)", () => { if (idText === undefined || homeDir === undefined || projectRoot === undefined) throw new Error("compiled start did not return a stack id"); + await writeFile( + path.join(projectRoot, "supabase", "config.toml"), + `${minimalConfig}\n[experimental]\nstack = true\n`, + ); + const aliasOptions = { + entrypoint: "legacy" as const, + cwd: projectRoot, + home: homeDir.dir, + exitTimeoutMs: START_TIMEOUT_MS, + }; + const aliasStatus = await runSupabase(["status", "--stack-id", idText], aliasOptions); + expect(aliasStatus.exitCode, aliasStatus.stderr).toBe(0); + expect(aliasStatus.stdout).toContain(idText); + + const aliasStop = await runSupabase(["stop", "--stack-id", idText], aliasOptions); + expect(aliasStop.exitCode, aliasStop.stderr).toBe(0); + const aliasStart = await runSupabase( + ["start", "--stack-id", idText, "--runtime", "native", "--eager"], + aliasOptions, + ); + expect(aliasStart.exitCode, aliasStart.stderr).toBe(0); + expect(aliasStart.stdout).toContain(idText); + const observed = await inspectAndDestroyStack(homeDir.dir, idText); stackDestroyed = true; expect(observed.owner).toBe("running"); diff --git a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md index e0581c4206..b80c8f7c13 100644 --- a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -1,4 +1,4 @@ -# `supabase experimental stack status` +# `supabase stack status` Reports the persisted identity and current owner state of a managed local stack. The command is read-only: it never creates, starts, prepares, stops, destroys, or diff --git a/apps/cli/src/commands/experimental/stack/status/status.handler.ts b/apps/cli/src/commands/experimental/stack/status/status.handler.ts index e93e1bfb57..b3a6b295d3 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -29,7 +29,7 @@ const classifyStackError = (error: StackError) => Match.tag("StackNotFoundError", () => ({ reason: "not-found" as const, suggestion: - "Choose an existing --stack-id, or run supabase experimental stack start without --stack-id to create one.", + "Choose an existing --stack-id, or run supabase stack start without --stack-id to create one.", })), Match.tag( "InvalidStackIdentityError", @@ -154,7 +154,7 @@ const findDescriptor = (projectRoot: string, name: string | undefined, id: strin return yield* new LegacyExperimentalStackStatusError({ reason: "not-found", message: "No managed stack exists for the selected project.", - suggestion: "Run supabase experimental stack start first.", + suggestion: "Run supabase stack start first.", }); return { descriptor: found.value, id: found.value.id, projectRoot: found.value.projectRoot }; }); diff --git a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts index 2c20e0b093..b12dbf6206 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -312,7 +312,7 @@ describe("experimental stack status", () => { Effect.flip, Effect.tap((error) => Effect.sync(() => { - expect(error.suggestion).toBe("Run supabase experimental stack start first."); + expect(error.suggestion).toBe("Run supabase stack start first."); expect(run.inspectInputs).toEqual([]); }), ), 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 fd67a07bac..ae2ab099c9 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 8df1870733..982ce6f6b8 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts @@ -19,7 +19,7 @@ export const legacyExperimentalStackStopCommand = Command.make("stop", config).p 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", }, ]), diff --git a/apps/cli/src/commands/start/SIDE_EFFECTS.md b/apps/cli/src/commands/start/SIDE_EFFECTS.md index 2d58a54bfd..38a4351e35 100644 --- a/apps/cli/src/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/start/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase start` +This document describes the legacy backend. With `[experimental] stack = true`, `supabase start` uses the new [`supabase stack start` implementation](../experimental/stack/start/SIDE_EFFECTS.md). 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/status/SIDE_EFFECTS.md b/apps/cli/src/commands/status/SIDE_EFFECTS.md index d56789b467..ccdac9d266 100644 --- a/apps/cli/src/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/status/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase status` +This document describes the legacy backend. With `[experimental] stack = true`, `supabase status` uses the new [`supabase stack status` implementation](../experimental/stack/status/SIDE_EFFECTS.md). See [backend selection](../../../docs/stack-commands.md). + TS-only divergence (CLI-2167 follow-up, no Go counterpart): `status` additionally resolves and surfaces the current linked project/branch — a "Linked Project:" block on stdout in human text mode, and additive fields in every machine-readable output — so an agent (or a human who forgot diff --git a/apps/cli/src/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/stop/SIDE_EFFECTS.md index 0f36839955..9b899016cd 100644 --- a/apps/cli/src/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/stop/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase stop` +This document describes the legacy backend. With `[experimental] stack = true`, `supabase stop` uses the new [`supabase stack stop` implementation](../experimental/stack/stop/SIDE_EFFECTS.md). 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/docs/legacy-docs-spec.tables.ts b/apps/cli/src/docs/legacy-docs-spec.tables.ts index d3648e8ad7..1d6e3662ce 100644 --- a/apps/cli/src/docs/legacy-docs-spec.tables.ts +++ b/apps/cli/src/docs/legacy-docs-spec.tables.ts @@ -67,6 +67,7 @@ export const LEGACY_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"], @@ -134,6 +135,10 @@ export const LEGACY_DOCS_EXCLUDED: ReadonlySet = new Set([ * flags add entries by hand. */ export const LEGACY_DOCS_DEFAULT_OVERRIDES: Readonly> = { + "supabase-stack-start runtime": "auto", + "supabase-stack-start preparation": "background", + "supabase-stack-prepare runtime": "auto", + "supabase-stack-logs tail": "100", "supabase agent": "auto", "supabase dns-resolver": "native", "supabase output": "pretty", diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index e0170392dc..5fa867d1cb 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -724,6 +724,8 @@ function cliProjectHomeLayerFor(runtimeLayer: Layer.Layer) { type AnyAnalyticsLayer = Layer.Layer; export interface RunCliOptions { + /** Runs after runtime services are installed and before command argument parsing. */ + readonly beforeParse?: Effect.Effect; readonly analyticsLayer: AnyAnalyticsLayer; /** * Runs just before the process exits on any invocation that exits 0 — the @@ -780,10 +782,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/packages/config/src/experimental.ts b/packages/config/src/experimental.ts index 7a04fec9ab..f7245b2619 100644 --- a/packages/config/src/experimental.ts +++ b/packages/config/src/experimental.ts @@ -32,6 +32,13 @@ 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, stop, and status 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 3aa99b3a7d..cf67c0d032 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 330519e13a..c429a6b9e0 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 fc5eb6dd2e..d3505c514c 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 fc845e21843066b29da9f29bb6a95090cfbd1961 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 12:00:02 +0200 Subject: [PATCH 2/3] fix(cli): align stack routing and command telemetry --- apps/cli/docs/stack-commands.md | 12 +- .../cli/legacy-complete.integration.test.ts | 40 ++++- apps/cli/src/cli/legacy-complete.ts | 7 +- apps/cli/src/cli/main.ts | 16 +- apps/cli/src/cli/root.ts | 28 +++- .../stack/stack-backend.integration.test.ts | 89 ++++++++++- .../experimental/stack/stack-backend.ts | 114 +++++++++----- ...tack-command-telemetry.integration.test.ts | 140 ++++++++++++++++++ .../experimental/stack/stack.command.ts | 30 +++- .../src/config/legacy-cli-settings.layer.ts | 4 +- apps/cli/src/shared/cli/agent-output.ts | 4 +- 11 files changed, 395 insertions(+), 89 deletions(-) create mode 100644 apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 0b8f23f961..68fc1a394d 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -23,16 +23,6 @@ The top-level `supabase start`, `supabase stop`, and `supabase status` commands stack = true ``` -The equivalent in `supabase/config.json` is: - -```json -{ - "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 continue to use the new backend. This flag currently selects only the `start`, `stop`, and `status` aliases. It does not switch the database, migration, functions, or storage command families to the new backend. @@ -41,4 +31,4 @@ This flag currently selects only the `start`, `stop`, and `status` aliases. It d 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 and is excluded from hosted project configuration. Project selection follows the CLI’s working-directory rules, including `--workdir` and `SUPABASE_WORKDIR`. +The flag is local CLI configuration in `supabase/config.toml` and is excluded from hosted project configuration. Lifecycle 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/legacy-complete.integration.test.ts b/apps/cli/src/cli/legacy-complete.integration.test.ts index 9abc6f8f3b..afcd684816 100644 --- a/apps/cli/src/cli/legacy-complete.integration.test.ts +++ b/apps/cli/src/cli/legacy-complete.integration.test.ts @@ -55,11 +55,12 @@ function makeCaptureTelemetry( function makeDeps( argv: ReadonlyArray, captureTelemetry: LegacyCompleteDeps["captureTelemetry"], + root: LegacyCompleteDeps["root"], ) { const stdoutWrites: Array = []; const exits: Array = []; const deps: LegacyCompleteDeps = { - root: legacyRoot, + root, argv, env: {}, stdoutWrite: (message) => { @@ -74,11 +75,45 @@ function makeDeps( } describe("legacy __complete telemetry (CLI-1965 review finding)", () => { + it.each(["__complete", "__completeNoDesc"])( + "keeps %s in completion handling when the selected command tree is unavailable", + async (completionCommand) => { + const analytics = mockAnalyticsWithContext(); + const { deps, stdoutWrites, exits } = makeDeps( + [completionCommand, "stack", "st"], + makeCaptureTelemetry(analytics.layer), + undefined, + ); + + expect(await legacyTryComplete(deps)).toBe(true); + expect(stdoutWrites).toEqual([]); + expect(exits).toEqual([1]); + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event?.command).toBe("__complete"); + expect(event?.properties[PropExitCode]).toBe(1); + }, + ); + + it("leaves regular invocations unhandled when the selected command tree is unavailable", async () => { + const analytics = mockAnalyticsWithContext(); + const { deps, stdoutWrites, exits } = makeDeps( + ["stack", "status"], + makeCaptureTelemetry(analytics.layer), + undefined, + ); + + expect(await legacyTryComplete(deps)).toBe(false); + expect(stdoutWrites).toEqual([]); + expect(exits).toEqual([]); + expect(analytics.captured).toEqual([]); + }); + it("fires cli_command_executed with command: __complete and exit_code: 0 for a normal completion request", async () => { const analytics = mockAnalyticsWithContext(); const { deps } = makeDeps( ["__complete", "migration", "li"], makeCaptureTelemetry(analytics.layer), + legacyRoot, ); expect(await legacyTryComplete(deps)).toBe(true); @@ -91,7 +126,7 @@ describe("legacy __complete telemetry (CLI-1965 review finding)", () => { it("records exit_code: 1 for an unresolvable completion request (zero completion args)", async () => { const analytics = mockAnalyticsWithContext(); - const { deps } = makeDeps(["__complete"], makeCaptureTelemetry(analytics.layer)); + const { deps } = makeDeps(["__complete"], makeCaptureTelemetry(analytics.layer), legacyRoot); expect(await legacyTryComplete(deps)).toBe(true); @@ -105,6 +140,7 @@ describe("legacy __complete telemetry (CLI-1965 review finding)", () => { const { deps } = makeDeps( ["__completeNoDesc", "migration", "li"], makeCaptureTelemetry(analytics.layer), + legacyRoot, ); await legacyTryComplete(deps); diff --git a/apps/cli/src/cli/legacy-complete.ts b/apps/cli/src/cli/legacy-complete.ts index 8b218efe62..3e27c6357f 100644 --- a/apps/cli/src/cli/legacy-complete.ts +++ b/apps/cli/src/cli/legacy-complete.ts @@ -110,7 +110,7 @@ export interface LegacyClassifyCompletionInput { } export interface LegacyCompleteDeps { - readonly root: Command.Command.Any; + readonly root: Command.Command.Any | undefined; readonly argv: ReadonlyArray; readonly env: Readonly>; readonly stdoutWrite: (message: string) => void; @@ -1549,10 +1549,11 @@ export function legacyClassifyCompletion( * (see the module doc comment for why that case isn't otherwise reproduced). */ export function legacyRespondToComplete( - root: Command.Command.Any, + root: Command.Command.Any | undefined, argv: ReadonlyArray, ): LegacyCompletionResult | 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; @@ -1769,7 +1770,7 @@ export async function legacyTryComplete(deps: LegacyCompleteDeps): Promise { }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); }); - it.effect("reads the same routing key from JSON and completion argv", () => { + it.effect("treats a separated global boolean value as a flag value", () => { + const root = project("[experimental]\nstack = true\n"); + return Effect.gen(function* () { + for (const flag of ["--debug", "--experimental", "--yes", "--create-ticket"]) + for (const value of ["false", "0", "no", "off"]) + expect(yield* resolve({ args: [flag, value, "start"], cwd: root, env: {} })).toBe( + "stack", + ); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); + }); + + it.effect("offers flags from the selected command tree after a separated boolean", () => { + const root = project("[experimental]\nstack = true\n"); + return Effect.gen(function* () { + const backend = yield* resolve({ + args: ["--debug", "false", "start", "--help"], + cwd: root, + env: {}, + }); + expect(backend).toBe("stack"); + const completion = legacyRespondToComplete(legacyRootForBackend(backend), [ + "__complete", + "start", + "--", + ]); + expect(completion?.candidates.map((candidate) => candidate.name)).toContain("--runtime"); + expect(completion?.candidates.map((candidate) => candidate.name)).not.toContain( + "--ignore-health-check", + ); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); + }); + + it.effect("matches handler workdir selection and first repeated workdir", () => { + const stackRoot = project("[experimental]\nstack = true\n"); + const legacyRoot = project("[experimental]\nstack = false\n"); + const nested = join(stackRoot, "nested"); + mkdirSync(join(nested, "child"), { recursive: true }); + return Effect.gen(function* () { + expect(yield* resolve({ args: ["start"], cwd: stackRoot, env: {} })).toBe("stack"); + expect(yield* resolve({ args: ["start"], cwd: nested, env: {} })).toBe("stack"); + expect( + yield* resolve({ + args: ["--workdir", stackRoot, "--workdir", legacyRoot, "start"], + cwd: nested, + env: {}, + }), + ).toBe("stack"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + rmSync(stackRoot, { recursive: true, force: true }); + rmSync(legacyRoot, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("does not treat a consumed profile value as workdir", () => { + const root = project("[experimental]\nstack = true\n"); + return resolve({ args: ["--profile", "--workdir=missing", "start"], cwd: root, env: {} }).pipe( + Effect.tap((backend) => Effect.sync(() => expect(backend).toBe("stack"))), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("ignores JSON-only projects and supports both completion modes", () => { const root = project('{"experimental":{"stack":true}}', "json"); return Effect.gen(function* () { - expect(yield* resolve({ args: ["__complete", "start"], cwd: root, env: {} })).toBe("stack"); + for (const mode of ["__complete", "__completeNoDesc"]) { + const backend = yield* resolve({ args: [mode, "start", "--"], cwd: root, env: {} }); + expect(backend).toBe("legacy"); + const response = legacyRespondToComplete(legacyRootForBackend(backend), [ + mode, + "start", + "--", + ]); + expect(response?.candidates.map(({ name }) => name)).toContain("--ignore-health-check"); + } }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); }); + it.effect("uses config.toml when JSON and TOML settings conflict", () => { + const root = project("[experimental]\nstack = false\n"); + writeFileSync(join(root, "supabase", "config.json"), '{"experimental":{"stack":true}}'); + return resolve({ args: ["start"], cwd: root, env: {} }).pipe( + Effect.tap((backend) => Effect.sync(() => expect(backend).toBe("legacy"))), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + it.effect("keeps the legacy backend when the setting is false", () => { const root = project("[experimental]\nstack = false\n"); return resolve({ args: ["stop"], cwd: root, env: {} }).pipe( diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.ts b/apps/cli/src/commands/experimental/stack/stack-backend.ts index 94fc7e6e7b..66fc765b28 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.ts +++ b/apps/cli/src/commands/experimental/stack/stack-backend.ts @@ -1,8 +1,10 @@ -import { CliConfigSchema, findCliProjectPaths } from "@supabase/config/effect"; -import { Data, Effect, FileSystem, Path, Schema } from "effect"; +import { CliConfigSchema } from "@supabase/config/effect"; +import { Data, Effect, FileSystem, Option, Path, Schema } from "effect"; +import { legacyResolveWorkdir } from "../../../config/legacy-cli-settings.layer.ts"; import * as SmolToml from "smol-toml"; -import { extractCommandPath, hasRootVersionFlag } from "../../../shared/cli/run.ts"; -import { lastExplicitLongFlagValue } from "../../../shared/cli/cobra-flag-groups.ts"; +import { hasRootVersionFlag, rootFlagTokens } from "../../../shared/cli/run.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 { actionability, type CliErrorActionabilityDeclaration, @@ -25,28 +27,57 @@ const stackRoutingSchema = Schema.Struct({ ), }); +const legacyFirstExplicitLongFlagValue = ( + 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 legacyExtractStackRoutingCommandPath = ( + 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 => - path.endsWith(".json") - ? Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(content).pipe( - Effect.mapError( - (cause) => - new LegacyExperimentalStackRoutingError({ - message: `Unable to read ${path}: ${String(cause)}`, - cause, - }), - ), - ) - : Effect.try({ - try: () => SmolToml.parse(content), - catch: (cause) => - new LegacyExperimentalStackRoutingError({ - message: `Unable to read ${path}: ${String(cause)}`, - cause, - }), - }); + Effect.try({ + try: () => SmolToml.parse(content), + catch: (cause) => + new LegacyExperimentalStackRoutingError({ + message: `Unable to read ${path}: ${String(cause)}`, + cause, + }), + }); const stackSettingFrom = ( path: string, @@ -75,8 +106,11 @@ export const legacyResolveExperimentalStackBackend = (input: { > => Effect.gen(function* () { if (hasRootVersionFlag(input.args)) return "legacy"; - const commandPath = extractCommandPath(input.args); - const completePath = commandPath[0] === "__complete" ? commandPath.slice(1) : commandPath; + const commandPath = legacyExtractStackRoutingCommandPath(input.args); + const completePath = + commandPath[0] === "__complete" || commandPath[0] === "__completeNoDesc" + ? commandPath.slice(1) + : commandPath; const command = completePath[0]; if (command === "stack") return "stack"; if (command !== "start" && command !== "stop" && command !== "status") { @@ -84,29 +118,27 @@ export const legacyResolveExperimentalStackBackend = (input: { } const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const explicitWorkdir = lastExplicitLongFlagValue(input.args, [], "workdir"); - const configuredWorkdir = - explicitWorkdir === undefined || explicitWorkdir.length === 0 - ? input.env["SUPABASE_WORKDIR"] - : explicitWorkdir; - const start = - configuredWorkdir === undefined || configuredWorkdir.length === 0 - ? input.cwd - : path.resolve(input.cwd, configuredWorkdir); - const project = yield* findCliProjectPaths(start, { - search: configuredWorkdir === undefined || configuredWorkdir.length === 0, - }); - if (project === null) return "legacy"; - const content = yield* fs.readFileString(project.configPath).pipe( + const explicitWorkdir = legacyFirstExplicitLongFlagValue(input.args, "workdir"); + const resolvedWorkdir = yield* legacyResolveWorkdir( + 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 "legacy"; + const content = yield* fs.readFileString(configPath).pipe( Effect.mapError( (cause) => new LegacyExperimentalStackRoutingError({ - message: `Unable to read ${project.configPath}: ${String(cause)}`, + message: `Unable to read ${configPath}: ${String(cause)}`, cause, }), ), ); - return yield* parseConfig(project.configPath, content).pipe( - Effect.flatMap((document) => stackSettingFrom(project.configPath, document)), + return yield* parseConfig(configPath, content).pipe( + Effect.flatMap((document) => stackSettingFrom(configPath, document)), ); }); 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..81c3349b84 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts @@ -0,0 +1,140 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option, Stream } from "effect"; +import { Command } from "effect/unstable/cli"; +import { StackIdSchema } from "@supabase/stack/effect"; +import type { EffectStack, StackStatus } from "@supabase/stack/effect"; +import { legacyExperimentalStackCommand } from "./stack.command.ts"; +import { + LegacyExperimentalStackApi, + legacyExperimentalStackTargetResolverLayer, +} from "./stack.shared.ts"; +import { legacyExperimentalStackStatusAliasCommand } from "../../../cli/root.ts"; +import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { mockLegacyCliSettings } from "../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { LegacyYesFlag } from "../../../shared/legacy/global-flags.ts"; +import { CurrentAnalyticsContext } from "../../../shared/telemetry/analytics-context.ts"; +import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; +import { OutputFormatFlag } from "../../../shared/cli/global-flags.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../shared/legacy/global-flags.ts"; +import { processControlLayer } from "../../../shared/runtime/process-control.layer.ts"; +import { + EventCommandExecuted, + PropCommand, + PropCommandRunId, +} from "../../../shared/telemetry/event-catalog.ts"; + +const stackId = StackIdSchema.make("a".repeat(64)); +const stackStatus: StackStatus = { + id: stackId, + lifecycle: "running", + desiredLifecycle: "running", + runtime: { kind: "native" }, + endpoints: {}, + versions: {}, + capabilities: [], + artifacts: [], +}; + +function setup() { + const output = mockOutput(); + const captured: Array<{ event: string; properties: Record }> = []; + const analytics = { + captured, + layer: Layer.succeed( + Analytics, + Analytics.of({ + capture: (event, properties = {}) => + Effect.gen(function* () { + const context = yield* CurrentAnalyticsContext; + captured.push({ event, properties: { ...context, ...properties } }); + }), + identify: () => Effect.void, + alias: () => Effect.void, + groupIdentify: () => Effect.void, + }), + ), + }; + const descriptor = { + id: stackId, + projectRoot: "/project", + name: "default", + branchContext: "ordinary-workspace", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }; + const stack = { + id: stackId, + status: () => Effect.succeed(stackStatus), + credentials: () => Effect.die("unused"), + prepare: () => Effect.die("unused"), + start: () => Effect.die("unused"), + stop: () => Effect.die("unused"), + destroy: () => Effect.die("unused"), + logs: () => Effect.die("unused"), + followLogs: () => Stream.empty, + } satisfies EffectStack; + const api = Layer.succeed(LegacyExperimentalStackApi, { + createStack: () => Effect.die("unused"), + listStacks: () => Effect.succeed([descriptor]), + findStack: () => Effect.succeed(Option.some(descriptor)), + openStack: () => Effect.succeed(stack), + inspectStack: () => Effect.succeed({ descriptor, owner: "running" as const }), + }); + return { + output, + analytics, + layer: Layer.mergeAll( + BunServices.layer, + mockLegacyCliSettings({ workdir: "/project" }), + processControlLayer, + output.layer, + analytics.layer, + api, + legacyExperimentalStackTargetResolverLayer, + mockStdin(false), + mockTty(), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(LegacyYesFlag, false), + ), + }; +} + +const testRoot = ( + command: typeof legacyExperimentalStackCommand | typeof legacyExperimentalStackStatusAliasCommand, +) => + Command.make("supabase").pipe( + Command.withGlobalFlags([OutputFormatFlag, ...LEGACY_GLOBAL_FLAGS]), + Command.withSubcommands([command]), + ); + +describe("stack command telemetry", () => { + it.live("records canonical and alias command paths with distinct run ids", () => { + const fixture = setup(); + return Effect.gen(function* () { + yield* Command.runWith(testRoot(legacyExperimentalStackCommand), { version: "0.0.0-test" })([ + "stack", + "list", + ]); + yield* Command.runWith(testRoot(legacyExperimentalStackCommand), { version: "0.0.0-test" })([ + "stack", + "list", + ]); + yield* Command.runWith(testRoot(legacyExperimentalStackStatusAliasCommand), { + version: "0.0.0-test", + })(["status"]); + const events = fixture.analytics.captured.filter( + (event) => event.event === EventCommandExecuted, + ); + expect(events.map((event) => event.properties[PropCommand])).toEqual([ + "stack list", + "stack list", + "status", + ]); + const runIds = events.map((event) => event.properties[PropCommandRunId]); + expect(runIds.every((runId) => typeof runId === "string")).toBe(true); + expect(new Set(runIds).size).toBe(3); + }).pipe(Effect.provide(fixture.layer)); + }); +}); diff --git a/apps/cli/src/commands/experimental/stack/stack.command.ts b/apps/cli/src/commands/experimental/stack/stack.command.ts index 617c143105..bf5ae8cd35 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 { legacyCliSettingsLayer } from "../../../config/legacy-cli-settings.layer.ts"; import { legacyDebugLoggerLayer } from "../../../command-internal/legacy-debug-logger.layer.ts"; import { legacyExperimentalStackStartCommand } from "./start/start.command.ts"; @@ -25,13 +26,26 @@ export const legacyExperimentalStackCommand = Command.make("stack").pipe( Command.withDescription("Manage a local Supabase stack with the new backend."), Command.withShortDescription("Manage local stacks"), Command.withSubcommands([ - legacyExperimentalStackStartCommand, - legacyExperimentalStackStopCommand, - legacyExperimentalStackStatusCommand, - legacyExperimentalStackListCommand, - legacyExperimentalStackLogsCommand, - legacyExperimentalStackPrepareCommand, - legacyExperimentalStackRestartCommand, + legacyExperimentalStackStartCommand.pipe( + Command.provide(commandRuntimeLayer(["stack", "start"])), + ), + legacyExperimentalStackStopCommand.pipe( + Command.provide(commandRuntimeLayer(["stack", "stop"])), + ), + legacyExperimentalStackStatusCommand.pipe( + Command.provide(commandRuntimeLayer(["stack", "status"])), + ), + legacyExperimentalStackListCommand.pipe( + Command.provide(commandRuntimeLayer(["stack", "list"])), + ), + legacyExperimentalStackLogsCommand.pipe( + Command.provide(commandRuntimeLayer(["stack", "logs"])), + ), + legacyExperimentalStackPrepareCommand.pipe( + Command.provide(commandRuntimeLayer(["stack", "prepare"])), + ), + legacyExperimentalStackRestartCommand.pipe( + Command.provide(commandRuntimeLayer(["stack", "restart"])), + ), ]), - Command.provide(legacyExperimentalStackRuntimeLayer), ); diff --git a/apps/cli/src/config/legacy-cli-settings.layer.ts b/apps/cli/src/config/legacy-cli-settings.layer.ts index 536a22d374..d779fa130f 100644 --- a/apps/cli/src/config/legacy-cli-settings.layer.ts +++ b/apps/cli/src/config/legacy-cli-settings.layer.ts @@ -102,7 +102,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 legacyResolveWorkdir( flagValue: Option.Option, envValue: string | undefined, cwd: string, @@ -182,7 +182,7 @@ export const legacyCliSettingsLayer = Layer.unwrap( ? Option.none() : Option.some(rawProjectId); - const { workdir, explicit: explicitWorkdir } = yield* resolveWorkdir( + const { workdir, explicit: explicitWorkdir } = yield* legacyResolveWorkdir( workdirFlag, env["SUPABASE_WORKDIR"], runtimeInfo.cwd, diff --git a/apps/cli/src/shared/cli/agent-output.ts b/apps/cli/src/shared/cli/agent-output.ts index 96eb621ca6..e59d8da3af 100644 --- a/apps/cli/src/shared/cli/agent-output.ts +++ b/apps/cli/src/shared/cli/agent-output.ts @@ -112,7 +112,7 @@ function isRootValueFlagWithInlineValue(arg: string): boolean { return false; } -const ROOT_BOOLEAN_FLAGS: ReadonlyArray = [ +export const ROOT_BOOLEAN_FLAGS: ReadonlyArray = [ "--debug", "--experimental", "--yes", @@ -131,7 +131,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", From 629364a73726145605eec64352439464af2e777d Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 13:30:20 +0200 Subject: [PATCH 3/3] feat(cli): support stack backend environment override --- apps/cli/docs/stack-commands.md | 4 +- .../stack/stack-backend.integration.test.ts | 95 ++++++++++++++++++- .../experimental/stack/stack-backend.ts | 9 ++ apps/cli/src/commands/start/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/status/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/stop/SIDE_EFFECTS.md | 2 +- 6 files changed, 109 insertions(+), 5 deletions(-) diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 68fc1a394d..54d23f2366 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -25,10 +25,12 @@ 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 continue to use the new backend. +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` in `config.toml`; 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. + This flag currently selects only the `start`, `stop`, and `status` aliases. It does not switch the database, migration, functions, or storage command families to the new backend. ## 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. Lifecycle 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. +The flag is local CLI configuration in `supabase/config.toml` and is excluded from hosted project configuration. When the environment override is absent or empty, lifecycle 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. Selecting a backend does not bypass validation when the selected command later loads its full configuration. 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 31418c5f06..a51bf8ccf9 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,93 @@ describe("legacyResolveExperimentalStackBackend", () => { }).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"); + for (const mode of ["__complete", "__completeNoDesc"]) { + const backend = yield* resolve({ + args: [mode, "start", "--"], + cwd: root, + env: { SUPABASE_EXPERIMENTAL_STACK: "0" }, + }); + expect(backend).toBe("legacy"); + expect( + legacyRespondToComplete(legacyRootForBackend(backend), [ + mode, + "start", + "--", + ])?.candidates.map(({ name }) => name), + ).toContain("--ignore-health-check"); + } + }).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(LegacyExperimentalStackRoutingError); + expect(String(error.value)).toContain("SUPABASE_EXPERIMENTAL_STACK"); + 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: ["stop"], + cwd: root, + env: { SUPABASE_EXPERIMENTAL_STACK: "0" }, + }), + ).toBe("legacy"); + expect( + yield* resolve({ + args: ["stack", "status"], + cwd: "/missing", + env: { SUPABASE_EXPERIMENTAL_STACK: "yes" }, + }), + ).toBe("stack"); + expect( + yield* resolve({ + args: ["login"], + cwd: "/missing", + env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, + }), + ).toBe("legacy"); + expect( + yield* resolve({ + args: ["--version"], + cwd: "/missing", + env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, + }), + ).toBe("legacy"); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); + }); + it.effect("treats a separated global boolean value as a flag value", () => { const root = project("[experimental]\nstack = true\n"); return Effect.gen(function* () { @@ -155,7 +242,13 @@ describe("legacyResolveExperimentalStackBackend", () => { return Effect.gen(function* () { expect(yield* resolve({ args: ["login"], cwd: root, env: {} })).toBe("legacy"); expect(yield* resolve({ args: ["--version", "start"], cwd: root, env: {} })).toBe("legacy"); - expect(yield* resolve({ args: ["stack", "start"], cwd: root, env: {} })).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 })))); }); diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.ts b/apps/cli/src/commands/experimental/stack/stack-backend.ts index 66fc765b28..9e6532df12 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.ts +++ b/apps/cli/src/commands/experimental/stack/stack-backend.ts @@ -12,6 +12,7 @@ import { } from "../../../shared/telemetry/error-actionability.ts"; export type LegacyExperimentalStackBackend = "legacy" | "stack"; +const LEGACY_EXPERIMENTAL_STACK_ENV = "SUPABASE_EXPERIMENTAL_STACK"; export class LegacyExperimentalStackRoutingError extends Data.TaggedError( "LegacyExperimentalStackRoutingError", @@ -116,6 +117,14 @@ export const legacyResolveExperimentalStackBackend = (input: { if (command !== "start" && command !== "stop" && command !== "status") { return "legacy"; } + const envOverride = input.env[LEGACY_EXPERIMENTAL_STACK_ENV]; + if (envOverride !== undefined && envOverride !== "") { + if (envOverride === "1") return "stack"; + if (envOverride === "0") return "legacy"; + return yield* new LegacyExperimentalStackRoutingError({ + message: `${LEGACY_EXPERIMENTAL_STACK_ENV} must be 0 or 1 when set`, + }); + } const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const explicitWorkdir = legacyFirstExplicitLongFlagValue(input.args, "workdir"); diff --git a/apps/cli/src/commands/start/SIDE_EFFECTS.md b/apps/cli/src/commands/start/SIDE_EFFECTS.md index 38a4351e35..1b861eb748 100644 --- a/apps/cli/src/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/start/SIDE_EFFECTS.md @@ -1,6 +1,6 @@ # `supabase start` -This document describes the legacy backend. With `[experimental] stack = true`, `supabase start` uses the new [`supabase stack start` implementation](../experimental/stack/start/SIDE_EFFECTS.md). See [backend selection](../../../docs/stack-commands.md). +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 diff --git a/apps/cli/src/commands/status/SIDE_EFFECTS.md b/apps/cli/src/commands/status/SIDE_EFFECTS.md index ccdac9d266..f3b3f2d655 100644 --- a/apps/cli/src/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/status/SIDE_EFFECTS.md @@ -1,6 +1,6 @@ # `supabase status` -This document describes the legacy backend. With `[experimental] stack = true`, `supabase status` uses the new [`supabase stack status` implementation](../experimental/stack/status/SIDE_EFFECTS.md). See [backend selection](../../../docs/stack-commands.md). +This document describes the legacy backend. With `SUPABASE_EXPERIMENTAL_STACK=1`, or `[experimental] stack = true` when the environment override is unset or empty, `supabase status` uses the new [`supabase stack status` implementation](../experimental/stack/status/SIDE_EFFECTS.md). `SUPABASE_EXPERIMENTAL_STACK=0` forces the legacy backend. See [backend selection](../../../docs/stack-commands.md). TS-only divergence (CLI-2167 follow-up, no Go counterpart): `status` additionally resolves and surfaces the current linked project/branch — a "Linked Project:" block on stdout in human text diff --git a/apps/cli/src/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/stop/SIDE_EFFECTS.md index 9b899016cd..6d61867335 100644 --- a/apps/cli/src/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/stop/SIDE_EFFECTS.md @@ -1,6 +1,6 @@ # `supabase stop` -This document describes the legacy backend. With `[experimental] stack = true`, `supabase stop` uses the new [`supabase stack stop` implementation](../experimental/stack/stop/SIDE_EFFECTS.md). See [backend selection](../../../docs/stack-commands.md). +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