From 21ac3aa20d0fdec26a5189a4081ba93770996395 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 11 Sep 2026 15:16:12 +0200 Subject: [PATCH 1/6] test(cli): allow cache-key checks time under load --- .../src/command-internal/db-bootstrap/shadow-cache.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts index 09336501bb..5ca9dfad05 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts @@ -61,7 +61,7 @@ describe("shadowCacheKey", () => { expect(shadowBaselineTarFileName(first)).toBe(`shadow-baseline-${first}.tar`); }); - it("changes when ANY baked-in input changes", () => { + it("changes when ANY baked-in input changes", { timeout: 30_000 }, () => { const base = baseKeyInputs(); const mutations: ReadonlyArray<{ readonly label: string; From 030eff7e194cec8affcd956cbb2303c7a57c5f80 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 11 Sep 2026 15:16:14 +0200 Subject: [PATCH 2/6] feat(cli): add stack command options and destruction --- apps/cli/docs/stack-commands.md | 24 +- .../stack/destroy/SIDE_EFFECTS.md | 21 ++ .../stack/destroy/destroy.command.ts | 34 +++ .../stack/destroy/destroy.errors.ts | 26 +++ .../stack/destroy/destroy.handler.ts | 131 +++++++++++ .../stack/destroy/destroy.integration.test.ts | 164 +++++++++++++ .../stack/stack-backend.integration.test.ts | 2 +- ...tack-command-telemetry.integration.test.ts | 31 ++- .../experimental/stack/stack.command.ts | 6 +- .../experimental/stack/stack.shared.ts | 7 + .../experimental/stack/start/SIDE_EFFECTS.md | 6 + .../experimental/stack/start/start.command.ts | 14 +- .../experimental/stack/start/start.handler.ts | 111 +++++++-- .../stack/start/start.integration.test.ts | 219 +++++++++++++++++- .../experimental/stack/start/start.options.ts | 12 + .../experimental/stack/stop/SIDE_EFFECTS.md | 13 +- .../experimental/stack/stop/stop.command.ts | 4 + .../experimental/stack/stop/stop.handler.ts | 47 +++- .../stack/stop/stop.integration.test.ts | 167 ++++++++++++- .../telemetry/__fixtures__/error-tags.txt | 1 + packages/stack/src/index.ts | 3 + packages/stack/src/public/EffectStack.ts | 76 +++++- packages/stack/src/public/PromiseStack.ts | 15 +- .../public/effect-stack.integration.test.ts | 24 ++ packages/stack/src/public/index.ts | 11 +- 25 files changed, 1115 insertions(+), 54 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 diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 971b9f047c..30c4cfc991 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -4,10 +4,11 @@ command interface may change, and it is excluded from the CLI compatibility promise. It is available regardless of the project's backend setting and supports both Docker and native runtimes. -| Command | Purpose | -| ---------------------- | -------------------------------------- | -| `supabase stack start` | Create or resume the project's stack. | -| `supabase stack stop` | Stop a stack while retaining its data. | +| 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. | Use each command's `--help` for its available targeting and runtime options. @@ -50,3 +51,18 @@ and seed configuration are separate from importing legacy database data. The flag is local CLI configuration in `supabase/config.toml` and is excluded from hosted project configuration. Routing reads that exact file after applying the CLI's working-directory rules, including `--workdir` and `SUPABASE_WORKDIR`; a JSON-only project does not enable the flag. + +## Service selection and shutdown + +`supabase stack start --exclude studio,analytics -x mail` disables those services in the effective +start configuration without changing the project file. Valid names are `rest`, `auth`, `realtime`, +`storage`, `functions`, `studio`, `mail`, `analytics`, and `pooler`; the database is required. The +effective configuration is retained in stack state, so starting without `--exclude` restores the +project's configured services. + +`supabase stack stop --all` stops every readable managed stack while preserving data. It continues +after unreadable entries or individual stop failures, reports warnings and counts, and exits +nonzero when anything was skipped or failed. Registry-root enumeration errors remain fatal. + +`supabase stack destroy --stack feature-a` permanently removes exactly that stack and its data after +confirmation. Use `--yes` for unattended execution. There is no bulk destroy option. 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..80f59e0ceb --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md @@ -0,0 +1,21 @@ +# `supabase stack destroy` + +Permanently stops and removes one managed 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. `SUPABASE_YES` participates in the existing confirmation setting; +an explicit `--yes=false` overrides it. It never accepts `--all`. + +The stack package reads the selected descriptor and removes resources and state under +`${SUPABASE_HOME:-~/.supabase}/managed/stacks/`. It owns stopping the Supervisor, removing +native processes or containers, and deleting persistent stack data. The CLI does not delete paths +or Docker resources itself and makes no Management API calls. Project files are retained. + +The confirmation prompt identifies the stack 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`; interruption follows the command runtime's interruption exit. +Standard command instrumentation records command metadata without exporting credentials, and +telemetry state flushes after both successful and failed runs. 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..5b6c44198d --- /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 type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; +import { stackDestroy } 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 type StackDestroyFlags = CliCommand.Command.Config.Infer; + +export const stackDestroyCommand = 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) => + stackDestroy(flags).pipe(withCommandTelemetry({ 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..c627b89a33 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts @@ -0,0 +1,26 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + +export class StackCommandDestroyError extends Data.TaggedError("ExperimentalStackDestroyError")<{ + 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; + } + } +} 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..42f680c5b5 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -0,0 +1,131 @@ +import { Effect, Match, Option } from "effect"; +import { isStackError, StackIdSchema } from "@supabase/stack/effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { OutputFlag, resolveYes } from "../../../../command-internal/global-flags.ts"; +import { promptYesNo } from "../../../../command-internal/prompt-yes-no.ts"; +import { Tty } from "../../../../shared/runtime/tty.service.ts"; +import { CommandSettings } from "../../../../config/command-settings.service.ts"; +import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts"; +import { + StackApi, + StackTargetError, + rejectStackOutput, + validateStackId, + validateStackTarget, +} from "../stack.shared.ts"; +import type { StackDestroyFlags } from "./destroy.command.ts"; +import { StackCommandDestroyError } from "./destroy.errors.ts"; + +const mapTargetError = (error: StackTargetError) => + new StackCommandDestroyError({ + reason: error.reason, + message: error.message, + suggestion: error.suggestion, + cause: error, + }); + +const destroyError = (error: unknown): StackCommandDestroyError => { + 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", + "StackDestructionError", + "StackUpgradeRequiredError", + () => "lifecycle" as const, + ), + Match.tag( + "InvalidStackConfigError", + "StackStateFormatUnsupportedError", + "InvalidProjectRootError", + "StackStateInvalidError", + () => "invalid-config" as const, + ), + Match.orElse(() => "unknown" as const), + ); + return new StackCommandDestroyError({ + reason, + message: stackError?.message ?? String(error), + cause: error, + }); +}; + +export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( + flags: StackDestroyFlags, +) { + const telemetryState = yield* TelemetryState; + const body = Effect.gen(function* () { + const output = yield* Output; + const settings = yield* CommandSettings; + const api = yield* StackApi; + const outputFlag = yield* Effect.serviceOption(OutputFlag); + yield* rejectStackOutput(outputFlag).pipe(Effect.mapError(mapTargetError)); + yield* validateStackTarget({ + stack: Option.getOrUndefined(flags.stack), + stackId: Option.getOrUndefined(flags.stackId), + }).pipe(Effect.mapError(mapTargetError)); + + const target = yield* Effect.gen(function* () { + if (Option.isSome(flags.stackId)) { + const id = yield* validateStackId(flags.stackId.value).pipe( + Effect.mapError(mapTargetError), + ); + return yield* api.inspectStack(id).pipe( + Effect.map(({ descriptor }) => descriptor), + Effect.mapError(destroyError), + ); + } + const found = yield* api + .findStack({ + projectRoot: settings.workdir, + ...(Option.isSome(flags.stack) ? { name: flags.stack.value } : {}), + }) + .pipe(Effect.mapError(destroyError)); + if (Option.isSome(found)) return found.value; + return yield* new StackCommandDestroyError({ + reason: "flags", + message: `No managed stack${Option.isSome(flags.stack) ? ` named "${flags.stack.value}"` : ""} was found for this project.`, + suggestion: "Choose an existing --stack name or omit --stack for the current project.", + }); + }); + const yes = yield* resolveYes; + const tty = yield* Tty; + if (!yes && (!tty.stdinIsTty || output.format !== "text")) + return yield* new StackCommandDestroyError({ + 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* promptYesNo( + output, + yes, + `Permanently destroy stack "${target.name}" at ${target.projectRoot} (${target.id}) and all of its data?`, + false, + ); + if (!confirmed) + return yield* new StackCommandDestroyError({ + 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 }); + }); + return yield* body.pipe(Effect.ensuring(telemetryState.flush)); +}); 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..680ef80411 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts @@ -0,0 +1,164 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option, Stream } from "effect"; +import { StackDestructionError, StackIdSchema } from "@supabase/stack/effect"; +import type { EffectStack } from "@supabase/stack/effect"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { YesFlag } from "../../../../command-internal/global-flags.ts"; +import { + mockCommandSettings, + mockTelemetryStateTracked, +} from "../../../../../tests/helpers/command-mocks.ts"; +import { + mockOutput, + mockStdin, + mockTty, + processEnvLayer, +} from "../../../../../tests/helpers/mocks.ts"; +import { StackApi } from "../stack.shared.ts"; +import { stackDestroy } from "./destroy.handler.ts"; +import { StackCommandDestroyError } from "./destroy.errors.ts"; + +const id = StackIdSchema.make("a".repeat(64)); +const descriptor = { + id, + projectRoot: "/project", + name: "feature-a", + branchContext: "ordinary-workspace" as const, + runtime: { kind: "native" as const }, + desiredLifecycle: "stopped" as const, +}; + +const flags = (stackId = Option.none(), stack = Option.none()) => ({ + stack, + stackId, +}); + +function setup(options: { + yes: boolean; + destroyFailure?: boolean; + interactive?: boolean; + promptConfirmResponses?: ReadonlyArray; + outputFormat?: "text" | "json"; + found?: boolean; +}) { + const output = mockOutput({ + format: options.outputFormat, + promptConfirmResponses: options.promptConfirmResponses, + }); + const telemetry = mockTelemetryStateTracked(); + const state = { destroyed: 0 }; + const stack: EffectStack = { + id, + status: () => Effect.die("unused"), + credentials: () => Effect.die("unused"), + prepare: () => Effect.die("unused"), + start: () => Effect.die("unused"), + stop: () => Effect.die("unused"), + destroy: () => + options.destroyFailure + ? Effect.fail(new StackDestructionError({ message: "destroy failed" })) + : Effect.sync(() => void state.destroyed++), + logs: () => Effect.die("unused"), + followLogs: () => Stream.empty, + }; + return { + output, + state, + telemetry, + layer: Layer.mergeAll( + output.layer, + telemetry.layer, + mockCommandSettings({ workdir: descriptor.projectRoot }), + mockTty({ + stdinIsTty: options.interactive ?? false, + stdoutIsTty: options.interactive ?? false, + }), + mockStdin(options.interactive ?? false), + processEnvLayer({}), + Layer.succeed(YesFlag, options.yes), + Layer.succeed(CliArgs, { args: options.yes ? ["--yes"] : [] }), + Layer.succeed(StackApi, { + findStack: () => + Effect.succeed(options.found === false ? Option.none() : Option.some(descriptor)), + createStack: () => Effect.die("unused"), + inspectStack: () => Effect.succeed({ descriptor, owner: "absent" as const }), + openStack: () => Effect.succeed(stack), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), + }), + BunServices.layer, + ), + }; +} + +describe("stack destroy", () => { + it.live("requires confirmation before mutating a noninteractive invocation", () => { + const fixture = setup({ yes: false }); + return Effect.gen(function* () { + const failure = yield* stackDestroy(flags()).pipe(Effect.flip); + expect(failure).toBeInstanceOf(StackCommandDestroyError); + expect(failure.message).toContain("requires confirmation"); + expect(fixture.state.destroyed).toBe(0); + }).pipe(Effect.provide(fixture.layer)); + }); + + it.live("destroys the selected stack after --yes", () => { + const fixture = setup({ yes: true }); + return Effect.gen(function* () { + yield* stackDestroy(flags(Option.some(id))); + expect(fixture.state.destroyed).toBe(1); + expect(fixture.output.stdoutText).toContain("destroyed"); + expect(fixture.telemetry.flushed).toBe(true); + }).pipe(Effect.provide(fixture.layer)); + }); + + it.live("emits a machine-readable result after --yes", () => { + const fixture = setup({ yes: true, outputFormat: "json" }); + return Effect.gen(function* () { + yield* stackDestroy(flags(Option.some(id))); + expect(fixture.output.messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "success", data: { destroyed: true, id } }), + ]), + ); + }).pipe(Effect.provide(fixture.layer)); + }); + + it.live("does not destroy a stack when interactive confirmation is declined", () => { + const fixture = setup({ yes: false, interactive: true, promptConfirmResponses: [false] }); + return Effect.gen(function* () { + const failure = yield* stackDestroy(flags()).pipe(Effect.flip); + expect(failure.message).toContain("not confirmed"); + expect(fixture.state.destroyed).toBe(0); + }).pipe(Effect.provide(fixture.layer)); + }); + + it.live("flushes telemetry when destruction fails", () => { + const fixture = setup({ yes: true, destroyFailure: true }); + return Effect.gen(function* () { + const failure = yield* stackDestroy(flags(Option.some(id))).pipe(Effect.flip); + expect(failure.message).toContain("destroy failed"); + expect(fixture.telemetry.flushed).toBe(true); + }).pipe(Effect.provide(fixture.layer)); + }); + + it.live("rejects malformed and conflicting targets without opening a stack", () => { + const fixture = setup({ yes: true }); + return Effect.gen(function* () { + const malformed = yield* stackDestroy(flags(Option.some("invalid"))).pipe(Effect.flip); + expect(malformed.message).toContain("lowercase SHA-256"); + const conflicting = yield* stackDestroy( + flags(Option.some(id), Option.some("feature-a")), + ).pipe(Effect.flip); + expect(conflicting.message).toContain("cannot be used together"); + }).pipe(Effect.provide(fixture.layer)); + }); + + it.live("reports an absent named target", () => { + const fixture = setup({ yes: true, found: false }); + return Effect.gen(function* () { + const failure = yield* stackDestroy(flags()).pipe(Effect.flip); + expect(failure.message).toContain("No managed stack"); + }).pipe(Effect.provide(fixture.layer)); + }); +}); diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts index 8c4b33e295..18f20afe78 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts @@ -222,7 +222,7 @@ stack = true "stack", "", ])?.candidates.map(({ name }) => name); - expect(stackCommands).toEqual(["start", "stop"]); + expect(stackCommands).toEqual(["destroy", "start", "stop"]); expect(completionFlags(backend, "status")).toContain("--override-name"); } 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 0cf1acf955..ebceffb713 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 @@ -13,10 +13,17 @@ import { mockProcessControl, mockRuntimeInfo, mockTelemetryRuntime, + mockStdin, + mockTty, processEnvLayer, } from "../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { DebugFlag, ProfileFlag, WorkdirFlag } from "../../../command-internal/global-flags.ts"; +import { + DebugFlag, + ProfileFlag, + WorkdirFlag, + YesFlag, +} from "../../../command-internal/global-flags.ts"; import { EventCommandExecuted, PropCommand, @@ -45,6 +52,9 @@ function setup() { Layer.succeed(DebugFlag, false), Layer.succeed(ProfileFlag, "supabase"), Layer.succeed(WorkdirFlag, Option.none()), + Layer.succeed(YesFlag, false), + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + mockStdin(false), mockRuntimeInfo({ cwd: root, homeDir: root }), mockTelemetryRuntime({ configDir: join(root, ".supabase"), @@ -81,4 +91,23 @@ describe("stack command telemetry", () => { Effect.ensuring(Effect.sync(() => rmSync(fixture.root, { recursive: true, force: true }))), ); }); + + it.live("records the destroy command identity on invalid target input", () => { + const fixture = setup(); + const command = stackCommand.pipe(Command.provide(fixture.layer)); + return Effect.gen(function* () { + yield* Command.runWith(command, { version: "0.0.0-test" })([ + "destroy", + "--stack-id", + "invalid", + ]).pipe(Effect.flip); + const event = fixture.analytics.captured.find( + (candidate) => candidate.event === EventCommandExecuted, + ); + expect(event?.properties[PropCommand]).toBe("stack destroy"); + }).pipe( + Effect.provide(fixture.layer), + Effect.ensuring(Effect.sync(() => rmSync(fixture.root, { recursive: true, force: true }))), + ); + }); }); diff --git a/apps/cli/src/commands/experimental/stack/stack.command.ts b/apps/cli/src/commands/experimental/stack/stack.command.ts index d85294f588..ddea323de6 100644 --- a/apps/cli/src/commands/experimental/stack/stack.command.ts +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -6,6 +6,7 @@ import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.t import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; import { stackStartCommand as stackStartCommandBase } from "./start/start.command.ts"; import { stackStopCommand as stackStopCommandBase } from "./stop/stop.command.ts"; +import { stackDestroyCommand as stackDestroyCommandBase } from "./destroy/destroy.command.ts"; import { stackApiLayer, stackTargetResolverLayer } from "./stack.shared.ts"; export const stackRuntimeLayer = Layer.mergeAll( @@ -21,12 +22,15 @@ const stackStartCommand = stackStartCommandBase.pipe( const stackStopCommand = stackStopCommandBase.pipe( Command.provide(commandRuntimeLayer(["stack", "stop"])), ); +const stackDestroyCommand = stackDestroyCommandBase.pipe( + Command.provide(commandRuntimeLayer(["stack", "destroy"])), +); export const stackCommand = Command.make("stack").pipe( Command.withDescription( "Manage an experimental, unstable local Supabase stack with the new backend. This command is excluded from the CLI compatibility promise.", ), Command.withShortDescription("Manage experimental local stacks"), - Command.withSubcommands([stackStartCommand, stackStopCommand]), + Command.withSubcommands([stackStartCommand, stackStopCommand, stackDestroyCommand]), Command.provide(stackRuntimeLayer), ); diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index 7e1e65211f..d098356e40 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -1,11 +1,13 @@ import { Context, Data, Effect, FileSystem, Layer, Option, Path, Crypto } from "effect"; import { createStack, + discoverStacks, findStack, inspectStack, isStackId, openStack, type StackRuntimePreference, + type StackDiscoveryResult, } from "@supabase/stack/effect"; import type { StackId } from "@supabase/stack"; import { StackNotFoundError } from "@supabase/stack/effect"; @@ -80,6 +82,9 @@ export class StackApi extends Context.Service< Effect.Success>, Effect.Error> >; + readonly discoverStacks: ( + ...args: Parameters + ) => Effect.Effect>>; } >()("supabase/experimental-stack/StackApi") {} @@ -141,6 +146,8 @@ export const stackApiLayer = Layer.effect( openStack: (...args: Parameters) => provideServices(openStack(...args)), inspectStack: (...args: Parameters) => provideServices(inspectStack(...args)), + discoverStacks: (...args: Parameters) => + provideServices(discoverStacks(...args)), }; }), ); 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 2cd090451d..c9eafe946f 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -55,6 +55,12 @@ runtime. `docker` and `native` select the requested runtime without fallback. `--preparation` controls background versus on-demand artifact preparation, and `--eager` requests enabled capabilities be activated before the command returns. +`--exclude` accepts repeated or comma-separated capability names (`rest`, `auth`, `realtime`, +`storage`, `functions`, `studio`, `mail`, `analytics`, and `pooler`) and disables those services +in the effective start configuration. The database cannot be excluded. Exclusions are applied in +memory and persisted with the stack state; the project configuration file is unchanged. If all +API gateway capabilities are disabled, the API listener is disabled as well. Eager activation never +re-enables an excluded capability. 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 08ad53ebbf..42e7267d48 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.command.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.command.ts @@ -2,9 +2,18 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; +import { stringSliceFlag } from "../../../../command-internal/string-slice-flag.ts"; import { stackStart } from "./start.handler.ts"; +import { STACK_START_EXCLUDABLE_CAPABILITIES } from "./start.options.ts"; + +const excludeFlag = stringSliceFlag( + "exclude", + `Capabilities to leave disabled. [${STACK_START_EXCLUDABLE_CAPABILITIES.join(", ")}]`, + { alias: "x" }, +); const config = { + exclude: excludeFlag, 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."), @@ -45,6 +54,9 @@ export const stackStartCommand = Command.make("start", config).pipe( }, ]), Command.withHandler((flags) => - stackStart(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), + stackStart(flags).pipe( + withCommandTelemetry({ flags, config, aliases: { x: "exclude" } }), + withJsonErrorHandling, + ), ), ); 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 fd65b223f4..6e81f61e19 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -1,6 +1,7 @@ import { Effect, Match, Option } from "effect"; import { isStackError, + type StackConfig, type StackStatus, type StackRuntimePreference, } from "@supabase/stack/effect"; @@ -18,6 +19,7 @@ import { import { loadStackConfig } from "../stack-config.ts"; import type { StackStartFlags } from "./start.command.ts"; import { StackCommandStartError } from "./start.errors.ts"; +import { STACK_START_EXCLUDABLE_CAPABILITIES } from "./start.options.ts"; const statusPayload = (status: StackStatus) => ({ id: status.id, @@ -55,6 +57,71 @@ const eagerlyActivate = < value: T, ): T => (value.enabled === false ? value : Object.assign({}, value, { activation: "eager" })); +const validateExclusions = (exclusions: ReadonlyArray) => { + const unknown = exclusions.filter( + (name) => + name !== "database" && + !STACK_START_EXCLUDABLE_CAPABILITIES.some((capability) => capability === name), + ); + if (unknown.length > 0) + return Effect.fail( + new StackCommandStartError({ + reason: "flags", + message: `Unknown stack capabilities in --exclude: ${unknown.map((name) => JSON.stringify(name)).join(", ")}`, + suggestion: `Choose from ${STACK_START_EXCLUDABLE_CAPABILITIES.join(", ")}.`, + }), + ); + if (exclusions.includes("database")) + return Effect.fail( + new StackCommandStartError({ + reason: "flags", + message: "The database capability cannot be excluded from a stack.", + suggestion: "Remove database from --exclude.", + }), + ); + return Effect.void; +}; + +const applyExclusions = (config: StackConfig, exclusions: ReadonlyArray): StackConfig => { + if (exclusions.length === 0) return config; + const excluded = new Set(exclusions); + const capabilities = config.capabilities; + const gatewayCapabilities = [ + "rest", + "auth", + "realtime", + "storage", + "functions", + "analytics", + ] as const; + const apiGatewayDisabled = gatewayCapabilities.every( + (name) => excluded.has(name) || capabilities?.[name]?.enabled === false, + ); + return { + ...config, + capabilities: { + ...capabilities, + ...(excluded.has("rest") ? { rest: { enabled: false as const } } : {}), + ...(excluded.has("auth") ? { auth: { enabled: false as const } } : {}), + ...(excluded.has("realtime") ? { realtime: { enabled: false as const } } : {}), + ...(excluded.has("storage") ? { storage: { enabled: false as const } } : {}), + ...(excluded.has("functions") ? { functions: { enabled: false as const } } : {}), + ...(excluded.has("studio") ? { studio: { enabled: false as const } } : {}), + ...(excluded.has("mail") ? { mail: { enabled: false as const } } : {}), + ...(excluded.has("analytics") ? { analytics: { enabled: false as const } } : {}), + ...(excluded.has("pooler") ? { pooler: { enabled: false as const } } : {}), + }, + ...(apiGatewayDisabled + ? { + listeners: { + ...config.listeners, + api: { enabled: false as const }, + }, + } + : {}), + }; +}; + const mapTargetError = (error: StackTargetError) => new StackCommandStartError({ reason: error.reason, @@ -71,6 +138,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags const resolver = yield* StackTargetResolver; const stackApi = yield* StackApi; const outputFlag = yield* Effect.serviceOption(OutputFlag); + yield* validateExclusions(flags.exclude); yield* rejectStackOutput(outputFlag).pipe(Effect.mapError(mapTargetError)); yield* validateStackTarget({ stack: Option.getOrUndefined(flags.stack), @@ -95,42 +163,43 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags }), ), ); + const configuredStart = applyExclusions(config, flags.exclude); 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 70401c2f65..706f241d7d 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 @@ -1,15 +1,16 @@ // oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary -import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, 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 { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Fiber, Layer, Option, Stream } from "effect"; +import { Deferred, Effect, Fiber, Layer, Option, Schema, Stream } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; import { ContainerEngineError, ContainerPullError, + StackConfigSchema, StackIdSchema, StackRuntimeError, StackStateInvalidError, @@ -89,7 +90,7 @@ const status = (id: string, runtime: "native" | "container" = "native") => function fakeStack( id: string, - start: (config: unknown) => Effect.Effect, + start: (config?: { readonly config?: unknown }) => Effect.Effect, ) { return { id: StackIdSchema.make(id), @@ -104,7 +105,10 @@ function fakeStack( } satisfies EffectStack; } -const flags = (overrides: Partial[0]> = {}) => ({ +const flags = ( + overrides: Partial[0]> = {}, +): Parameters[0] => ({ + exclude: [], stack: Option.none(), stackId: Option.none(), runtime: "auto" as const, @@ -145,6 +149,7 @@ function handlerLayer(opts: { return Effect.succeed(opts.stack); }, inspectStack: () => Effect.die("inspect not used in handler test"), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), }); return { out, @@ -161,6 +166,141 @@ function handlerLayer(opts: { } describe("stack start targeting", () => { + it.live("applies exclusions to the effective config without changing the project file", () => { + const root = project(); + const configBefore = readFileSync(join(root, "supabase", "config.toml"), "utf8"); + let startedConfig: unknown; + const stack = fakeStack("e".repeat(64), (config) => + Effect.gen(function* () { + startedConfig = config; + yield* Schema.decodeUnknownEffect(StackConfigSchema)(config?.config, { + onExcessProperty: "error", + }).pipe(Effect.mapError((error) => new StackStateInvalidError({ message: error.message }))); + return status("e".repeat(64)); + }), + ); + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + yield* stackStart(flags({ exclude: ["studio", "analytics"] })); + expect(startedConfig).toEqual( + expect.objectContaining({ + config: expect.objectContaining({ + capabilities: expect.objectContaining({ + studio: { enabled: false }, + analytics: { enabled: false }, + }), + }), + }), + ); + expect(readFileSync(join(root, "supabase", "config.toml"), "utf8")).toBe(configBefore); + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.live("disables the API listener when all gateway capabilities are excluded", () => { + const root = project(); + writeFileSync( + join(root, "supabase", "config.toml"), + 'project_id = "start-test"\n[api]\nport = 55421\n', + ); + let startedConfig: unknown; + const stack = fakeStack("7".repeat(64), (config) => + Effect.sync(() => { + startedConfig = config; + return status("7".repeat(64)); + }), + ); + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + yield* stackStart( + flags({ exclude: ["rest", "auth", "realtime", "storage", "functions", "analytics"] }), + ); + expect(startedConfig).toEqual( + expect.objectContaining({ + config: expect.objectContaining({ + listeners: expect.objectContaining({ api: { enabled: false } }), + }), + }), + ); + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.live("preserves the configured API listener on partial gateway exclusion", () => { + const root = project(); + writeFileSync( + join(root, "supabase", "config.toml"), + 'project_id = "start-test"\n[api]\nport = 55421\n', + ); + let startedConfig: unknown; + const stack = fakeStack("8".repeat(64), (config) => + Effect.sync(() => { + startedConfig = config; + return status("8".repeat(64)); + }), + ); + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + yield* stackStart(flags({ exclude: ["rest"] })); + expect(startedConfig).toEqual( + expect.objectContaining({ + config: expect.objectContaining({ + listeners: expect.objectContaining({ api: { port: 55421 } }), + }), + }), + ); + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.live("counts configured-disabled gateway capabilities toward API listener disablement", () => { + const root = project(); + writeFileSync( + join(root, "supabase", "config.toml"), + `project_id = "start-test" +[api] +port = 55421 +[auth] +enabled = false +[realtime] +enabled = false +[storage] +enabled = false +[edge_runtime] +enabled = false +[analytics] +enabled = false +`, + ); + let startedConfig: unknown; + const stack = fakeStack("9".repeat(64), (config) => + Effect.sync(() => { + startedConfig = config; + return status("9".repeat(64)); + }), + ); + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + yield* stackStart(flags({ exclude: ["rest"] })); + expect(startedConfig).toEqual( + expect.objectContaining({ + config: expect.objectContaining({ + listeners: expect.objectContaining({ api: { enabled: false } }), + }), + }), + ); + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + it.live("leaves auto runtime selection to the package for a new stack", () => { const root = project(); let createOptions: unknown; @@ -275,6 +415,7 @@ describe("stack start targeting", () => { }, owner: "running", }), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), }); return Effect.gen(function* () { const resolver = yield* StackTargetResolver; @@ -312,6 +453,7 @@ describe("stack start targeting", () => { runtime: "native", preparation: "on-demand", eager: true, + exclude: ["studio"], }), ); expect(createOptions).toEqual({ @@ -320,9 +462,16 @@ describe("stack start targeting", () => { runtime: { kind: "native" }, }); expect(startConfig).toMatchObject({ config: { preparation: "on-demand" } }); - expect(startConfig).toMatchObject({ - config: { capabilities: { rest: { activation: "eager" } } }, - }); + expect(startConfig).toEqual( + expect.objectContaining({ + config: expect.objectContaining({ + capabilities: expect.objectContaining({ + rest: expect.objectContaining({ activation: "eager" }), + studio: { enabled: false }, + }), + }), + }), + ); expect(setup.out.stdoutText).toContain("Stack"); expect(setup.telemetry.flushed).toBe(true); }).pipe( @@ -574,6 +723,7 @@ describe("stack start targeting", () => { }, openStack: () => Effect.die("open should not run"), inspectStack: () => Effect.die("inspect should not run"), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), }), BunServices.layer, ); @@ -645,6 +795,24 @@ describe("stack start parser", () => { ); }); + it.live("parses repeated, comma-separated, and short exclusion flags", () => { + let parsed: ReadonlyArray | undefined; + const command = stackStartCommand.pipe( + Command.withHandler((flags) => Effect.sync(() => void (parsed = flags.exclude))), + ); + return Effect.gen(function* () { + yield* Command.runWith(command, { version: "0.0.0-test" })([ + "--exclude", + "studio,analytics", + "-x", + "mail", + ]); + expect(parsed).toEqual(["studio", "analytics", "mail"]); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), + ); + }); + it.live("rejects a root legacy output value before resolving the stack target", () => { const root = project(); let resolved = false; @@ -676,4 +844,41 @@ describe("stack start parser", () => { Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), ); }); + + it.live("rejects unknown and database exclusions before resolving the target", () => { + const root = project(); + let resolved = false; + const setup = handlerLayer({ + root, + target: { projectRoot: root }, + stack: fakeStack("b".repeat(64), () => Effect.succeed(status("b".repeat(64)))), + }); + const target = Layer.succeed(StackTargetResolver, { + resolve: () => + Effect.sync(() => { + resolved = true; + return { projectRoot: root }; + }), + }); + return Effect.gen(function* () { + for (const { exclusion, message } of [ + { exclusion: "", message: 'Unknown stack capabilities in --exclude: ""' }, + { + exclusion: " analytics", + message: 'Unknown stack capabilities in --exclude: " analytics"', + }, + { exclusion: "unknown", message: 'Unknown stack capabilities in --exclude: "unknown"' }, + { exclusion: "database", message: "database capability cannot be excluded" }, + ]) { + const failure = yield* stackStart(flags({ exclude: [exclusion] })).pipe(Effect.flip); + expect(failure).toBeInstanceOf(StackCommandStartError); + expect(failure.reason).toBe("flags"); + expect(failure.message).toContain(message); + } + expect(resolved).toBe(false); + }).pipe( + Effect.provide(Layer.mergeAll(setup.layer, target)), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); }); 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..cd470693bc --- /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 STACK_START_EXCLUDABLE_CAPABILITIES = [ + "rest", + "auth", + "realtime", + "storage", + "functions", + "studio", + "mail", + "analytics", + "pooler", +] as const; 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 f8baa63c19..e846c5d20a 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 discovers every readable managed stack and attempts each +stop while preserving persistent state and data volumes. `--all` cannot be combined with a named +stack or `--stack-id`; the command never destroys stacks. ## Files read and written @@ -23,9 +24,11 @@ returning. That process is package-owned and is not managed directly by the CLI. 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 +no-stack result. Bulk mode attempts all readable stacks, warns for unreadable entries, and reports +stopped, failed, and skipped counts. It exits nonzero when an entry is skipped or a stop fails. +Registry-root enumeration failures remain fatal. 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. Telemetry state is flushed to `/telemetry.json` 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 2d61b99cd6..f53e8068e1 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts @@ -5,6 +5,10 @@ import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts import { stackStop } from "./stop.handler.ts"; const config = { + all: Flag.boolean("all").pipe( + Flag.withDescription("Stop every readable managed stack."), + Flag.withDefault(false), + ), stack: Flag.string("stack").pipe( Flag.withDescription("Stop the stack with this name (defaults to the current project stack)."), Flag.optional, 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 ee09c15a4b..84e67f8dab 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 { type StackDescriptor, type OpenStackError, @@ -69,12 +69,57 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: const settings = yield* CommandSettings; const stackApi = yield* StackApi; const outputFlag = yield* Effect.serviceOption(OutputFlag); + const stopAll = flags.all; yield* rejectStackOutput(outputFlag).pipe(Effect.mapError(mapTargetError)); + if (stopAll && (Option.isSome(flags.stack) || Option.isSome(flags.stackId))) + return yield* new StackCommandStopError({ + reason: "flags", + message: "--all cannot be combined with --stack or --stack-id", + }); yield* validateStackTarget({ stack: Option.getOrUndefined(flags.stack), stackId: Option.getOrUndefined(flags.stackId), }).pipe(Effect.mapError(mapTargetError)); + if (stopAll) { + 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 stopping = yield* output.task( + `Stopping ${discovered.stacks.length} managed stack(s)...`, + ); + const results = yield* Effect.forEach( + discovered.stacks, + (descriptor) => + stackApi.openStack(descriptor.id).pipe( + Effect.flatMap((stack) => stack.stop()), + Effect.result, + Effect.map((result) => ({ descriptor, result })), + ), + { concurrency: 1 }, + ); + const failed = results.flatMap(({ descriptor, result }) => + Result.isFailure(result) ? [{ descriptor, error: result.failure }] : [], + ); + if (failed.length > 0 || discovered.errors.length > 0) { + const message = `Stopped ${discovered.stacks.length - failed.length} managed stack(s); failed to stop ${failed.length} and skipped ${discovered.errors.length}: ${[ + ...failed.map(({ descriptor, error }) => `${descriptor.id}: ${error.message}`), + ...discovered.errors.map(({ id, error }) => `${id}: ${error.message}`), + ].join("; ")}`; + yield* stopping.fail(message); + return yield* new StackCommandStopError({ + reason: "lifecycle", + message, + cause: { failures: failed, discovery: discovered.errors }, + }); + } + yield* stopping.clear(); + if (output.format === "text") + yield* output.raw(`Stopped ${discovered.stacks.length} managed stack(s).\n`); + else yield* output.success("", { stopped: discovered.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 042363948e..74321ad0b2 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 @@ -16,6 +16,7 @@ import type { EffectStack, OpenStackError, StackDiscoveryError, + StackDescriptor, StackStatus, StackStopError as ApiStackStopError, } from "@supabase/stack/effect"; @@ -46,7 +47,10 @@ const status = (id: string): StackStatus => ({ artifacts: [], }); -const flags = (overrides: Partial[0]> = {}) => ({ +const flags = ( + overrides: Partial[0]> = {}, +): Parameters[0] => ({ + all: false, stack: Option.none(), stackId: Option.none(), ...overrides, @@ -58,6 +62,14 @@ function setup(opts: { stop?: () => Effect.Effect; openFailure?: OpenStackError; findFailure?: StackDiscoveryError; + discoveryFailure?: StackDiscoveryError; + discovered?: { + readonly stacks: ReadonlyArray; + readonly errors: ReadonlyArray<{ + readonly id: StackDescriptor["id"]; + readonly error: StackDiscoveryError; + }>; + }; }) { const out = mockOutput(); const telemetry = mockTelemetryStateTracked(); @@ -120,6 +132,13 @@ function setup(opts: { }); }, inspectStack: () => Effect.die("must not inspect"), + discoverStacks: () => + opts.discoveryFailure === undefined + ? Effect.succeed({ + stacks: opts.discovered?.stacks ?? [], + errors: opts.discovered?.errors ?? [], + }) + : Effect.fail(opts.discoveryFailure), }), BunServices.layer, ); @@ -127,6 +146,152 @@ function setup(opts: { } describe("stack stop", () => { + it.effect("stops every discovered stack when --all is selected", () => { + const root = "/tmp/supabase-stack-stop-all"; + const id = "b".repeat(64); + const setupResult = setup({ + root, + found: { id, name: "feature-a" }, + discovered: { + stacks: [ + { + id: StackIdSchema.make(id), + projectRoot: root, + name: "feature-a", + branchContext: "ordinary-workspace", + runtime: { kind: "native" }, + desiredLifecycle: "running", + }, + { + id: StackIdSchema.make("c".repeat(64)), + projectRoot: root, + name: "feature-b", + branchContext: "ordinary-workspace", + runtime: { kind: "native" }, + desiredLifecycle: "running", + }, + ], + errors: [], + }, + }); + return Effect.gen(function* () { + yield* stackStop(flags({ all: true })); + expect(setupResult.state.openedIds).toEqual([id, "c".repeat(64)]); + expect(setupResult.state.stopCalls).toBe(2); + expect(setupResult.state.destroyCalled).toBe(false); + expect(setupResult.out.stdoutText).toContain("Stopped 2"); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("continues attempting every stack after a stop failure", () => { + const root = "/tmp/supabase-stack-stop-all-failure"; + const first = "d".repeat(64); + const second = "e".repeat(64); + const setupResult = setup({ + root, + found: { id: first }, + stop: () => Effect.fail(new StackCleanupError({ message: "stop failed" })), + discovered: { + stacks: [ + { + id: StackIdSchema.make(first), + projectRoot: root, + name: "first", + branchContext: "ordinary-workspace", + runtime: { kind: "native" }, + desiredLifecycle: "running", + }, + { + id: StackIdSchema.make(second), + projectRoot: root, + name: "second", + branchContext: "ordinary-workspace", + runtime: { kind: "native" }, + desiredLifecycle: "running", + }, + ], + errors: [], + }, + }); + return Effect.gen(function* () { + const failure = yield* stackStop(flags({ all: true })).pipe(Effect.flip); + expect(failure.message).toContain("failed to stop 2"); + expect(setupResult.state.openedIds).toEqual([first, second]); + expect(setupResult.state.destroyCalled).toBe(false); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("reports corrupt discovery entries while stopping healthy stacks", () => { + const root = "/tmp/supabase-stack-stop-all-corrupt"; + const healthy = "f".repeat(64); + const corrupt = "1".repeat(64); + const setupResult = setup({ + root, + found: { id: healthy }, + discovered: { + stacks: [ + { + id: StackIdSchema.make(healthy), + projectRoot: root, + name: "healthy", + branchContext: "ordinary-workspace", + runtime: { kind: "native" }, + desiredLifecycle: "running", + }, + ], + errors: [ + { + id: StackIdSchema.make(corrupt), + error: new StackStateInvalidError({ message: "corrupt state" }), + }, + ], + }, + }); + return Effect.gen(function* () { + const failure = yield* stackStop(flags({ all: true })).pipe(Effect.flip); + expect(failure.message).toContain("skipped 1"); + expect(setupResult.state.stopCalls).toBe(1); + expect(setupResult.state.destroyCalled).toBe(false); + expect(setupResult.out.messages).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "warn" })]), + ); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("fails before opening any stack when registry discovery fails", () => { + const setupResult = setup({ + root: "/tmp/supabase-stack-stop-all-discovery-failure", + discoveryFailure: new StackStateInvalidError({ message: "registry is unreadable" }), + }); + return Effect.gen(function* () { + const failure = yield* stackStop(flags({ all: true })).pipe(Effect.flip); + expect(failure.message).toContain("registry is unreadable"); + expect(setupResult.state.openedIds).toEqual([]); + expect(setupResult.state.destroyCalled).toBe(false); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("treats an empty registry as a successful bulk no-op", () => { + const setupResult = setup({ root: "/tmp/supabase-stack-stop-all-empty" }); + return Effect.gen(function* () { + yield* stackStop(flags({ all: true })); + expect(setupResult.state.openedIds).toEqual([]); + expect(setupResult.state.stopCalls).toBe(0); + expect(setupResult.state.destroyCalled).toBe(false); + expect(setupResult.out.stdoutText).toContain("Stopped 0 managed stack(s)."); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("rejects bulk stop target combinations", () => { + const setupResult = setup({ root: "/tmp/supabase-stack-stop-conflict" }); + return Effect.gen(function* () { + const failure = yield* stackStop(flags({ all: true, stack: Option.some("feature-a") })).pipe( + Effect.flip, + ); + expect(failure.message).toContain("cannot be combined"); + }).pipe(Effect.provide(setupResult.layer)); + }); + it.effect("stops a named stack without calling destroy", () => { const root = "/tmp/supabase-stack-stop"; const setupResult = setup({ diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 9417dee711..9f750f3cfb 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -232,6 +232,7 @@ EncryptionNetworkError EncryptionUnexpectedStatusError ExperimentalFeatureFlagError ExperimentalRequiredError +ExperimentalStackDestroyError ExperimentalStackStartError ExperimentalStackStopError ExperimentalStackTargetError diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 9f9ca11994..4d863bb877 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 { @@ -13,6 +14,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 71f93e9c98..81acc13bad 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -7,9 +7,11 @@ import { Exit, FileSystem, Fiber, + Match, Option, Path, Predicate, + Result, Schedule, Schema, Stream, @@ -1077,10 +1079,43 @@ export const findStack = ( return state === undefined ? Option.none() : Option.some(descriptor(state, id)); }); -export const listStacks = ( +export interface StackDiscoveryIssue { + readonly id: StackId; + readonly error: StackDiscoveryError; +} + +/** The managed stack registry with entry-level read errors retained for bulk operations. */ +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", + (value) => new InvalidProjectRootError({ ...value, message, cause: error }), + ), + Match.tag( + "StackStateInvalidError", + (value) => new StackStateInvalidError({ ...value, stackId: entry, message, cause: error }), + ), + Match.tag( + "StackStateFormatUnsupportedError", + (value) => new StackStateFormatUnsupportedError({ ...value, message, cause: error }), + ), + Match.exhaustive, + ); +}; + +export const discoverStacks = ( options: ListStacksOptions = {}, ): Effect.Effect< - ReadonlyArray, + StackDiscoveryResult, StackDiscoveryError, FileSystem.FileSystem | Path.Path | Crypto.Crypto > => @@ -1101,25 +1136,48 @@ 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.catchIf(isMissingStateRemnantError, () => Effect.void)); + 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, entry)); + stacks.push(descriptor(state, entry)); } - 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); + }), + ); + export const inspectStack = ( id: StackId, ): Effect.Effect< diff --git a/packages/stack/src/public/PromiseStack.ts b/packages/stack/src/public/PromiseStack.ts index a3b9855017..ccddeddd95 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"; @@ -62,6 +65,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) => Promise; } @@ -174,6 +178,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) => run(inspectEffectStack(id)), }; }; @@ -183,6 +188,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 71dce6dcaf..f736f9e610 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, findStack, inspectStack, listStacks, @@ -1130,6 +1131,29 @@ describe("Effect stack lifecycle handoff", () => { ), ); + it.live("retains healthy stacks while reporting unreadable registry entries", () => + 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"); + yield* fs.makeDirectory(corruptProject); + const corrupt = yield* createStack({ projectRoot: corruptProject }); + const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: corrupt.id }); + yield* fs.writeFileString(paths.stateDocument, "{ malformed"); + + const discovered = yield* discoverStacks(); + + expect(discovered.stacks.map(({ id }) => id)).toEqual([healthy.id]); + expect(discovered.errors).toHaveLength(1); + expect(discovered.errors[0]?.id).toBe(corrupt.id); + expect(discovered.errors[0]?.error).toBeInstanceOf(StackStateInvalidError); + }), + ), + ); + it.live("recovers a same-identity runtime-only missing-state remnant during create", () => withRuntimeRoot((project) => Effect.gen(function* () { diff --git a/packages/stack/src/public/index.ts b/packages/stack/src/public/index.ts index bd911e2d46..80b763ddf0 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, StartStackOptions, @@ -15,6 +22,8 @@ export type { CreateStackOptions, FindStackOptions, ListStacksOptions, + StackDiscoveryIssue, + StackDiscoveryResult, PreparedCapability, PrepareStackResult, } from "./EffectStack.ts"; From 8a45f141196241d17cdc66c33348fbfaf9802479 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 11 Sep 2026 16:18:11 +0200 Subject: [PATCH 3/6] fix(cli): respect stack exclusion dependencies --- apps/cli/docs/stack-commands.md | 11 +- .../stack/destroy/destroy.handler.ts | 2 +- .../experimental/stack/start/SIDE_EFFECTS.md | 7 +- .../experimental/stack/start/start.handler.ts | 48 +------- .../stack/start/start.integration.test.ts | 103 +++++++++++++----- .../experimental/stack/start/start.options.ts | 16 +-- .../experimental/stack/stop/SIDE_EFFECTS.md | 3 +- .../experimental/stack/stop/stop.errors.ts | 1 + .../experimental/stack/stop/stop.handler.ts | 15 ++- .../stack/stop/stop.integration.test.ts | 55 +++++++++- apps/cli/tests/helpers/mocks.ts | 3 + packages/stack/README.md | 4 + packages/stack/src/model/Exclusions.ts | 33 ++++++ .../src/model/compiler.integration.test.ts | 36 ++++++ packages/stack/src/public/index.ts | 2 + 15 files changed, 237 insertions(+), 102 deletions(-) create mode 100644 packages/stack/src/model/Exclusions.ts diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 30c4cfc991..c2382fd8fa 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -56,13 +56,14 @@ including `--workdir` and `SUPABASE_WORKDIR`; a JSON-only project does not enabl `supabase stack start --exclude studio,analytics -x mail` disables those services in the effective start configuration without changing the project file. Valid names are `rest`, `auth`, `realtime`, -`storage`, `functions`, `studio`, `mail`, `analytics`, and `pooler`; the database is required. The -effective configuration is retained in stack state, so starting without `--exclude` restores the -project's configured services. +`storage`, `functions`, `studio`, `mail`, `analytics`, and `pooler`; the database is required. +Excluding `rest` or `analytics` also disables Studio. The effective configuration is +retained in stack state, so starting without `--exclude` restores the project's configured services. `supabase stack stop --all` stops every readable managed stack while preserving data. It continues -after unreadable entries or individual stop failures, reports warnings and counts, and exits -nonzero when anything was skipped or failed. Registry-root enumeration errors remain fatal. +after unreadable entries or individual stop failures, reports a bounded stopped/failed/skipped +summary with per-stack details, and exits nonzero when anything was skipped or failed. Registry-root +enumeration errors remain fatal. `supabase stack destroy --stack feature-a` permanently removes exactly that stack and its data after confirmation. Use `--yes` for unattended execution. There is no bulk destroy option. 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 42f680c5b5..c6f573fb84 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -20,7 +20,7 @@ const mapTargetError = (error: StackTargetError) => new StackCommandDestroyError({ reason: error.reason, message: error.message, - suggestion: error.suggestion, + ...(error.suggestion === undefined ? {} : { suggestion: error.suggestion }), cause: error, }); 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 c9eafe946f..8bfc8ef9d0 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -58,9 +58,10 @@ activated before the command returns. `--exclude` accepts repeated or comma-separated capability names (`rest`, `auth`, `realtime`, `storage`, `functions`, `studio`, `mail`, `analytics`, and `pooler`) and disables those services in the effective start configuration. The database cannot be excluded. Exclusions are applied in -memory and persisted with the stack state; the project configuration file is unchanged. If all -API gateway capabilities are disabled, the API listener is disabled as well. Eager activation never -re-enables an excluded capability. +memory and persisted with the stack state; the project configuration file is unchanged. A capability +and its dependents are disabled together, so excluding `rest` or `analytics` also disables `studio`. +Listeners are derived by the runtime from enabled capability routes; route-less listeners are therefore omitted. +Eager activation never re-enables an excluded capability. 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.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index 6e81f61e19..316c25048b 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -1,7 +1,7 @@ import { Effect, Match, Option } from "effect"; import { + excludeStackCapabilities, isStackError, - type StackConfig, type StackStatus, type StackRuntimePreference, } from "@supabase/stack/effect"; @@ -79,47 +79,9 @@ const validateExclusions = (exclusions: ReadonlyArray) => { suggestion: "Remove database from --exclude.", }), ); - return Effect.void; -}; - -const applyExclusions = (config: StackConfig, exclusions: ReadonlyArray): StackConfig => { - if (exclusions.length === 0) return config; - const excluded = new Set(exclusions); - const capabilities = config.capabilities; - const gatewayCapabilities = [ - "rest", - "auth", - "realtime", - "storage", - "functions", - "analytics", - ] as const; - const apiGatewayDisabled = gatewayCapabilities.every( - (name) => excluded.has(name) || capabilities?.[name]?.enabled === false, + return Effect.succeed( + STACK_START_EXCLUDABLE_CAPABILITIES.filter((name) => exclusions.includes(name)), ); - return { - ...config, - capabilities: { - ...capabilities, - ...(excluded.has("rest") ? { rest: { enabled: false as const } } : {}), - ...(excluded.has("auth") ? { auth: { enabled: false as const } } : {}), - ...(excluded.has("realtime") ? { realtime: { enabled: false as const } } : {}), - ...(excluded.has("storage") ? { storage: { enabled: false as const } } : {}), - ...(excluded.has("functions") ? { functions: { enabled: false as const } } : {}), - ...(excluded.has("studio") ? { studio: { enabled: false as const } } : {}), - ...(excluded.has("mail") ? { mail: { enabled: false as const } } : {}), - ...(excluded.has("analytics") ? { analytics: { enabled: false as const } } : {}), - ...(excluded.has("pooler") ? { pooler: { enabled: false as const } } : {}), - }, - ...(apiGatewayDisabled - ? { - listeners: { - ...config.listeners, - api: { enabled: false as const }, - }, - } - : {}), - }; }; const mapTargetError = (error: StackTargetError) => @@ -138,8 +100,8 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags const resolver = yield* StackTargetResolver; const stackApi = yield* StackApi; const outputFlag = yield* Effect.serviceOption(OutputFlag); - yield* validateExclusions(flags.exclude); yield* rejectStackOutput(outputFlag).pipe(Effect.mapError(mapTargetError)); + const exclusions = yield* validateExclusions(flags.exclude); yield* validateStackTarget({ stack: Option.getOrUndefined(flags.stack), stackId: Option.getOrUndefined(flags.stackId), @@ -163,7 +125,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags }), ), ); - const configuredStart = applyExclusions(config, flags.exclude); + const configuredStart = excludeStackCapabilities(config, exclusions); const startConfig = flags.eager ? { ...configuredStart, 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 706f241d7d..c605198f87 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 @@ -34,6 +34,7 @@ import { StackApi, } from "../stack.shared.ts"; import { stackStart } from "./start.handler.ts"; +import { compileStack } from "../../../../../../../packages/stack/src/model/Compiler.ts"; import { StackCommandStartError } from "./start.errors.ts"; import { stackStartCommand } from "./start.command.ts"; import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; @@ -166,6 +167,43 @@ function handlerLayer(opts: { } describe("stack start targeting", () => { + for (const exclusion of ["rest", "analytics"] as const) { + it.live(`compiles ${exclusion} exclusion and dependent Studio`, () => { + const root = project(); + const configBefore = readFileSync(join(root, "supabase", "config.toml"), "utf8"); + const stack = fakeStack("c".repeat(64), (input) => + Effect.gen(function* () { + const stackConfig = yield* Schema.decodeUnknownEffect(StackConfigSchema)( + input?.config, + ).pipe( + Effect.mapError((error) => new StackStateInvalidError({ message: error.message })), + ); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + config: stackConfig, + }).pipe( + Effect.mapError((error) => new StackStateInvalidError({ message: error.message })), + Effect.provide(BunServices.layer), + ); + expect(compiled.definition.capabilities[exclusion].enabled).toBe(false); + expect(compiled.definition.capabilities.studio.enabled).toBe(false); + expect(compiled.definition.capabilities.auth.enabled).toBe(true); + return status("c".repeat(64)); + }), + ); + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + for (const eager of [false, true]) + yield* stackStart(flags({ exclude: [exclusion], eager })); + expect(readFileSync(join(root, "supabase", "config.toml"), "utf8")).toBe(configBefore); + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + } + it.live("applies exclusions to the effective config without changing the project file", () => { const root = project(); const configBefore = readFileSync(join(root, "supabase", "config.toml"), "utf8"); @@ -199,36 +237,39 @@ describe("stack start targeting", () => { ); }); - it.live("disables the API listener when all gateway capabilities are excluded", () => { - const root = project(); - writeFileSync( - join(root, "supabase", "config.toml"), - 'project_id = "start-test"\n[api]\nport = 55421\n', - ); - let startedConfig: unknown; - const stack = fakeStack("7".repeat(64), (config) => - Effect.sync(() => { - startedConfig = config; - return status("7".repeat(64)); - }), - ); - const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); - return Effect.gen(function* () { - yield* stackStart( - flags({ exclude: ["rest", "auth", "realtime", "storage", "functions", "analytics"] }), + it.live( + "leaves listener configuration to the compiled runtime when capabilities are excluded", + () => { + const root = project(); + writeFileSync( + join(root, "supabase", "config.toml"), + 'project_id = "start-test"\n[api]\nport = 55421\n', ); - expect(startedConfig).toEqual( - expect.objectContaining({ - config: expect.objectContaining({ - listeners: expect.objectContaining({ api: { enabled: false } }), - }), + let startedConfig: unknown; + const stack = fakeStack("7".repeat(64), (config) => + Effect.sync(() => { + startedConfig = config; + return status("7".repeat(64)); }), ); - }).pipe( - Effect.provide(setup.layer), - Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), - ); - }); + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + yield* stackStart( + flags({ exclude: ["rest", "auth", "realtime", "storage", "functions", "analytics"] }), + ); + expect(startedConfig).toEqual( + expect.objectContaining({ + config: expect.objectContaining({ + listeners: expect.objectContaining({ api: { port: 55421 } }), + }), + }), + ); + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }, + ); it.live("preserves the configured API listener on partial gateway exclusion", () => { const root = project(); @@ -259,7 +300,7 @@ describe("stack start targeting", () => { ); }); - it.live("counts configured-disabled gateway capabilities toward API listener disablement", () => { + it.live("does not rewrite listeners for configured-disabled capabilities", () => { const root = project(); writeFileSync( join(root, "supabase", "config.toml"), @@ -291,7 +332,7 @@ enabled = false expect(startedConfig).toEqual( expect.objectContaining({ config: expect.objectContaining({ - listeners: expect.objectContaining({ api: { enabled: false } }), + listeners: expect.objectContaining({ api: { port: 55421 } }), }), }), ); @@ -829,7 +870,9 @@ describe("stack start parser", () => { }), }); return Effect.gen(function* () { - const failure = yield* stackStart(flags()).pipe(Effect.flip); + const failure = yield* stackStart(flags({ exclude: ["bogus"] })).pipe(Effect.flip); + expect(failure.message).toContain("--output"); + expect(failure.message).not.toContain("Unknown stack capabilities"); expect(failure).toBeInstanceOf(StackCommandStartError); if (failure instanceof StackCommandStartError) { expect(failure.reason).toBe("flags"); diff --git a/apps/cli/src/commands/experimental/stack/start/start.options.ts b/apps/cli/src/commands/experimental/stack/start/start.options.ts index cd470693bc..28c5e7e730 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.options.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.options.ts @@ -1,12 +1,6 @@ +import { CAPABILITY_NAMES } from "@supabase/stack/effect"; + /** Optional capabilities accepted by `stack start --exclude`. */ -export const STACK_START_EXCLUDABLE_CAPABILITIES = [ - "rest", - "auth", - "realtime", - "storage", - "functions", - "studio", - "mail", - "analytics", - "pooler", -] as const; +export const STACK_START_EXCLUDABLE_CAPABILITIES = CAPABILITY_NAMES.filter( + (name) => name !== "database", +); 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 e846c5d20a..929ba6cfbd 100644 --- a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md @@ -25,7 +25,8 @@ returning. That process is package-owned and is not managed directly by the CLI. 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. Bulk mode attempts all readable stacks, warns for unreadable entries, and reports -stopped, failed, and skipped counts. It exits nonzero when an entry is skipped or a stop fails. +stopped, failed, and skipped counts with per-stack details. It exits nonzero when an entry is skipped +or a stop fails. Registry-root enumeration failures remain fatal. 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 diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.errors.ts b/apps/cli/src/commands/experimental/stack/stop/stop.errors.ts index 12a5068ce8..460e5efff3 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.errors.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.errors.ts @@ -8,6 +8,7 @@ import { export class StackCommandStopError extends Data.TaggedError("ExperimentalStackStopError")<{ readonly reason: "flags" | "invalid-config" | "lifecycle" | "unknown"; readonly message: string; + readonly detail?: string; readonly suggestion?: string; readonly cause?: unknown; }> { 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 84e67f8dab..c7313ce375 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -102,14 +102,21 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: Result.isFailure(result) ? [{ descriptor, error: result.failure }] : [], ); if (failed.length > 0 || discovered.errors.length > 0) { - const message = `Stopped ${discovered.stacks.length - failed.length} managed stack(s); failed to stop ${failed.length} and skipped ${discovered.errors.length}: ${[ - ...failed.map(({ descriptor, error }) => `${descriptor.id}: ${error.message}`), - ...discovered.errors.map(({ id, error }) => `${id}: ${error.message}`), - ].join("; ")}`; + const message = `Stopped ${discovered.stacks.length - failed.length} managed stack(s); failed ${failed.length}; skipped ${discovered.errors.length}`; + const detail = [ + ...failed.map( + ({ descriptor, error }) => + `Failed to stop managed stack ${descriptor.id}: ${error.message}`, + ), + ...discovered.errors.map( + ({ id, error }) => `Skipped managed stack ${id}: ${error.message}`, + ), + ].join("\n"); yield* stopping.fail(message); return yield* new StackCommandStopError({ reason: "lifecycle", message, + detail, cause: { failures: failed, discovery: discovered.errors }, }); } 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 74321ad0b2..9c7ab6313a 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 @@ -20,7 +20,7 @@ import type { StackStatus, StackStopError as ApiStackStopError, } from "@supabase/stack/effect"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockProcessControl } from "../../../../../tests/helpers/mocks.ts"; import { mockCommandSettings, mockTelemetryStateTracked, @@ -35,6 +35,7 @@ import { textCliOutputFormatter } from "../../../../shared/output/text-formatter import { stackStop } from "./stop.handler.ts"; import { StackCommandStopError } from "./stop.errors.ts"; import { stackStopCommand } from "./stop.command.ts"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; const status = (id: string): StackStatus => ({ id: StackIdSchema.make(id), @@ -58,6 +59,7 @@ const flags = ( function setup(opts: { root: string; + format?: "text" | "json" | "stream-json"; found?: { id: string; name?: string }; stop?: () => Effect.Effect; openFailure?: OpenStackError; @@ -71,7 +73,7 @@ function setup(opts: { }>; }; }) { - const out = mockOutput(); + const out = mockOutput({ format: opts.format }); const telemetry = mockTelemetryStateTracked(); const state = { findInputs: [] as Array<{ projectRoot: string; name?: string }>, @@ -111,6 +113,7 @@ function setup(opts: { : undefined; const layer = Layer.mergeAll( out.layer, + mockProcessControl().layer, telemetry.layer, mockCommandSettings({ workdir: opts.root }), Layer.succeed(StackApi, { @@ -215,9 +218,12 @@ describe("stack stop", () => { }); return Effect.gen(function* () { const failure = yield* stackStop(flags({ all: true })).pipe(Effect.flip); - expect(failure.message).toContain("failed to stop 2"); + expect(failure.message).toContain("failed 2"); expect(setupResult.state.openedIds).toEqual([first, second]); expect(setupResult.state.destroyCalled).toBe(false); + expect(failure.detail).toBe( + `Failed to stop managed stack ${first}: stop failed\nFailed to stop managed stack ${second}: stop failed`, + ); }).pipe(Effect.provide(setupResult.layer)); }); @@ -249,7 +255,8 @@ describe("stack stop", () => { }); return Effect.gen(function* () { const failure = yield* stackStop(flags({ all: true })).pipe(Effect.flip); - expect(failure.message).toContain("skipped 1"); + expect(failure.message).toBe("Stopped 1 managed stack(s); failed 0; skipped 1"); + expect(failure.detail).toBe(`Skipped managed stack ${corrupt}: corrupt state`); expect(setupResult.state.stopCalls).toBe(1); expect(setupResult.state.destroyCalled).toBe(false); expect(setupResult.out.messages).toEqual( @@ -258,6 +265,46 @@ describe("stack stop", () => { }).pipe(Effect.provide(setupResult.layer)); }); + it.effect("preserves bulk failure details through JSON error handling", () => { + const failed = "2".repeat(64); + const skipped = "3".repeat(64); + const setupResult = setup({ + root: "/tmp/supabase-stack-stop-json", + format: "json", + found: { id: failed }, + stop: () => Effect.fail(new StackCleanupError({ message: "cleanup failed" })), + discovered: { + stacks: [ + { + id: StackIdSchema.make(failed), + projectRoot: "/tmp/supabase-stack-stop-json", + name: "failed", + branchContext: "ordinary-workspace", + runtime: { kind: "native" }, + desiredLifecycle: "running", + }, + ], + errors: [ + { + id: StackIdSchema.make(skipped), + error: new StackStateInvalidError({ message: "invalid state" }), + }, + ], + }, + }); + return Effect.gen(function* () { + yield* stackStop(flags({ all: true })).pipe(withJsonErrorHandling); + expect(setupResult.out.failures).toEqual([ + expect.objectContaining({ + message: "Stopped 0 managed stack(s); failed 1; skipped 1", + detail: `Failed to stop managed stack ${failed}: cleanup failed\nSkipped managed stack ${skipped}: invalid state`, + }), + ]); + expect(setupResult.state.openedIds).toEqual([failed]); + expect(setupResult.state.destroyCalled).toBe(false); + }).pipe(Effect.provide(setupResult.layer)); + }); + it.effect("fails before opening any stack when registry discovery fails", () => { const setupResult = setup({ root: "/tmp/supabase-stack-stop-all-discovery-failure", diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 00ea574274..d1176dde2a 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -242,6 +242,7 @@ export function mockOutput( } = {}, ) { const messages: OutputMessage[] = []; + const failures: Array["fail"]>[0]> = []; const progressEvents: ProgressEvent[] = []; const events: OutputEvent[] = []; const rawChunks: Array<{ text: string; stream: "stdout" | "stderr" }> = []; @@ -362,6 +363,7 @@ export function mockOutput( fail: (err: { code: string; message: string; detail?: string; suggestion?: string }) => Effect.sync(() => { messages.push({ type: "fail", message: err.message }); + failures.push(err); }), progress: (opts: { max: number }) => Effect.sync(() => ({ @@ -446,6 +448,7 @@ export function mockOutput( }), }), messages, + failures, progressEvents, events, promptConfirmCalls, diff --git a/packages/stack/README.md b/packages/stack/README.md index e4e0f98345..2690732e2a 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -24,6 +24,10 @@ capability is lazy. Starting the stack therefore launches only PostgreSQL by default; capabilities configured as eager join its startup dependency closure. The remaining lazy capabilities activate through the stack's listeners on demand for the current running session. +The Effect API's `excludeStackCapabilities` helper disables requested optional capabilities and +their dependents in an in-memory config. Excluding `rest` or `analytics` also disables `studio`, +while the database remains required. The project config is unchanged, and runtime listeners are +created only for enabled capability routes. Native workloads have a two-minute readiness budget to allow cold starts to load shared libraries; container workloads retain a 30-second budget, and PostgreSQL uses its configured `health_timeout`. Each readiness probe returns immediately when its endpoint becomes healthy. diff --git a/packages/stack/src/model/Exclusions.ts b/packages/stack/src/model/Exclusions.ts new file mode 100644 index 0000000000..f357d9524e --- /dev/null +++ b/packages/stack/src/model/Exclusions.ts @@ -0,0 +1,33 @@ +import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; +import type { StackConfig } from "../public/Config.ts"; +import { CAPABILITY_MODULES } from "./ExecutionPlan.ts"; + +export type ExcludableCapabilityName = Exclude; + +/** Disable requested optional capabilities and their transitive dependents. */ +export const excludeStackCapabilities = ( + config: StackConfig, + exclusions: readonly ExcludableCapabilityName[], +): StackConfig => { + if (exclusions.length === 0) return config; + + const disabled = new Set(exclusions); + let changed = true; + while (changed) { + changed = false; + for (const name of CAPABILITY_NAMES) { + if (name === "database" || disabled.has(name)) continue; + if (CAPABILITY_MODULES[name].dependencies.some((dependency) => disabled.has(dependency))) { + disabled.add(name); + changed = true; + } + } + } + + const capabilities = { ...config.capabilities }; + for (const name of CAPABILITY_NAMES) { + if (name === "database" || !disabled.has(name)) continue; + capabilities[name] = { enabled: false }; + } + return { ...config, capabilities }; +}; diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index 58e0489c66..ff6dc6d62e 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -6,6 +6,7 @@ import { canonicalize, compileStack, rebuildExecutionPlan, sameDefinition } from import { resolveThirdPartyIssuer } from "./capabilities/auth-third-party.ts"; import { DEFAULT_DATABASE_HEALTH_TIMEOUT } from "./capabilities/database.ts"; import { catalogEntryFor } from "./WorkloadCatalog.ts"; +import { excludeStackCapabilities } from "./Exclusions.ts"; const layer = NodeServices.layer; const compile = ( @@ -21,6 +22,41 @@ const failureOf = (exit: Exit.Exit): E | undefined => Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; describe("closed capability compiler", () => { + it.live("compiles every optional exclusion and closes Studio dependents", () => + Effect.gen(function* () { + for (const name of [ + "rest", + "auth", + "realtime", + "storage", + "functions", + "studio", + "mail", + "analytics", + "pooler", + ] as const) { + const config = { capabilities: { rest: { settings: { max_rows: 42 } } } }; + const excluded = excludeStackCapabilities(config, [name]); + expect(config.capabilities?.rest).toEqual({ settings: { max_rows: 42 } }); + const result = yield* compile(excluded); + expect(result.definition.capabilities[name].enabled).toBe(false); + if (name !== "rest") expect(result.definition.capabilities.rest.settings.max_rows).toBe(42); + if (name === "rest" || name === "analytics") + expect(result.definition.capabilities.studio.enabled).toBe(false); + } + const combined = excludeStackCapabilities({}, ["rest", "analytics"]); + const result = yield* compile(combined); + expect(result.definition.capabilities.studio.enabled).toBe(false); + expect(result.definition.capabilities.rest.enabled).toBe(false); + expect(result.definition.capabilities.analytics.enabled).toBe(false); + expect(result.definition.capabilities.auth.enabled).toBe(true); + expect(excludeStackCapabilities({}, [])).toEqual({}); + const studioExcluded = yield* compile(excludeStackCapabilities({}, ["studio"])); + expect(studioExcluded.definition.capabilities.rest.enabled).toBe(true); + expect(studioExcluded.definition.capabilities.analytics.enabled).toBe(true); + }), + ); + it.live("accepts named storage byte limit formats during compilation", () => Effect.gen(function* () { for (const input of ["50MiB", "1.5KB", "2 GiB"]) { diff --git a/packages/stack/src/public/index.ts b/packages/stack/src/public/index.ts index 80b763ddf0..392661da79 100644 --- a/packages/stack/src/public/index.ts +++ b/packages/stack/src/public/index.ts @@ -7,6 +7,8 @@ export * from "./Logs.ts"; export * from "./Credentials.ts"; export * from "./Errors.ts"; export * from "./Config.ts"; +export { excludeStackCapabilities } from "../model/Exclusions.ts"; +export type { ExcludableCapabilityName } from "../model/Exclusions.ts"; export { createStack, openStack, From d3af8152883ac8361f619430764101263a210cda Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 11 Sep 2026 16:46:12 +0200 Subject: [PATCH 4/6] fix(cli): serialize concurrent stack creation --- packages/stack/src/public/EffectStack.ts | 21 +++-- .../supervisor/handles.integration.test.ts | 94 +++++++++++++++++++ 2 files changed, 107 insertions(+), 8 deletions(-) diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 81acc13bad..f03ecee34b 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -29,6 +29,7 @@ import { toPersistedIdentity } from "../state/StackState.ts"; import { isMissingStateRemnantError, makeStackStateStore, + withRegistryLock, type StackStateStore, } from "../state/StackStateStore.ts"; import { resolveStackPaths } from "../state/Paths.ts"; @@ -982,15 +983,19 @@ export const createStack = ( }); const stackId = yield* deriveStackId(identity); const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); - const persisted = yield* store - .read(stackId) - .pipe( - Effect.catch((error) => - isMissingStateRemnantError(error) - ? Effect.map(Effect.void, () => undefined) - : Effect.fail(error), + // Concurrent initial writes may expose temporary files before state.json is published. + const persisted = yield* withRegistryLock( + env.stateRoot, + store + .read(stackId) + .pipe( + Effect.catch((error) => + isMissingStateRemnantError(error) + ? Effect.map(Effect.void, () => undefined) + : Effect.fail(error), + ), ), - ); + ); const resolverOption = yield* Effect.serviceOption(ContainerEngineResolver).pipe( Effect.map(Option.getOrUndefined), ); diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index e69fd04a4c..e642023e69 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -10,6 +10,7 @@ import { Fiber, Option, Path, + Predicate, Redacted, Result, Schema, @@ -27,6 +28,7 @@ import { listStacks, } from "../public/EffectStack.ts"; import type { StackStatus } from "../public/Status.ts"; +import type { CreateStackError } from "../public/Errors.ts"; import { deriveStackId, resolveStackIdentity } from "../identity/Identity.ts"; import { defaultRuntimeEnvironment, @@ -327,6 +329,26 @@ describe("managed stack handles", { timeout: 30_000 }, () => { ), ); + it.live("reuses an existing runtime without probing the container resolver", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const created = yield* createStack({ + projectRoot: project, + runtime: { kind: "container", engine: "podman" }, + }); + const resolver = { + isInstalled: () => Effect.die("resolver must not be called"), + resolve: () => Effect.die("resolver must not be called"), + }; + const reopened = yield* createStack({ projectRoot: project }).pipe( + Effect.provideService(ContainerEngineResolver, resolver), + ); + expect(reopened.id).toBe(created.id); + expect((yield* reopened.status()).runtime).toEqual({ kind: "container", engine: "podman" }); + }), + ), + ); + it.live("prepares through only the explicitly selected Podman engine", () => withRuntimeRoot((project) => Effect.gen(function* () { @@ -657,6 +679,78 @@ describe("managed stack handles", { timeout: 30_000 }, () => { ), ); + it.live("concurrent creates preserve the published state across an advisory read race", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const env = yield* StackRuntimeEnvironment; + const identity = yield* resolveStackIdentity({ projectRoot: project }); + const stackId = yield* deriveStackId(identity); + const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId }); + const path = yield* Path.Path; + const registryLock = path.join(path.resolve(env.stateRoot), ".stack-registry.lock"); + const writerReady = yield* Deferred.make(); + const releaseWriter = yield* Deferred.make(); + const writerPublished = yield* Deferred.make(); + const firstFs: FileSystem.FileSystem = { + ...fs, + rename: (from, to) => + to === paths.stateDocument + ? Deferred.succeed(writerReady, undefined).pipe( + Effect.andThen(Deferred.await(releaseWriter)), + Effect.andThen(fs.rename(from, to)), + Effect.tap(() => Deferred.succeed(writerPublished, undefined)), + ) + : fs.rename(from, to), + }; + const secondFs: FileSystem.FileSystem = { + ...fs, + readFileString: (candidate, encoding) => { + if (candidate === registryLock) + return Deferred.succeed(releaseWriter, undefined).pipe( + Effect.andThen(fs.readFileString(candidate, encoding)), + ); + if (candidate !== paths.stateDocument) return fs.readFileString(candidate, encoding); + return fs + .readFileString(candidate, encoding) + .pipe( + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") + ? Deferred.succeed(releaseWriter, undefined).pipe( + Effect.andThen(Deferred.await(writerPublished)), + Effect.andThen(Effect.fail(error)), + ) + : Effect.fail(error), + ), + ); + }, + }; + const create = (fileSystem: FileSystem.FileSystem) => + createStack({ projectRoot: project }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + const first = yield* Effect.forkChild( + create(firstFs).pipe( + Effect.catchCause((cause) => + Deferred.failCause(writerReady, cause).pipe(Effect.andThen(Effect.failCause(cause))), + ), + ), + { startImmediately: true }, + ); + yield* Deferred.await(writerReady); + const second = yield* Effect.forkChild(create(secondFs), { startImmediately: true }); + const [firstHandle, secondHandle] = yield* Effect.all( + [Fiber.join(first), Fiber.join(second)], + { + concurrency: 2, + }, + ); + expect(secondHandle.id).toBe(firstHandle.id); + expect((yield* secondHandle.status()).lifecycle).toBe("unconfigured"); + }), + ), + ); + it.live("concurrent caller processes share one stack identity after exit", () => withRuntimeRoot((project) => Effect.gen(function* () { From 1797aac3d2220bbe97e19b7ece76174671dd9208 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 11 Sep 2026 17:46:25 +0200 Subject: [PATCH 5/6] fix(cli): refine stack command errors and confirmation --- .../db-bootstrap/shadow-cache.unit.test.ts | 1 + .../stack/destroy/SIDE_EFFECTS.md | 12 +- .../stack/destroy/destroy.errors.ts | 13 +- .../stack/destroy/destroy.handler.ts | 25 ++-- .../stack/destroy/destroy.integration.test.ts | 71 +++++++++-- .../experimental/stack/stop/SIDE_EFFECTS.md | 14 ++- .../experimental/stack/stop/stop.command.ts | 2 +- .../experimental/stack/stop/stop.handler.ts | 32 +++-- .../stack/stop/stop.integration.test.ts | 114 +++++++++++++++--- 9 files changed, 235 insertions(+), 49 deletions(-) diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts index 5ca9dfad05..313c8504df 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts @@ -61,6 +61,7 @@ describe("shadowCacheKey", () => { expect(shadowBaselineTarFileName(first)).toBe(`shadow-baseline-${first}.tar`); }); + // Each variant performs an intentionally expensive scrypt derivation; parallel suite load needs headroom. it("changes when ANY baked-in input changes", { timeout: 30_000 }, () => { const base = baseKeyInputs(); const mutations: ReadonlyArray<{ diff --git a/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md index 80f59e0ceb..e988b8f9c3 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md @@ -3,8 +3,9 @@ Permanently stops and removes one managed 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. `SUPABASE_YES` participates in the existing confirmation setting; +`--stack-id`. It requires an interactive text terminal on both stdout and stdin for confirmation; +`--yes` is required for redirected, non-interactive, and machine-readable invocations. +`SUPABASE_YES` participates in the existing confirmation setting; an explicit `--yes=false` overrides it. It never accepts `--all`. The stack package reads the selected descriptor and removes resources and state under @@ -14,8 +15,9 @@ or Docker resources itself and makes no Management API calls. Project files are The confirmation prompt identifies the stack 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`; interruption follows the command runtime's interruption exit. +reports the destroyed stack ID. JSON returns +`{ "destroyed": true, "id": "...", "message": "" }`; stream-JSON wraps the same payload in +the standard result event. Success exits `0`; invalid targets, confirmation refusal, and +destruction failures exit `1`; interruption follows the command runtime's interruption exit. Standard command instrumentation records command metadata without exporting credentials, and telemetry state flushes after both successful and failed runs. diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts index c627b89a33..a950e0485a 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts @@ -6,7 +6,14 @@ import { } from "../../../../shared/telemetry/error-actionability.ts"; export class StackCommandDestroyError extends Data.TaggedError("ExperimentalStackDestroyError")<{ - readonly reason: "flags" | "confirmation" | "invalid-config" | "lifecycle" | "unknown"; + readonly reason: + | "flags" + | "confirmation" + | "cancelled" + | "invalid-config" + | "runtime" + | "lifecycle" + | "unknown"; readonly message: string; readonly suggestion?: string; readonly cause?: unknown; @@ -16,9 +23,13 @@ export class StackCommandDestroyError extends Data.TaggedError("ExperimentalStac case "flags": case "confirmation": return actionability.provideFlags; + case "cancelled": + return actionability.cancelled; case "invalid-config": case "lifecycle": return actionability.invalidConfig; + case "runtime": + return actionability.dockerNotRunning; case "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 index c6f573fb84..eea67d4587 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -26,11 +26,18 @@ const mapTargetError = (error: StackTargetError) => const destroyError = (error: unknown): StackCommandDestroyError => { const stackError = isStackError(error) ? error : undefined; - const reason = + const classification = stackError === undefined - ? "unknown" + ? { reason: "unknown" as const } : Match.value(stackError).pipe( - Match.tag("StackNotFoundError", "InvalidStackIdentityError", () => "flags" as const), + Match.tag("StackNotFoundError", "InvalidStackIdentityError", () => ({ + reason: "flags" as const, + })), + Match.tag("ContainerEngineError", () => ({ + reason: "runtime" as const, + suggestion: + "Check that the selected container engine is installed and its daemon is running, then retry the command.", + })), Match.tag( "StackOwnershipConflictError", "StackNotRunningError", @@ -40,19 +47,19 @@ const destroyError = (error: unknown): StackCommandDestroyError => { "StackCleanupError", "StackDestructionError", "StackUpgradeRequiredError", - () => "lifecycle" as const, + () => ({ reason: "lifecycle" as const }), ), Match.tag( "InvalidStackConfigError", "StackStateFormatUnsupportedError", "InvalidProjectRootError", "StackStateInvalidError", - () => "invalid-config" as const, + () => ({ reason: "invalid-config" as const }), ), - Match.orElse(() => "unknown" as const), + Match.orElse(() => ({ reason: "unknown" as const })), ); return new StackCommandDestroyError({ - reason, + ...classification, message: stackError?.message ?? String(error), cause: error, }); @@ -98,7 +105,7 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( }); const yes = yield* resolveYes; const tty = yield* Tty; - if (!yes && (!tty.stdinIsTty || output.format !== "text")) + if (!yes && (!tty.stdinIsTty || !output.interactive || output.format !== "text")) return yield* new StackCommandDestroyError({ reason: "confirmation", message: "Destroying a stack requires confirmation; rerun with --yes.", @@ -112,7 +119,7 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( ); if (!confirmed) return yield* new StackCommandDestroyError({ - reason: "confirmation", + reason: "cancelled", message: "Stack destruction was not confirmed.", }); const stack = yield* api 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 680ef80411..aa5a124059 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 @@ -1,10 +1,14 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer, Option, Stream } from "effect"; -import { StackDestructionError, StackIdSchema } from "@supabase/stack/effect"; +import { ContainerEngineError, StackDestructionError, StackIdSchema } from "@supabase/stack/effect"; import type { EffectStack } from "@supabase/stack/effect"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; -import { YesFlag } from "../../../../command-internal/global-flags.ts"; +import { OutputFlag, YesFlag } from "../../../../command-internal/global-flags.ts"; +import { + ErrorActionabilityId, + actionability, +} from "../../../../shared/telemetry/error-actionability.ts"; import { mockCommandSettings, mockTelemetryStateTracked, @@ -37,17 +41,20 @@ const flags = (stackId = Option.none(), stack = Option.none()) = function setup(options: { yes: boolean; destroyFailure?: boolean; + destroyContainerFailure?: boolean; interactive?: boolean; + outputInteractive?: boolean; promptConfirmResponses?: ReadonlyArray; outputFormat?: "text" | "json"; found?: boolean; }) { const output = mockOutput({ format: options.outputFormat, + interactive: options.outputInteractive, promptConfirmResponses: options.promptConfirmResponses, }); const telemetry = mockTelemetryStateTracked(); - const state = { destroyed: 0 }; + const state = { destroyed: 0, opened: 0 }; const stack: EffectStack = { id, status: () => Effect.die("unused"), @@ -56,9 +63,11 @@ function setup(options: { start: () => Effect.die("unused"), stop: () => Effect.die("unused"), destroy: () => - options.destroyFailure - ? Effect.fail(new StackDestructionError({ message: "destroy failed" })) - : Effect.sync(() => void state.destroyed++), + options.destroyContainerFailure + ? Effect.fail(new ContainerEngineError({ message: "container engine unavailable" })) + : options.destroyFailure + ? Effect.fail(new StackDestructionError({ message: "destroy failed" })) + : Effect.sync(() => void state.destroyed++), logs: () => Effect.die("unused"), followLogs: () => Stream.empty, }; @@ -83,7 +92,11 @@ function setup(options: { Effect.succeed(options.found === false ? Option.none() : Option.some(descriptor)), createStack: () => Effect.die("unused"), inspectStack: () => Effect.succeed({ descriptor, owner: "absent" as const }), - openStack: () => Effect.succeed(stack), + openStack: () => + Effect.sync(() => { + state.opened++; + return stack; + }), discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), }), BunServices.layer, @@ -129,6 +142,30 @@ describe("stack destroy", () => { return Effect.gen(function* () { const failure = yield* stackDestroy(flags()).pipe(Effect.flip); expect(failure.message).toContain("not confirmed"); + expect(failure.reason).toBe("cancelled"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.cancelled); + expect(fixture.state.destroyed).toBe(0); + }).pipe(Effect.provide(fixture.layer)); + }); + + it.live("destroys a stack after interactive confirmation is accepted", () => { + const fixture = setup({ yes: false, interactive: true, promptConfirmResponses: [true] }); + return Effect.gen(function* () { + yield* stackDestroy(flags()); + expect(fixture.state.destroyed).toBe(1); + }).pipe(Effect.provide(fixture.layer)); + }); + + it.live("requires --yes when stdout is redirected", () => { + const fixture = setup({ + yes: false, + interactive: true, + outputInteractive: false, + promptConfirmResponses: [true], + }); + return Effect.gen(function* () { + const failure = yield* stackDestroy(flags()).pipe(Effect.flip); + expect(failure.reason).toBe("confirmation"); expect(fixture.state.destroyed).toBe(0); }).pipe(Effect.provide(fixture.layer)); }); @@ -142,6 +179,26 @@ describe("stack destroy", () => { }).pipe(Effect.provide(fixture.layer)); }); + it.live("classifies container engine failures with runtime guidance", () => { + const fixture = setup({ yes: true, destroyContainerFailure: true }); + return Effect.gen(function* () { + const failure = yield* stackDestroy(flags(Option.some(id))).pipe(Effect.flip); + expect(failure.reason).toBe("runtime"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.dockerNotRunning); + }).pipe(Effect.provide(fixture.layer)); + }); + + it.live("rejects the legacy output flag before opening a stack", () => { + const fixture = setup({ yes: true }); + return Effect.gen(function* () { + const failure = yield* stackDestroy(flags(Option.some(id))).pipe(Effect.flip); + expect(failure.reason).toBe("flags"); + expect(fixture.state.opened).toBe(0); + }).pipe( + Effect.provide(Layer.mergeAll(fixture.layer, Layer.succeed(OutputFlag, Option.some("json")))), + ); + }); + it.live("rejects malformed and conflicting targets without opening a stack", () => { const fixture = setup({ yes: true }); return Effect.gen(function* () { 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 929ba6cfbd..a1c7dd1a57 100644 --- a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md @@ -22,11 +22,15 @@ 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. Bulk mode attempts all readable stacks, warns for unreadable entries, and reports -stopped, failed, and skipped counts with per-stack details. It exits nonzero when an entry is skipped -or a stop fails. +Text mode reports the selected stack and stopped outcome. A successful single-stack JSON response is +`{ "found": true, "id": "", "lifecycle": "stopped", "message": "" }`; when no current +stack exists it is `{ "found": false, "message": "No managed stack found for this context." }`. +A successful bulk JSON response is `{ "stopped": ["", ...], "message": "" }`. +Stream-json wraps the same payload in its standard result event. Bulk mode attempts all readable +stacks, warns for unreadable entries, and reports stopped, failed, and skipped counts with per-stack +details. It exits nonzero when an entry is skipped or a stop fails. +The `--all` flag is presence-sensitive for target validation, so `--all=false` still conflicts with +`--stack` and `--stack-id`; `--all=false` alone uses single-stack mode. Registry-root enumeration failures remain fatal. 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 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 f53e8068e1..39ec3ec781 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts @@ -7,7 +7,7 @@ import { stackStop } from "./stop.handler.ts"; const config = { all: Flag.boolean("all").pipe( Flag.withDescription("Stop every readable managed stack."), - Flag.withDefault(false), + Flag.optional, ), stack: Flag.string("stack").pipe( Flag.withDescription("Stop the stack with this name (defaults to the current project stack)."), 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 c7313ce375..0e5f523180 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -69,9 +69,9 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: const settings = yield* CommandSettings; const stackApi = yield* StackApi; const outputFlag = yield* Effect.serviceOption(OutputFlag); - const stopAll = flags.all; + const stopAll = Option.getOrElse(flags.all, () => false); yield* rejectStackOutput(outputFlag).pipe(Effect.mapError(mapTargetError)); - if (stopAll && (Option.isSome(flags.stack) || Option.isSome(flags.stackId))) + if (Option.isSome(flags.all) && (Option.isSome(flags.stack) || Option.isSome(flags.stackId))) return yield* new StackCommandStopError({ reason: "flags", message: "--all cannot be combined with --stack or --stack-id", @@ -84,7 +84,7 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: if (stopAll) { 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}`); + yield* output.warn(`Skipping managed stack: ${issue.error.message}`); const stopping = yield* output.task( `Stopping ${discovered.stacks.length} managed stack(s)...`, ); @@ -108,15 +108,33 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: ({ descriptor, error }) => `Failed to stop managed stack ${descriptor.id}: ${error.message}`, ), - ...discovered.errors.map( - ({ id, error }) => `Skipped managed stack ${id}: ${error.message}`, - ), + ...discovered.errors.map(({ error }) => `Skipped managed stack: ${error.message}`), ].join("\n"); yield* stopping.fail(message); + const classifications = [ + ...failed.map(({ error }) => stopError(error)), + ...discovered.errors.map(({ error }) => stopError(error)), + ]; + const firstClassification = classifications[0]; + const reason = + firstClassification !== undefined && + classifications.every( + (classification) => classification.reason === firstClassification.reason, + ) + ? firstClassification.reason + : "unknown"; + const suggestion = + firstClassification?.suggestion !== undefined && + classifications.every( + (classification) => classification.suggestion === firstClassification.suggestion, + ) + ? firstClassification.suggestion + : undefined; return yield* new StackCommandStopError({ - reason: "lifecycle", + reason, message, detail, + ...(suggestion === undefined ? {} : { suggestion }), cause: { failures: failed, discovery: discovered.errors }, }); } 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 9c7ab6313a..23b6866825 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 @@ -51,7 +51,7 @@ const status = (id: string): StackStatus => ({ const flags = ( overrides: Partial[0]> = {}, ): Parameters[0] => ({ - all: false, + all: Option.none(), stack: Option.none(), stackId: Option.none(), ...overrides, @@ -178,7 +178,7 @@ describe("stack stop", () => { }, }); return Effect.gen(function* () { - yield* stackStop(flags({ all: true })); + yield* stackStop(flags({ all: Option.some(true) })); expect(setupResult.state.openedIds).toEqual([id, "c".repeat(64)]); expect(setupResult.state.stopCalls).toBe(2); expect(setupResult.state.destroyCalled).toBe(false); @@ -217,13 +217,15 @@ describe("stack stop", () => { }, }); return Effect.gen(function* () { - const failure = yield* stackStop(flags({ all: true })).pipe(Effect.flip); + const failure = yield* stackStop(flags({ all: Option.some(true) })).pipe(Effect.flip); expect(failure.message).toContain("failed 2"); expect(setupResult.state.openedIds).toEqual([first, second]); expect(setupResult.state.destroyCalled).toBe(false); expect(failure.detail).toBe( `Failed to stop managed stack ${first}: stop failed\nFailed to stop managed stack ${second}: stop failed`, ); + expect(failure.reason).toBe("unknown"); + expect(failure.suggestion).toContain("--debug"); }).pipe(Effect.provide(setupResult.layer)); }); @@ -248,15 +250,19 @@ describe("stack stop", () => { errors: [ { id: StackIdSchema.make(corrupt), - error: new StackStateInvalidError({ message: "corrupt state" }), + error: new StackStateInvalidError({ + message: `Failed to read managed stack ${corrupt}: corrupt state`, + }), }, ], }, }); return Effect.gen(function* () { - const failure = yield* stackStop(flags({ all: true })).pipe(Effect.flip); + const failure = yield* stackStop(flags({ all: Option.some(true) })).pipe(Effect.flip); expect(failure.message).toBe("Stopped 1 managed stack(s); failed 0; skipped 1"); - expect(failure.detail).toBe(`Skipped managed stack ${corrupt}: corrupt state`); + expect(failure.detail).toBe( + `Skipped managed stack: Failed to read managed stack ${corrupt}: corrupt state`, + ); expect(setupResult.state.stopCalls).toBe(1); expect(setupResult.state.destroyCalled).toBe(false); expect(setupResult.out.messages).toEqual( @@ -287,17 +293,19 @@ describe("stack stop", () => { errors: [ { id: StackIdSchema.make(skipped), - error: new StackStateInvalidError({ message: "invalid state" }), + error: new StackStateInvalidError({ + message: `Failed to read managed stack ${skipped}: invalid state`, + }), }, ], }, }); return Effect.gen(function* () { - yield* stackStop(flags({ all: true })).pipe(withJsonErrorHandling); + yield* stackStop(flags({ all: Option.some(true) })).pipe(withJsonErrorHandling); expect(setupResult.out.failures).toEqual([ expect.objectContaining({ message: "Stopped 0 managed stack(s); failed 1; skipped 1", - detail: `Failed to stop managed stack ${failed}: cleanup failed\nSkipped managed stack ${skipped}: invalid state`, + detail: `Failed to stop managed stack ${failed}: cleanup failed\nSkipped managed stack: Failed to read managed stack ${skipped}: invalid state`, }), ]); expect(setupResult.state.openedIds).toEqual([failed]); @@ -311,7 +319,7 @@ describe("stack stop", () => { discoveryFailure: new StackStateInvalidError({ message: "registry is unreadable" }), }); return Effect.gen(function* () { - const failure = yield* stackStop(flags({ all: true })).pipe(Effect.flip); + const failure = yield* stackStop(flags({ all: Option.some(true) })).pipe(Effect.flip); expect(failure.message).toContain("registry is unreadable"); expect(setupResult.state.openedIds).toEqual([]); expect(setupResult.state.destroyCalled).toBe(false); @@ -321,7 +329,7 @@ describe("stack stop", () => { it.effect("treats an empty registry as a successful bulk no-op", () => { const setupResult = setup({ root: "/tmp/supabase-stack-stop-all-empty" }); return Effect.gen(function* () { - yield* stackStop(flags({ all: true })); + yield* stackStop(flags({ all: Option.some(true) })); expect(setupResult.state.openedIds).toEqual([]); expect(setupResult.state.stopCalls).toBe(0); expect(setupResult.state.destroyCalled).toBe(false); @@ -332,13 +340,60 @@ describe("stack stop", () => { it.effect("rejects bulk stop target combinations", () => { const setupResult = setup({ root: "/tmp/supabase-stack-stop-conflict" }); return Effect.gen(function* () { - const failure = yield* stackStop(flags({ all: true, stack: Option.some("feature-a") })).pipe( - Effect.flip, - ); + const failure = yield* stackStop( + flags({ all: Option.some(true), stack: Option.some("feature-a") }), + ).pipe(Effect.flip); expect(failure.message).toContain("cannot be combined"); }).pipe(Effect.provide(setupResult.layer)); }); + it.effect("rejects an explicit false --all with a stack target", () => { + const setupResult = setup({ root: "/tmp/supabase-stack-stop-explicit-false" }); + return Effect.gen(function* () { + const failure = yield* stackStop( + flags({ all: Option.some(false), stack: Option.some("feature-a") }), + ).pipe(Effect.flip); + expect(failure.message).toContain("cannot be combined"); + expect(setupResult.state.findInputs).toEqual([]); + expect(setupResult.state.openedIds).toEqual([]); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("uses unknown classification when bulk failures disagree", () => { + const failed = "6".repeat(64); + const skipped = "5".repeat(64); + const setupResult = setup({ + root: "/tmp/supabase-stack-stop-mixed", + found: { id: failed }, + stop: () => Effect.fail(new StackCleanupError({ message: "cleanup failed" })), + discovered: { + stacks: [ + { + id: StackIdSchema.make(failed), + projectRoot: "/tmp/supabase-stack-stop-mixed", + name: "failed", + branchContext: "ordinary-workspace", + runtime: { kind: "native" }, + desiredLifecycle: "running", + }, + ], + errors: [ + { + id: StackIdSchema.make(skipped), + error: new StackStateInvalidError({ + message: `Failed to read managed stack ${skipped}: corrupt state`, + }), + }, + ], + }, + }); + return Effect.gen(function* () { + const failure = yield* stackStop(flags({ all: Option.some(true) })).pipe(Effect.flip); + expect(failure.reason).toBe("unknown"); + expect(failure.suggestion).toBeUndefined(); + }).pipe(Effect.provide(setupResult.layer)); + }); + it.effect("stops a named stack without calling destroy", () => { const root = "/tmp/supabase-stack-stop"; const setupResult = setup({ @@ -600,4 +655,35 @@ describe("stack stop parser", () => { Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), ); }); + + it.live("preserves explicit false for --all alongside a target", () => { + let parsed: Parameters[0] | undefined; + const command = stackStopCommand.pipe( + Command.withHandler((flags) => Effect.sync(() => (parsed = flags))), + ); + return Effect.gen(function* () { + yield* Command.runWith(command, { version: "0.0.0-test" })([ + "--all=false", + "--stack", + "feature-a", + ]); + expect(parsed?.all).toEqual(Option.some(false)); + expect(parsed?.stack).toEqual(Option.some("feature-a")); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), + ); + }); + + it.live("parses bare --all as true", () => { + let parsed: Parameters[0] | undefined; + const command = stackStopCommand.pipe( + Command.withHandler((flags) => Effect.sync(() => (parsed = flags))), + ); + return Effect.gen(function* () { + yield* Command.runWith(command, { version: "0.0.0-test" })(["--all"]); + expect(parsed?.all).toEqual(Option.some(true)); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), + ); + }); }); From 6c2a2acd73c681679286a83b4cc0c8bc7fce217c Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 11 Sep 2026 18:33:58 +0200 Subject: [PATCH 6/6] fix(stack): serialize graceful native shutdown --- packages/stack/src/runtime/NativeProcess.ts | 78 ++++++++++++------ .../native-runtime.integration.test.ts | 80 +++++++++++++++++++ 2 files changed, 134 insertions(+), 24 deletions(-) diff --git a/packages/stack/src/runtime/NativeProcess.ts b/packages/stack/src/runtime/NativeProcess.ts index 368a667673..b3f524d1a4 100644 --- a/packages/stack/src/runtime/NativeProcess.ts +++ b/packages/stack/src/runtime/NativeProcess.ts @@ -1,4 +1,4 @@ -import { Data, Duration, Effect, Option, Scope, Stream } from "effect"; +import { Data, Duration, Effect, Fiber, Option, Result, Scope, Stream } from "effect"; import { fileURLToPath } from "node:url"; import { ChildProcess } from "effect/unstable/process"; import type { PlatformError } from "effect/PlatformError"; @@ -163,15 +163,39 @@ export const spawnNativeProcess = ( }, catch: (error) => mapProcessError(error, spec), }); - const exitCode = yield* Effect.cached( - mapError(handle.exitCode).pipe( - Effect.tap( - () => - // The launcher exits with the workload's code, but its descendants - // can keep the detached process group alive. The parent still owns - // that exact group, so terminate it after capturing the exit code. - cleanupProcessGroup, - ), + const signalLauncher = (signal: NodeJS.Signals) => + Effect.try({ + try: () => { + try { + globalThis.process.kill(Number(handle.pid), signal); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ESRCH" + ) + return; + throw error; + } + }, + catch: (error) => mapProcessError(error, spec), + }); + const observeExit = mapError(handle.exitCode).pipe(Effect.result); + const exitFiber = yield* observeExit.pipe( + Effect.tap( + () => + // The launcher exits with the workload's code, but its descendants + // can keep the detached process group alive. The parent still owns + // that exact group, so terminate it after capturing the exit code. + cleanupProcessGroup, + ), + Effect.forkScoped({ startImmediately: true }), + ); + const exitResult = Fiber.join(exitFiber); + const exitCode = exitResult.pipe( + Effect.flatMap((result) => + Result.isSuccess(result) ? Effect.succeed(result.success) : Effect.fail(result.failure), ), ); return { @@ -180,19 +204,25 @@ export const spawnNativeProcess = ( stderr: mapStreamError(handle.stderr), exitCode, isRunning: mapError(handle.isRunning), - kill: mapError( - Effect.gen(function* () { - // NodeChildProcessSpawner owns the exact process group. Its kill - // effect sends the signal and waits for the launcher exit event; - // bound that wait before forcing the same group. - const graceful = yield* handle - .kill({ killSignal: spec.gracefulStopSignal ?? "SIGTERM" }) - .pipe(Effect.timeoutOption(spec.gracefulStopTimeout ?? "2 seconds")); - if (Option.isNone(graceful)) { - const running = yield* handle.isRunning; - if (running) yield* handle.kill({ killSignal: "SIGKILL" }); - } - }), - ), + kill: Effect.gen(function* () { + const signal = spec.gracefulStopSignal ?? "SIGTERM"; + // The launcher records the explicit stop before forwarding it; broadcasting the + // signal to the whole group can race that bookkeeping with the exit callback. + const gracefulStop = + globalThis.process.platform === "win32" + ? mapError(handle.kill({ killSignal: signal })) + : signalLauncher(signal).pipe( + Effect.andThen(observeExit), + Effect.andThen(cleanupProcessGroup), + Effect.asVoid, + ); + const graceful = yield* gracefulStop.pipe( + Effect.timeoutOption(spec.gracefulStopTimeout ?? "2 seconds"), + ); + if (Option.isNone(graceful)) { + const running = yield* mapError(handle.isRunning); + if (running) yield* mapError(handle.kill({ killSignal: "SIGKILL" })); + } + }), } satisfies NativeProcess; }).pipe(Effect.mapError((error) => mapProcessError(error, spec))); diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts index f3499704e2..2f5b5cd5f2 100644 --- a/packages/stack/src/runtime/native-runtime.integration.test.ts +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -10,8 +10,10 @@ import { Fiber, FileSystem, Path, + Option, Ref, Schedule, + Scope, Stream, } from "effect"; import { ChildProcess } from "effect/unstable/process"; @@ -28,6 +30,7 @@ import { defaultNativeProcessLauncher, nativeLauncherEntrypointFor, NATIVE_PROCESS_DISPATCH_SENTINEL, + NativeProcessError, spawnNativeProcess, type NativeProcess, } from "./NativeProcess.ts"; @@ -271,6 +274,83 @@ describe("native runtime", { timeout: 15_000 }, () => { ), ); + it.live("force-kills an explicit stop when the workload ignores the graceful signal", () => + withPlatform( + Effect.gen(function* () { + const ready = yield* Deferred.make(); + const native = yield* spawnNativeProcess({ + executable: process.execPath, + gracefulStopTimeout: "100 millis", + args: [ + "-e", + 'process.on("SIGTERM", () => {}); process.stdout.write("ready\\n"); setInterval(() => {}, 1000)', + ], + }); + const stdout = yield* native.stdout.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runForEach((line) => + line === "ready" ? Deferred.succeed(ready, undefined) : Effect.void, + ), + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(ready).pipe(Effect.timeout("3 seconds")); + const exitObserver = yield* native.exitCode.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Fiber.interrupt(exitObserver); + yield* native.kill; + expect(yield* native.isRunning).toBe(false); + const exit = yield* native.exitCode.pipe(Effect.exit); + const error = Exit.isFailure(exit) + ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) + : undefined; + expect(error).toBeInstanceOf(NativeProcessError); + yield* Fiber.join(stdout); + }), + ), + ); + + it.live("completes an explicit stop while its owner scope closes", () => + withPlatform( + Effect.gen(function* () { + const ownerScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const readySignal = yield* Deferred.make(); + const termSignal = yield* Deferred.make(); + const native = yield* spawnNativeProcess({ + executable: process.execPath, + args: [ + "-e", + 'let signals=0; process.on("SIGTERM", () => { if (++signals > 1) process.exit(0); process.stdout.write("term\\n") }); process.stdout.write("ready\\n"); setInterval(() => {}, 1000)', + ], + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + yield* Effect.addFinalizer(() => + native.kill.pipe(Effect.timeout("5 seconds"), Effect.ignore), + ); + const stdout = yield* native.stdout.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runForEach((line) => + line === "ready" + ? Deferred.succeed(readySignal, undefined) + : line === "term" + ? Deferred.succeed(termSignal, undefined) + : Effect.void, + ), + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(readySignal).pipe(Effect.timeout("3 seconds")); + const stopping = yield* native.kill.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(termSignal).pipe(Effect.timeout("3 seconds")); + yield* Scope.close(ownerScope, Exit.void); + yield* Fiber.join(stopping).pipe(Effect.timeout("5 seconds")); + yield* Fiber.join(stdout); + }), + ), + ); + it.live("keeps a ready workload after the losing exit observer is interrupted", () => withPlatform( Effect.gen(function* () {