Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions apps/cli/docs/stack-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -50,3 +51,19 @@ 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.
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 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.
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ describe("shadowCacheKey", () => {
expect(shadowBaselineTarFileName(first)).toBe(`shadow-baseline-${first}.tar`);
});

it("changes when ANY baked-in input changes", () => {
// Each variant performs an intentionally expensive scrypt derivation; parallel suite load needs headroom.
it("changes when ANY baked-in input changes", { timeout: 30_000 }, () => {
Comment thread
jgoux marked this conversation as resolved.
const base = baseKeyInputs();
const mutations: ReadonlyArray<{
readonly label: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# `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 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
`${SUPABASE_HOME:-~/.supabase}/managed/stacks/<id>`. 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 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.
Original file line number Diff line number Diff line change
@@ -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<typeof config>;

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),
),
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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"
| "cancelled"
| "invalid-config"
| "runtime"
| "lifecycle"
| "unknown";
readonly message: string;
readonly suggestion?: string;
readonly cause?: unknown;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
switch (this.reason) {
case "flags":
case "confirmation":
return actionability.provideFlags;
Comment thread
jgoux marked this conversation as resolved.
case "cancelled":
return actionability.cancelled;
case "invalid-config":
case "lifecycle":
return actionability.invalidConfig;
case "runtime":
return actionability.dockerNotRunning;
case "unknown":
return actionability.unknown;
}
}
}
138 changes: 138 additions & 0 deletions apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
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,
...(error.suggestion === undefined ? {} : { suggestion: error.suggestion }),
cause: error,
});
Comment thread
jgoux marked this conversation as resolved.

const destroyError = (error: unknown): StackCommandDestroyError => {
const stackError = isStackError(error) ? error : undefined;
const classification =
stackError === undefined
? { reason: "unknown" as const }
: Match.value(stackError).pipe(
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",
"StackMustBeStoppedError",
"StackLifecycleConflictError",
"StackRuntimeError",
"StackCleanupError",
"StackDestructionError",
"StackUpgradeRequiredError",
() => ({ reason: "lifecycle" as const }),
),
Match.tag(
"InvalidStackConfigError",
"StackStateFormatUnsupportedError",
"InvalidProjectRootError",
"StackStateInvalidError",
() => ({ reason: "invalid-config" as const }),
),
Match.orElse(() => ({ reason: "unknown" as const })),
);
return new StackCommandDestroyError({
...classification,
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.interactive || 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: "cancelled",
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));
});
Loading