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..24858a1580 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md @@ -0,0 +1,66 @@ +# `supabase experimental stack restart` + +## 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 + +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. 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..b86f900309 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/restart/restart.command.ts @@ -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; + +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, + ), + ), +); 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..f1ae21a39d --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/restart/restart.errors.ts @@ -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; + } +} 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..7f25d9d705 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts @@ -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.", + }), + ), + 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 = (effect: Effect.Effect) => + 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.", + }); + 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", + }); + 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.", + }); + return { id: found.value.id, projectRoot: found.value.projectRoot }; + }); + 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()), + ); + if (output.format === "text") yield* output.raw(legacyRenderStackStatus(status)); + else yield* output.success("", legacyStackStatusPayload(status)); + return status; + }, +); 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..a166019c7d --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts @@ -0,0 +1,318 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary +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 at this boundary +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option, Stream } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { + PortUnavailableError, + StackIdSchema, + StackLifecycleConflictError, + StackPreparationError, + StackRuntimeError, + type EffectStack, + type StackStatus, +} from "@supabase/stack/effect"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockLegacyCliSettings } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + actionability, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; +import { LegacyExperimentalStackApi } from "../stack.shared.ts"; +import { legacyExperimentalStackCommand } from "../stack.command.ts"; +import { legacyExperimentalStackRestart } from "./restart.handler.ts"; +import { legacyExperimentalStackRestartCommand } from "./restart.command.ts"; +import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; + +const stackId = StackIdSchema.make("a".repeat(64)); +const status = (lifecycle: StackStatus["lifecycle"] = "running", rich = false): StackStatus => ({ + id: stackId, + lifecycle, + desiredLifecycle: "running", + runtime: { kind: "native" }, + endpoints: rich + ? { + api: { + protocol: "http", + address: "127.0.0.1", + port: 54321, + url: "http://127.0.0.1:54321", + }, + } + : {}, + versions: {}, + capabilities: rich + ? [ + { + name: "functions", + activation: "lazy", + state: "dormant", + }, + ] + : [], + artifacts: [], +}); + +const flags = (stack = Option.none(), stackIdFlag = Option.none()) => ({ + stack, + stackId: stackIdFlag, +}); + +const makeFixture = (options: { + readonly target?: "current" | "id" | "name"; + readonly config?: "valid" | "invalid"; + readonly prepare?: "ok" | "fail"; + readonly stop?: "ok" | "fail"; + readonly start?: "ok" | "fail" | "port"; + readonly format?: "text" | "json"; + readonly legacyOutput?: boolean; + readonly missingTarget?: boolean; + readonly richStatus?: boolean; +}) => { + const root = mkdtempSync(join(tmpdir(), "supabase-experimental-stack-restart-")); + const projectRoot = join(root, "selected-project"); + mkdirSync(join(projectRoot, "supabase"), { recursive: true }); + writeFileSync( + join(projectRoot, "supabase", "config.toml"), + options.config === "invalid" ? 'project_id = "unterminated\n' : 'project_id = "restart-test"\n', + ); + const calls: string[] = []; + let lifecycle: StackStatus["lifecycle"] = "running"; + const stack: EffectStack = { + id: stackId, + status: () => Effect.succeed(status(lifecycle, options.richStatus)), + credentials: () => Effect.die("credentials unused"), + prepare: () => { + calls.push("prepare"); + return options.prepare === "fail" + ? Effect.fail(new StackPreparationError({ message: "prepare failed" })) + : Effect.succeed({ capabilities: [] }); + }, + stop: () => { + calls.push("stop"); + return options.stop === "fail" + ? Effect.fail(new StackLifecycleConflictError({ message: "stop failed" })) + : Effect.sync(() => { + lifecycle = "stopped"; + }); + }, + start: () => { + calls.push("start"); + return options.start === "fail" + ? Effect.fail(new StackRuntimeError({ message: "start failed" })) + : options.start === "port" + ? Effect.fail(new PortUnavailableError({ message: "port 54321 is unavailable" })) + : Effect.sync(() => { + lifecycle = "running"; + return status(lifecycle, options.richStatus); + }); + }, + destroy: () => { + calls.push("destroy"); + return Effect.die("destroy must not run"); + }, + logs: () => Effect.die("logs unused"), + followLogs: () => Stream.empty, + }; + const descriptor = { + id: stackId, + projectRoot, + name: "feature-a", + branchContext: "restart-test", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }; + const out = mockOutput({ format: options.format }); + const api = Layer.succeed(LegacyExperimentalStackApi, { + createStack: () => Effect.die("create must not run"), + listStacks: () => Effect.succeed([]), + findStack: () => + Effect.succeed(options.missingTarget === true ? Option.none() : Option.some(descriptor)), + openStack: () => { + calls.push("open"); + return Effect.succeed(stack); + }, + inspectStack: () => Effect.succeed({ descriptor, owner: "running" as const }), + }); + const layer = Layer.mergeAll( + out.layer, + api, + mockLegacyCliSettings({ workdir: root }), + ...(options.legacyOutput ? [Layer.succeed(LegacyOutputFlag, Option.some("json"))] : []), + BunServices.layer, + ); + const selectedFlags = + options.target === "id" + ? flags(Option.none(), Option.some(stackId)) + : options.target === "name" + ? flags(Option.some("feature-a")) + : flags(); + return { + calls, + stack, + out, + cleanup: () => rmSync(root, { recursive: true, force: true }), + layer, + effect: legacyExperimentalStackRestart(selectedFlags).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ), + }; +}; + +describe("experimental stack restart", () => { + it.live("parses --stack-id through the command", () => { + let parsed: Option.Option | undefined; + const command = legacyExperimentalStackRestartCommand.pipe( + Command.withHandler((flags) => + Effect.sync(() => { + parsed = flags.stackId; + }), + ), + ); + return Effect.gen(function* () { + yield* Command.runWith(command, { version: "0.0.0-test" })(["--stack-id", "a".repeat(64)]); + expect(parsed).toEqual(Option.some("a".repeat(64))); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), + ); + }); + + it("registers restart once in the actual parent command", () => { + const commands = legacyExperimentalStackCommand.subcommands.flatMap(({ commands }) => commands); + expect(commands.filter((command) => command.name === "restart")).toHaveLength(1); + }); + + it.effect("prepares, stops, and starts the same stack in order", () => { + const fixture = makeFixture({ target: "id", format: "json" }); + return fixture.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(fixture.calls).toEqual(["open", "prepare", "stop", "start"]); + expect( + fixture.out.messages.find((message) => message.data !== undefined)?.data, + ).toMatchObject({ + id: stackId, + lifecycle: "running", + }); + }), + ), + ); + }); + + it.effect("renders a concise text result after restart", () => { + const fixture = makeFixture({ format: "text", richStatus: true }); + return fixture.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(fixture.out.stdoutText).toContain(`Stack ${stackId}`); + expect(fixture.out.stdoutText).toContain("Lifecycle: running"); + expect(fixture.out.stdoutText).toContain("http://127.0.0.1:54321"); + expect(fixture.out.stdoutText).toContain("Dormant capabilities: functions"); + }), + ), + ); + }); + + it.effect("uses named target selection and never creates a stack", () => { + const fixture = makeFixture({ target: "name" }); + return fixture.effect.pipe( + Effect.tap(() => + Effect.sync(() => expect(fixture.calls).toEqual(["open", "prepare", "stop", "start"])), + ), + ); + }); + + it.effect("does not stop when config or preparation fails", () => { + const invalid = makeFixture({ config: "invalid" }); + const preparation = makeFixture({ prepare: "fail" }); + return Effect.gen(function* () { + expect(Exit.isFailure(yield* invalid.effect.pipe(Effect.exit))).toBe(true); + expect(Exit.isFailure(yield* preparation.effect.pipe(Effect.exit))).toBe(true); + expect(invalid.calls).toEqual([]); + expect(preparation.calls).toEqual(["open", "prepare"]); + }); + }); + + it.effect( + "does not start after stop failure and retains stopped ownership after start failure", + () => { + const stop = makeFixture({ stop: "fail" }); + const start = makeFixture({ start: "fail" }); + return Effect.gen(function* () { + expect(Exit.isFailure(yield* stop.effect.pipe(Effect.exit))).toBe(true); + const failure = yield* start.effect.pipe(Effect.flip); + expect(failure.reason).toBe("lifecycle"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.invalidConfig); + expect(failure.suggestion).toContain("experimental stack status"); + expect(stop.calls).toEqual(["open", "prepare", "stop"]); + expect(start.calls).toEqual(["open", "prepare", "stop", "start"]); + expect(start.calls).not.toContain("destroy"); + expect(yield* start.stack.status()).toMatchObject({ id: stackId, lifecycle: "stopped" }); + }); + }, + ); + + it.effect("classifies an unavailable start port as actionable configuration", () => { + const fixture = makeFixture({ start: "port" }); + return Effect.gen(function* () { + const failure = yield* fixture.effect.pipe(Effect.flip); + expect(failure.reason).toBe("port"); + expect(failure.suggestion).toContain("port"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.invalidConfig); + expect(fixture.calls).toEqual(["open", "prepare", "stop", "start"]); + }); + }); + + it.effect("rejects invalid flags and legacy output before lifecycle calls", () => { + const invalid = makeFixture({}); + const legacy = makeFixture({ legacyOutput: true }); + const invalidEffect = legacyExperimentalStackRestart( + flags(Option.some("name"), Option.some(stackId)), + ).pipe(Effect.provide(invalid.layer), Effect.exit); + return Effect.gen(function* () { + const legacyError = yield* legacy.effect.pipe(Effect.flip); + expect(legacyError.suggestion).toContain("--output-format"); + expect(Exit.isFailure(yield* invalidEffect)).toBe(true); + expect(legacy.calls).toEqual([]); + expect(invalid.calls).toEqual([]); + }).pipe(Effect.ensuring(Effect.sync(invalid.cleanup))); + }); + + it.effect("rejects malformed stack ids before inspection or lifecycle calls", () => { + const fixture = makeFixture({}); + const malformed = legacyExperimentalStackRestart( + flags(Option.none(), Option.some("not-a-stack-id")), + ).pipe(Effect.provide(fixture.layer)); + return malformed.pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.reason).toBe("flags"); + expect(error[ErrorActionabilityId]).toEqual(actionability.provideFlags); + expect(fixture.calls).toEqual([]); + }), + ), + Effect.ensuring(Effect.sync(fixture.cleanup)), + ); + }); + + it.effect("fails a missing named target without lifecycle calls", () => { + const fixture = makeFixture({ target: "name", missingTarget: true }); + return fixture.effect.pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.message).toContain('No managed stack named "feature-a"'); + expect(error.suggestion).toContain("existing --stack name"); + expect(fixture.calls).toEqual([]); + }), + ), + ); + }); +}); diff --git a/apps/cli/src/commands/experimental/stack/stack.command.ts b/apps/cli/src/commands/experimental/stack/stack.command.ts index 99688392b2..ce6370529f 100644 --- a/apps/cli/src/commands/experimental/stack/stack.command.ts +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -8,6 +8,7 @@ import { legacyExperimentalStackStatusCommand } from "./status/status.command.ts import { legacyExperimentalStackListCommand } from "./list/list.command.ts"; import { legacyExperimentalStackLogsCommand } from "./logs/logs.command.ts"; import { legacyExperimentalStackPrepareCommand } from "./prepare/prepare.command.ts"; +import { legacyExperimentalStackRestartCommand } from "./restart/restart.command.ts"; import { legacyExperimentalStackApiLayer, legacyExperimentalStackTargetResolverLayer, @@ -23,6 +24,7 @@ export const legacyExperimentalStackCommand = Command.make("stack").pipe( legacyExperimentalStackListCommand, legacyExperimentalStackLogsCommand, legacyExperimentalStackPrepareCommand, + legacyExperimentalStackRestartCommand, ]), Command.provide(legacyExperimentalStackTargetResolverLayer), Command.provide(legacyExperimentalStackApiLayer), diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index 3d42fefb7f..604486b41a 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -9,6 +9,7 @@ import { type StackRuntimePreference, } from "@supabase/stack/effect"; import type { StackId } from "@supabase/stack"; +import type { StackStatus } from "@supabase/stack/effect"; import { StackNotFoundError } from "@supabase/stack/effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { @@ -122,6 +123,35 @@ export const legacyExperimentalStackApiLayer = Layer.effect( }), ); +export const legacyStackStatusPayload = (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 legacyRenderStackStatus = (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`; +}; + /** Runtime configuration for the first stack command. Later commands reuse this layer. */ export const legacyExperimentalStackTargetResolverLayer = Layer.succeed( LegacyExperimentalStackTargetResolver, 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 008f81c047..83d8988090 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -1,15 +1,13 @@ import { Effect, Match, Option } from "effect"; -import { - isStackError, - type StackStatus, - type StackRuntimePreference, -} from "@supabase/stack/effect"; +import { isStackError, type StackRuntimePreference } 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, LegacyExperimentalStackTargetResolver, + legacyRenderStackStatus, + legacyStackStatusPayload, } from "../stack.shared.ts"; import { legacyLoadStackConfig } from "../stack-config.ts"; import type { LegacyExperimentalStackStartFlags } from "./start.command.ts"; @@ -18,36 +16,6 @@ import { LegacyExperimentalStackTargetFlagsError, } from "./start.errors.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" }, >( @@ -155,9 +123,9 @@ export const legacyExperimentalStackStart = Effect.fn("legacy.experimental.stack Effect.mapError(legacyStackStartError), ); if (output.format === "text") { - yield* output.raw(renderStatus(status)); + yield* output.raw(legacyRenderStackStatus(status)); } else { - yield* output.success("", statusPayload(status)); + yield* output.success("", legacyStackStatusPayload(status)); } return status; });