From 0a6769b3ec4be832c938beaf08a5727ead494837 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 14 Sep 2026 15:09:40 +0200 Subject: [PATCH 1/2] feat(cli): add stack restart --- apps/cli/docs/stack-commands.md | 8 + .../stack/restart/SIDE_EFFECTS.md | 73 ++++ .../stack/restart/restart.command.ts | 31 ++ .../stack/restart/restart.errors.ts | 41 +++ .../stack/restart/restart.handler.ts | 156 +++++++++ .../stack/restart/restart.integration.test.ts | 319 ++++++++++++++++++ .../stack/stack-backend.integration.test.ts | 4 +- ...tack-command-telemetry.integration.test.ts | 19 ++ .../experimental/stack/stack.command.ts | 5 + .../experimental/stack/stack.shared.ts | 30 ++ .../experimental/stack/start/start.handler.ts | 37 +- .../telemetry/__fixtures__/error-tags.txt | 1 + 12 files changed, 690 insertions(+), 34 deletions(-) create mode 100644 apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md create mode 100644 apps/cli/src/commands/experimental/stack/restart/restart.command.ts create mode 100644 apps/cli/src/commands/experimental/stack/restart/restart.errors.ts create mode 100644 apps/cli/src/commands/experimental/stack/restart/restart.handler.ts create mode 100644 apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index e0c635af84..feed17ef20 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -12,6 +12,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` | Prepare the current project configuration, then restart an existing stack. | | `supabase stack stop` | Stop a stack while retaining its data. | Use each command's `--help` for its available targeting and runtime options. @@ -151,6 +152,13 @@ including `--workdir` and `SUPABASE_WORKDIR`, and prefers JSON when both files e ## Service selection and shutdown +`supabase stack restart` applies the current project configuration to an existing stack. It +prepares required artifacts before stopping, then stops and starts the same stack identity. Stack +data and durable identity are preserved. Select a stack with `--stack ` or `--stack-id `; +the selected stack's persisted project root supplies the configuration for ID targets. Restart does +not retain one-off `start` flags such as `--exclude`, `--eager`, or `--preparation`; those settings +fall back to the project's configuration and runtime defaults. + `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. diff --git a/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md new file mode 100644 index 0000000000..2c8b36fdbc --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md @@ -0,0 +1,73 @@ +# `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 `/managed/stacks//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 + +Telemetry state is flushed to `/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. 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. + +Restart does not retain one-off `start` flags such as `--exclude`, `--eager`, or +`--preparation`; it uses the current project configuration and runtime defaults. diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.command.ts b/apps/cli/src/commands/experimental/stack/restart/restart.command.ts new file mode 100644 index 0000000000..f13fdd9d3f --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/restart/restart.command.ts @@ -0,0 +1,31 @@ +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."), 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; + +export const stackRestartCommand = Command.make("restart", config).pipe( + Command.withDescription( + "Restart an existing managed local Supabase stack using the current project configuration, prepared before stop.", + ), + 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), + ), +); diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.errors.ts b/apps/cli/src/commands/experimental/stack/restart/restart.errors.ts new file mode 100644 index 0000000000..f5a6560bcb --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/restart/restart.errors.ts @@ -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; + } + } +} diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts b/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts new file mode 100644 index 0000000000..dcff158d3f --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts @@ -0,0 +1,156 @@ +import { Effect, Match, Option } from "effect"; +import type { 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 { loadStackConfig } from "../stack-config.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 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 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.orElse(() => ({ reason: "unknown" as const })), + ); + 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 target: { readonly projectRoot: string; readonly id: StackId }; + if (Option.isSome(flags.stackId)) { + const validId = yield* validateStackId(flags.stackId.value).pipe( + Effect.mapError(mapTargetError), + ); + const inspection = yield* api.inspectStack(validId).pipe(Effect.mapError(mapStackError)); + target = { projectRoot: inspection.descriptor.projectRoot, id: inspection.descriptor.id }; + } 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.", + }); + target = { projectRoot: found.value.projectRoot, id: found.value.id }; + } + const config = yield* loadStackConfig(target.projectRoot).pipe( + Effect.mapError( + (error) => + new StackCommandRestartError({ + reason: "invalid-config", + message: error.message, + cause: error, + }), + ), + ); + const stack = yield* api.openStack(target.id).pipe(Effect.mapError(mapStackError)); + const task = yield* output.task("Preparing local Supabase stack..."); + yield* stack.prepare({ config }).pipe( + Effect.mapError(mapStackError), + Effect.tapError((error) => task.fail(error.message)), + ); + yield* task.message("Restarting local Supabase stack..."); + yield* stack.stop.pipe( + Effect.mapError(mapStackError), + Effect.tapError((error) => task.fail(error.message)), + ); + const status = yield* stack.start({ config }).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)); +}); diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts b/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts new file mode 100644 index 0000000000..a55c8e7792 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts @@ -0,0 +1,319 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option, Stream } from "effect"; +import { + StackIdSchema, + StackPreparationError, + StackStateInvalidError, + type EffectStack, + type StackStatus, +} from "@supabase/stack/effect"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + mockCommandSettings, + mockTelemetryStateTracked, +} from "../../../../../tests/helpers/command-mocks.ts"; +import { StackApi } from "../stack.shared.ts"; +import { stackRestart } from "./restart.handler.ts"; + +const id = StackIdSchema.make("a".repeat(64)); +const project = (projectId = "restart-test") => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-restart-")); + mkdirSync(join(root, "supabase"), { recursive: true }); + writeFileSync( + join(root, "supabase", "config.toml"), + `[api]\nmax_rows = ${projectId === "id-project" ? 2345 : 1234}\n`, + ); + return root; +}; +const status = (): StackStatus => ({ + id, + lifecycle: "running", + desiredLifecycle: "running", + runtime: { kind: "native" }, + endpoints: {}, + versions: {}, + capabilities: [], + artifacts: [], +}); + +const flags = (overrides: Partial[0]> = {}) => ({ + stack: Option.none(), + stackId: Option.none(), + ...overrides, +}); + +const fixture = (options: { + prepare?: "ok" | "fail"; + stop?: "ok" | "fail"; + start?: "ok" | "fail"; + idProject?: boolean; + format?: "text" | "json"; + config?: "valid" | "invalid"; + found?: boolean; +}) => { + const root = project(); + if (options.config === "invalid") + writeFileSync(join(root, "supabase", "config.toml"), 'project_id = "unterminated\n'); + const idRoot = options.idProject === true ? project("id-project") : root; + const calls: string[] = []; + let lifecycle: StackStatus["lifecycle"] = "running"; + let preparedConfig: unknown; + let startedConfig: unknown; + let selectedName: string | undefined; + const output = mockOutput({ format: options.format }); + const telemetry = mockTelemetryStateTracked(); + const stack: EffectStack = { + id, + status: Effect.sync(() => ({ ...status(), lifecycle })), + credentials: Effect.die("unused"), + prepare: (input) => + Effect.sync(() => { + calls.push("prepare"); + preparedConfig = input?.config; + }).pipe( + Effect.flatMap(() => + options.prepare === "fail" + ? Effect.fail(new StackPreparationError({ message: "prepare failed" })) + : Effect.succeed({ capabilities: [] }), + ), + ), + stop: Effect.gen(function* () { + calls.push("stop"); + if (options.stop === "fail") + return yield* new StackStateInvalidError({ message: "stop failed" }); + lifecycle = "stopped"; + }), + start: (input) => + Effect.sync(() => calls.push("start")).pipe( + Effect.tap(() => + Effect.sync(() => { + startedConfig = input?.config; + }), + ), + Effect.flatMap(() => + options.start === "fail" + ? Effect.fail(new StackPreparationError({ message: "start failed" })) + : Effect.sync(() => { + lifecycle = "running"; + return status(); + }), + ), + ), + destroy: Effect.die("unused"), + logs: () => Effect.die("unused"), + followLogs: () => Stream.empty, + }; + const layer = Layer.mergeAll( + output.layer, + telemetry.layer, + mockCommandSettings({ workdir: root }), + Layer.succeed(StackApi, { + findStack: ({ projectRoot, name }) => + Effect.sync(() => { + selectedName = name; + return options.found === false || projectRoot !== root + ? Option.none() + : Option.some({ + id, + projectRoot: root, + name: name ?? "restart-test", + branchContext: "default", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }); + }), + createStack: () => Effect.die("create must not run"), + openStack: () => Effect.succeed(stack), + inspectStack: () => + options.idProject === true + ? Effect.succeed({ + descriptor: { + id, + projectRoot: idRoot, + name: "id-project", + branchContext: "default", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }, + owner: "running" as const, + }) + : Effect.die("inspect unused"), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), + }), + BunServices.layer, + ); + return { + root, + idRoot, + calls, + output, + telemetry, + layer, + get preparedConfig() { + return preparedConfig; + }, + get lifecycle() { + return lifecycle; + }, + get startedConfig() { + return startedConfig; + }, + get selectedName() { + return selectedName; + }, + cleanup: () => { + rmSync(root, { recursive: true, force: true }); + if (idRoot !== root) rmSync(idRoot, { recursive: true, force: true }); + }, + }; +}; + +describe("stack restart", () => { + it.live("prepares before stopping and starts the same stack", () => { + const setup = fixture({}); + return stackRestart(flags()).pipe( + Effect.provide(setup.layer), + Effect.tap(() => + Effect.sync(() => { + expect(setup.calls).toEqual(["prepare", "stop", "start"]); + expect(setup.preparedConfig).toMatchObject({ + capabilities: { rest: { settings: { max_rows: 1234 } } }, + }); + expect(setup.lifecycle).toBe("running"); + expect(setup.startedConfig).toBe(setup.preparedConfig); + expect(setup.telemetry.flushed).toBe(true); + expect(setup.output.stdoutText).toContain(`Stack ${id}`); + }), + ), + Effect.ensuring(Effect.sync(setup.cleanup)), + ); + }); + + it.live("does not stop when preparation fails", () => { + const setup = fixture({ prepare: "fail" }); + return stackRestart(flags()).pipe( + Effect.provide(setup.layer), + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.message).toContain("prepare failed"); + expect(setup.calls).toEqual(["prepare"]); + expect(setup.telemetry.flushed).toBe(true); + }), + ), + Effect.ensuring(Effect.sync(setup.cleanup)), + ); + }); + + it.live("does not start when stopping fails", () => { + const setup = fixture({ stop: "fail" }); + return stackRestart(flags()).pipe( + Effect.provide(setup.layer), + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.message).toContain("stop failed"); + expect(setup.calls).toEqual(["prepare", "stop"]); + }), + ), + Effect.ensuring(Effect.sync(setup.cleanup)), + ); + }); + + it.live("leaves the stack stopped and recoverable when start fails", () => { + const setup = fixture({ start: "fail" }); + return stackRestart(flags()).pipe( + Effect.provide(setup.layer), + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.message).toContain("start failed"); + expect(setup.calls).toEqual(["prepare", "stop", "start"]); + expect(setup.lifecycle).toBe("stopped"); + expect(setup.telemetry.flushed).toBe(true); + }), + ), + Effect.ensuring(Effect.sync(setup.cleanup)), + ); + }); + + it.live("loads configuration from the persisted project root for an id target", () => { + const setup = fixture({ idProject: true, format: "json" }); + return stackRestart(flags({ stackId: Option.some(id) })).pipe( + Effect.provide(setup.layer), + Effect.tap(() => + Effect.sync(() => { + expect(setup.preparedConfig).toMatchObject({ + capabilities: { rest: { settings: { max_rows: 2345 } } }, + }); + expect(setup.output.messages.find(({ type }) => type === "success")?.data).toMatchObject({ + id, + lifecycle: "running", + }); + }), + ), + Effect.ensuring(Effect.sync(setup.cleanup)), + ); + }); + + it.live("fails invalid configuration before preparing or stopping", () => { + const setup = fixture({ config: "invalid" }); + return stackRestart(flags()).pipe( + Effect.provide(setup.layer), + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.reason).toBe("invalid-config"); + expect(setup.calls).toEqual([]); + }), + ), + Effect.ensuring(Effect.sync(setup.cleanup)), + ); + }); + + it.live("fails without lifecycle calls when no existing stack is found", () => { + const setup = fixture({ found: false }); + return stackRestart(flags()).pipe( + Effect.provide(setup.layer), + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.reason).toBe("not-found"); + expect(setup.calls).toEqual([]); + }), + ), + Effect.ensuring(Effect.sync(setup.cleanup)), + ); + }); + + it.live("selects a named existing stack", () => { + const setup = fixture({}); + return stackRestart(flags({ stack: Option.some("feature-a") })).pipe( + Effect.provide(setup.layer), + Effect.tap(() => Effect.sync(() => expect(setup.selectedName).toBe("feature-a"))), + Effect.ensuring(Effect.sync(setup.cleanup)), + ); + }); + + it.live("reports an actionable error for a missing named stack", () => { + const setup = fixture({ found: false }); + return stackRestart(flags({ stack: Option.some("missing") })).pipe( + Effect.provide(setup.layer), + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(setup.selectedName).toBe("missing"); + expect(error.message).toContain('No managed stack named "missing"'); + expect(error.suggestion).toContain("existing --stack name"); + }), + ), + Effect.ensuring(Effect.sync(setup.cleanup)), + ); + }); +}); diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts index 09f3429df0..61cf95d1e1 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts @@ -286,7 +286,9 @@ stack = true "", ])?.candidates.map(({ name }) => name); expect(stackCommands).toEqual( - backend === "stack" ? ["destroy", "list", "logs", "start", "status", "stop"] : [], + backend === "stack" + ? ["destroy", "list", "logs", "restart", "start", "status", "stop"] + : [], ); } expect(completionFlags("stack", "status")).toContain("--override-name"); diff --git a/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts index b845402a8d..10c6d736c6 100644 --- a/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts @@ -149,4 +149,23 @@ describe("stack command telemetry", () => { Effect.ensuring(Effect.sync(() => rmSync(fixture.root, { recursive: true, force: true }))), ); }); + + it.live("records the restart command identity on invalid target input", () => { + const fixture = setup(); + const command = stackCommand.pipe(Command.provide(fixture.layer)); + return Effect.gen(function* () { + yield* Command.runWith(command, { version: "0.0.0-test" })([ + "restart", + "--stack-id", + "invalid", + ]).pipe(Effect.flip); + const event = fixture.analytics.captured.find( + (candidate) => candidate.event === EventCommandExecuted, + ); + expect(event?.properties[PropCommand]).toBe("stack restart"); + }).pipe( + Effect.provide(fixture.layer), + Effect.ensuring(Effect.sync(() => rmSync(fixture.root, { recursive: true, force: true }))), + ); + }); }); diff --git a/apps/cli/src/commands/experimental/stack/stack.command.ts b/apps/cli/src/commands/experimental/stack/stack.command.ts index 1285f23406..25b3d3ac19 100644 --- a/apps/cli/src/commands/experimental/stack/stack.command.ts +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -10,6 +10,7 @@ import { stackStatusCommand as stackStatusCommandBase } from "./status/status.co import { stackDestroyCommand as stackDestroyCommandBase } from "./destroy/destroy.command.ts"; import { stackLogsCommand as stackLogsCommandBase } from "./logs/logs.command.ts"; import { stackListCommand as stackListCommandBase } from "./list/list.command.ts"; +import { stackRestartCommand as stackRestartCommandBase } from "./restart/restart.command.ts"; import { stackApiLayer, stackTargetResolverLayer } from "./stack.shared.ts"; export const stackRuntimeLayer = Layer.mergeAll( @@ -37,6 +38,9 @@ const stackLogsCommand = stackLogsCommandBase.pipe( const stackListCommand = stackListCommandBase.pipe( Command.provide(commandRuntimeLayer(["stack", "list"])), ); +const stackRestartCommand = stackRestartCommandBase.pipe( + Command.provide(commandRuntimeLayer(["stack", "restart"])), +); export const stackCommand = Command.make("stack").pipe( Command.withDescription( @@ -47,6 +51,7 @@ export const stackCommand = Command.make("stack").pipe( stackDestroyCommand, stackListCommand, stackLogsCommand, + stackRestartCommand, stackStartCommand, stackStatusCommand, stackStopCommand, diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index d098356e40..cb037ca68b 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -8,6 +8,7 @@ import { openStack, type StackRuntimePreference, type StackDiscoveryResult, + type StackStatus, } from "@supabase/stack/effect"; import type { StackId } from "@supabase/stack"; import { StackNotFoundError } from "@supabase/stack/effect"; @@ -125,6 +126,35 @@ export const rejectStackOutput = ( ) : Effect.void; +export const stackStatusPayload = (status: StackStatus) => ({ + id: status.id, + lifecycle: status.lifecycle, + desired_lifecycle: status.desiredLifecycle, + runtime: status.runtime, + endpoints: status.endpoints, + versions: status.versions, + capabilities: status.capabilities, + artifacts: status.artifacts, +}); + +export const renderStackStatus = (status: StackStatus): string => { + const lines = [ + `Stack ${status.id}`, + `Runtime: ${status.runtime.kind}`, + `Lifecycle: ${status.lifecycle}`, + ]; + const endpoints = Object.entries(status.endpoints); + if (endpoints.length > 0) { + lines.push("Endpoints:"); + for (const [name, endpoint] of endpoints) + if (endpoint !== undefined) lines.push(` ${name}: ${endpoint.url}`); + } + const dormant = status.capabilities.filter(({ state }) => state === "dormant"); + if (dormant.length > 0) + lines.push(`Dormant capabilities: ${dormant.map(({ name }) => name).join(", ")}`); + return `${lines.join("\n")}\n`; +}; + export const stackApiLayer = Layer.effect( StackApi, Effect.gen(function* () { diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index 316c25048b..a25d4215e1 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -2,7 +2,6 @@ import { Effect, Match, Option } from "effect"; import { excludeStackCapabilities, isStackError, - type StackStatus, type StackRuntimePreference, } from "@supabase/stack/effect"; import { Output } from "../../../../shared/output/output.service.ts"; @@ -14,6 +13,8 @@ import { StackTargetError, StackTargetResolver, rejectStackOutput, + renderStackStatus, + stackStatusPayload, validateStackTarget, } from "../stack.shared.ts"; import { loadStackConfig } from "../stack-config.ts"; @@ -21,36 +22,6 @@ import type { StackStartFlags } from "./start.command.ts"; import { StackCommandStartError } from "./start.errors.ts"; import { STACK_START_EXCLUDABLE_CAPABILITIES } from "./start.options.ts"; -const statusPayload = (status: StackStatus) => ({ - id: status.id, - lifecycle: status.lifecycle, - desired_lifecycle: status.desiredLifecycle, - runtime: status.runtime, - endpoints: status.endpoints, - versions: status.versions, - capabilities: status.capabilities, - artifacts: status.artifacts, -}); - -const renderStatus = (status: StackStatus): string => { - const lines = [ - `Stack ${status.id}`, - `Runtime: ${status.runtime.kind}`, - `Lifecycle: ${status.lifecycle}`, - ]; - const endpoints = Object.entries(status.endpoints); - if (endpoints.length > 0) { - lines.push("Endpoints:"); - for (const [name, endpoint] of endpoints) { - if (endpoint !== undefined) lines.push(` ${name}: ${endpoint.url}`); - } - } - const dormant = status.capabilities.filter((capability) => capability.state === "dormant"); - if (dormant.length > 0) - lines.push(`Dormant capabilities: ${dormant.map(({ name }) => name).join(", ")}`); - return `${lines.join("\n")}\n`; -}; - const eagerlyActivate = < T extends { readonly enabled?: boolean; readonly activation?: "eager" | "lazy" }, >( @@ -184,9 +155,9 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags Effect.mapError(stackStartError), ); if (output.format === "text") { - yield* output.raw(renderStatus(status)); + yield* output.raw(renderStackStatus(status)); } else { - yield* output.success("", statusPayload(status)); + yield* output.success("", stackStatusPayload(status)); } return status; }); diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 73f3fcb9a5..77be92454d 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -234,6 +234,7 @@ ExperimentalRequiredError ExperimentalStackDestroyError ExperimentalStackListError ExperimentalStackLogsError +ExperimentalStackRestartError ExperimentalStackStartError ExperimentalStackStatusError ExperimentalStackStopError From e8f5e6731482e58954d92475779d9b082ca7078a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 14 Sep 2026 15:47:12 +0200 Subject: [PATCH 2/2] fix(cli): preserve saved stack settings on restart --- apps/cli/docs/stack-commands.md | 17 +- .../stack/restart/SIDE_EFFECTS.md | 41 +-- .../stack/restart/restart.command.ts | 7 +- .../stack/restart/restart.handler.ts | 54 ++- .../stack/restart/restart.integration.test.ts | 319 +++++++++++++----- 5 files changed, 301 insertions(+), 137 deletions(-) diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index feed17ef20..f749066104 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -12,7 +12,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` | Prepare the current project configuration, then restart an existing stack. | +| `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. @@ -152,12 +152,15 @@ including `--workdir` and `SUPABASE_WORKDIR`, and prefers JSON when both files e ## Service selection and shutdown -`supabase stack restart` applies the current project configuration to an existing stack. It -prepares required artifacts before stopping, then stops and starts the same stack identity. Stack -data and durable identity are preserved. Select a stack with `--stack ` or `--stack-id `; -the selected stack's persisted project root supplies the configuration for ID targets. Restart does -not retain one-off `start` flags such as `--exclude`, `--eager`, or `--preparation`; those settings -fall back to the project's configuration and runtime defaults. +`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 ` or +`--stack-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`, diff --git a/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md index 2c8b36fdbc..821c2b699e 100644 --- a/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md @@ -6,18 +6,21 @@ configuration or `SUPABASE_EXPERIMENTAL_STACK=1`. It has no top-level alias. ## Files Read Reads the selected stack's descriptor and `/managed/stacks//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/`. +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. 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. +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 @@ -29,18 +32,18 @@ Artifact URLs and registry requests depend on the selected runtime and releases. - `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. +- 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/configuration, or a configuration, preparation, stop, or start failure. | -| `130` | The CLI waiter was interrupted. | +| 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 @@ -63,11 +66,11 @@ 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 +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 does not retain one-off `start` flags such as `--exclude`, `--eager`, or -`--preparation`; it uses the current project configuration and runtime defaults. +Restart reuses saved one-off `start` flags such as `--exclude`, `--eager`, and +`--preparation`; a normal `start` reloads project configuration and current flags. diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.command.ts b/apps/cli/src/commands/experimental/stack/restart/restart.command.ts index f13fdd9d3f..e96199c7ec 100644 --- a/apps/cli/src/commands/experimental/stack/restart/restart.command.ts +++ b/apps/cli/src/commands/experimental/stack/restart/restart.command.ts @@ -5,7 +5,10 @@ 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."), Flag.optional), + 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, @@ -16,7 +19,7 @@ export type StackRestartFlags = CliCommand.Command.Config.Infer; export const stackRestartCommand = Command.make("restart", config).pipe( Command.withDescription( - "Restart an existing managed local Supabase stack using the current project configuration, prepared before stop.", + "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([ diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts b/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts index dcff158d3f..8354213d9b 100644 --- a/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts +++ b/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts @@ -1,5 +1,5 @@ import { Effect, Match, Option } from "effect"; -import type { StackError, StackId } from "@supabase/stack/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"; @@ -13,7 +13,6 @@ import { validateStackId, validateStackTarget, } from "../stack.shared.ts"; -import { loadStackConfig } from "../stack-config.ts"; import type { StackRestartFlags } from "./restart.command.ts"; import { StackCommandRestartError } from "./restart.errors.ts"; @@ -32,7 +31,7 @@ const mapStackError = (error: StackError) => { Match.tag("PortUnavailableError", "PortAllocationError", () => ({ reason: "port" as const, suggestion: - "Free the conflicting port or update the local stack port configuration, then retry.", + "Free the conflicting port and retry, or stop the stack and use supabase stack start to apply updated project port configuration.", })), Match.tag( "InvalidStackConfigError", @@ -72,6 +71,14 @@ const mapStackError = (error: StackError) => { 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 })), ); return new StackCommandRestartError({ @@ -96,13 +103,12 @@ export const stackRestart = Effect.fn("experimental.stack.restart")(function* ( stackId: Option.getOrUndefined(flags.stackId), }).pipe(Effect.mapError(mapTargetError)); - let target: { readonly projectRoot: string; readonly id: StackId }; + let id: StackId; + let desiredLifecycle: StackDescriptor["desiredLifecycle"]; if (Option.isSome(flags.stackId)) { - const validId = yield* validateStackId(flags.stackId.value).pipe( - Effect.mapError(mapTargetError), - ); - const inspection = yield* api.inspectStack(validId).pipe(Effect.mapError(mapStackError)); - target = { projectRoot: inspection.descriptor.projectRoot, id: inspection.descriptor.id }; + 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({ @@ -120,30 +126,22 @@ export const stackRestart = Effect.fn("experimental.stack.restart")(function* ( ? "Choose an existing --stack name or omit --stack for the current project." : "Run supabase stack start first.", }); - target = { projectRoot: found.value.projectRoot, id: found.value.id }; + id = found.value.id; + desiredLifecycle = found.value.desiredLifecycle; } - const config = yield* loadStackConfig(target.projectRoot).pipe( - Effect.mapError( - (error) => - new StackCommandRestartError({ - reason: "invalid-config", - message: error.message, - cause: error, - }), - ), - ); - const stack = yield* api.openStack(target.id).pipe(Effect.mapError(mapStackError)); - const task = yield* output.task("Preparing local Supabase stack..."); - yield* stack.prepare({ config }).pipe( - Effect.mapError(mapStackError), - Effect.tapError((error) => task.fail(error.message)), - ); - yield* task.message("Restarting local Supabase stack..."); + 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({ config }).pipe( + const status = yield* stack.start().pipe( Effect.mapError(mapStackError), Effect.tapError((error) => task.fail(error.message)), Effect.tap(() => task.clear()), diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts b/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts index a55c8e7792..6ebf4d34b2 100644 --- a/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts @@ -9,26 +9,29 @@ import { Effect, Layer, Option, Stream } from "effect"; import { StackIdSchema, StackPreparationError, + StackRuntimeError, StackStateInvalidError, + StackCleanupError, type EffectStack, type StackStatus, + type StackConfig, } from "@supabase/stack/effect"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { mockCommandSettings, mockTelemetryStateTracked, } from "../../../../../tests/helpers/command-mocks.ts"; -import { StackApi } from "../stack.shared.ts"; +import { StackApi, StackTargetResolver } from "../stack.shared.ts"; import { stackRestart } from "./restart.handler.ts"; +import { stackStart } from "../start/start.handler.ts"; +import { stackStop } from "../stop/stop.handler.ts"; +import { OutputFlag } from "../../../../command-internal/global-flags.ts"; const id = StackIdSchema.make("a".repeat(64)); -const project = (projectId = "restart-test") => { +const project = () => { const root = mkdtempSync(join(tmpdir(), "supabase-stack-restart-")); mkdirSync(join(root, "supabase"), { recursive: true }); - writeFileSync( - join(root, "supabase", "config.toml"), - `[api]\nmax_rows = ${projectId === "id-project" ? 2345 : 1234}\n`, - ); + writeFileSync(join(root, "supabase", "config.toml"), "[api]\nmax_rows = 1234\n"); return root; }; const status = (): StackStatus => ({ @@ -49,21 +52,20 @@ const flags = (overrides: Partial[0]> = {}) => ( }); const fixture = (options: { - prepare?: "ok" | "fail"; stop?: "ok" | "fail"; start?: "ok" | "fail"; - idProject?: boolean; format?: "text" | "json"; config?: "valid" | "invalid"; found?: boolean; + unconfigured?: boolean; + startRuntimeFailure?: boolean; + inspectFailure?: boolean; }) => { const root = project(); if (options.config === "invalid") writeFileSync(join(root, "supabase", "config.toml"), 'project_id = "unterminated\n'); - const idRoot = options.idProject === true ? project("id-project") : root; const calls: string[] = []; let lifecycle: StackStatus["lifecycle"] = "running"; - let preparedConfig: unknown; let startedConfig: unknown; let selectedName: string | undefined; const output = mockOutput({ format: options.format }); @@ -72,21 +74,10 @@ const fixture = (options: { id, status: Effect.sync(() => ({ ...status(), lifecycle })), credentials: Effect.die("unused"), - prepare: (input) => - Effect.sync(() => { - calls.push("prepare"); - preparedConfig = input?.config; - }).pipe( - Effect.flatMap(() => - options.prepare === "fail" - ? Effect.fail(new StackPreparationError({ message: "prepare failed" })) - : Effect.succeed({ capabilities: [] }), - ), - ), + prepare: () => Effect.die("restart must not prepare explicitly"), stop: Effect.gen(function* () { calls.push("stop"); - if (options.stop === "fail") - return yield* new StackStateInvalidError({ message: "stop failed" }); + if (options.stop === "fail") return yield* new StackCleanupError({ message: "stop failed" }); lifecycle = "stopped"; }), start: (input) => @@ -98,7 +89,11 @@ const fixture = (options: { ), Effect.flatMap(() => options.start === "fail" - ? Effect.fail(new StackPreparationError({ message: "start failed" })) + ? Effect.fail( + options.startRuntimeFailure === true + ? new StackRuntimeError({ message: "runtime failed" }) + : new StackPreparationError({ message: "start failed" }), + ) : Effect.sync(() => { lifecycle = "running"; return status(); @@ -125,39 +120,39 @@ const fixture = (options: { name: name ?? "restart-test", branchContext: "default", runtime: { kind: "native" as const }, - desiredLifecycle: "running" as const, + desiredLifecycle: options.unconfigured + ? ("unconfigured" as const) + : ("running" as const), }); }), createStack: () => Effect.die("create must not run"), openStack: () => Effect.succeed(stack), inspectStack: () => - options.idProject === true - ? Effect.succeed({ + options.inspectFailure + ? Effect.fail(new StackStateInvalidError({ message: "inspect failed" })) + : Effect.succeed({ descriptor: { id, - projectRoot: idRoot, - name: "id-project", + projectRoot: root, + name: "restart-test", branchContext: "default", runtime: { kind: "native" as const }, - desiredLifecycle: "running" as const, + desiredLifecycle: options.unconfigured + ? ("unconfigured" as const) + : ("running" as const), }, owner: "running" as const, - }) - : Effect.die("inspect unused"), + }), discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), }), BunServices.layer, ); return { root, - idRoot, calls, output, telemetry, layer, - get preparedConfig() { - return preparedConfig; - }, get lifecycle() { return lifecycle; }, @@ -169,24 +164,20 @@ const fixture = (options: { }, cleanup: () => { rmSync(root, { recursive: true, force: true }); - if (idRoot !== root) rmSync(idRoot, { recursive: true, force: true }); }, }; }; describe("stack restart", () => { - it.live("prepares before stopping and starts the same stack", () => { + it.live("stops and starts the saved stack configuration", () => { const setup = fixture({}); return stackRestart(flags()).pipe( Effect.provide(setup.layer), Effect.tap(() => Effect.sync(() => { - expect(setup.calls).toEqual(["prepare", "stop", "start"]); - expect(setup.preparedConfig).toMatchObject({ - capabilities: { rest: { settings: { max_rows: 1234 } } }, - }); + expect(setup.calls).toEqual(["stop", "start"]); expect(setup.lifecycle).toBe("running"); - expect(setup.startedConfig).toBe(setup.preparedConfig); + expect(setup.startedConfig).toBeUndefined(); expect(setup.telemetry.flushed).toBe(true); expect(setup.output.stdoutText).toContain(`Stack ${id}`); }), @@ -195,22 +186,6 @@ describe("stack restart", () => { ); }); - it.live("does not stop when preparation fails", () => { - const setup = fixture({ prepare: "fail" }); - return stackRestart(flags()).pipe( - Effect.provide(setup.layer), - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - expect(error.message).toContain("prepare failed"); - expect(setup.calls).toEqual(["prepare"]); - expect(setup.telemetry.flushed).toBe(true); - }), - ), - Effect.ensuring(Effect.sync(setup.cleanup)), - ); - }); - it.live("does not start when stopping fails", () => { const setup = fixture({ stop: "fail" }); return stackRestart(flags()).pipe( @@ -219,7 +194,10 @@ describe("stack restart", () => { Effect.tap((error) => Effect.sync(() => { expect(error.message).toContain("stop failed"); - expect(setup.calls).toEqual(["prepare", "stop"]); + expect(error.reason).toBe("unknown"); + expect(error.suggestion).toContain("--debug"); + expect(error.suggestion).toContain("cleanup"); + expect(setup.calls).toEqual(["stop"]); }), ), Effect.ensuring(Effect.sync(setup.cleanup)), @@ -234,7 +212,7 @@ describe("stack restart", () => { Effect.tap((error) => Effect.sync(() => { expect(error.message).toContain("start failed"); - expect(setup.calls).toEqual(["prepare", "stop", "start"]); + expect(setup.calls).toEqual(["stop", "start"]); expect(setup.lifecycle).toBe("stopped"); expect(setup.telemetry.flushed).toBe(true); }), @@ -243,15 +221,13 @@ describe("stack restart", () => { ); }); - it.live("loads configuration from the persisted project root for an id target", () => { - const setup = fixture({ idProject: true, format: "json" }); + it.live("reuses saved configuration for an id target", () => { + const setup = fixture({ format: "json", config: "invalid" }); return stackRestart(flags({ stackId: Option.some(id) })).pipe( Effect.provide(setup.layer), Effect.tap(() => Effect.sync(() => { - expect(setup.preparedConfig).toMatchObject({ - capabilities: { rest: { settings: { max_rows: 2345 } } }, - }); + expect(setup.startedConfig).toBeUndefined(); expect(setup.output.messages.find(({ type }) => type === "success")?.data).toMatchObject({ id, lifecycle: "running", @@ -262,21 +238,6 @@ describe("stack restart", () => { ); }); - it.live("fails invalid configuration before preparing or stopping", () => { - const setup = fixture({ config: "invalid" }); - return stackRestart(flags()).pipe( - Effect.provide(setup.layer), - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - expect(error.reason).toBe("invalid-config"); - expect(setup.calls).toEqual([]); - }), - ), - Effect.ensuring(Effect.sync(setup.cleanup)), - ); - }); - it.live("fails without lifecycle calls when no existing stack is found", () => { const setup = fixture({ found: false }); return stackRestart(flags()).pipe( @@ -316,4 +277,200 @@ describe("stack restart", () => { Effect.ensuring(Effect.sync(setup.cleanup)), ); }); + + it.live("rejects an unconfigured current stack before stopping", () => { + const setup = fixture({ unconfigured: true }); + return stackRestart(flags()).pipe( + Effect.provide(setup.layer), + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.reason).toBe("lifecycle"); + expect(error.suggestion).toContain("Run supabase stack start"); + expect(setup.calls).toEqual([]); + }), + ), + Effect.ensuring(Effect.sync(setup.cleanup)), + ); + }); + + it.live("rejects an unconfigured id target before stopping", () => { + const setup = fixture({ unconfigured: true }); + return stackRestart(flags({ stackId: Option.some(id) })).pipe( + Effect.provide(setup.layer), + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.reason).toBe("lifecycle"); + expect(setup.calls).toEqual([]); + }), + ), + Effect.ensuring(Effect.sync(setup.cleanup)), + ); + }); + + it.live("provides retry guidance for runtime failures", () => { + const setup = fixture({ start: "fail", startRuntimeFailure: true }); + return stackRestart(flags()).pipe( + Effect.provide(setup.layer), + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.reason).toBe("unknown"); + expect(error.suggestion).toContain("--debug"); + }), + ), + Effect.ensuring(Effect.sync(setup.cleanup)), + ); + }); + + it.live("short circuits invalid target and output flags before lifecycle", () => { + const setup = fixture({}); + const invalidTarget = stackRestart( + flags({ stack: Option.some("named"), stackId: Option.some(id) }), + ).pipe(Effect.provide(setup.layer), Effect.flip); + const outputRejected = stackRestart(flags()).pipe( + Effect.provide(Layer.merge(setup.layer, Layer.succeed(OutputFlag, Option.some("json")))), + Effect.flip, + ); + return Effect.gen(function* () { + expect((yield* invalidTarget).reason).toBe("flags"); + expect((yield* outputRejected).reason).toBe("flags"); + expect(setup.calls).toEqual([]); + }).pipe(Effect.ensuring(Effect.sync(setup.cleanup))); + }); + + it.live("short circuits an ID inspection failure before lifecycle", () => { + const setup = fixture({ inspectFailure: true }); + return stackRestart(flags({ stackId: Option.some(id) })).pipe( + Effect.provide(setup.layer), + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.reason).toBe("invalid-config"); + expect(setup.calls).toEqual([]); + }), + ), + Effect.ensuring(Effect.sync(setup.cleanup)), + ); + }); + + it.live( + "preserves effective start options through restart and reloads them on plain start", + () => { + const root = project(); + const output = mockOutput({ format: "json" }); + const telemetry = mockTelemetryStateTracked(); + const calls: string[] = []; + let lifecycle: StackStatus["lifecycle"] = "stopped"; + const persisted: { config?: StackConfig } = {}; + const state = () => ({ + id, + lifecycle, + desiredLifecycle: "running" as const, + runtime: { kind: "native" as const }, + endpoints: {}, + versions: {}, + capabilities: [], + artifacts: [], + }); + const stack: EffectStack = { + id, + status: Effect.sync(state), + credentials: Effect.die("unused"), + prepare: () => Effect.die("restart must not prepare explicitly"), + stop: Effect.sync(() => { + calls.push("stop"); + lifecycle = "stopped"; + }), + start: (input) => + Effect.sync(() => { + calls.push("start"); + if (input?.config !== undefined) persisted.config = input.config; + lifecycle = "running"; + return state(); + }), + destroy: Effect.die("unused"), + logs: () => Effect.die("unused"), + followLogs: () => Stream.empty, + }; + const descriptor = { + id, + projectRoot: root, + name: "restart-flow", + branchContext: "default", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }; + const layer = Layer.mergeAll( + output.layer, + telemetry.layer, + mockCommandSettings({ workdir: root }), + Layer.succeed(StackTargetResolver, { + resolve: ({ id: targetId }) => + Effect.succeed({ + projectRoot: root, + ...(targetId === undefined ? {} : { id: StackIdSchema.make(targetId) }), + }), + }), + Layer.succeed(StackApi, { + findStack: () => Effect.succeed(Option.some(descriptor)), + createStack: () => Effect.succeed(stack), + openStack: () => Effect.succeed(stack), + inspectStack: () => Effect.die("inspect unused"), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), + }), + BunServices.layer, + ); + const initialStart = { + exclude: ["studio"], + stack: Option.none(), + stackId: Option.none(), + runtime: "auto" as const, + preparation: "on-demand" as const, + eager: true, + }; + const plainStart = { + exclude: [], + stack: Option.none(), + stackId: Option.none(), + runtime: "auto" as const, + preparation: "background" as const, + eager: false, + }; + const stopFlags = { + all: Option.none(), + stack: Option.none(), + stackId: Option.none(), + }; + return Effect.gen(function* () { + yield* stackStart(initialStart); + expect(persisted.config).toMatchObject({ + preparation: "on-demand", + capabilities: { + studio: { enabled: false }, + rest: { activation: "eager" }, + }, + }); + const saved = persisted.config; + yield* stackRestart(flags()); + expect(persisted.config).toBe(saved); + yield* stackStop(stopFlags); + yield* stackStart(plainStart); + expect(persisted.config).toMatchObject({ + preparation: "background", + capabilities: { + studio: { settings: {} }, + rest: { settings: { max_rows: 1234 } }, + }, + }); + expect(persisted.config?.capabilities?.studio?.enabled).not.toBe(false); + expect(persisted.config?.capabilities?.rest).not.toHaveProperty("activation", "eager"); + expect(calls).toEqual(["start", "stop", "start", "stop", "start"]); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }, + ); });