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
11 changes: 11 additions & 0 deletions apps/cli/docs/stack-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ native runtimes.
| `supabase stack start` | Create or resume the project's stack. |
| `supabase stack status` | Show identity, readiness, and drift, or export connection variables with `--env`. |
| `supabase stack logs` | Read retained or live stack logs. |
| `supabase stack restart` | Restart an existing stack using its saved effective configuration. |
| `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 @@ -163,6 +164,16 @@ including `--workdir` and `SUPABASE_WORKDIR`, and prefers JSON when both files e

## Service selection and shutdown

`supabase stack restart` reuses an existing stack's saved effective configuration. It stops and
starts the same stack identity, preserving its data. Normal startup may still download missing
artifacts according to the saved preparation policy. Select a stack with `--stack <name>` or
`--stack-id <id>`. The restart handler does not reload project configuration. Set
`SUPABASE_EXPERIMENTAL_STACK=1` when restarting by ID outside the project or with invalid project
configuration, so feature routing does not depend on that configuration. Start flags such as `--exclude`, `--eager`, or
`--preparation` remain in the saved stack configuration; a later normal `start` reloads the project
configuration and current flags.
An unconfigured stack must be initialized with `supabase stack start` before it can be restarted.

`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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# `supabase stack restart`

The command is available only when `experimental.stack` is enabled through project
configuration or `SUPABASE_EXPERIMENTAL_STACK=1`. It has no top-level alias.

## Files Read

Reads the selected stack's descriptor and `<SUPABASE_HOME>/managed/stacks/<id>/state.json`,
plus owner metadata in `control.json` when present. Restart does not load project
configuration; it uses the selected stack's saved effective configuration and preparation policy.
Feature routing and workdir discovery may still read `supabase/config.toml` or
`supabase/config.json`. Use `SUPABASE_EXPERIMENTAL_STACK=1` with `--stack-id` when
project configuration is invalid or unavailable. Runtime startup can read files
referenced by the saved definition, such as signing material.

## Files Written

The CLI does not rewrite project configuration. The stack package updates its
state record, owner metadata, runtime files, logs, and service data beneath the
selected stack directory. Restart preserves the stack ID and data; it never calls
create or destroy. There is no explicit `prepare()` call. Runtime startup reuses
cached artifacts and may fetch missing ones; the saved `background` preparation
policy can also prefetch enabled lazy services, while `on-demand` skips that prefetch.

## API Routes

No Management API routes. The command uses local stack control RPC and delegates
artifact downloads, container operations, and service startup to the package.
Artifact URLs and registry requests depend on the selected runtime and releases.

## Environment Variables

- `SUPABASE_HOME`: managed state location; defaults to the user's `.supabase` directory.
- `HOME`: participates in default home resolution.
- Project configuration and function dotenv overrides are not reloaded by the
restart handler. Saved secret values are not emitted.
- Standard CLI settings, output, and telemetry environment controls apply through
the existing CLI layers; restart adds no command-specific environment variables.

## Exit Codes

| Code | Condition |
| ----- | --------------------------------------------------------- |
| `0` | The selected stack restarted successfully. |
| `1` | Invalid flags, missing stack, or a stop or start failure. |
| `130` | The CLI waiter was interrupted. |

## Telemetry Events Fired

Telemetry state is flushed to `<SUPABASE_HOME or ~/.supabase>/telemetry.json`
after successful and failed runs. Standard command instrumentation emits `cli_command_executed` for success or
failure, with canonical command identity `stack restart`, duration, sanitized flags, and error classification. Restart adds
no custom telemetry event and does not emit configuration or credential values.

## Output

- `--output-format text`: stack ID, runtime, lifecycle, configured endpoints, and
dormant capabilities. Progress is cleared after success or failed before propagation.
- `--output-format json`: one status object containing `id`, `lifecycle`,
`desired_lifecycle`, `runtime`, `endpoints`, `versions`, `capabilities`, and `artifacts`.
- `--output-format stream-json`: standard progress events, followed by a `result`
event carrying the same status object, or an `error` event on failure.

