From 5c1af242b871c6d67fdcf472dc21558e0ef0380c Mon Sep 17 00:00:00 2001 From: Tirthraj Chavan Date: Tue, 6 Oct 2026 00:47:37 +0530 Subject: [PATCH] feat(stack): allow repeatable --stack-id in stack destroy (#6964) --- .../stack/destroy/destroy.command.ts | 10 +- .../stack/destroy/destroy.errors.ts | 1 + .../stack/destroy/destroy.handler.ts | 235 ++++++++++++++---- .../stack/destroy/destroy.integration.test.ts | 129 ++++++++++ .../stack/stack.shared.integration.test.ts | 40 +++ .../experimental/stack/stack.shared.ts | 10 +- 6 files changed, 368 insertions(+), 57 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts index d32d57f57e..a5660b10ac 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts @@ -13,8 +13,10 @@ const config = { Flag.optional, ), stackId: Flag.string("stack-id").pipe( - Flag.withDescription("Destroy an existing stack by id or unique id prefix."), - Flag.optional, + Flag.withDescription( + "Destroy an existing stack by id or unique id prefix; repeat to select several.", + ), + Flag.atLeast(0), ), } as const; @@ -30,6 +32,10 @@ export const stackDestroyCommand = Command.make("destroy", config).pipe( command: "supabase stack destroy --stack feature-a --yes", description: "Permanently destroy the feature-a stack", }, + { + command: "supabase stack destroy --stack-id 019532ab --stack-id 019532cd --yes", + description: "Permanently destroy multiple stacks by id or unique id prefix", + }, ]), 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 index a950e0485a..0dc8036870 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts @@ -15,6 +15,7 @@ export class StackCommandDestroyError extends Data.TaggedError("ExperimentalStac | "lifecycle" | "unknown"; readonly message: string; + readonly detail?: string; readonly suggestion?: string; readonly cause?: 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 07cde13aee..b5a99a3038 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -8,6 +8,7 @@ import { CommandSettings } from "../../../../config/command-settings.service.ts" import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts"; import { StackApi, + type StackTarget, StackTargetError, rejectStackOutput, skippedRuntimeCleanupWarning, @@ -32,8 +33,13 @@ const destroyError = (cause: StackError) => cause, }); +export interface StackDestroyInput { + readonly stack?: Option.Option | string; + readonly stackId?: ReadonlyArray | Option.Option | string; +} + export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( - flags: StackDestroyFlags, + flags: StackDestroyFlags | StackDestroyInput, ) { const telemetryState = yield* TelemetryState; const body = Effect.gen(function* () { @@ -42,29 +48,74 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( const api = yield* StackApi; const outputFlag = yield* Effect.serviceOption(OutputFlag); yield* rejectStackOutput(outputFlag).pipe(Effect.mapError(mapTargetError)); + + const stackOption = + flags.stack !== undefined && Option.isOption(flags.stack) + ? flags.stack + : typeof flags.stack === "string" + ? Option.some(flags.stack) + : Option.none(); + + const rawStackId = flags.stackId; + const stackIds: ReadonlyArray = + rawStackId === undefined + ? [] + : Array.isArray(rawStackId) + ? rawStackId + : typeof rawStackId === "string" + ? [rawStackId] + : Option.isOption(rawStackId) && Option.isSome(rawStackId) + ? [rawStackId.value] + : []; + yield* validateStackTarget({ - stack: Option.getOrUndefined(flags.stack), - stackId: Option.getOrUndefined(flags.stackId), + stack: Option.getOrUndefined(stackOption), + stackId: stackIds, }).pipe(Effect.mapError(mapTargetError)); const resolver = yield* StackTargetResolver; const path = yield* Path.Path; - const target = yield* resolver - .resolve({ - projectRoot: settings.workdir, - ...(Option.isSome(flags.stack) ? { name: flags.stack.value } : {}), - ...(Option.isSome(flags.stackId) ? { id: flags.stackId.value } : {}), - runtime: "auto", - }) - .pipe(Effect.mapError(mapTargetError)); - if (target.id === undefined) - return yield* new StackCommandDestroyError({ - reason: "flags", - message: Option.isSome(flags.stack) - ? `No managed stack named "${flags.stack.value}" was found for this project.` - : "No managed stack was found for this project.", - suggestion: "Choose an existing --stack name or --stack-id.", - }); + + const targets: StackTarget[] = []; + if (stackIds.length > 0) { + for (const id of stackIds) { + const target = yield* resolver + .resolve({ + projectRoot: settings.workdir, + id, + runtime: "auto", + }) + .pipe(Effect.mapError(mapTargetError)); + targets.push(target); + } + } else { + const target = yield* resolver + .resolve({ + projectRoot: settings.workdir, + ...(Option.isSome(stackOption) ? { name: stackOption.value } : {}), + runtime: "auto", + }) + .pipe(Effect.mapError(mapTargetError)); + if (target.id === undefined) + return yield* new StackCommandDestroyError({ + reason: "flags", + message: Option.isSome(stackOption) + ? `No managed stack named "${stackOption.value}" was found for this project.` + : "No managed stack was found for this project.", + suggestion: "Choose an existing --stack name or --stack-id.", + }); + targets.push(target); + } + + const seen = new Set(); + const uniqueTargets: StackTarget[] = []; + for (const target of targets) { + if (target.id !== undefined && !seen.has(target.id)) { + seen.add(target.id); + uniqueTargets.push(target); + } + } + const yes = yield* resolveYes; const tty = yield* Tty; if (!yes && (!tty.stdinIsTty || !output.interactive || output.format !== "text")) @@ -73,8 +124,13 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( message: "Destroying a stack requires confirmation; rerun with --yes.", suggestion: "Pass --yes when running non-interactively or in a machine-readable format.", }); - const scope = `stack ${target.id} at ${target.projectRoot} and its owned data`; + const preserved = "Storage upload files will be preserved."; + const scope = + uniqueTargets.length === 1 + ? `stack ${uniqueTargets[0]!.id} at ${uniqueTargets[0]!.projectRoot} and its owned data` + : `stacks ${uniqueTargets.map((t) => t.id).join(", ")} and their owned data`; + if (yes) yield* output.raw(`Permanently destroying ${scope}. ${preserved}\n`, "stderr"); else { const confirmed = yield* promptYesNo( @@ -89,38 +145,113 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( message: "Stack destruction was not confirmed.", }); } - const stack = yield* api - .open({ - id: target.id, - stateRoot: path.join(settings.supabaseHome, "stacks"), - cacheRoot: path.join(settings.supabaseHome, "cache", "stack"), - }) - .pipe(Effect.mapError(destroyError)); - const destroying = yield* output.task(`Destroying stack ${target.id}...`); - const result = yield* stack.destroy.pipe( - Effect.onExit((exit) => - Exit.isSuccess(exit) - ? destroying.clear - : Cause.hasInterruptsOnly(exit.cause) - ? destroying.cancel() - : destroying.fail(Option.getOrUndefined(Exit.findErrorOption(exit))?.message), - ), - Effect.mapError(destroyError), - ); - yield* Effect.annotateCurrentSpan({ - "stack.prompted": !yes, - "stack.runtime_cleanup": result.runtimeCleanup, - }); - if (result.runtimeCleanup === "skipped") - yield* output.warn(skippedRuntimeCleanupWarning(`stack ${target.id}`, result)); - if (output.format !== "text") - yield* output.success("", { destroyed: true, id: target.id, ...result }); - else if (result.runtimeCleanup === "complete") - yield* output.raw(`Stack ${target.id} destroyed.\n`); - else - yield* output.raw( - `Stack ${target.id} was removed locally; its ${result.engine === "docker" ? "Docker" : "Podman"} resources remain until the commands above are run.\n`, - ); + + const successes: Array<{ + readonly id: string; + readonly result: { + readonly runtimeCleanup: "complete" | "partial" | "skipped"; + readonly engine?: "docker" | "podman"; + readonly remainingCommands?: ReadonlyArray; + }; + }> = []; + const failures: Array<{ + readonly id: string; + readonly error: StackCommandDestroyError; + }> = []; + + for (const target of uniqueTargets) { + const targetId = target.id!; + const destroySingle = Effect.gen(function* () { + const stack = yield* api + .open({ + id: targetId, + stateRoot: path.join(settings.supabaseHome, "stacks"), + cacheRoot: path.join(settings.supabaseHome, "cache", "stack"), + }) + .pipe(Effect.mapError(destroyError)); + const destroying = yield* output.task(`Destroying stack ${targetId}...`); + const result = yield* stack.destroy.pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) + ? destroying.clear + : Cause.hasInterruptsOnly(exit.cause) + ? destroying.cancel() + : destroying.fail(Option.getOrUndefined(Exit.findErrorOption(exit))?.message), + ), + Effect.mapError(destroyError), + ); + return result; + }); + + const exit = yield* Effect.exit(destroySingle); + if (Exit.isSuccess(exit)) { + const result = exit.value; + successes.push({ id: targetId, result }); + if (result.runtimeCleanup === "skipped") + yield* output.warn(skippedRuntimeCleanupWarning(`stack ${targetId}`, result)); + if (output.format === "text") { + if (result.runtimeCleanup === "complete") + yield* output.raw(`Stack ${targetId} destroyed.\n`); + else + yield* output.raw( + `Stack ${targetId} was removed locally; its ${result.engine === "docker" ? "Docker" : "Podman"} resources remain until the commands above are run.\n`, + ); + } + } else { + const error = Option.getOrElse( + Exit.findErrorOption(exit), + () => + new StackCommandDestroyError({ + reason: "unknown", + message: `Failed to destroy stack ${targetId}`, + }), + ); + failures.push({ id: targetId, error }); + yield* output.error(`Failed to destroy stack ${targetId}: ${error.message}`); + } + } + + if (uniqueTargets.length === 1 && successes.length === 1) { + yield* Effect.annotateCurrentSpan({ + "stack.prompted": !yes, + "stack.runtime_cleanup": successes[0]!.result.runtimeCleanup, + }); + } else { + yield* Effect.annotateCurrentSpan({ + "stack.prompted": !yes, + "stack.count": uniqueTargets.length, + "stack.destroyed_count": successes.length, + "stack.failed_count": failures.length, + }); + } + + if (output.format !== "text") { + if (uniqueTargets.length === 1 && successes.length === 1) { + yield* output.success("", { + destroyed: true, + id: successes[0]!.id, + ...successes[0]!.result, + }); + } else { + yield* output.success("", { + destroyed: failures.length === 0, + stacks: successes.map(({ id, result }) => ({ id, ...result })), + failures: failures.map(({ id, error }) => ({ id, message: error.message })), + }); + } + } + + if (failures.length > 0) { + if (failures.length === 1 && uniqueTargets.length === 1) { + return yield* failures[0]!.error; + } + return yield* new StackCommandDestroyError({ + reason: "unknown", + message: `Failed to destroy ${failures.length} managed stack(s).`, + detail: failures.map(({ id, error }) => `${id}: ${error.message}`).join("\n"), + cause: failures, + }); + } }); 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 index b09a00292a..49239f6680 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts @@ -8,6 +8,7 @@ import { mockTelemetryStateTracked, } from "../../../../../tests/helpers/command-mocks.ts"; import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { StackError } from "@supabase/stack/effect"; import { StackApi, stackApiLayer, stackTargetResolverLayer } from "../stack.shared.ts"; import { stackDestroy } from "./destroy.handler.ts"; @@ -177,4 +178,132 @@ describe("stack destroy", () => { ); }).pipe(Effect.provide(live)), ); + + it.live("destroys multiple stacks when repeated --stack-id flags are provided", () => + Effect.gen(function* () { + const f = yield* fixture(true); + const second = yield* f.api.create({ + ...f.locations, + projectRoot: f.root, + name: "second", + runtime: "native", + }); + const third = yield* f.api.create({ + ...f.locations, + projectRoot: f.root, + name: "third", + runtime: "native", + }); + + yield* stackDestroy({ + ...f.flags, + stackId: [f.stack.id, second.id], + }).pipe(Effect.provide(f.layer)); + + const remaining = yield* f.api.discover(f.locations); + expect(remaining.map(({ definition }) => definition.id)).toEqual([third.id]); + expect(f.output.stderrText).toContain( + `Permanently destroying stacks ${f.stack.id}, ${second.id} and their owned data. Storage upload files will be preserved.\n`, + ); + expect(f.output.stdoutText).toContain(`Stack ${f.stack.id} destroyed.`); + expect(f.output.stdoutText).toContain(`Stack ${second.id} destroyed.`); + expect(f.telemetry.flushed).toBe(true); + }).pipe(Effect.provide(live)), + ); + + it.live( + "prompts once for multiple stacks on an interactive terminal and cancels all when declined", + () => + Effect.gen(function* () { + const f = yield* fixture(false, { answer: false }); + const second = yield* f.api.create({ + ...f.locations, + projectRoot: f.root, + name: "second", + runtime: "native", + }); + + const error = yield* stackDestroy({ + ...f.flags, + stackId: [f.stack.id, second.id], + }).pipe(Effect.provide(f.layer), Effect.flip); + + expect(error.reason).toBe("cancelled"); + expect(f.output.promptConfirmCalls.map(({ message }) => message)).toEqual([ + `Permanently destroy stacks ${f.stack.id}, ${second.id} and their owned data? Storage upload files will be preserved.`, + ]); + const saved = yield* f.api.discover(f.locations); + expect(saved.map(({ definition }) => definition.id).sort()).toEqual( + [f.stack.id, second.id].sort(), + ); + }).pipe(Effect.provide(live)), + ); + + it.live("rejects combining --stack and multiple --stack-id", () => + Effect.gen(function* () { + const f = yield* fixture(true); + const second = yield* f.api.create({ + ...f.locations, + projectRoot: f.root, + name: "second", + runtime: "native", + }); + + const error = yield* stackDestroy({ + stack: Option.some("custom-stack"), + stackId: [f.stack.id, second.id], + }).pipe(Effect.provide(f.layer), Effect.flip); + + expect(error.reason).toBe("flags"); + expect(error.message).toBe("--stack and --stack-id cannot be used together"); + }).pipe(Effect.provide(live)), + ); + + it.live("continues destroying remaining stacks when one fails and exits with error", () => + Effect.gen(function* () { + const f = yield* fixture(true); + const second = yield* f.api.create({ + ...f.locations, + projectRoot: f.root, + name: "second", + runtime: "native", + }); + + const failingApi = StackApi.of({ + ...f.api, + open: (opts) => + f.api.open(opts).pipe( + Effect.map((s) => + s.id === f.stack.id + ? { + ...s, + destroy: Effect.fail( + new StackError({ + operation: "destroy", + message: "simulated destruction failure", + }), + ), + } + : s, + ), + ), + }); + + const error = yield* stackDestroy({ + ...f.flags, + stackId: [f.stack.id, second.id], + }).pipe( + Effect.provide(Layer.merge(f.layer, Layer.succeed(StackApi, failingApi))), + Effect.flip, + ); + + expect(error.reason).toBe("unknown"); + expect(error.message).toContain("Failed to destroy 1 managed stack(s)."); + expect(error.detail).toContain(`${f.stack.id}: simulated destruction failure`); + + const remaining = yield* f.api.discover(f.locations); + expect(remaining.map(({ definition }) => definition.id)).toEqual([f.stack.id]); + expect(f.output.stdoutText).toContain(`Stack ${second.id} destroyed.`); + }).pipe(Effect.provide(live)), + ); }); diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts index dcdb889a29..f4bd6ee3d6 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts @@ -7,6 +7,7 @@ import { StackTargetResolver, stackApiLayer, stackTargetResolverLayer, + validateStackTarget, } from "./stack.shared.ts"; import { mockCommandSettings } from "../../../../tests/helpers/command-mocks.ts"; import { @@ -275,3 +276,42 @@ describe("stack API layer", () => { }).pipe(Effect.provide(BunServices.layer)), ); }); + +describe("validateStackTarget", () => { + it.live("allows stack without stackId", () => + Effect.gen(function* () { + yield* validateStackTarget({ stack: "my-stack" }); + yield* validateStackTarget({ stack: "my-stack", stackId: [] }); + }), + ); + + it.live("allows single or multiple stackId without stack", () => + Effect.gen(function* () { + yield* validateStackTarget({ stackId: "id1" }); + yield* validateStackTarget({ stackId: ["id1", "id2"] }); + yield* validateStackTarget({}); + }), + ); + + it.live("fails when stack is combined with string stackId", () => + Effect.gen(function* () { + const error = yield* validateStackTarget({ + stack: "my-stack", + stackId: "id1", + }).pipe(Effect.flip); + expect(error.reason).toBe("flags"); + expect(error.message).toBe("--stack and --stack-id cannot be used together"); + }), + ); + + it.live("fails when stack is combined with array stackId", () => + Effect.gen(function* () { + const error = yield* validateStackTarget({ + stack: "my-stack", + stackId: ["id1", "id2"], + }).pipe(Effect.flip); + expect(error.reason).toBe("flags"); + expect(error.message).toBe("--stack and --stack-id cannot be used together"); + }), + ); +}); diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index 604dab3612..159ed4b8e1 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -60,9 +60,12 @@ export class StackTargetResolver extends Context.Service< export const validateStackTarget = (input: { readonly stack?: string; - readonly stackId?: string; -}): Effect.Effect => - input.stack !== undefined && input.stackId !== undefined + readonly stackId?: string | readonly string[]; +}): Effect.Effect => { + const hasStackId = + input.stackId !== undefined && + (typeof input.stackId === "string" ? true : input.stackId.length > 0); + return input.stack !== undefined && hasStackId ? Effect.fail( new StackTargetError({ message: "--stack and --stack-id cannot be used together", @@ -70,6 +73,7 @@ export const validateStackTarget = (input: { }), ) : Effect.void; +}; const isStackId = Schema.is(StackId); const STACK_ID_PREFIX = /^[0-9a-f]{4,64}$/;