Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# `supabase experimental stack restart`

## Files Read

Reads the selected stack's descriptor and `<SUPABASE_HOME>/managed/stacks/<id>/state.json`,
plus owner metadata in `control.json` when present. Configuration comes from the
selected descriptor's project root: `supabase/config.toml` or `supabase/config.json`,
project environment input through the config loader, configured signing material,
and enabled function dotenv files under `supabase/functions/`.

## 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. Preparation may populate the package's artifact cache
or the container engine's image store. Restart preserves the stack ID and data;
it never calls create or destroy.

## 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.
- Environment references in project configuration and function dotenv files are
resolved by the shared config loader. Their 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/configuration, or a configuration, preparation, stop, or start failure. |
| `130` | The CLI waiter was interrupted. |

## Telemetry Events Fired

Standard command instrumentation emits `cli_command_executed` for success or
failure, with 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. Configuration validation and preparation precede stop. A preparation
failure leaves the running stack untouched; 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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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 { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts";
import { legacyExperimentalStackRestart } from "./restart.handler.ts";

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

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

export const legacyExperimentalStackRestartCommand = Command.make("restart", config).pipe(
Command.withDescription("Restart an existing managed local Supabase stack."),
Command.withShortDescription("Restart a managed local stack"),
Command.withHandler((flags) =>
legacyExperimentalStackRestart(flags).pipe(
withLegacyCommandInstrumentation({ flags, config }),
withJsonErrorHandling,
),
),
Comment on lines +17 to +25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ NIT · documentation · source: claude

The new restart command supplies no Command.withExamples metadata, so its generated help and reference material lack examples for current, named, or ID-based target selection.

Evidence: restart.command.ts:17-25 includes descriptions and a handler but no examples. start.command.ts:32-41, stop.command.ts:20-25, prepare.command.ts:29-38, and logs.command.ts:44-53 provide examples; trusted/docs/adr/0003-self-documenting-cli.md identifies command examples as generated reference metadata.

Suggested fix: Add examples for restarting the current project stack and a named or ID-selected stack using the full experimental command path.

);
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Data } from "effect";
import {
actionability,
type CliErrorActionabilityDeclaration,
ErrorActionabilityId,
} from "../../../../shared/telemetry/error-actionability.ts";

export class LegacyExperimentalStackRestartError extends Data.TaggedError(
"LegacyExperimentalStackRestartError",
)<{
readonly message: string;
readonly reason:
| "flags"
| "not-found"
| "invalid-config"
| "port"
| "lifecycle"
| "docker"
| "registry"
| "artifact"
| "unknown";
readonly suggestion?: string;
readonly cause?: unknown;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
if (this.reason === "flags" || this.reason === "not-found") return actionability.provideFlags;
if (this.reason === "invalid-config" || this.reason === "lifecycle")
return actionability.invalidConfig;
if (this.reason === "port") return actionability.invalidConfig;
if (this.reason === "docker") return actionability.dockerNotRunning;
if (this.reason === "registry" || this.reason === "artifact")
return actionability.externalNetwork;
return actionability.unknown;
}
Comment on lines +25 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ NIT · maintainability · source: claude

The actionability getter uses a non-exhaustive if chain with a redundant port branch, so a future reason can silently fall through to unknown.

Evidence: restart.errors.ts:25-34 returns invalidConfig separately for port and for invalid-config/lifecycle, then defaults to unknown. start.errors.ts:33-50 uses an exhaustive switch over its closed reason union.

Suggested fix: Use an exhaustive switch and group reasons that share the same actionability.

}
158 changes: 158 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,158 @@
import { Effect, Match, Option } from "effect";
import { isStackError, isStackId, type StackError } from "@supabase/stack/effect";
import { Output } from "../../../../shared/output/output.service.ts";
import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts";
import { LegacyCliSettings } from "../../../../config/legacy-cli-settings.service.ts";
import {
LegacyExperimentalStackApi,
legacyRenderStackStatus,
legacyStackStatusPayload,
} from "../stack.shared.ts";
import { legacyLoadStackConfig } from "../stack-config.ts";
import type { LegacyExperimentalStackRestartFlags } from "./restart.command.ts";
import { LegacyExperimentalStackRestartError } from "./restart.errors.ts";

const validateFlags = (flags: LegacyExperimentalStackRestartFlags) =>
Option.isSome(flags.stack) && Option.isSome(flags.stackId)
? Effect.fail(
new LegacyExperimentalStackRestartError({
reason: "flags",
message: "--stack and --stack-id cannot be used together",
}),
)
: Effect.void;

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 or update the local stack port configuration, then retry.",
})),
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 --stack name to use another runtime.",
})),
Match.tag(
"StackLifecycleConflictError",
"StackNotRunningError",
"StackMustBeStoppedError",
"StackOwnershipConflictError",
"StackUpgradeRequiredError",
"StackRuntimeError",
"StackCleanupError",
() => ({
reason: "lifecycle" as const,
suggestion: "Run supabase experimental stack status to inspect the stack state.",
}),
),
Comment on lines +49 to +61

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR · error-handling · source: claude

Restart classifies StackRuntimeError and StackCleanupError as user-actionable lifecycle/configuration failures, while start classifies the same errors from stack.start() as unknown failures with debug guidance, producing inconsistent telemetry and remediation.

Evidence: restart.handler.ts:49-61 maps both tags to lifecycle; restart.errors.ts:25-34 maps lifecycle to actionability.invalidConfig. start.handler.ts:193-200 maps them to unknown, and packages/stack/src/public/Errors.ts:276-297 confirms both can be returned by start().