Legacy `-o/--output` is rejected with guidance to use `--output-format`.

## Notes

Targets one existing stack through `--stack`, `--stack-id`, or the current
project. The command stops and starts without an explicit configuration. Stop failure prevents
start; start failure leaves the same stack stopped and available for recovery. Interrupting
the CLI waiter follows the package's owner lifecycle contract and does not invoke
destroy from the command handler.
An unconfigured stack must be initialized with `supabase stack start` before it can be restarted.

Restart reuses saved one-off `start` flags such as `--exclude`, `--eager`, and
`--preparation`; a normal `start` reloads project configuration and current flags.
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 { stackRestart } from "./restart.handler.ts";

const config = {
stack: Flag.string("stack").pipe(
Flag.withDescription("Restart a named stack (defaults to the current project stack)."),
Flag.optional,
),
stackId: Flag.string("stack-id").pipe(
Flag.withDescription("Restart an existing stack by id."),
Flag.optional,
),
} as const;

export type StackRestartFlags = CliCommand.Command.Config.Infer<typeof config>;

export const stackRestartCommand = Command.make("restart", config).pipe(
Command.withDescription(
"Restart an existing managed local Supabase stack using its saved configuration, including the previous start's service selection and preparation policy.",
),
Command.withShortDescription("Restart a managed local stack"),
Command.withExamples([
{
command: "supabase stack restart",
description: "Restart the current project stack",
},
]),
Command.withHandler((flags) =>
stackRestart(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling),
),
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Data } from "effect";
import {
actionability,
type CliErrorActionabilityDeclaration,
ErrorActionabilityId,
} from "../../../../shared/telemetry/error-actionability.ts";

export class StackCommandRestartError extends Data.TaggedError("ExperimentalStackRestartError")<{
readonly reason:
| "flags"
| "not-found"
| "invalid-config"
| "port"
| "lifecycle"
| "runtime"
| "registry"
| "artifact"
| "unknown";
readonly message: string;
readonly suggestion?: string;
readonly cause?: unknown;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
switch (this.reason) {
case "flags":
case "not-found":
return actionability.provideFlags;
case "invalid-config":
case "port":
case "lifecycle":
return actionability.invalidConfig;
case "runtime":
return actionability.dockerNotRunning;
case "registry":
case "artifact":
return actionability.externalNetwork;
case "unknown":
return actionability.unknown;
}
}
}
154 changes: 154 additions & 0 deletions apps/cli/src/commands/experimental/stack/restart/restart.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { Effect, Match, Option } from "effect";
import type { StackDescriptor, StackError, StackId } from "@supabase/stack/effect";
import { Output } from "../../../../shared/output/output.service.ts";
import { OutputFlag } from "../../../../command-internal/global-flags.ts";
import { CommandSettings } from "../../../../config/command-settings.service.ts";
import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts";
import {
StackApi,
StackTargetError,
rejectStackOutput,
renderStackStatus,
stackStatusPayload,
validateStackId,
validateStackTarget,
} from "../stack.shared.ts";
import type { StackRestartFlags } from "./restart.command.ts";
import { StackCommandRestartError } from "./restart.errors.ts";

const mapTargetError = (error: StackTargetError) =>
new StackCommandRestartError({
reason: error.reason,
message: error.message,
...(error.suggestion === undefined ? {} : { suggestion: error.suggestion }),
cause: error,
});

