From bcb4160b90d4a573ad9fd3904a7d94befd5d16d5 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 10:45:35 +0200 Subject: [PATCH 1/5] feat(cli): add stack command options and explicit destruction --- apps/cli/docs/stack-commands.md | 40 ++++ .../stack/destroy/SIDE_EFFECTS.md | 24 ++ .../stack/destroy/destroy.command.ts | 34 +++ .../stack/destroy/destroy.errors.ts | 29 +++ .../stack/destroy/destroy.handler.ts | 140 +++++++++++ .../stack/destroy/destroy.integration.test.ts | 177 ++++++++++++++ .../stack-command-routing.integration.test.ts | 9 +- .../experimental/stack/stack.command.ts | 4 + .../experimental/stack/start/SIDE_EFFECTS.md | 12 +- .../experimental/stack/start/start.command.ts | 7 + .../stack/start/start.e2e.test.ts | 20 +- .../experimental/stack/start/start.handler.ts | 114 +++++++-- .../stack/start/start.integration.test.ts | 78 +++++++ .../experimental/stack/start/start.options.ts | 12 + .../experimental/stack/status/SIDE_EFFECTS.md | 20 +- .../stack/status/status.command.ts | 9 + .../experimental/stack/status/status.env.ts | 95 ++++++++ .../stack/status/status.env.unit.test.ts | 28 +++ .../stack/status/status.handler.ts | 26 +++ .../stack/status/status.integration.test.ts | 220 +++++++++++++++++- .../experimental/stack/stop/SIDE_EFFECTS.md | 14 +- .../experimental/stack/stop/stop.command.ts | 10 +- .../experimental/stack/stop/stop.handler.ts | 46 +++- .../stack/stop/stop.integration.test.ts | 69 +++++- packages/stack/src/public/Credentials.ts | 18 +- .../stack/src/public/whole-stack.e2e.test.ts | 19 +- packages/stack/src/supervisor/Supervisor.ts | 33 ++- .../supervisor/supervisor.integration.test.ts | 214 +++++++++-------- 28 files changed, 1333 insertions(+), 188 deletions(-) create mode 100644 apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md create mode 100644 apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts create mode 100644 apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts create mode 100644 apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts create mode 100644 apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts create mode 100644 apps/cli/src/commands/experimental/stack/start/start.options.ts create mode 100644 apps/cli/src/commands/experimental/stack/status/status.env.ts create mode 100644 apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 54d23f2366..c19a15d312 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -5,6 +5,7 @@ | Command | Purpose | | ------------------------ | ------------------------------------------- | | `supabase stack start` | Create or resume the project’s stack. | +| `supabase stack destroy` | Permanently delete one stack and its data. | | `supabase stack stop` | Stop a stack while retaining its data. | | `supabase stack status` | Inspect stack state and endpoints. | | `supabase stack list` | List registered stacks. | @@ -34,3 +35,42 @@ 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 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. + +## Service selection and shutdown + +`supabase stack start --exclude studio,analytics -x mail` disables those services +in the effective start configuration. Valid names are `rest`, `auth`, `realtime`, +`storage`, `functions`, `studio`, `mail`, `analytics`, and `pooler`; `database` is +required. The project file is unchanged. The effective configuration is retained +in stack state, so stop the stack and start without `--exclude` to restore the +project’s configured services. `--eager` waits for enabled services to become ready. + +`supabase stack stop --all` stops every stack in the new backend’s registry and +preserves data. It cannot be combined with `--stack` or `--stack-id`. Failures +are reported after attempting the other stacks. + +`supabase stack destroy --stack feature-a` permanently removes exactly that +stack and its data after confirmation. Use `--yes` for unattended execution. +There is no top-level `destroy` alias and no bulk destroy option. + +## Exporting environment variables + +```sh +supabase stack status --env --output-format text > .env.local +supabase status --env --override-name API_URL=NEXT_PUBLIC_SUPABASE_URL,ANON_KEY=NEXT_PUBLIC_SUPABASE_ANON_KEY +supabase stack status --env --output-format json +``` + +The top-level example requires the backend flag. `--env` exports URLs and credentials +from a running stack: `DB_URL`, `API_URL`, `ANON_KEY`, `SERVICE_ROLE_KEY`, +`PUBLISHABLE_KEY`, `SECRET_KEY`, and available `STUDIO_URL`, `INBUCKET_URL`, +`S3_PROTOCOL_ACCESS_KEY_ID`, `S3_PROTOCOL_ACCESS_KEY_SECRET`, `S3_PROTOCOL_REGION`, +and `S3_PROTOCOL_URL`. API credentials are omitted when Auth is disabled; optional endpoints and S3 +credentials are omitted when unavailable. + +Text mode emits dotenv assignments; JSON and stream-JSON modes emit a variable +map. For an explicit dotenv file regardless of automatic agent output detection, +add `--output-format text`. This is dotenv data, not a shell script to execute. +Only this explicit export reveals credentials; ordinary status remains secret-free. +`--override-name` accepts repeated or comma-separated `EXPORTED_VARIABLE=NAME` +entries, requires `--env`, and rejects unknown variables, invalid names, and collisions. diff --git a/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md new file mode 100644 index 0000000000..c366e7df1a --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md @@ -0,0 +1,24 @@ +# `supabase stack destroy` + +Permanently stops and removes one managed new-backend stack, including its persisted data. + +The command targets the current project stack by default, or an explicit `--stack` name or +`--stack-id`. It requires interactive confirmation; `--yes` is required for non-interactive and +machine-readable invocations. It never accepts `--all`. + +The stack package reads the selected descriptor and removes its resources and +state under `${SUPABASE_HOME:-~/.supabase}/managed/stacks/`. It owns stopping +the supervisor, removing native processes or containers, and deleting the +stack’s persistent data. The CLI does not delete paths or Docker resources +itself and makes no Management API calls. Project files are retained. + +The normal CLI settings select the working directory and stack home. +`SUPABASE_YES` participates in the existing confirmation setting; explicit +`--yes=false` overrides it. The prompt identifies the name, project directory, +and immutable stack ID. Rejection or missing noninteractive confirmation +performs no destructive operation. + +Text output reports the destroyed stack ID. JSON and stream-JSON return +`{ "destroyed": true, "id": "..." }`. Success exits 0; invalid targets, +confirmation refusal, and destruction failures exit 1. Standard command +instrumentation records command metadata without exporting credentials. diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts new file mode 100644 index 0000000000..777ba496b5 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts @@ -0,0 +1,34 @@ +import { Command, Flag } from "effect/unstable/cli"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyExperimentalStackDestroy } from "./destroy.handler.ts"; + +const config = { + stack: Flag.string("stack").pipe( + Flag.withDescription( + "Destroy the stack with this name (defaults to the current project stack).", + ), + Flag.optional, + ), + stackId: Flag.string("stack-id").pipe( + Flag.withDescription("Destroy an existing stack by id."), + Flag.optional, + ), +} as const; + +export const legacyExperimentalStackDestroyCommand = Command.make("destroy", config).pipe( + Command.withDescription("Permanently destroy a managed local Supabase stack and its data."), + Command.withShortDescription("Destroy a managed local stack"), + Command.withExamples([ + { + command: "supabase stack destroy --stack feature-a --yes", + description: "Permanently destroy the feature-a stack", + }, + ]), + Command.withHandler((flags) => + legacyExperimentalStackDestroy(flags).pipe( + withLegacyCommandInstrumentation({ flags, config }), + withJsonErrorHandling, + ), + ), +); diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts new file mode 100644 index 0000000000..f1a1af443a --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts @@ -0,0 +1,29 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + +export class LegacyExperimentalStackDestroyError extends Data.TaggedError( + "LegacyExperimentalStackDestroyError", +)<{ + readonly reason: "flags" | "confirmation" | "invalid-config" | "lifecycle" | "unknown"; + readonly message: string; + readonly suggestion?: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "flags": + case "confirmation": + return actionability.provideFlags; + case "invalid-config": + case "lifecycle": + return actionability.invalidConfig; + case "unknown": + return actionability.unknown; + } + return actionability.unknown; + } +} diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts new file mode 100644 index 0000000000..6cb7e8f084 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -0,0 +1,140 @@ +import { Effect, Match, Option } from "effect"; +import { isStackError, isStackId, StackIdSchema } from "@supabase/stack/effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyOutputFlag, legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { legacyPromptYesNo } from "../../../../shared/legacy/legacy-prompt-yes-no.ts"; +import { Tty } from "../../../../shared/runtime/tty.service.ts"; +import { LegacyCliSettings } from "../../../../config/legacy-cli-settings.service.ts"; +import { LegacyExperimentalStackApi } from "../stack.shared.ts"; +import { LegacyExperimentalStackDestroyError } from "./destroy.errors.ts"; + +export interface LegacyExperimentalStackDestroyFlags { + readonly stack: Option.Option; + readonly stackId: Option.Option; +} + +export const legacyValidateExperimentalStackDestroyTarget = ( + flags: Pick, +) => + Option.isSome(flags.stack) && Option.isSome(flags.stackId) + ? Effect.fail( + new LegacyExperimentalStackDestroyError({ + reason: "flags", + message: "--stack and --stack-id cannot be used together", + }), + ) + : Effect.void; + +const destroyError = (error: unknown): LegacyExperimentalStackDestroyError => { + const stackError = isStackError(error) ? error : undefined; + const reason = + stackError === undefined + ? "unknown" + : Match.value(stackError).pipe( + Match.tag("StackNotFoundError", "InvalidStackIdentityError", () => "flags" as const), + Match.tag( + "StackOwnershipConflictError", + "StackNotRunningError", + "StackMustBeStoppedError", + "StackLifecycleConflictError", + "StackRuntimeError", + "StackCleanupError", + "StackUpgradeRequiredError", + () => "lifecycle" as const, + ), + Match.tag( + "InvalidStackConfigError", + "StackStateFormatUnsupportedError", + "InvalidProjectRootError", + "StackStateInvalidError", + () => "invalid-config" as const, + ), + Match.orElse(() => "unknown" as const), + ); + return new LegacyExperimentalStackDestroyError({ + reason, + message: stackError?.message ?? String(error), + cause: error, + }); +}; + +const resolveTarget = Effect.fnUntraced(function* ( + flags: LegacyExperimentalStackDestroyFlags, + projectRoot: string, +) { + const api = yield* LegacyExperimentalStackApi; + if (Option.isSome(flags.stackId)) { + const id = flags.stackId.value; + if (!isStackId(id)) + return yield* new LegacyExperimentalStackDestroyError({ + reason: "flags", + message: "--stack-id must be a lowercase SHA-256 stack id", + }); + return yield* api.inspectStack(id).pipe( + Effect.map(({ descriptor }) => descriptor), + Effect.mapError(destroyError), + ); + } + const found = yield* api + .findStack({ + projectRoot, + ...(Option.isSome(flags.stack) ? { name: flags.stack.value } : {}), + }) + .pipe(Effect.mapError(destroyError)); + if (Option.isNone(found)) { + const label = Option.isSome(flags.stack) ? ` named "${flags.stack.value}"` : ""; + return yield* new LegacyExperimentalStackDestroyError({ + reason: "flags", + message: `No managed stack${label} was found for this project.`, + suggestion: "Choose an existing --stack name or omit --stack for the current project.", + }); + } + return found.value; +}); + +export const legacyExperimentalStackDestroy = Effect.fn("legacy.experimental.stack.destroy")( + function* (flags: LegacyExperimentalStackDestroyFlags) { + const output = yield* Output; + const settings = yield* LegacyCliSettings; + const api = yield* LegacyExperimentalStackApi; + const legacyOutput = yield* Effect.serviceOption(LegacyOutputFlag); + if (Option.isSome(legacyOutput) && Option.isSome(legacyOutput.value)) + return yield* new LegacyExperimentalStackDestroyError({ + reason: "flags", + message: "The legacy -o/--output flag is not supported here; use --output-format json.", + suggestion: "Use --output-format json or --output-format text.", + }); + yield* legacyValidateExperimentalStackDestroyTarget(flags); + const target = yield* resolveTarget(flags, settings.workdir); + const yes = yield* legacyResolveYes; + const tty = yield* Tty; + if (!yes && (!tty.stdinIsTty || output.format !== "text")) + return yield* new LegacyExperimentalStackDestroyError({ + reason: "confirmation", + message: "Destroying a stack requires confirmation; rerun with --yes.", + suggestion: "Pass --yes when running non-interactively or in a machine-readable format.", + }); + const confirmed = yield* legacyPromptYesNo( + output, + yes, + `Permanently destroy stack "${target.name}" at ${target.projectRoot} (${target.id}) and all of its data?`, + false, + ); + if (!confirmed) + return yield* new LegacyExperimentalStackDestroyError({ + reason: "confirmation", + message: "Stack destruction was not confirmed.", + }); + const stack = yield* api + .openStack(StackIdSchema.make(target.id)) + .pipe(Effect.mapError(destroyError)); + const destroying = yield* output.task(`Destroying stack ${target.id}...`); + yield* stack.destroy().pipe( + Effect.tapError((error) => destroying.fail(error.message)), + Effect.tap(() => destroying.clear()), + Effect.mapError(destroyError), + ); + if (output.format === "text") yield* output.raw(`Stack ${target.id} destroyed.\n`); + else yield* output.success("", { destroyed: true, id: target.id }); + }, +); diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts new file mode 100644 index 0000000000..b4f872a96c --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Layer, Option, Stream } from "effect"; +import { Command } from "effect/unstable/cli"; +import { StackDestructionError, StackIdSchema } from "@supabase/stack/effect"; +import type { EffectStack } from "@supabase/stack/effect"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { mockLegacyCliSettings } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { LegacyExperimentalStackApi } from "../stack.shared.ts"; +import { + legacyExperimentalStackDestroy, + legacyValidateExperimentalStackDestroyTarget, +} from "./destroy.handler.ts"; +import { LegacyExperimentalStackDestroyError } from "./destroy.errors.ts"; +import { legacyExperimentalStackDestroyCommand } from "./destroy.command.ts"; + +const id = "a".repeat(64); +const flags = (overrides: Partial[0]> = {}) => ({ + stack: Option.none(), + stackId: Option.none(), + ...overrides, +}); + +function setup(opts: { + yes: boolean; + stdinIsTty: boolean; + outputFormat?: "text" | "json"; + promptConfirmResponses?: ReadonlyArray; + found?: boolean; + destroyFailure?: boolean; +}) { + const output = mockOutput({ + format: opts.outputFormat, + promptConfirmResponses: opts.promptConfirmResponses, + }); + const state = { destroyed: 0, openedIds: [] as string[] }; + const descriptor = { + id: StackIdSchema.make(id), + projectRoot: "/project", + name: "feature-a", + branchContext: "ordinary-workspace" as const, + runtime: { kind: "native" as const }, + desiredLifecycle: "stopped" as const, + }; + const stack = { + id: descriptor.id, + status: () => Effect.die("unused"), + credentials: () => Effect.die("unused"), + prepare: () => Effect.die("unused"), + start: () => Effect.die("unused"), + stop: () => Effect.die("unused"), + destroy: () => + opts.destroyFailure + ? Effect.fail(new StackDestructionError({ message: "destroy failed" })) + : Effect.sync(() => void state.destroyed++), + logs: () => Effect.die("unused"), + followLogs: () => Stream.empty, + } satisfies EffectStack; + const layer = Layer.mergeAll( + output.layer, + mockStdin(opts.stdinIsTty), + mockLegacyCliSettings({ workdir: "/project" }), + mockTty({ stdinIsTty: opts.stdinIsTty, stdoutIsTty: opts.stdinIsTty }), + Layer.succeed(LegacyYesFlag, opts.yes), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(LegacyExperimentalStackApi, { + createStack: () => Effect.die("unused"), + listStacks: () => Effect.succeed([]), + findStack: () => + Effect.succeed(opts.found === false ? Option.none() : Option.some(descriptor)), + inspectStack: () => Effect.succeed({ descriptor, owner: "absent" as const }), + openStack: (stackId) => + Effect.sync(() => { + state.openedIds.push(stackId); + return { ...stack, id: StackIdSchema.make(stackId) }; + }), + }), + ); + return { layer, output, state }; +} + +describe("experimental stack destroy", () => { + it.live("requires --yes in a noninteractive session and does not mutate", () => { + const setupResult = setup({ yes: false, stdinIsTty: false }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackDestroy(flags()).pipe(Effect.flip); + expect(failure).toBeInstanceOf(LegacyExperimentalStackDestroyError); + expect(failure.message).toContain("requires confirmation"); + expect(setupResult.state.destroyed).toBe(0); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.live("destroys the exact selected stack after --yes", () => { + const setupResult = setup({ yes: true, stdinIsTty: false }); + return Effect.gen(function* () { + yield* legacyExperimentalStackDestroy(flags({ stackId: Option.some(id) })); + expect(setupResult.state.destroyed).toBe(1); + expect(setupResult.state.openedIds).toEqual([id]); + expect(setupResult.output.stdoutText).toContain("destroyed"); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.live("does not destroy when an interactive confirmation is declined", () => { + const setupResult = setup({ yes: false, stdinIsTty: true, promptConfirmResponses: [false] }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackDestroy(flags()).pipe(Effect.flip); + expect(failure.message).toContain("not confirmed"); + expect(setupResult.state.openedIds).toEqual([]); + expect(setupResult.state.destroyed).toBe(0); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.live("reports a destroy failure without reporting success", () => { + const setupResult = setup({ yes: true, stdinIsTty: false, destroyFailure: true }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackDestroy(flags()).pipe(Effect.flip); + expect(failure.message).toContain("destroy failed"); + expect(setupResult.state.openedIds).toEqual([id]); + expect(setupResult.output.messages.some((message) => message.type === "success")).toBe(false); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.live("emits JSON success after confirmed destruction", () => { + const setupResult = setup({ yes: true, stdinIsTty: false, outputFormat: "json" }); + return Effect.gen(function* () { + yield* legacyExperimentalStackDestroy(flags()); + expect(setupResult.output.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: { destroyed: true, id }, + }), + ); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.live("rejects a missing named stack and malformed id before opening", () => { + const missing = setup({ yes: true, stdinIsTty: false, found: false }); + const malformed = setup({ yes: true, stdinIsTty: false }); + return Effect.gen(function* () { + const missingFailure = yield* legacyExperimentalStackDestroy( + flags({ stack: Option.some("missing") }), + ).pipe(Effect.flip, Effect.provide(missing.layer)); + const malformedFailure = yield* legacyExperimentalStackDestroy( + flags({ stackId: Option.some("invalid") }), + ).pipe(Effect.flip, Effect.provide(malformed.layer)); + expect(missingFailure.message).toContain("No managed stack named"); + expect(malformedFailure.message).toContain("lowercase SHA-256"); + expect(missing.state.openedIds).toEqual([]); + expect(malformed.state.openedIds).toEqual([]); + }); + }); + + it.effect("rejects a stack name and id together before side effects", () => + Effect.gen(function* () { + const failure = yield* legacyValidateExperimentalStackDestroyTarget({ + stack: Option.some("feature-a"), + stackId: Option.some(id), + }).pipe(Effect.flip); + expect(failure.message).toContain("cannot be used together"); + }), + ); +}); + +describe("experimental stack destroy parser", () => { + it.live("parses an explicit stack id", () => { + let parsed: Option.Option | undefined; + const command = legacyExperimentalStackDestroyCommand.pipe( + Command.withHandler((parsedFlags) => Effect.sync(() => (parsed = parsedFlags.stackId))), + ); + return Command.runWith(command, { version: "0.0.0-test" })(["--stack-id", id]).pipe( + Effect.andThen(Effect.sync(() => expect(parsed).toEqual(Option.some(id)))), + Effect.provide(BunServices.layer), + ); + }); +}); 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 index 9468cea7b0..af269c2b75 100644 --- 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 @@ -5,6 +5,7 @@ import { legacyRootForBackend } from "../../../cli/root.ts"; const canonicalStackCommands = [ "start", "stop", + "destroy", "status", "list", "logs", @@ -19,7 +20,7 @@ const complete = (backend: "legacy" | "stack", args: ReadonlyArray) => { }; describe("experimental stack command routing", () => { - it("exposes the same seven canonical stack paths from both backend roots", () => { + it("exposes the same eight canonical stack paths from both backend roots", () => { for (const backend of ["legacy", "stack"] as const) { expect(complete(backend, ["stack", ""])).toEqual( expect.arrayContaining([...canonicalStackCommands]), @@ -50,7 +51,11 @@ describe("experimental stack command routing", () => { expect(complete("legacy", ["status", "--"])).toEqual( expect.arrayContaining(["--override-name"]), ); - expect(complete("stack", ["status", "--"])).not.toContain("--override-name"); + expect(complete("stack", ["status", "--"])).toEqual( + expect.arrayContaining(["--env", "--override-name"]), + ); + expect(complete("stack", ["stop", "--"])).toContain("--all"); + expect(complete("stack", ["start", "--"])).toContain("--exclude"); 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 bf5ae8cd35..9f492b5fc0 100644 --- a/apps/cli/src/commands/experimental/stack/stack.command.ts +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -1,3 +1,4 @@ +import { legacyExperimentalStackDestroyCommand } from "./destroy/destroy.command.ts"; import { Layer } from "effect"; import { Command } from "effect/unstable/cli"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; @@ -32,6 +33,9 @@ export const legacyExperimentalStackCommand = Command.make("stack").pipe( legacyExperimentalStackStopCommand.pipe( Command.provide(commandRuntimeLayer(["stack", "stop"])), ), + legacyExperimentalStackDestroyCommand.pipe( + Command.provide(commandRuntimeLayer(["stack", "destroy"])), + ), legacyExperimentalStackStatusCommand.pipe( Command.provide(commandRuntimeLayer(["stack", "status"])), ), 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 55b347e5ea..1e89185043 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -20,8 +20,16 @@ never emits those values. `--stack` and `--stack-id` are mutually exclusive. `--runtime auto` uses the package default; `docker` selects the Docker container runtime; `native` selects the native runtime. `--preparation` controls background versus -on-demand artifact preparation, and `--eager` requests enabled capabilities be -activated before the command returns. +on-demand artifact preparation, and `--eager` requests every enabled capability +be activated and ready before the command returns. `--exclude` is a +per-invocation override for optional capabilities (`rest`, `auth`, `realtime`, +`storage`, `functions`, `studio`, `mail`, `analytics`, and `pooler`). It does +not modify the project config, but the effective configuration is persisted in +the stack state by the package. A later start without `--exclude` uses the +project configuration again and restores those capabilities; stop the stack +first when applying that changed configuration. The database capability is +mandatory and cannot be excluded; unknown capability names are rejected before +the stack is created or opened. The command owns only the start request. Once the package reports readiness, the detached stack owner remains alive after the CLI process exits. If the CLI 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 36d26b993d..5eaabc7df0 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.command.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.command.ts @@ -1,10 +1,17 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { legacyStringSliceFlag } from "../../../../command-internal/legacy-string-slice-flag.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts"; import { legacyExperimentalStackStart } from "./start.handler.ts"; +import { legacyExperimentalStackStartExcludableCapabilities } from "./start.options.ts"; const config = { + exclude: legacyStringSliceFlag( + "exclude", + `Capabilities to leave disabled. [${legacyExperimentalStackStartExcludableCapabilities.join(", ")}]`, + { alias: "x" }, + ), stack: Flag.string("stack").pipe(Flag.withDescription("Name this stack."), Flag.optional), stackId: Flag.string("stack-id").pipe( Flag.withDescription("Open an existing stack by id."), 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 90cc100154..5ebdf2e722 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 @@ -9,6 +9,7 @@ import { access, mkdir, mkdtemp, readdir, realpath, rm, writeFile } from "node:f import { execFile as execFileCallback } from "node:child_process"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- compiled CLI fixture requires host process/filesystem APIs import path from "node:path"; +import { parse as parseDotenv } from "dotenv"; import { promisify } from "node:util"; import { afterEach, describe, expect, test } from "vitest"; import { makeTempHome, runSupabase } from "../../../../../tests/helpers/cli.ts"; @@ -51,14 +52,14 @@ enabled = false `; // oxlint-disable-next-line effecttsgo/async-function -- subprocess cleanup is a foreign Promise boundary -async function inspectAndDestroyStack(home: string, stackId: string) { +async function inspectAndDestroyStack(home: string, stackId: string, destroy = true) { const script = ` import { inspectStack, openStack, StackIdSchema } from "@supabase/stack"; const id = StackIdSchema.make(process.argv.at(-1)); const inspection = await inspectStack(id); const stack = await openStack(id); const status = await stack.status(); - await stack.destroy(); + if (${destroy}) await stack.destroy(); console.log(JSON.stringify({ owner: inspection.owner, projectRoot: inspection.descriptor.projectRoot, @@ -170,14 +171,25 @@ describe("experimental stack start (compiled e2e)", () => { expect(aliasStart.exitCode, aliasStart.stderr).toBe(0); expect(aliasStart.stdout).toContain(idText); - const observed = await inspectAndDestroyStack(homeDir.dir, idText); - stackDestroyed = true; + const envStatus = await runSupabase( + ["status", "--stack-id", idText, "--env", "--output-format", "text"], + aliasOptions, + ); + expect(envStatus.exitCode, envStatus.stderr).toBe(0); + expect(parseDotenv(envStatus.stdout).DB_URL).toMatch(/^postgres(?:ql)?:\/\//u); + const observed = await inspectAndDestroyStack(homeDir.dir, idText, false); expect(observed.owner).toBe("running"); expect(observed.projectRoot).toBe(await realpath(projectRoot)); expect(observed.runtime).toEqual({ kind: "native" }); expect(observed.lifecycle).toBe("running"); expect(observed.database).toBe("ready"); + const destroyed = await runSupabase( + ["stack", "destroy", "--stack-id", idText, "--yes"], + aliasOptions, + ); + expect(destroyed.exitCode, destroyed.stderr).toBe(0); + stackDestroyed = true; await expect(access(path.join(homeDir.dir, "managed", "stacks", idText))).rejects.toThrow(); }, ); diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index 83d8988090..f0bad13a3d 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -15,6 +15,9 @@ import { LegacyExperimentalStackStartError, LegacyExperimentalStackTargetFlagsError, } from "./start.errors.ts"; +import { legacyExperimentalStackStartExcludableCapabilities } from "./start.options.ts"; + +type LegacyLoadedStackConfig = Effect.Success>; const eagerlyActivate = < T extends { readonly enabled?: boolean; readonly activation?: "eager" | "lazy" }, @@ -22,6 +25,72 @@ const eagerlyActivate = < value: T, ): T => (value.enabled === false ? value : Object.assign({}, value, { activation: "eager" })); +const legacyValidateExperimentalStackStartExclusions = (exclusions: readonly string[]) => { + const unknown = exclusions.filter( + (name) => + name !== "database" && + !legacyExperimentalStackStartExcludableCapabilities.some((capability) => capability === name), + ); + if (unknown.length > 0) + return Effect.fail( + new LegacyExperimentalStackStartError({ + reason: "flags", + message: `Unknown stack capabilities in --exclude: ${unknown.join(", ")}`, + suggestion: `Choose from ${legacyExperimentalStackStartExcludableCapabilities.join(", ")}.`, + }), + ); + if (exclusions.includes("database")) + return Effect.fail( + new LegacyExperimentalStackStartError({ + reason: "flags", + message: "The database capability cannot be excluded from a stack.", + suggestion: "Remove database from --exclude.", + }), + ); + return Effect.void; +}; + +const legacyApplyExperimentalStackStartExclusions = ( + config: LegacyLoadedStackConfig, + exclusions: readonly string[], +) => { + if (exclusions.length === 0) return config; + const excluded = new Set(exclusions); + return { + ...config, + capabilities: { + ...config.capabilities, + ...(excluded.has("rest") + ? { rest: { ...config.capabilities?.rest, enabled: false as const } } + : {}), + ...(excluded.has("auth") + ? { auth: { ...config.capabilities?.auth, enabled: false as const } } + : {}), + ...(excluded.has("realtime") + ? { realtime: { ...config.capabilities?.realtime, enabled: false as const } } + : {}), + ...(excluded.has("storage") + ? { storage: { ...config.capabilities?.storage, enabled: false as const } } + : {}), + ...(excluded.has("functions") + ? { functions: { ...config.capabilities?.functions, enabled: false as const } } + : {}), + ...(excluded.has("studio") + ? { studio: { ...config.capabilities?.studio, enabled: false as const } } + : {}), + ...(excluded.has("mail") + ? { mail: { ...config.capabilities?.mail, enabled: false as const } } + : {}), + ...(excluded.has("analytics") + ? { analytics: { ...config.capabilities?.analytics, enabled: false as const } } + : {}), + ...(excluded.has("pooler") + ? { pooler: { ...config.capabilities?.pooler, enabled: false as const } } + : {}), + }, + }; +}; + export const legacyValidateExperimentalStackStartTarget = ( flags: Pick, ) => @@ -47,6 +116,8 @@ export const legacyExperimentalStackStart = Effect.fn("legacy.experimental.stack message: "The legacy -o/--output flag is not supported here; use --output-format json.", suggestion: "Use --output-format json or --output-format text.", }); + const exclusions = flags.exclude; + yield* legacyValidateExperimentalStackStartExclusions(exclusions); yield* legacyValidateExperimentalStackStartTarget(flags); const target = yield* resolver.resolve({ @@ -65,42 +136,43 @@ export const legacyExperimentalStackStart = Effect.fn("legacy.experimental.stack }), ), ); + const configuredStart = legacyApplyExperimentalStackStartExclusions(config, exclusions); const startConfig = flags.eager ? { - ...config, + ...configuredStart, capabilities: { - ...config.capabilities, - ...(config.capabilities?.rest === undefined + ...configuredStart.capabilities, + ...(configuredStart.capabilities?.rest === undefined ? {} - : { rest: eagerlyActivate(config.capabilities.rest) }), - ...(config.capabilities?.auth === undefined + : { rest: eagerlyActivate(configuredStart.capabilities.rest) }), + ...(configuredStart.capabilities?.auth === undefined ? {} - : { auth: eagerlyActivate(config.capabilities.auth) }), - ...(config.capabilities?.realtime === undefined + : { auth: eagerlyActivate(configuredStart.capabilities.auth) }), + ...(configuredStart.capabilities?.realtime === undefined ? {} - : { realtime: eagerlyActivate(config.capabilities.realtime) }), - ...(config.capabilities?.storage === undefined + : { realtime: eagerlyActivate(configuredStart.capabilities.realtime) }), + ...(configuredStart.capabilities?.storage === undefined ? {} - : { storage: eagerlyActivate(config.capabilities.storage) }), - ...(config.capabilities?.functions === undefined + : { storage: eagerlyActivate(configuredStart.capabilities.storage) }), + ...(configuredStart.capabilities?.functions === undefined ? {} - : { functions: eagerlyActivate(config.capabilities.functions) }), - ...(config.capabilities?.studio === undefined + : { functions: eagerlyActivate(configuredStart.capabilities.functions) }), + ...(configuredStart.capabilities?.studio === undefined ? {} - : { studio: eagerlyActivate(config.capabilities.studio) }), - ...(config.capabilities?.mail === undefined + : { studio: eagerlyActivate(configuredStart.capabilities.studio) }), + ...(configuredStart.capabilities?.mail === undefined ? {} - : { mail: eagerlyActivate(config.capabilities.mail) }), - ...(config.capabilities?.analytics === undefined + : { mail: eagerlyActivate(configuredStart.capabilities.mail) }), + ...(configuredStart.capabilities?.analytics === undefined ? {} - : { analytics: eagerlyActivate(config.capabilities.analytics) }), - ...(config.capabilities?.pooler === undefined + : { analytics: eagerlyActivate(configuredStart.capabilities.analytics) }), + ...(configuredStart.capabilities?.pooler === undefined ? {} - : { pooler: eagerlyActivate(config.capabilities.pooler) }), + : { pooler: eagerlyActivate(configuredStart.capabilities.pooler) }), }, preparation: flags.preparation, } - : { ...config, preparation: flags.preparation }; + : { ...configuredStart, preparation: flags.preparation }; const runtime: StackRuntimePreference | undefined = target.runtime; // The package's public Effect API reads SUPABASE_HOME only at its runtime // composition boundary and launches the detached owner through the compiled 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 f53f9a5e22..40131e4f32 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 @@ -102,6 +102,7 @@ const flags = (overrides: Partial { ); }); + it.live("applies capability exclusions only to the start request", () => { + const root = project(); + let startConfig: unknown; + const stack = fakeStack("e".repeat(64), (config) => { + startConfig = config; + return Effect.succeed(status("e".repeat(64))); + }); + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + yield* legacyExperimentalStackStart(flags({ exclude: ["rest", "functions"] })); + expect(startConfig).toMatchObject({ + config: { + capabilities: { + rest: { enabled: false }, + functions: { enabled: false }, + }, + }, + }); + expect(startConfig).not.toMatchObject({ + config: { capabilities: { auth: { enabled: false } } }, + }); + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.live("rejects unknown and mandatory capability exclusions before resolving a target", () => { + const root = project(); + let resolved = false; + const setup = handlerLayer({ + root, + target: { projectRoot: root }, + stack: fakeStack("e".repeat(64), () => Effect.succeed(status("e".repeat(64)))), + }); + const resolver = Layer.succeed(LegacyExperimentalStackTargetResolver, { + resolve: () => { + resolved = true; + return Effect.die("resolver should not run"); + }, + }); + return Effect.gen(function* () { + const unknown = yield* legacyExperimentalStackStart(flags({ exclude: ["gotrue"] })).pipe( + Effect.flip, + ); + expect(unknown.message).toContain("Unknown stack capabilities"); + const database = yield* legacyExperimentalStackStart(flags({ exclude: ["database"] })).pipe( + Effect.flip, + ); + expect(database.message).toContain("cannot be excluded"); + expect(resolved).toBe(false); + }).pipe( + Effect.provide(Layer.mergeAll(setup.layer, resolver)), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + it.live("opens an addressed existing stack using its own project root", () => { const settingsRoot = project(); const targetRoot = project(); @@ -537,6 +595,26 @@ describe("experimental stack start targeting", () => { }); describe("experimental stack start parser", () => { + it.live("parses comma-separated and repeated capability exclusions", () => { + let parsed: readonly string[] | undefined; + const command = legacyExperimentalStackStartCommand.pipe( + Command.withHandler((flags) => Effect.sync(() => (parsed = flags.exclude))), + ); + return Effect.gen(function* () { + yield* Command.runWith(command, { version: "0.0.0-test" })([ + "--exclude", + "rest,auth", + "--exclude", + "functions", + "-x", + "storage", + ]); + expect(parsed).toEqual(["rest", "auth", "functions", "storage"]); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), + ); + }); + it.live("parses --stack and --runtime through the command", () => { let parsed: { stack: Option.Option; runtime: string } | undefined; const command = legacyExperimentalStackStartCommand.pipe( diff --git a/apps/cli/src/commands/experimental/stack/start/start.options.ts b/apps/cli/src/commands/experimental/stack/start/start.options.ts new file mode 100644 index 0000000000..97bb537ee2 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/start/start.options.ts @@ -0,0 +1,12 @@ +/** Optional capabilities accepted by `stack start --exclude`. */ +export const legacyExperimentalStackStartExcludableCapabilities = [ + "rest", + "auth", + "realtime", + "storage", + "functions", + "studio", + "mail", + "analytics", + "pooler", +] as const; 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 b80c8f7c13..a747af4acd 100644 --- a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -1,8 +1,8 @@ # `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 -opens a stack handle. +The command is read-only: it never creates, starts, prepares, stops, or destroys a stack. With `--env`, it opens a read-only stack handle to retrieve +current status and credentials. Target selection accepts the current project, `--stack `, or `--stack-id `. `--stack` and `--stack-id` are mutually exclusive. An explicit @@ -21,3 +21,19 @@ not be loaded. Drift compares the persisted effective stack definition with the configuration-derived candidate, so explicit start policies such as `--eager` or `--preparation on-demand` remain visible as intentional policy drift on a later status check. + +`--env` exports the selected running stack’s connection variables. Text output +is dotenv content; `--output-format json` and `stream-json` return a variable map. +`--override-name API_URL=NEXT_PUBLIC_SUPABASE_URL` renames an exported variable; +it supports CSV and repeated values, requires `--env`, and rejects unknown +source names, invalid environment names, and duplicate destination names. + +Variables are `DB_URL`, `API_URL`, `ANON_KEY`, `SERVICE_ROLE_KEY`, +`PUBLISHABLE_KEY`, `SECRET_KEY`, `STUDIO_URL`, `INBUCKET_URL`, +`S3_PROTOCOL_ACCESS_KEY_ID`, `S3_PROTOCOL_ACCESS_KEY_SECRET`, +`S3_PROTOCOL_REGION`, and `S3_PROTOCOL_URL`. API credentials are omitted when Auth is disabled. Optional service endpoints +and S3 credentials are omitted when unavailable. These values come from the running +stack, never legacy containers or project config. This explicit export reveals +credentials; ordinary status output does not. A stopped stack or credential +retrieval failure produces an error without partial output. Environment export +does not compare project configuration or report drift. diff --git a/apps/cli/src/commands/experimental/stack/status/status.command.ts b/apps/cli/src/commands/experimental/stack/status/status.command.ts index 8188e516f3..fc393a4a1b 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.command.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.command.ts @@ -1,3 +1,4 @@ +import { legacyStringSliceFlag } from "../../../../command-internal/legacy-string-slice-flag.ts"; 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"; @@ -5,6 +6,14 @@ import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-c import { legacyExperimentalStackStatus } from "./status.handler.ts"; const config = { + env: Flag.boolean("env").pipe( + Flag.withDescription("Export connection URLs and credentials as environment variables."), + Flag.withDefault(false), + ), + overrideName: legacyStringSliceFlag( + "override-name", + "Rename an exported variable: API_URL=NEXT_PUBLIC_SUPABASE_URL (requires --env).", + ), stack: Flag.string("stack").pipe(Flag.withDescription("Inspect a named stack."), Flag.optional), stackId: Flag.string("stack-id").pipe( Flag.withDescription("Inspect an existing stack by id."), diff --git a/apps/cli/src/commands/experimental/stack/status/status.env.ts b/apps/cli/src/commands/experimental/stack/status/status.env.ts new file mode 100644 index 0000000000..d58fb9325c --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/status.env.ts @@ -0,0 +1,95 @@ +import type { EffectStackCredentials, StackStatus } from "@supabase/stack/effect"; +import { Effect, Redacted } from "effect"; +import { LegacyExperimentalStackStatusError } from "./status.errors.ts"; + +const variableNames = [ + "API_URL", + "DB_URL", + "ANON_KEY", + "SERVICE_ROLE_KEY", + "PUBLISHABLE_KEY", + "SECRET_KEY", + "STUDIO_URL", + "INBUCKET_URL", + "S3_PROTOCOL_ACCESS_KEY_ID", + "S3_PROTOCOL_ACCESS_KEY_SECRET", + "S3_PROTOCOL_REGION", + "S3_PROTOCOL_URL", +] as const; + +export const legacyStackEnvOverrides = (entries: ReadonlyArray) => + Effect.gen(function* () { + const names = new Map(variableNames.map((name) => [String(name), String(name)])); + for (const entry of entries) { + const [source, target, extra] = entry.split("="); + if ( + source === undefined || + !names.has(source) || + target === undefined || + extra !== undefined || + !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(target) + ) + return yield* new LegacyExperimentalStackStatusError({ + reason: "flags", + message: + "--override-name must be EXPORTED_VARIABLE=VALID_ENV_NAME; for example API_URL=NEXT_PUBLIC_SUPABASE_URL.", + }); + names.set(source, target); + } + if (new Set(names.values()).size !== names.size) + return yield* new LegacyExperimentalStackStatusError({ + reason: "flags", + message: "--override-name produces duplicate environment variable names.", + }); + return names; + }); + +export const legacyStackEnvValues = ( + status: StackStatus, + credentials: EffectStackCredentials, + names: ReadonlyMap, +): Readonly> => { + const values: Record = { + DB_URL: Redacted.value(credentials.database.url), + ...(credentials.api === undefined + ? {} + : { + ANON_KEY: credentials.api.anonJwt, + SERVICE_ROLE_KEY: Redacted.value(credentials.api.serviceRoleJwt), + PUBLISHABLE_KEY: credentials.api.publishableKey, + SECRET_KEY: Redacted.value(credentials.api.secretKey), + }), + ...(status.endpoints.api === undefined ? {} : { API_URL: status.endpoints.api.url }), + ...(status.endpoints.studio === undefined ? {} : { STUDIO_URL: status.endpoints.studio.url }), + ...(status.endpoints.mailUi === undefined ? {} : { INBUCKET_URL: status.endpoints.mailUi.url }), + ...(credentials.storage === undefined + ? {} + : { + S3_PROTOCOL_ACCESS_KEY_ID: credentials.storage.accessKeyId, + S3_PROTOCOL_ACCESS_KEY_SECRET: Redacted.value(credentials.storage.secretAccessKey), + S3_PROTOCOL_REGION: credentials.storage.region, + S3_PROTOCOL_URL: credentials.storage.endpoint, + }), + }; + return Object.fromEntries( + Object.entries(values).map(([key, value]) => [names.get(key) ?? key, value]), + ); +}; + +/** Dotenv quoting preserves URLs and keys verbatim, including literal backslashes. */ +export const legacyEncodeStackEnv = (values: Readonly>) => + Effect.forEach( + Object.entries(values).sort(([left], [right]) => left.localeCompare(right)), + ([name, value]) => { + const quote = ["'", "`"].find((candidate) => !value.includes(candidate)); + if (quote === undefined || value.includes("\r")) + return Effect.fail( + new LegacyExperimentalStackStatusError({ + reason: "runtime", + message: + "A credential cannot be represented losslessly as dotenv. Use --env --output-format json.", + }), + ); + return Effect.succeed(`${name}=${quote}${value}${quote}`); + }, + ).pipe(Effect.map((lines) => `${lines.join("\n")}\n`)); diff --git a/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts b/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts new file mode 100644 index 0000000000..f80ad5c174 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "@effect/vitest"; +import { parse } from "dotenv"; +import { Effect, Exit } from "effect"; +import { legacyEncodeStackEnv } from "./status.env.ts"; + +describe("stack dotenv encoding", () => { + it.effect("round-trips literal credentials without expanding or changing characters", () => + Effect.gen(function* () { + const values = { + TOKEN: "000123", + SECRET: "literal\\n$HOME#hash=equals\nnew line", + QUOTED: "it's a secret", + EMPTY: "", + }; + const encoded = yield* legacyEncodeStackEnv(values); + expect(parse(encoded)).toEqual(values); + }), + ); + + it.effect("fails without exposing values that dotenv cannot represent losslessly", () => + Effect.gen(function* () { + for (const value of ["both'and`quotes", "carriage\rreturn"]) { + const result = yield* legacyEncodeStackEnv({ SECRET: value }).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + } + }), + ); +}); 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 b3a6b295d3..6b1c53506e 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -1,3 +1,8 @@ +import { + legacyEncodeStackEnv, + legacyStackEnvOverrides, + legacyStackEnvValues, +} from "./status.env.ts"; import { Effect, Match, Option } from "effect"; import { isStackError, @@ -171,12 +176,33 @@ export const legacyExperimentalStackStatus = Effect.fn("legacy.experimental.stac suggestion: "Use --output-format json or --output-format text.", }); yield* validateFlags(flags); + if (!flags.env && flags.overrideName.length > 0) + return yield* new LegacyExperimentalStackStatusError({ + reason: "flags", + message: "--override-name requires --env.", + }); + const envNames = yield* legacyStackEnvOverrides(flags.overrideName); const target = yield* findDescriptor( settings.workdir, Option.getOrUndefined(flags.stack), Option.getOrUndefined(flags.stackId), ); const api = yield* LegacyExperimentalStackApi; + if (flags.env) { + const stack = yield* catchStackError(api.openStack(target.id)); + const status = yield* catchStackError(stack.status()); + if (status.lifecycle !== "running") + return yield* new LegacyExperimentalStackStatusError({ + reason: "runtime", + message: "The stack must be running to export connection variables.", + suggestion: "Run supabase stack start first.", + }); + const credentials = yield* catchStackError(stack.credentials()); + const values = legacyStackEnvValues(status, credentials, envNames); + if (output.format === "text") yield* output.raw(yield* legacyEncodeStackEnv(values)); + else yield* output.success("", values); + return target.inspection; + } const loaded = yield* legacyLoadStackConfig(target.projectRoot).pipe( Effect.map((config) => ({ config, warning: undefined })), Effect.catchTag("LegacyStackConfigError", () => 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 b12dbf6206..24c6b3a0d0 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 @@ -3,13 +3,15 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary import { join } from "node:path"; +import { parse as parseDotenv } from "dotenv"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option, Redacted, Stream } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; import { InvalidStackConfigError, StackNotFoundError, + StackNotRunningError, StackIdSchema, StackStateFormatUnsupportedError, type StackInspection, @@ -43,6 +45,8 @@ const capabilityNames = [ const flags = (stack = Option.none(), stackId = Option.none()) => ({ stack, stackId, + env: false, + overrideName: [] as string[], }); const makeStatus = ( @@ -68,13 +72,16 @@ const makeStatus = ( const runStatus = (options: { readonly config?: "valid" | "missing" | "invalid"; readonly owner?: StackInspection["owner"]; + readonly credentialFailure?: boolean; + readonly storageCredentials?: boolean; + readonly authDisabled?: boolean; readonly status?: StackStatus; readonly drift?: StackInspection["configDrift"]; readonly flags?: ReturnType; readonly compareFailure?: "typed" | "defect"; readonly missingTarget?: boolean; readonly legacyOutput?: boolean; - readonly outputFormat?: "text" | "json"; + readonly outputFormat?: "text" | "json" | "stream-json"; }) => { const root = mkdtempSync(join(tmpdir(), "supabase-stack-status-")); const projectRoot = join(root, "project"); @@ -110,7 +117,48 @@ const runStatus = (options: { return Effect.succeed(options.missingTarget ? Option.none() : Option.some(descriptor)); }, listStacks: () => Effect.succeed([]), - openStack: () => Effect.die("open must not run"), + openStack: () => + Effect.succeed({ + id, + status: () => Effect.succeed(options.status ?? makeStatus(id)), + credentials: () => + options.credentialFailure + ? Effect.fail( + new StackNotRunningError({ stackId: id, message: "Stack is not running" }), + ) + : Effect.succeed({ + database: { + url: Redacted.make("postgresql://postgres:p%40ss@127.0.0.1:54322/postgres"), + password: Redacted.make("p@ss"), + }, + ...(options.authDisabled + ? {} + : { + api: { + anonJwt: "anon-token", + serviceRoleJwt: Redacted.make("service-role-token"), + publishableKey: "sb_publishable_test", + secretKey: Redacted.make("sb_secret_test"), + }, + }), + ...(options.storageCredentials + ? { + storage: { + endpoint: "http://127.0.0.1:54321/storage/v1/s3", + region: "local", + accessKeyId: "storage-access", + secretAccessKey: Redacted.make("storage-secret"), + }, + } + : {}), + }), + prepare: () => Effect.die("unused"), + start: () => Effect.die("unused"), + stop: () => Effect.die("unused"), + destroy: () => Effect.die("unused"), + logs: () => Effect.die("unused"), + followLogs: () => Stream.empty, + }), inspectStack: (_stackId, inspectOptions) => { inspectInputs.push(inspectOptions); if (options.missingTarget === true) @@ -366,6 +414,172 @@ describe("experimental stack status", () => { }); }); + it.effect("parses env selection and repeated CSV variable overrides", () => + Command.runWith( + legacyExperimentalStackStatusCommand.pipe( + Command.withHandler((input) => + Effect.sync(() => { + expect(input.env).toBe(true); + expect(input.overrideName).toEqual([ + "API_URL=APP_URL", + "ANON_KEY=APP_KEY", + "DB_URL=DATABASE_URL", + ]); + }), + ), + ), + { version: "0.0.0-test" }, + )([ + "--env", + "--override-name", + "API_URL=APP_URL,ANON_KEY=APP_KEY", + "--override-name", + "DB_URL=DATABASE_URL", + ]).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), + ), + ); + + it.effect("exports the running stack credentials as dotenv with renamed variables", () => { + const run = runStatus({ + config: "invalid", + flags: { ...flags(), env: true, overrideName: ["API_URL=NEXT_PUBLIC_SUPABASE_URL"] }, + }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(parseDotenv(run.out.stdoutText)).toEqual({ + NEXT_PUBLIC_SUPABASE_URL: "http://127.0.0.1:54321", + DB_URL: "postgresql://postgres:p%40ss@127.0.0.1:54322/postgres", + ANON_KEY: "anon-token", + SERVICE_ROLE_KEY: "service-role-token", + PUBLISHABLE_KEY: "sb_publishable_test", + SECRET_KEY: "sb_secret_test", + }); + expect(run.inspectInputs).toHaveLength(0); + }), + ), + ); + }); + + for (const outputFormat of ["json", "stream-json"] as const) { + it.effect(`exports a variable map in ${outputFormat}`, () => { + const run = runStatus({ outputFormat, flags: { ...flags(), env: true } }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect( + run.out.messages.find((message) => message.type === "success")?.data, + ).toMatchObject({ API_URL: "http://127.0.0.1:54321", SECRET_KEY: "sb_secret_test" }); + expect(run.out.stdoutText).toBe(""); + }), + ), + ); + }); + } + + it.effect("exports optional service URLs and storage credentials only when available", () => { + const run = runStatus({ + storageCredentials: true, + flags: { ...flags(), env: true }, + status: { + ...makeStatus(id), + endpoints: { + studio: { + protocol: "http", + address: "127.0.0.1", + port: 54323, + url: "http://127.0.0.1:54323", + }, + mailUi: { + protocol: "http", + address: "127.0.0.1", + port: 54324, + url: "http://127.0.0.1:54324", + }, + }, + }, + }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + const values = parseDotenv(run.out.stdoutText); + expect(values.API_URL).toBeUndefined(); + expect(values).toMatchObject({ + STUDIO_URL: "http://127.0.0.1:54323", + INBUCKET_URL: "http://127.0.0.1:54324", + S3_PROTOCOL_ACCESS_KEY_SECRET: "storage-secret", + S3_PROTOCOL_REGION: "local", + }); + }), + ), + ); + }); + + it.effect("exports a database-only stack without inventing API credentials", () => { + const run = runStatus({ + authDisabled: true, + flags: { ...flags(), env: true }, + status: { ...makeStatus(id), endpoints: {} }, + }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(parseDotenv(run.out.stdoutText)).toEqual({ + DB_URL: "postgresql://postgres:p%40ss@127.0.0.1:54322/postgres", + }); + }), + ), + ); + }); + + it.effect("keeps ordinary status independent of credentials and free of secrets", () => { + const run = runStatus({ status: makeStatus(id), credentialFailure: true }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(run.out.stdoutText).toContain("Lifecycle: running"); + expect(run.out.stdoutText).not.toContain("sb_secret_test"); + }), + ), + ); + }); + + it.effect("rejects invalid or colliding variable renames before discovery", () => + Effect.forEach( + [ + { ...flags(), overrideName: ["API_URL=APP_URL"] }, + { ...flags(), env: true, overrideName: ["UNKNOWN=APP_URL"] }, + { ...flags(), env: true, overrideName: ["API_URL=NOT-VALID"] }, + { ...flags(), env: true, overrideName: ["API_URL=DB_URL"] }, + { ...flags(), env: true, overrideName: ["API_URL"] }, + { ...flags(), env: true, overrideName: ["API_URL=A=B"] }, + ], + (input) => + Effect.gen(function* () { + const run = runStatus({ flags: input }); + expect(Exit.isFailure(yield* run.effect.pipe(Effect.exit))).toBe(true); + expect(run.findInputs).toHaveLength(0); + expect(run.out.stdoutText).toBe(""); + }), + ), + ); + + it.effect("exports no partial secrets when the stack is stopped or credentials fail", () => + Effect.forEach( + [ + { status: { ...makeStatus(id), lifecycle: "stopped" as const } }, + { credentialFailure: true }, + ], + (options) => + Effect.gen(function* () { + const run = runStatus({ ...options, flags: { ...flags(), env: true } }); + expect(Exit.isFailure(yield* run.effect.pipe(Effect.exit))).toBe(true); + expect(run.out.stdoutText).toBe(""); + }), + ), + ); + it.effect("does not retry discovery failures", () => { const run = runStatus({}); const discovery = Layer.succeed(LegacyExperimentalStackApi, { 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 ae2ab099c9..079c49b318 100644 --- a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md @@ -1,8 +1,9 @@ # `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 -preserving the stack's persistent state and data volumes. It never destroys the stack. +name, or `--stack-id`. With `--all`, it enumerates every registered new-backend stack and +attempts each stop while preserving every stack's persistent state and data volumes. `--all` +cannot be combined with `--stack` or `--stack-id`. It never destroys a stack. ## Files read and written @@ -20,8 +21,9 @@ returning. That process is package-owned and is not managed directly by the CLI. ## Output and telemetry Text mode reports the selected stack and stopped outcome. Structured modes include the selected -stack id and stopped outcome. If no current stack exists, the command succeeds with an explicit -no-stack result. Exit status is `0` for a successful stop or no current stack, `1` for a -missing named stack or any typed stop failure, and `130` if the command is interrupted before -the stop completes. Standard command instrumentation records command +stack id and stopped outcome. For `--all`, all stops are attempted and a partial failure returns +one aggregate error naming failed stack ids. An empty registry succeeds. If no current stack +exists, the command succeeds with an explicit no-stack result. Exit status is `0` for a +successful stop or no current stack, `1` for a missing named stack or any typed stop failure, +and `130` if the command is interrupted before the stop completes. Standard command instrumentation records command metadata; stack data and credentials are not emitted as telemetry properties. 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 982ce6f6b8..a879e1f6c7 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts @@ -4,6 +4,10 @@ import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-c import { legacyExperimentalStackStop } from "./stop.handler.ts"; const config = { + all: Flag.boolean("all").pipe( + Flag.withDescription("Stop every managed stack while preserving its data."), + Flag.withDefault(false), + ), stack: Flag.string("stack").pipe( Flag.withDescription("Stop the stack with this name (defaults to the current project stack)."), Flag.optional, @@ -15,13 +19,17 @@ const config = { } as const; export const legacyExperimentalStackStopCommand = Command.make("stop", config).pipe( - Command.withDescription("Stop a managed local Supabase stack while preserving its data."), + Command.withDescription("Stop one or all managed local Supabase stacks while preserving data."), Command.withShortDescription("Stop a managed local stack"), Command.withExamples([ { command: "supabase stack stop --stack feature-a", description: "Stop the existing feature-a stack", }, + { + command: "supabase stack stop --all", + description: "Stop every managed stack", + }, ]), Command.withHandler((flags) => legacyExperimentalStackStop(flags).pipe( diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts index 930b19a06b..3a43cd888f 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -1,4 +1,4 @@ -import { Effect, Match, Option } from "effect"; +import { Effect, Match, Option, Result } from "effect"; import { isStackError, isStackId, @@ -12,18 +12,20 @@ import { LegacyExperimentalStackApi } from "../stack.shared.ts"; import { LegacyExperimentalStackStopError } from "./stop.errors.ts"; export interface LegacyExperimentalStackStopFlags { + readonly all: boolean; readonly stack: Option.Option; readonly stackId: Option.Option; } export const legacyValidateExperimentalStackStopTarget = ( - flags: Pick, + flags: Pick, ) => - Option.isSome(flags.stack) && Option.isSome(flags.stackId) + (flags.all && (Option.isSome(flags.stack) || Option.isSome(flags.stackId))) || + (Option.isSome(flags.stack) && Option.isSome(flags.stackId)) ? Effect.fail( new LegacyExperimentalStackStopError({ reason: "flags", - message: "--stack and --stack-id cannot be used together", + message: "--all, --stack, and --stack-id cannot be used together", }), ) : Effect.void; @@ -80,6 +82,42 @@ export const legacyExperimentalStackStop = Effect.fn("legacy.experimental.stack. }); yield* legacyValidateExperimentalStackStopTarget(flags); + if (flags.all) { + const stacks = yield* stackApi.listStacks().pipe(Effect.mapError(stopError)); + const stopping = yield* output.task(`Stopping ${stacks.length} managed stack(s)...`); + const results = yield* Effect.forEach( + stacks, + (descriptor) => + stackApi.openStack(descriptor.id).pipe( + Effect.flatMap((stack) => stack.stop()), + Effect.result, + Effect.map((result) => ({ descriptor, result })), + ), + { concurrency: 1 }, + ); + const failures = results.flatMap(({ descriptor, result }) => + Result.isFailure(result) ? [{ descriptor, error: result.failure }] : [], + ); + if (failures.length > 0) { + const message = `Failed to stop ${failures.length} of ${stacks.length} managed stacks: ${failures + .map( + ({ descriptor, error }) => + `${descriptor.id}: ${error instanceof Error ? error.message : String(error)}`, + ) + .join("; ")}`; + yield* stopping.fail(message); + return yield* new LegacyExperimentalStackStopError({ + reason: "lifecycle", + message, + cause: failures, + }); + } + yield* stopping.clear(); + if (output.format === "text") yield* output.raw(`Stopped ${stacks.length} managed stack(s).\n`); + else yield* output.success("", { stopped: stacks.map(({ id }) => id) }); + return; + } + const id = Option.isSome(flags.stackId) ? flags.stackId.value : undefined; const targetOption = id === undefined diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts index 2337c88a64..b6b92aeefa 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -52,6 +52,7 @@ const status = (id: string): StackStatus => ({ }); const flags = (overrides: Partial[0]> = {}) => ({ + all: false, stack: Option.none(), stackId: Option.none(), ...overrides, @@ -63,6 +64,8 @@ function setup(opts: { stop?: () => Effect.Effect; openFailure?: OpenStackError; findFailure?: StackDiscoveryError; + allStacks?: ReadonlyArray; + stopFailureIds?: ReadonlyArray; }) { const out = mockOutput(); const state = { @@ -72,18 +75,21 @@ function setup(opts: { destroyCalled: false, }; const id = opts.found?.id ?? "a".repeat(64); + const stopFor = (stackId: string) => + opts.stopFailureIds?.includes(stackId) + ? Effect.fail(new StackStateInvalidError({ message: `stop failed for ${stackId}` })) + : opts.stop === undefined + ? Effect.sync(() => { + state.stopCalls += 1; + }) + : opts.stop(); const stack = { id: StackIdSchema.make(id), status: () => Effect.succeed(status(id)), credentials: () => Effect.die("unused"), prepare: () => Effect.die("unused"), start: () => Effect.die("unused"), - stop: - opts.stop ?? - (() => - Effect.sync(() => { - state.stopCalls += 1; - })), + stop: () => stopFor(id), destroy: () => Effect.sync(() => { state.destroyCalled = true; @@ -101,12 +107,20 @@ function setup(opts: { desiredLifecycle: "running" as const, } : undefined; + const allDescriptors = (opts.allStacks ?? []).map((stackId) => ({ + id: StackIdSchema.make(stackId), + projectRoot: opts.root, + name: `stack-${stackId.slice(0, 6)}`, + branchContext: "ordinary-workspace", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + })); const layer = Layer.mergeAll( out.layer, mockLegacyCliSettings({ workdir: opts.root }), Layer.succeed(LegacyExperimentalStackApi, { createStack: () => Effect.die("must not create"), - listStacks: () => Effect.succeed([]), + listStacks: () => Effect.succeed(allDescriptors), findStack: (input) => Effect.sync(() => { state.findInputs.push(input); @@ -120,7 +134,7 @@ function setup(opts: { if (opts.openFailure !== undefined) return Effect.fail(opts.openFailure); return Effect.sync(() => { state.openedIds.push(stackId); - return stack; + return { ...stack, id: StackIdSchema.make(stackId), stop: () => stopFor(stackId) }; }); }, inspectStack: () => Effect.die("must not inspect"), @@ -266,8 +280,7 @@ describe("experimental stack stop", () => { }); it.effect("is idempotent when no current stack exists and does not read config", () => { - // oxlint-disable-next-line effecttsgo/global-date -- unique fixture directory identity - const root = join(tmpdir(), `supabase-stack-stop-missing-${Date.now()}`); + const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-missing-")); const setupResult = setup({ root }); return Effect.gen(function* () { yield* legacyExperimentalStackStop(flags()); @@ -276,12 +289,16 @@ describe("experimental stack stop", () => { message.message.includes("No managed stack found"), ), ).toBe(true); - }).pipe(Effect.provide(setupResult.layer)); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); }); it.effect("rejects explicit legacy output and mutually exclusive targets", () => Effect.gen(function* () { const targetFailure = yield* legacyValidateExperimentalStackStopTarget({ + all: false, stack: Option.some("feature-a"), stackId: Option.some("a".repeat(64)), }).pipe(Effect.flip); @@ -290,6 +307,36 @@ describe("experimental stack stop", () => { }), ); + it.effect("treats --all as a successful no-op when no managed stacks exist", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-all-empty-")); + const setupResult = setup({ root }); + return Effect.gen(function* () { + yield* legacyExperimentalStackStop(flags({ all: true })); + expect(setupResult.state.openedIds).toEqual([]); + expect(setupResult.state.stopCalls).toBe(0); + expect(setupResult.out.stdoutText).toContain("Stopped 0 managed stack"); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("attempts every stack and reports a partial failure", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-all-partial-")); + const first = "1".repeat(64); + const second = "2".repeat(64); + const setupResult = setup({ root, allStacks: [first, second], stopFailureIds: [first] }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStop(flags({ all: true })).pipe(Effect.flip); + expect(failure.message).toContain("Failed to stop 1 of 2"); + expect(setupResult.state.openedIds).toEqual([first, second]); + expect(setupResult.state.stopCalls).toBe(1); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + it.effect("does not report success when package stop fails", () => { const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-failure-")); const setupResult = setup({ diff --git a/packages/stack/src/public/Credentials.ts b/packages/stack/src/public/Credentials.ts index f75b47c31e..8b78a3e631 100644 --- a/packages/stack/src/public/Credentials.ts +++ b/packages/stack/src/public/Credentials.ts @@ -22,7 +22,7 @@ const EffectStorageCredentialsSchema = Schema.Struct({ export const EffectStackCredentialsSchema = Schema.Struct({ database: EffectDatabaseCredentialsSchema, - api: EffectApiCredentialsSchema, + api: Schema.optionalKey(EffectApiCredentialsSchema), storage: Schema.optionalKey(EffectStorageCredentialsSchema), }); export interface EffectStackCredentials { @@ -30,7 +30,7 @@ export interface EffectStackCredentials { readonly url: Redacted.Redacted; readonly password: Redacted.Redacted; }; - readonly api: { + readonly api?: { readonly publishableKey: string; readonly secretKey: Redacted.Redacted; readonly anonJwt: string; @@ -49,12 +49,14 @@ export const PromiseStackCredentialsSchema = Schema.Struct({ url: Schema.String, password: Schema.String, }), - api: Schema.Struct({ - publishableKey: Schema.String, - secretKey: Schema.String, - anonJwt: Schema.String, - serviceRoleJwt: Schema.String, - }), + api: Schema.optionalKey( + Schema.Struct({ + publishableKey: Schema.String, + secretKey: Schema.String, + anonJwt: Schema.String, + serviceRoleJwt: Schema.String, + }), + ), storage: Schema.optionalKey( Schema.Struct({ endpoint: Schema.String, diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index cdc3d8a5c1..f0074b8af4 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -448,16 +448,21 @@ const databaseQuery = async ( } }; +const apiCredentials = (credentials: PromiseStackCredentials) => { + if (credentials.api === undefined) throw new Error("API credentials are required"); + return credentials.api; +}; + const apiHeaders = ( credentials: PromiseStackCredentials, - token: string = credentials.api.anonJwt, + token: string = apiCredentials(credentials).anonJwt, ): Record => ({ - apikey: credentials.api.publishableKey, + apikey: apiCredentials(credentials).publishableKey, Authorization: `Bearer ${token}`, }); const serviceHeaders = (credentials: PromiseStackCredentials): Record => - apiHeaders(credentials, credentials.api.serviceRoleJwt); + apiHeaders(credentials, apiCredentials(credentials).serviceRoleJwt); const functionSource = (table: string, marker: string): string => ` Deno.serve(async () => { @@ -771,7 +776,9 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom const socket = await (async (): Promise => { try { return await activate(stack, "realtime", async () => { - const candidate = await openSocket(makeRealtimeUrl(api, credentials.api.publishableKey)); + const candidate = await openSocket( + makeRealtimeUrl(api, apiCredentials(credentials).publishableKey), + ); openedSocket = candidate; return candidate; }); @@ -987,7 +994,9 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom await request(api.url, "/auth/v1/settings", { headers: apiHeaders(credentials) }); }); await activate(stack, "realtime", async () => { - const probe = await openSocket(makeRealtimeUrl(api, credentials.api.publishableKey)); + const probe = await openSocket( + makeRealtimeUrl(api, apiCredentials(credentials).publishableKey), + ); probe.close(); }); await activate(stack, "storage", async () => { diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index b46e0644b4..885eae3315 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -818,12 +818,6 @@ export const makeSupervisor = ( ), ); - const auth = definition.capabilities.auth; - if (!auth.enabled) - return yield* Effect.fail( - rpcError("InvalidStackConfigError", "Stack credentials require Auth to be enabled"), - ); - const requiredSecret = (slot: string): Effect.Effect => { const value = state.secrets[slot]?.value; return value === undefined || value.length === 0 @@ -839,22 +833,27 @@ export const makeSupervisor = ( databasePassword, )}@${databaseHost}:${databaseAssignment.port}/postgres`; - const publishableKey = yield* requiredSecret(AUTH_PUBLISHABLE_KEY_SLOT); - const secretKey = yield* requiredSecret(AUTH_SECRET_KEY_SLOT); - const anonJwt = yield* requiredSecret(AUTH_ANON_KEY_SLOT); - const serviceRoleJwt = yield* requiredSecret(AUTH_SERVICE_ROLE_KEY_SLOT); - + const auth = definition.capabilities.auth; + const api = auth.enabled + ? yield* Effect.gen(function* () { + const publishableKey = yield* requiredSecret(AUTH_PUBLISHABLE_KEY_SLOT); + const secretKey = yield* requiredSecret(AUTH_SECRET_KEY_SLOT); + const anonJwt = yield* requiredSecret(AUTH_ANON_KEY_SLOT); + const serviceRoleJwt = yield* requiredSecret(AUTH_SERVICE_ROLE_KEY_SLOT); + return { + publishableKey, + secretKey: Redacted.make(secretKey), + anonJwt, + serviceRoleJwt: Redacted.make(serviceRoleJwt), + }; + }) + : undefined; const base: EffectStackCredentials = { database: { url: Redacted.make(databaseUrl), password: Redacted.make(databasePassword), }, - api: { - publishableKey, - secretKey: Redacted.make(secretKey), - anonJwt, - serviceRoleJwt: Redacted.make(serviceRoleJwt), - }, + ...(api === undefined ? {} : { api }), }; const storage = definition.capabilities.storage; const s3 = storage.settings.s3_protocol; diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index e9c5a620bc..e5108012f7 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -1199,6 +1199,8 @@ describe("Supervisor composition", () => { /^postgresql:\/\/postgres:.+@127\.0\.0\.1:\d+\/postgres$/, ); expect(Redacted.value(credentials.database.password)).toEqual(expect.any(String)); + if (credentials.api === undefined) + return yield* new StackStateInvalidError({ message: "API credentials are missing" }); expect(credentials.api.publishableKey).toEqual(expect.any(String)); expect(Redacted.value(credentials.api.secretKey)).toEqual(expect.any(String)); expect(credentials.api.anonJwt).toEqual(expect.any(String)); @@ -1290,113 +1292,121 @@ describe("Supervisor composition", () => { ), ); - it.live("fails closed when Auth is disabled or a required secret slot is absent", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: {}, auth: { enabled: false } } }, - }); - const running = yield* fixture.store - .read(fixture.id) - .pipe(Effect.provideContext(fixture.context)); - if (running === undefined) - return yield* new StackStateInvalidError({ message: "running fixture state is missing" }); - const state = { - ...running, - ports: [ - { field: "api", port: 55433, intent: "exact" as const }, - { field: "database", port: 55432, intent: "exact" as const }, - ] as const, - }; - yield* fixture.store - .replace(fixture.id, state) - .pipe(Effect.provideContext(fixture.context)); - const authDisabled = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); - expect(errorOf(authDisabled)).toEqual( - expect.objectContaining({ tag: "InvalidStackConfigError" }), - ); - if (state.definition === undefined) - return yield* new StackStateInvalidError({ message: "running definition is missing" }); - const baseSecrets = { - ...state.secrets, - "secret:auth.settings.publishable_key": { - policy: "managed" as const, - value: "publishable", - }, - "secret:auth.settings.secret_key": { policy: "managed" as const, value: "secret" }, - "secret:auth.settings.anon_key": { policy: "managed" as const, value: "anon" }, - "secret:auth.settings.service_role_key": { policy: "managed" as const, value: "service" }, - }; - const missingSecret = { - ...state, - definition: { - ...state.definition, - capabilities: { - ...state.definition.capabilities, - auth: { ...state.definition.capabilities.auth, enabled: true }, + it.live( + "returns database credentials when Auth is disabled and fails closed for missing secrets", + () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ + config: { capabilities: { rest: {}, auth: { enabled: false } } }, + }); + const running = yield* fixture.store + .read(fixture.id) + .pipe(Effect.provideContext(fixture.context)); + if (running === undefined) + return yield* new StackStateInvalidError({ + message: "running fixture state is missing", + }); + const state = { + ...running, + ports: [ + { field: "api", port: 55433, intent: "exact" as const }, + { field: "database", port: 55432, intent: "exact" as const }, + ] as const, + }; + yield* fixture.store + .replace(fixture.id, state) + .pipe(Effect.provideContext(fixture.context)); + const authDisabled = yield* invokeCredentials(fixture.supervisor); + expect(authDisabled.database.url).toEqual(expect.anything()); + expect(authDisabled.api).toBeUndefined(); + if (state.definition === undefined) + return yield* new StackStateInvalidError({ message: "running definition is missing" }); + const baseSecrets = { + ...state.secrets, + "secret:auth.settings.publishable_key": { + policy: "managed" as const, + value: "publishable", }, - }, - secrets: Object.fromEntries( - Object.entries(baseSecrets).filter( - ([slot]) => slot !== "secret:auth.settings.publishable_key", + "secret:auth.settings.secret_key": { policy: "managed" as const, value: "secret" }, + "secret:auth.settings.anon_key": { policy: "managed" as const, value: "anon" }, + "secret:auth.settings.service_role_key": { + policy: "managed" as const, + value: "service", + }, + }; + const missingSecret = { + ...state, + definition: { + ...state.definition, + capabilities: { + ...state.definition.capabilities, + auth: { ...state.definition.capabilities.auth, enabled: true }, + }, + }, + secrets: Object.fromEntries( + Object.entries(baseSecrets).filter( + ([slot]) => slot !== "secret:auth.settings.publishable_key", + ), ), - ), - }; - yield* fixture.store - .replace(fixture.id, missingSecret) - .pipe(Effect.provideContext(fixture.context)); - const missingExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); - expect(errorOf(missingExit)).toEqual( - expect.objectContaining({ tag: "StackSecretMismatchError" }), - ); - const storageSecretMissing = { - ...missingSecret, - secrets: Object.fromEntries( - Object.entries(baseSecrets).filter( - ([slot]) => slot !== "secret:storage.settings.s3_protocol.secret_access_key", + }; + yield* fixture.store + .replace(fixture.id, missingSecret) + .pipe(Effect.provideContext(fixture.context)); + const missingExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); + expect(errorOf(missingExit)).toEqual( + expect.objectContaining({ tag: "StackSecretMismatchError" }), + ); + const storageSecretMissing = { + ...missingSecret, + secrets: Object.fromEntries( + Object.entries(baseSecrets).filter( + ([slot]) => slot !== "secret:storage.settings.s3_protocol.secret_access_key", + ), ), - ), - }; - yield* fixture.store - .replace(fixture.id, storageSecretMissing) - .pipe(Effect.provideContext(fixture.context)); - const storageExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); - expect(errorOf(storageExit)).toEqual( - expect.objectContaining({ tag: "StackSecretMismatchError" }), - ); - const complete = { ...missingSecret, secrets: baseSecrets }; - const missingApiListener = { - ...complete, - ports: [{ field: "database", port: 55432, intent: "exact" as const }] as const, - }; - yield* fixture.store - .replace(fixture.id, missingApiListener) - .pipe(Effect.provideContext(fixture.context)); - const listenerExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); - expect(errorOf(listenerExit)).toEqual( - expect.objectContaining({ tag: "InvalidStackConfigError" }), - ); + }; + yield* fixture.store + .replace(fixture.id, storageSecretMissing) + .pipe(Effect.provideContext(fixture.context)); + const storageExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); + expect(errorOf(storageExit)).toEqual( + expect.objectContaining({ tag: "StackSecretMismatchError" }), + ); + const complete = { ...missingSecret, secrets: baseSecrets }; + const missingApiListener = { + ...complete, + ports: [{ field: "database", port: 55432, intent: "exact" as const }] as const, + }; + yield* fixture.store + .replace(fixture.id, missingApiListener) + .pipe(Effect.provideContext(fixture.context)); + const listenerExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); + expect(errorOf(listenerExit)).toEqual( + expect.objectContaining({ tag: "InvalidStackConfigError" }), + ); - const disabledDatabase = { - ...complete, - definition: { - ...complete.definition, - listeners: { - ...complete.definition.listeners, - database: { ...complete.definition.listeners.database, enabled: false }, + const disabledDatabase = { + ...complete, + definition: { + ...complete.definition, + listeners: { + ...complete.definition.listeners, + database: { ...complete.definition.listeners.database, enabled: false }, + }, }, - }, - }; - yield* fixture.store - .replace(fixture.id, disabledDatabase) - .pipe(Effect.provideContext(fixture.context)); - const disabledDatabaseExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); - expect(errorOf(disabledDatabaseExit)).toEqual( - expect.objectContaining({ tag: "InvalidStackConfigError" }), - ); - }), - ), + }; + yield* fixture.store + .replace(fixture.id, disabledDatabase) + .pipe(Effect.provideContext(fixture.context)); + const disabledDatabaseExit = yield* invokeCredentials(fixture.supervisor).pipe( + Effect.exit, + ); + expect(errorOf(disabledDatabaseExit)).toEqual( + expect.objectContaining({ tag: "InvalidStackConfigError" }), + ); + }), + ), ); it.live("acknowledges stop only after runtime cleanup", () => From 2ce6dcf994dbe94a785aae4918e6ebbdaf4327ff Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 12:01:18 +0200 Subject: [PATCH 2/5] fix(cli): classify stack destruction failures --- .../commands/experimental/stack/destroy/destroy.handler.ts | 1 + .../experimental/stack/destroy/destroy.integration.test.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts index 6cb7e8f084..7b87ea1893 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -39,6 +39,7 @@ const destroyError = (error: unknown): LegacyExperimentalStackDestroyError => { "StackLifecycleConflictError", "StackRuntimeError", "StackCleanupError", + "StackDestructionError", "StackUpgradeRequiredError", () => "lifecycle" as const, ), diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts index b4f872a96c..18b516329e 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts @@ -8,6 +8,10 @@ import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; import { mockLegacyCliSettings } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { + actionability, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; import { LegacyExperimentalStackApi } from "../stack.shared.ts"; import { legacyExperimentalStackDestroy, @@ -117,6 +121,8 @@ describe("experimental stack destroy", () => { return Effect.gen(function* () { const failure = yield* legacyExperimentalStackDestroy(flags()).pipe(Effect.flip); expect(failure.message).toContain("destroy failed"); + expect(failure.reason).toBe("lifecycle"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.invalidConfig); expect(setupResult.state.openedIds).toEqual([id]); expect(setupResult.output.messages.some((message) => message.type === "success")).toBe(false); }).pipe(Effect.provide(setupResult.layer)); From 03a2ab453eef3e789d06d95ebc91c7c3ba8931a1 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 14:13:18 +0200 Subject: [PATCH 3/5] fix(cli): record stack shorthand flags consistently --- ...tack-command-telemetry.integration.test.ts | 59 ++++++++++++++++++- .../experimental/stack/start/start.command.ts | 2 +- 2 files changed, 58 insertions(+), 3 deletions(-) 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 81c3349b84..0c08866977 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 @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option, Stream } from "effect"; +import { Effect, Layer, Option, Sink, Stdio, Stream } from "effect"; import { Command } from "effect/unstable/cli"; import { StackIdSchema } from "@supabase/stack/effect"; import type { EffectStack, StackStatus } from "@supabase/stack/effect"; @@ -23,6 +23,7 @@ import { EventCommandExecuted, PropCommand, PropCommandRunId, + PropFlags, } from "../../../shared/telemetry/event-catalog.ts"; const stackId = StackIdSchema.make("a".repeat(64)); @@ -37,7 +38,7 @@ const stackStatus: StackStatus = { artifacts: [], }; -function setup() { +function setup(args: ReadonlyArray = []) { const output = mockOutput(); const captured: Array<{ event: string; properties: Record }> = []; const analytics = { @@ -87,6 +88,15 @@ function setup() { analytics, layer: Layer.mergeAll( BunServices.layer, + Layer.succeed( + Stdio.Stdio, + Stdio.make({ + args: Effect.succeed(args), + stdin: Stream.empty, + stdout: () => Sink.drain, + stderr: () => Sink.drain, + }), + ), mockLegacyCliSettings({ workdir: "/project" }), processControlLayer, output.layer, @@ -137,4 +147,49 @@ describe("stack command telemetry", () => { expect(new Set(runIds).size).toBe(3); }).pipe(Effect.provide(fixture.layer)); }); + + it.live("records shorthand aliases under their canonical flag names", () => { + const fixture = setup(["stack", "logs", "--stack-id", "invalid", "-f"]); + return Effect.gen(function* () { + yield* Effect.exit( + Command.runWith(testRoot(legacyExperimentalStackCommand), { version: "0.0.0-test" })([ + "stack", + "logs", + "--stack-id", + "invalid", + "-f", + ]), + ); + const event = fixture.analytics.captured.find( + (entry) => + entry.event === EventCommandExecuted && entry.properties[PropCommand] === "stack logs", + ); + const flags = event?.properties[PropFlags]; + expect(flags).toEqual(expect.objectContaining({ follow: true })); + expect(flags).not.toEqual(expect.objectContaining({ f: expect.anything() })); + }).pipe(Effect.provide(fixture.layer)); + }); + + it.live("records the start shorthand under exclude", () => { + const fixture = setup(["stack", "start", "--stack-id", "invalid", "-x", "database"]); + return Effect.gen(function* () { + yield* Effect.exit( + Command.runWith(testRoot(legacyExperimentalStackCommand), { version: "0.0.0-test" })([ + "stack", + "start", + "--stack-id", + "invalid", + "-x", + "database", + ]), + ); + const event = fixture.analytics.captured.find( + (entry) => + entry.event === EventCommandExecuted && entry.properties[PropCommand] === "stack start", + ); + const flags = event?.properties[PropFlags]; + expect(flags).toEqual(expect.objectContaining({ exclude: "" })); + expect(flags).not.toEqual(expect.objectContaining({ x: expect.anything() })); + }).pipe(Effect.provide(fixture.layer)); + }); }); 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 5eaabc7df0..9fe8045c03 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.command.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.command.ts @@ -48,7 +48,7 @@ export const legacyExperimentalStackStartCommand = Command.make("start", config) ]), Command.withHandler((flags) => legacyExperimentalStackStart(flags).pipe( - withLegacyCommandInstrumentation({ flags, config }), + withLegacyCommandInstrumentation({ flags, config, aliases: { x: "exclude" } }), withJsonErrorHandling, ), ), From 1e87f8e7e381a110baa5575aff14de4f25464529 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 14:22:06 +0200 Subject: [PATCH 4/5] fix(cli): align API listeners with excluded services --- apps/cli/docs/stack-commands.md | 4 +- .../experimental/stack/start/start.handler.ts | 15 ++++ .../stack/start/start.integration.test.ts | 75 ++++++++++++++++++- .../experimental/stack/stop/SIDE_EFFECTS.md | 7 +- 4 files changed, 96 insertions(+), 5 deletions(-) diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index c19a15d312..186acf276d 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -47,7 +47,9 @@ project’s configured services. `--eager` waits for enabled services to become `supabase stack stop --all` stops every stack in the new backend’s registry and preserves data. It cannot be combined with `--stack` or `--stack-id`. Failures -are reported after attempting the other stacks. +are reported after attempting the other stacks once registry enumeration succeeds. If a registry +entry is unreadable or unsupported, discovery fails before any stack is stopped; repair that +entry or stop known stacks individually with `--stack-id`. `supabase stack destroy --stack feature-a` permanently removes exactly that stack and its data after confirmation. Use `--yes` for unattended execution. diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index f0bad13a3d..3521213dab 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -56,6 +56,13 @@ const legacyApplyExperimentalStackStartExclusions = ( ) => { if (exclusions.length === 0) return config; const excluded = new Set(exclusions); + const apiGatewayDisabled = + (excluded.has("rest") || config.capabilities?.rest?.enabled === false) && + (excluded.has("auth") || config.capabilities?.auth?.enabled === false) && + (excluded.has("realtime") || config.capabilities?.realtime?.enabled === false) && + (excluded.has("storage") || config.capabilities?.storage?.enabled === false) && + (excluded.has("functions") || config.capabilities?.functions?.enabled === false) && + (excluded.has("analytics") || config.capabilities?.analytics?.enabled === false); return { ...config, capabilities: { @@ -88,6 +95,14 @@ const legacyApplyExperimentalStackStartExclusions = ( ? { pooler: { ...config.capabilities?.pooler, enabled: false as const } } : {}), }, + ...(apiGatewayDisabled + ? { + listeners: { + ...config.listeners, + api: { ...config.listeners?.api, enabled: false as const }, + }, + } + : {}), }; }; 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 40131e4f32..b42e67e1ea 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 @@ -283,6 +283,10 @@ describe("experimental stack start targeting", () => { it.live("applies capability exclusions only to the start request", () => { const root = project(); + writeFileSync( + join(root, "supabase", "config.toml"), + 'project_id = "start-test"\n[api]\nport = 55422\n', + ); let startConfig: unknown; const stack = fakeStack("e".repeat(64), (config) => { startConfig = config; @@ -290,17 +294,61 @@ describe("experimental stack start targeting", () => { }); const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); return Effect.gen(function* () { - yield* legacyExperimentalStackStart(flags({ exclude: ["rest", "functions"] })); + yield* legacyExperimentalStackStart( + flags({ exclude: ["rest", "auth", "realtime", "storage", "functions"] }), + ); expect(startConfig).toMatchObject({ config: { capabilities: { rest: { enabled: false }, + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, functions: { enabled: false }, }, }, }); expect(startConfig).not.toMatchObject({ - config: { capabilities: { auth: { enabled: false } } }, + config: { capabilities: { analytics: { enabled: false } } }, + }); + expect(startConfig).toMatchObject({ config: { listeners: { api: { port: 55422 } } } }); + expect(startConfig).not.toMatchObject({ config: { listeners: { api: { enabled: false } } } }); + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.live("disables the API listener when every gateway capability is excluded", () => { + const root = project(); + writeFileSync( + join(root, "supabase", "config.toml"), + 'project_id = "start-test"\n[api]\nport = 55423\n', + ); + let startConfig: unknown; + const stack = fakeStack("f".repeat(64), (config) => { + startConfig = config; + return Effect.succeed(status("f".repeat(64))); + }); + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + yield* legacyExperimentalStackStart( + flags({ + exclude: ["rest", "auth", "realtime", "storage", "functions", "analytics"], + }), + ); + expect(startConfig).toMatchObject({ + config: { + capabilities: { + rest: { enabled: false }, + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + analytics: { enabled: false }, + }, + listeners: { api: { enabled: false, port: 55423 } }, + }, }); }).pipe( Effect.provide(setup.layer), @@ -308,6 +356,29 @@ describe("experimental stack start targeting", () => { ); }); + it.live("counts configured-disabled gateway capabilities with exclusions", () => { + const root = project(); + writeFileSync( + join(root, "supabase", "config.toml"), + 'project_id = "start-test"\n[api]\nport = 55424\n[analytics]\nenabled = false\n', + ); + let startConfig: unknown; + const stack = fakeStack("a".repeat(64), (config) => { + startConfig = config; + return Effect.succeed(status("a".repeat(64))); + }); + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + yield* legacyExperimentalStackStart( + flags({ exclude: ["rest", "auth", "realtime", "storage", "functions"] }), + ); + expect(startConfig).toMatchObject({ config: { listeners: { api: { enabled: false } } } }); + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + it.live("rejects unknown and mandatory capability exclusions before resolving a target", () => { const root = project(); let resolved = false; 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 079c49b318..a0914ffc05 100644 --- a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md @@ -21,8 +21,11 @@ returning. That process is package-owned and is not managed directly by the CLI. ## Output and telemetry Text mode reports the selected stack and stopped outcome. Structured modes include the selected -stack id and stopped outcome. For `--all`, all stops are attempted and a partial failure returns -one aggregate error naming failed stack ids. An empty registry succeeds. If no current stack +stack id and stopped outcome. For `--all`, registry enumeration must succeed before any stop +is attempted. An unreadable or unsupported registry entry fails discovery with the affected stack +id; repair that entry or stop known stacks individually by id. After successful enumeration, all +stops are attempted and a partial failure returns one aggregate error naming failed stack ids. +An empty registry succeeds. If no current stack exists, the command succeeds with an explicit no-stack result. Exit status is `0` for a successful stop or no current stack, `1` for a missing named stack or any typed stop failure, and `130` if the command is interrupted before the stop completes. Standard command instrumentation records command From 95551dda43408c6b9d74d0565f86d2addc30edfe Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 14:58:40 +0200 Subject: [PATCH 5/5] fix(cli): stop healthy stacks despite corrupt registry entries --- apps/cli/docs/stack-commands.md | 10 +- .../stack/destroy/destroy.integration.test.ts | 1 + .../stack/list/list.integration.test.ts | 2 + .../stack/logs/logs.integration.test.ts | 1 + .../stack/prepare/prepare.integration.test.ts | 1 + .../stack/restart/restart.integration.test.ts | 1 + ...tack-command-telemetry.integration.test.ts | 1 + .../experimental/stack/stack.shared.ts | 9 ++ .../stack/start/start.integration.test.ts | 3 + .../stack/status/status.integration.test.ts | 2 + .../experimental/stack/stop/SIDE_EFFECTS.md | 10 +- .../experimental/stack/stop/stop.handler.ts | 19 ++- .../stack/stop/stop.integration.test.ts | 69 ++++++++- packages/stack/src/index.ts | 3 + packages/stack/src/public/EffectStack.ts | 133 ++++++++++++------ packages/stack/src/public/PromiseStack.ts | 15 +- .../public/effect-stack.integration.test.ts | 51 +++++++ packages/stack/src/public/index.ts | 11 +- 18 files changed, 278 insertions(+), 64 deletions(-) diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 186acf276d..8d27e0e273 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -45,11 +45,11 @@ required. The project file is unchanged. The effective configuration is retained in stack state, so stop the stack and start without `--exclude` to restore the project’s configured services. `--eager` waits for enabled services to become ready. -`supabase stack stop --all` stops every stack in the new backend’s registry and -preserves data. It cannot be combined with `--stack` or `--stack-id`. Failures -are reported after attempting the other stacks once registry enumeration succeeds. If a registry -entry is unreadable or unsupported, discovery fails before any stack is stopped; repair that -entry or stop known stacks individually with `--stack-id`. +`supabase stack stop --all` stops every readable stack in the new backend’s registry and +preserves data. It cannot be combined with `--stack` or `--stack-id`. All discovered stops are +attempted; unreadable or unsupported entries produce warnings and are skipped. If any entry is skipped or a stop fails, +the command exits nonzero with stopped, failed, and skipped counts. A registry-root enumeration failure prevents +any stack from being stopped. `supabase stack destroy --stack feature-a` permanently removes exactly that stack and its data after confirmation. Use `--yes` for unattended execution. diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts index 18b516329e..7a5350ac98 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts @@ -72,6 +72,7 @@ function setup(opts: { Layer.succeed(LegacyExperimentalStackApi, { createStack: () => Effect.die("unused"), listStacks: () => Effect.succeed([]), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), findStack: () => Effect.succeed(opts.found === false ? Option.none() : Option.some(descriptor)), inspectStack: () => Effect.succeed({ descriptor, owner: "absent" as const }), diff --git a/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts b/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts index 3462b38409..62c7d4294e 100644 --- a/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts @@ -54,6 +54,7 @@ const runList = ( listCalls++; return stacks; }), + discoverStacks: () => Effect.succeed({ stacks, errors: [] }), openStack: () => { otherApiCalls++; return Effect.die("open must not run"); @@ -187,6 +188,7 @@ describe("experimental stack list", () => { findStack: () => Effect.succeed(Option.none()), listStacks: () => Effect.fail(new StackStateFormatUnsupportedError({ message: "registry unreadable" })), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), openStack: () => Effect.die("unused"), inspectStack: () => Effect.die("unused"), }); diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts index 9d0e5a1045..1236c154a7 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts @@ -127,6 +127,7 @@ function setup(opts: { Layer.succeed(LegacyExperimentalStackApi, { createStack: () => Effect.die("must not create"), listStacks: () => Effect.succeed([]), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), findStack: (query) => opts.findFailure === undefined ? Effect.succeed( diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts index 79691e1ff7..16e7eff3cf 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts @@ -91,6 +91,7 @@ function setup(opts: { }, findStack: () => Effect.die("find not used in prepare test"), listStacks: () => Effect.succeed([]), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), openStack: () => { opts.onOpen?.(); return Effect.succeed(opts.stack); 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 55018e13fc..fc5db050b2 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 @@ -131,6 +131,7 @@ const makeFixture = (options: { const api = Layer.succeed(LegacyExperimentalStackApi, { createStack: () => Effect.die("create must not run"), listStacks: () => Effect.succeed([]), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), findStack: () => Effect.succeed(options.missingTarget === true ? Option.none() : Option.some(descriptor)), openStack: () => { 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 0c08866977..2dc7165155 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 @@ -79,6 +79,7 @@ function setup(args: ReadonlyArray = []) { const api = Layer.succeed(LegacyExperimentalStackApi, { createStack: () => Effect.die("unused"), listStacks: () => Effect.succeed([descriptor]), + discoverStacks: () => Effect.succeed({ stacks: [descriptor], errors: [] }), findStack: () => Effect.succeed(Option.some(descriptor)), openStack: () => Effect.succeed(stack), inspectStack: () => Effect.succeed({ descriptor, owner: "running" as const }), diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index 604486b41a..1726d494e2 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -1,6 +1,7 @@ import { Context, Data, Effect, FileSystem, Layer, Path, Crypto } from "effect"; import { createStack, + discoverStacks, findStack, inspectStack, listStacks, @@ -82,6 +83,12 @@ export class LegacyExperimentalStackApi extends Context.Service< Effect.Success>, Effect.Error> >; + readonly discoverStacks: ( + ...args: Parameters + ) => Effect.Effect< + Effect.Success>, + Effect.Error> + >; readonly openStack: ( ...args: Parameters ) => Effect.Effect< @@ -116,6 +123,8 @@ export const legacyExperimentalStackApiLayer = Layer.effect( provideServices(createStack(...args)), findStack: (...args: Parameters) => provideServices(findStack(...args)), listStacks: (...args: Parameters) => provideServices(listStacks(...args)), + discoverStacks: (...args: Parameters) => + provideServices(discoverStacks(...args)), openStack: (...args: Parameters) => provideServices(openStack(...args)), inspectStack: (...args: Parameters) => provideServices(inspectStack(...args)), 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 b42e67e1ea..af633bb9b4 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 @@ -127,6 +127,7 @@ function handlerLayer(opts: { }, findStack: () => Effect.succeed(Option.none()), listStacks: () => Effect.succeed([]), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), openStack: () => { opts.onOpen?.(); return Effect.succeed(opts.stack); @@ -198,6 +199,7 @@ describe("experimental stack start targeting", () => { createStack: () => Effect.die("unused"), findStack: () => Effect.succeed(Option.none()), listStacks: () => Effect.succeed([]), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), openStack: () => Effect.die("unused"), inspectStack: () => Effect.succeed({ @@ -646,6 +648,7 @@ describe("experimental stack start targeting", () => { }, findStack: () => Effect.succeed(Option.none()), listStacks: () => Effect.succeed([]), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), openStack: () => Effect.die("open should not run"), inspectStack: () => Effect.die("inspect should not run"), }), 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 24c6b3a0d0..cdc394ed09 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 @@ -117,6 +117,7 @@ const runStatus = (options: { return Effect.succeed(options.missingTarget ? Option.none() : Option.some(descriptor)); }, listStacks: () => Effect.succeed([]), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), openStack: () => Effect.succeed({ id, @@ -587,6 +588,7 @@ describe("experimental stack status", () => { findStack: () => Effect.fail(new StackStateFormatUnsupportedError({ message: "discovery failed" })), listStacks: () => Effect.succeed([]), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), openStack: () => Effect.die("open must not run"), inspectStack: () => Effect.die("inspect must not run"), }); 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 a0914ffc05..363e1fbfea 100644 --- a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md @@ -21,12 +21,12 @@ returning. That process is package-owned and is not managed directly by the CLI. ## Output and telemetry Text mode reports the selected stack and stopped outcome. Structured modes include the selected -stack id and stopped outcome. For `--all`, registry enumeration must succeed before any stop -is attempted. An unreadable or unsupported registry entry fails discovery with the affected stack -id; repair that entry or stop known stacks individually by id. After successful enumeration, all -stops are attempted and a partial failure returns one aggregate error naming failed stack ids. +stack id and stopped outcome. For `--all`, a registry-root enumeration failure prevents any stop +from starting. An unreadable or unsupported entry is reported as a warning with its stack id; +healthy entries are still stopped. Every healthy stop is attempted, and a partial result returns +one aggregate error with stopped, failed, and skipped counts. Corrupt entries are never mutated. An empty registry succeeds. If no current stack exists, the command succeeds with an explicit no-stack result. Exit status is `0` for a -successful stop or no current stack, `1` for a missing named stack or any typed stop failure, +successful stop or no current stack, `1` for a missing named stack, a skipped registry entry, or any typed stop failure, and `130` if the command is interrupted before the stop completes. Standard command instrumentation records command metadata; stack data and credentials are not emitted as telemetry properties. diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts index 3a43cd888f..48cab3f521 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -83,7 +83,10 @@ export const legacyExperimentalStackStop = Effect.fn("legacy.experimental.stack. yield* legacyValidateExperimentalStackStopTarget(flags); if (flags.all) { - const stacks = yield* stackApi.listStacks().pipe(Effect.mapError(stopError)); + const discovered = yield* stackApi.discoverStacks().pipe(Effect.mapError(stopError)); + for (const issue of discovered.errors) + yield* output.warn(`Skipping managed stack ${issue.id}: ${issue.error.message}`); + const stacks = discovered.stacks; const stopping = yield* output.task(`Stopping ${stacks.length} managed stack(s)...`); const results = yield* Effect.forEach( stacks, @@ -98,18 +101,20 @@ export const legacyExperimentalStackStop = Effect.fn("legacy.experimental.stack. const failures = results.flatMap(({ descriptor, result }) => Result.isFailure(result) ? [{ descriptor, error: result.failure }] : [], ); - if (failures.length > 0) { - const message = `Failed to stop ${failures.length} of ${stacks.length} managed stacks: ${failures - .map( + const stoppedCount = stacks.length - failures.length; + if (failures.length > 0 || discovered.errors.length > 0) { + const message = `Stopped ${stoppedCount} managed stack(s); failed to stop ${failures.length} and skipped ${discovered.errors.length}: ${[ + ...failures.map( ({ descriptor, error }) => `${descriptor.id}: ${error instanceof Error ? error.message : String(error)}`, - ) - .join("; ")}`; + ), + ...discovered.errors.map(({ id, error }) => `${id}: ${error.message}`), + ].join("; ")}`; yield* stopping.fail(message); return yield* new LegacyExperimentalStackStopError({ reason: "lifecycle", message, - cause: failures, + cause: { failures, discovery: discovered.errors }, }); } yield* stopping.clear(); diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts index b6b92aeefa..140c195004 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -66,6 +66,8 @@ function setup(opts: { findFailure?: StackDiscoveryError; allStacks?: ReadonlyArray; stopFailureIds?: ReadonlyArray; + discoveryErrors?: ReadonlyArray<{ id: string; error: StackDiscoveryError }>; + discoveryFailure?: StackDiscoveryError; }) { const out = mockOutput(); const state = { @@ -121,6 +123,16 @@ function setup(opts: { Layer.succeed(LegacyExperimentalStackApi, { createStack: () => Effect.die("must not create"), listStacks: () => Effect.succeed(allDescriptors), + discoverStacks: () => + opts.discoveryFailure !== undefined + ? Effect.fail(opts.discoveryFailure) + : Effect.succeed({ + stacks: allDescriptors, + errors: (opts.discoveryErrors ?? []).map(({ id, error }) => ({ + id: StackIdSchema.make(id), + error, + })), + }), findStack: (input) => Effect.sync(() => { state.findInputs.push(input); @@ -328,7 +340,9 @@ describe("experimental stack stop", () => { const setupResult = setup({ root, allStacks: [first, second], stopFailureIds: [first] }); return Effect.gen(function* () { const failure = yield* legacyExperimentalStackStop(flags({ all: true })).pipe(Effect.flip); - expect(failure.message).toContain("Failed to stop 1 of 2"); + expect(failure.message).toContain( + "Stopped 1 managed stack(s); failed to stop 1 and skipped 0", + ); expect(setupResult.state.openedIds).toEqual([first, second]); expect(setupResult.state.stopCalls).toBe(1); }).pipe( @@ -337,6 +351,59 @@ describe("experimental stack stop", () => { ); }); + it.effect("stops healthy stacks while warning about skipped discovery entries", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-all-discovery-")); + const failed = "3".repeat(64); + const healthy = "5".repeat(64); + const skipped = "4".repeat(64); + const setupResult = setup({ + root, + allStacks: [failed, healthy], + stopFailureIds: [failed], + discoveryErrors: [ + { + id: skipped, + error: new StackStateInvalidError({ message: "malformed state" }), + }, + ], + }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStop(flags({ all: true })).pipe(Effect.flip); + expect(failure.message).toContain( + "Stopped 1 managed stack(s); failed to stop 1 and skipped 1", + ); + expect(setupResult.state.destroyCalled).toBe(false); + expect(setupResult.state.openedIds).toEqual([failed, healthy]); + expect(setupResult.state.stopCalls).toBe(1); + expect( + setupResult.out.messages.some( + (message) => message.type === "warn" && message.message.includes(skipped), + ), + ).toBe(true); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("does not stop any stack when registry enumeration fails", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-all-registry-error-")); + const setupResult = setup({ + root, + allStacks: ["5".repeat(64)], + discoveryFailure: new StackStateInvalidError({ message: "registry unreadable" }), + }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStop(flags({ all: true })).pipe(Effect.flip); + expect(failure.message).toContain("registry unreadable"); + expect(setupResult.state.openedIds).toEqual([]); + expect(setupResult.state.stopCalls).toBe(0); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + it.effect("does not report success when package stop fails", () => { const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-failure-")); const setupResult = setup({ diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 561d2be33f..afaed2a6e1 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -3,6 +3,7 @@ export { openStack, findStack, listStacks, + discoverStacks, inspectStack, } from "./public/PromiseStack.ts"; export type { @@ -14,6 +15,8 @@ export type { CreateStackOptions, FindStackOptions, ListStacksOptions, + StackDiscoveryIssue, + StackDiscoveryResult, PreparedCapability, } from "./public/PromiseStack.ts"; export type { diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 39bf9b0816..0033506881 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -12,6 +12,7 @@ import { Path, Predicate, Redacted, + Result, Schedule, Schema, Stream, @@ -1067,10 +1068,67 @@ export const findStack = ( return state === undefined ? Option.none() : Option.some(descriptor(state)); }); -export const listStacks = ( +export interface StackDiscoveryIssue { + readonly id: StackId; + readonly error: StackDiscoveryError; +} + +/** + * The result of reading the managed stack registry. Entry-level state errors are + * collected so callers can continue operating on healthy stacks; registry root + * enumeration errors remain fatal. Discovery never mutates managed state. + */ +export interface StackDiscoveryResult { + readonly stacks: ReadonlyArray; + readonly errors: ReadonlyArray; +} + +const enrichStackDiscoveryError = ( + entry: StackId, + error: Effect.Error>, +): StackDiscoveryError => { + const message = `Failed to read managed stack ${entry}: ${error.message}`; + return Match.value(error).pipe( + Match.tag( + "InvalidProjectRootError", + (error) => + new InvalidProjectRootError({ + projectRoot: error.projectRoot, + stateRoot: error.stateRoot, + message, + cause: error, + }), + ), + Match.tag( + "StackStateInvalidError", + (error) => + new StackStateInvalidError({ + stackId: entry, + path: error.path, + code: error.code, + slot: error.slot, + message, + cause: error, + }), + ), + Match.tag( + "StackStateFormatUnsupportedError", + (error) => + new StackStateFormatUnsupportedError({ + format: error.format, + message, + cause: error, + }), + ), + Match.exhaustive, + ); +}; + +/** Reads all managed stacks, retaining healthy descriptors when individual state documents fail. */ +export const discoverStacks = ( options: ListStacksOptions = {}, ): Effect.Effect< - ReadonlyArray, + StackDiscoveryResult, StackDiscoveryError, FileSystem.FileSystem | Path.Path | Crypto.Crypto > => @@ -1091,64 +1149,51 @@ export const listStacks = ( .exists(env.stateRoot) .pipe(Effect.mapError((error) => new StackStateInvalidError({ message: error.message })))) ) - return []; + return { stacks: [], errors: [] }; const entries = yield* fs .readDirectory(env.stateRoot) .pipe(Effect.mapError((error) => new StackStateInvalidError({ message: error.message }))); - const result: StackDescriptor[] = []; + const stacks: StackDescriptor[] = []; + const errors: StackDiscoveryIssue[] = []; for (const entry of entries) { if (!Schema.is(StackIdSchema)(entry)) continue; - const state = yield* store.read(entry).pipe( - Effect.mapError((error) => { - const message = `Failed to read managed stack ${entry}: ${error.message}`; - return Match.value(error).pipe( - Match.tag( - "InvalidProjectRootError", - (error) => - new InvalidProjectRootError({ - projectRoot: error.projectRoot, - stateRoot: error.stateRoot, - message, - cause: error, - }), - ), - Match.tag( - "StackStateInvalidError", - (error) => - new StackStateInvalidError({ - stackId: entry, - path: error.path, - code: error.code, - slot: error.slot, - message, - cause: error, - }), - ), - Match.tag( - "StackStateFormatUnsupportedError", - (error) => - new StackStateFormatUnsupportedError({ - format: error.format, - message, - cause: error, - }), - ), - Match.exhaustive, - ); - }), + const result = yield* store.read(entry).pipe( Effect.catchTag("StackStateInvalidError", (error) => isMissingStateRemnantError(error) ? Effect.void : Effect.fail(error), ), + Effect.result, ); + if (Result.isFailure(result)) { + errors.push({ + id: entry, + error: enrichStackDiscoveryError(entry, result.failure), + }); + continue; + } + const state = result.success; if ( state !== undefined && (projectRoot === undefined || state.identity.projectRoot === projectRoot) ) - result.push(descriptor(state)); + stacks.push(descriptor(state)); } - return result; + return { stacks, errors }; }); +export const listStacks = ( + options: ListStacksOptions = {}, +): Effect.Effect< + ReadonlyArray, + StackDiscoveryError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto +> => + discoverStacks(options).pipe( + Effect.flatMap(({ stacks, errors }) => { + const firstError = errors[0]; + return firstError === undefined ? Effect.succeed(stacks) : Effect.fail(firstError.error); + }), + ); + type ConfigDrift = NonNullable; const isPlainRecord = (value: unknown): value is Readonly> => diff --git a/packages/stack/src/public/PromiseStack.ts b/packages/stack/src/public/PromiseStack.ts index 12c5b7d67a..3f9629df8a 100644 --- a/packages/stack/src/public/PromiseStack.ts +++ b/packages/stack/src/public/PromiseStack.ts @@ -3,6 +3,7 @@ import { Crypto, Effect, FileSystem, Layer, Option, Path, Redacted, Schema, Stre import { ChildProcessSpawner } from "effect/unstable/process"; import { createStack as createEffectStack, + discoverStacks as discoverEffectStacks, findStack as findEffectStack, inspectStack as inspectEffectStack, listStacks as listEffectStacks, @@ -11,6 +12,8 @@ import { type CreateStackOptions, type FindStackOptions, type ListStacksOptions, + type StackDiscoveryResult, + type StackDiscoveryIssue, type PrepareStackOptions, type StartStackOptions, } from "./EffectStack.ts"; @@ -67,6 +70,7 @@ interface PromiseStackApi { readonly openStack: (id: StackId) => Promise; readonly findStack: (options: FindStackOptions) => Promise; readonly listStacks: (options?: ListStacksOptions) => Promise>; + readonly discoverStacks: (options?: ListStacksOptions) => Promise; readonly inspectStack: ( id: StackId, options?: PromiseInspectStackOptions, @@ -184,6 +188,7 @@ export const makePromiseApi = ( findStack: (options) => run(findEffectStack(options)).then((value) => Option.getOrUndefined(value)), listStacks: (options) => run(listEffectStacks(options)), + discoverStacks: (options) => run(discoverEffectStacks(options)), inspectStack: (id, options) => run( options?.config === undefined @@ -200,6 +205,14 @@ export const createStack = defaultApi.createStack; export const openStack = defaultApi.openStack; export const findStack = defaultApi.findStack; export const listStacks = defaultApi.listStacks; +export const discoverStacks = defaultApi.discoverStacks; export const inspectStack = defaultApi.inspectStack; -export type { CreateStackOptions, FindStackOptions, ListStacksOptions, PreparedCapability }; +export type { + CreateStackOptions, + FindStackOptions, + ListStacksOptions, + PreparedCapability, + StackDiscoveryIssue, + StackDiscoveryResult, +}; diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index ef73bbf742..9b1d60b344 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -60,6 +60,7 @@ import { import type { LogQuery, StackLogBatch, StackLogEntry } from "./Logs.ts"; import { createStack, + discoverStacks, inspectStack, listStacks, makeHandle, @@ -1027,6 +1028,56 @@ describe("Effect stack lifecycle handoff", () => { ), ); + it.live("reports corrupt entries while retaining healthy stack discovery", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const env = yield* StackRuntimeEnvironment; + const healthy = yield* createStack({ projectRoot: project }); + const corruptProject = path.join(project, "corrupt"); + const unsupportedProject = path.join(project, "unsupported"); + yield* fs.makeDirectory(corruptProject); + yield* fs.makeDirectory(unsupportedProject); + const corrupt = yield* createStack({ projectRoot: corruptProject }); + const unsupported = yield* createStack({ projectRoot: unsupportedProject }); + const corruptPaths = yield* resolveStackPaths({ + stateRoot: env.stateRoot, + stackId: corrupt.id, + }); + const unsupportedPaths = yield* resolveStackPaths({ + stateRoot: env.stateRoot, + stackId: unsupported.id, + }); + yield* fs.writeFileString(corruptPaths.stateDocument, "{ malformed"); + yield* fs.writeFileString( + unsupportedPaths.stateDocument, + JSON.stringify({ format: "supabase-stack-state-v2" }), + ); + const corruptContents = yield* fs.readFileString(corruptPaths.stateDocument); + const unsupportedContents = yield* fs.readFileString(unsupportedPaths.stateDocument); + + const discovered = yield* discoverStacks(); + + expect(discovered.stacks.map(({ id }) => id)).toEqual([healthy.id]); + expect(discovered.errors).toHaveLength(2); + expect(discovered.errors.map(({ id }) => id)).toEqual( + expect.arrayContaining([corrupt.id, unsupported.id]), + ); + expect(discovered.errors.find(({ id }) => id === corrupt.id)?.error).toBeInstanceOf( + StackStateInvalidError, + ); + expect(discovered.errors.find(({ id }) => id === unsupported.id)?.error).toBeInstanceOf( + StackStateFormatUnsupportedError, + ); + expect(yield* fs.readFileString(corruptPaths.stateDocument)).toBe(corruptContents); + expect(yield* fs.readFileString(unsupportedPaths.stateDocument)).toBe(unsupportedContents); + const strict = yield* listStacks().pipe(Effect.exit); + expect(Exit.isFailure(strict)).toBe(true); + }), + ), + ); + it.live("treats an omitted container engine as Docker for runtime identity", () => withRuntimeRoot((project) => Effect.gen(function* () { diff --git a/packages/stack/src/public/index.ts b/packages/stack/src/public/index.ts index 2ec7a1951b..a35884baf2 100644 --- a/packages/stack/src/public/index.ts +++ b/packages/stack/src/public/index.ts @@ -7,7 +7,14 @@ export * from "./Logs.ts"; export * from "./Credentials.ts"; export * from "./Errors.ts"; export * from "./Config.ts"; -export { createStack, openStack, findStack, listStacks, inspectStack } from "./EffectStack.ts"; +export { + createStack, + openStack, + findStack, + listStacks, + discoverStacks, + inspectStack, +} from "./EffectStack.ts"; export type { EffectStack, InspectStackOptions, @@ -16,6 +23,8 @@ export type { CreateStackOptions, FindStackOptions, ListStacksOptions, + StackDiscoveryIssue, + StackDiscoveryResult, PreparedCapability, PrepareStackResult, } from "./EffectStack.ts";