Suggested fix: Classify these start-phase failures consistently with start.handler.ts, using unknown and phase-appropriate debug guidance, then update the restart test.

Match.tag("ContainerEngineError", () => ({
reason: "docker" 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.orElse(() => ({ reason: "unknown" as const })),
);
return new LegacyExperimentalStackRestartError({
...classification,
message: error.message,
cause: error,
});
};

const catchStackError = <A, R>(effect: Effect.Effect<A, StackError, R>) =>
effect.pipe(Effect.catchIf(isStackError, (error) => Effect.fail(mapStackError(error))));

export const legacyExperimentalStackRestart = Effect.fn("legacy.experimental.stack.restart")(
function* (flags: LegacyExperimentalStackRestartFlags) {
const output = yield* Output;
const settings = yield* LegacyCliSettings;
const legacyOutput = yield* Effect.serviceOption(LegacyOutputFlag);
if (Option.isSome(legacyOutput) && Option.isSome(legacyOutput.value))
return yield* new LegacyExperimentalStackRestartError({
reason: "flags",
message: "The legacy -o/--output flag is not supported here; use --output-format json.",
suggestion: "Use --output-format json or --output-format text.",
});
Comment thread
jgoux marked this conversation as resolved.
yield* validateFlags(flags);
const api = yield* LegacyExperimentalStackApi;
const stackId = Option.getOrUndefined(flags.stackId);
const stackName = Option.getOrUndefined(flags.stack);
const target = yield* stackId !== undefined
? Effect.gen(function* () {
const id = stackId;
if (!isStackId(id))
return yield* new LegacyExperimentalStackRestartError({
reason: "flags",
message: "--stack-id must be a lowercase SHA-256 stack id",
});
Comment thread
jgoux marked this conversation as resolved.
const inspection = yield* catchStackError(api.inspectStack(id));
return { id, projectRoot: inspection.descriptor.projectRoot };
})
: Effect.gen(function* () {
const found = yield* catchStackError(
api.findStack({
projectRoot: settings.workdir,
...(stackName === undefined ? {} : { name: stackName }),
}),
);
if (Option.isNone(found))
return yield* new LegacyExperimentalStackRestartError({
reason: "not-found",
message:
stackName === undefined
? "No managed stack exists for the selected project."
: `No managed stack named "${stackName}" was found for this project.`,
suggestion:
stackName === undefined
? "Run supabase experimental stack start first."
: "Choose an existing --stack name or omit --stack for the current project.",
});
Comment thread
jgoux marked this conversation as resolved.
return { id: found.value.id, projectRoot: found.value.projectRoot };
});
Comment on lines +101 to +132

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR · duplication · source: claude

Restart duplicates stack target validation and resolution logic already implemented across sibling handlers instead of sharing it, contrary to the trusted Hoist Before You Duplicate convention.

Evidence: restart.handler.ts:15-23 repeats the mutual-exclusion validation found in start, stop, prepare, logs, and status. restart.handler.ts:101-132 closely repeats status.handler.ts:133-160. trusted/apps/cli/CLAUDE.md:233-250 requires overlapping same-family handler logic to be hoisted.

Suggested fix: Hoist common target validation/resolution into stack.shared.ts and refactor existing callers, or reuse LegacyExperimentalStackTargetResolver for its applicable validation and inspection behavior while sharing the existing-stack lookup separately.

const config = yield* legacyLoadStackConfig(target.projectRoot).pipe(
Effect.mapError(
(error) =>
new LegacyExperimentalStackRestartError({
reason: "invalid-config",
message: error.message,
cause: error,
}),
),
);
const stack = yield* catchStackError(api.openStack(target.id));
const task = yield* output.task("Preparing local Supabase stack...");
yield* catchStackError(stack.prepare({ config })).pipe(
Effect.tapError((error) => task.fail(error.message)),
);
yield* task.message("Restarting local Supabase stack...");
yield* catchStackError(stack.stop()).pipe(Effect.tapError((error) => task.fail(error.message)));
const status = yield* catchStackError(stack.start({ config })).pipe(
Effect.tapError((error) => task.fail(error.message)),
Effect.tap(() => task.clear()),
);
Comment on lines +149 to +153

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR · user-experience · source: claude

If stop succeeds and start fails, the stack remains stopped, but the surfaced error does not disclose that state change or provide explicit recovery guidance.

Evidence: restart.handler.ts:149-153 performs stop before start and applies the same phase-agnostic mapper to start failures. SIDE_EFFECTS.md:61-66 documents that a start failure leaves the stack stopped, and restart.integration.test.ts:242-257 verifies that resulting lifecycle.

Suggested fix: Map start-phase failures separately and append guidance explaining that the stack is stopped and can be recovered after fixing the reported problem.

Comment on lines +144 to +153

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ NIT · user-experience · source: claude

Interrupting restart during prepare, stop, or start leaves its progress task unsettled because only ordinary success and typed-error paths finalize it.

Evidence: restart.handler.ts:144-153 only calls task.fail through Effect.tapError and task.clear through Effect.tap. output.service.ts:7-14 exposes cancel and clear, while shared/cli/run.ts:831-849 interrupts the command fiber on a signal; no interruption finalizer settles this task.

Suggested fix: Attach Effect.onInterrupt to cancel or clear the task around the lifecycle sequence, and consider applying the same cleanup to sibling stack handlers.

if (output.format === "text") yield* output.raw(legacyRenderStackStatus(status));
else yield* output.success("", legacyStackStatusPayload(status));
return status;
},
);
Loading
Loading