const mapStackError = (error: StackError) => {
const classification = Match.value(error).pipe(
Match.tag("StackNotFoundError", () => ({ reason: "not-found" as const })),
Match.tag("InvalidStackIdentityError", () => ({ reason: "flags" as const })),
Match.tag("PortUnavailableError", "PortAllocationError", () => ({
reason: "port" as const,
suggestion:
"Free the conflicting port and retry, or stop the stack and use supabase stack start to apply updated project port configuration.",
})),
Match.tag(
"InvalidStackConfigError",
"StackVersionUnsupportedError",
"InvalidProjectRootError",
"StackStateInvalidError",
"StackStateFormatUnsupportedError",
"StackSecretMismatchError",
"InvalidJwtSigningMaterialError",
() => ({ reason: "invalid-config" as const }),
),
Match.tag("StackRuntimeMismatchError", () => ({
reason: "flags" as const,
suggestion:
"Restart preserves the existing runtime; choose a different existing stack if needed.",
})),
Match.tag(
"StackLifecycleConflictError",
"StackNotRunningError",
"StackMustBeStoppedError",
"StackOwnershipConflictError",
"StackUpgradeRequiredError",
() => ({
reason: "lifecycle" as const,
suggestion: "Run supabase stack status to inspect the stack state.",
}),
),
Match.tag("ContainerEngineError", () => ({
reason: "runtime" as const,
suggestion: "Ensure the selected container engine is running and retry the command.",
})),
Match.tag("ContainerPullError", () => ({
reason: "registry" as const,
suggestion: "Check registry connectivity and image availability, then retry the command.",
})),
Match.tag("ArtifactIntegrityError", "StackPreparationError", () => ({
reason: "artifact" as const,
suggestion: "Retry the stack restart with --debug if the artifact cannot be prepared.",
})),
Match.tag("StackRuntimeError", () => ({
reason: "unknown" as const,
suggestion: "Retry the stack restart with --debug and inspect the runtime diagnostics.",
})),
Match.tag("StackCleanupError", () => ({
reason: "unknown" as const,
suggestion: "Retry the stack restart with --debug and inspect cleanup diagnostics.",
})),
Match.orElse(() => ({ reason: "unknown" as const })),
Comment thread
jgoux marked this conversation as resolved.
);
return new StackCommandRestartError({
...classification,
message: error.message,
cause: error,
});
};

export const stackRestart = Effect.fn("experimental.stack.restart")(function* (
flags: StackRestartFlags,
) {
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));

let id: StackId;
let desiredLifecycle: StackDescriptor["desiredLifecycle"];
if (Option.isSome(flags.stackId)) {
id = yield* validateStackId(flags.stackId.value).pipe(Effect.mapError(mapTargetError));
const inspection = yield* api.inspectStack(id).pipe(Effect.mapError(mapStackError));
desiredLifecycle = inspection.descriptor.desiredLifecycle;
} else {
const found = yield* api
.findStack({
projectRoot: settings.workdir,
...(Option.isSome(flags.stack) ? { name: flags.stack.value } : {}),
})
.pipe(Effect.mapError(mapStackError));
if (Option.isNone(found))
return yield* new StackCommandRestartError({
reason: "not-found",
message: Option.isSome(flags.stack)
? `No managed stack named "${flags.stack.value}" was found for this project.`
: "No managed stack exists for the selected project.",
suggestion: Option.isSome(flags.stack)
? "Choose an existing --stack name or omit --stack for the current project."
: "Run supabase stack start first.",
});
id = found.value.id;
desiredLifecycle = found.value.desiredLifecycle;
}
if (desiredLifecycle === "unconfigured")
return yield* new StackCommandRestartError({
reason: "lifecycle",
message: "The selected stack has not been configured yet.",
suggestion: "Run supabase stack start to configure the stack first.",
});
const stack = yield* api.openStack(id).pipe(Effect.mapError(mapStackError));
const task = yield* output.task("Restarting local Supabase stack...");
yield* stack.stop.pipe(
Effect.mapError(mapStackError),
Effect.tapError((error) => task.fail(error.message)),
);
const status = yield* stack.start().pipe(
Effect.mapError(mapStackError),
Effect.tapError((error) => task.fail(error.message)),
Effect.tap(() => task.clear()),
);
if (output.format === "text") yield* output.raw(renderStackStatus(status));
else yield* output.success("", stackStatusPayload(status));
return status;
});
return yield* body.pipe(Effect.ensuring(telemetryState.flush));
});
Loading