-
Notifications
You must be signed in to change notification settings - Fork 523
feat(cli): add experimental stack restart #6512
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0a3f676
e805360
508d0cf
455663f
aab76dd
29b4652
938a600
c7e2e96
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
| ), | ||
| ), | ||
| ); | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⚪ NIT · 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. |
||
| } | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR · 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.", | ||
| }); | ||
|
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", | ||
| }); | ||
|
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.", | ||
| }); | ||
|
jgoux marked this conversation as resolved.
|
||
| return { id: found.value.id, projectRoot: found.value.projectRoot }; | ||
| }); | ||
|
Comment on lines
+101
to
+132
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR · 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR · 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⚪ NIT · 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; | ||
| }, | ||
| ); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
⚪ NIT ·
documentation· source: claudeThe 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.