diff --git a/apps/cli/src/command-internal/legacy-db-target-flags.ts b/apps/cli/src/command-internal/legacy-db-target-flags.ts index dbeafdc527..84d237b3f0 100644 --- a/apps/cli/src/command-internal/legacy-db-target-flags.ts +++ b/apps/cli/src/command-internal/legacy-db-target-flags.ts @@ -186,6 +186,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "stack", "stack-id", "preparation", + "capability", ]); /** diff --git a/apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md new file mode 100644 index 0000000000..d9e2274142 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md @@ -0,0 +1,19 @@ +# `experimental stack prepare` + +## Reads + +- Reads `supabase/config.toml` from the selected project root. +- Reads the selected stack descriptor and state when `--stack-id` addresses an existing stack. +- Reads or downloads the artifact inputs for the selected capabilities. + +## Writes + +- Creates a durable stack descriptor and state when a named or current project stack is created. +- Writes prepared runtime artifacts to the stack artifact cache. +- Does not start, stop, destroy, or otherwise activate the stack. + +## Network and subprocesses + +- May access the container registry when preparing a Docker runtime. +- May download native runtime artifacts. +- May invoke the configured runtime tooling through the stack package. diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts new file mode 100644 index 0000000000..a93150a12d --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts @@ -0,0 +1,45 @@ +import { Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { CAPABILITY_NAMES } from "@supabase/stack/effect"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyExperimentalStackPrepare } from "./prepare.handler.ts"; + +const config = { + stack: Flag.string("stack").pipe(Flag.withDescription("Name this stack."), Flag.optional), + stackId: Flag.string("stack-id").pipe( + Flag.withDescription("Open an existing stack by id."), + Flag.optional, + ), + runtime: Flag.choice("runtime", ["auto", "docker", "native"] as const).pipe( + Flag.withDescription("Runtime to use for a new stack."), + Flag.withDefault("auto" as const), + ), + capability: Flag.atMost( + Flag.choice("capability", CAPABILITY_NAMES), + CAPABILITY_NAMES.length, + ).pipe(Flag.withDescription("Capability to prepare (repeatable).")), +} as const; + +export type LegacyExperimentalStackPrepareFlags = CliCommand.Command.Config.Infer; + +export const legacyExperimentalStackPrepareCommand = Command.make("prepare", config).pipe( + Command.withDescription("Prepare artifacts for a managed local Supabase stack."), + Command.withShortDescription("Prepare a managed local stack"), + Command.withExamples([ + { + command: "supabase experimental stack prepare", + description: "Prepare all enabled stack capabilities", + }, + { + command: "supabase experimental stack prepare --stack feature-a --capability rest", + description: "Prepare one capability in a named stack", + }, + ]), + Command.withHandler((flags) => + legacyExperimentalStackPrepare(flags).pipe( + withLegacyCommandInstrumentation({ flags, config }), + withJsonErrorHandling, + ), + ), +); diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts new file mode 100644 index 0000000000..a89479c74e --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts @@ -0,0 +1,101 @@ +import { Data, Match } from "effect"; +import { isStackError } from "@supabase/stack/effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + +export class LegacyExperimentalStackPrepareError extends Data.TaggedError( + "LegacyExperimentalStackPrepareError", +)<{ + readonly reason: + | "invalid-config" + | "flags" + | "runtime" + | "registry" + | "artifact" + | "lifecycle" + | "unknown"; + readonly message: string; + readonly detail?: string; + readonly suggestion?: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "invalid-config": + return actionability.invalidConfig; + case "flags": + return actionability.provideFlags; + case "runtime": + return actionability.dockerNotRunning; + case "registry": + case "artifact": + return actionability.externalNetwork; + case "lifecycle": + return actionability.invalidConfig; + case "unknown": + return actionability.unknown; + } + } +} + +export const legacyStackPrepareError = (error: unknown) => { + const stackError = isStackError(error) ? error : undefined; + const message = stackError === undefined ? String(error) : stackError.message; + const classification = + stackError === undefined + ? { reason: "unknown" as const } + : Match.value(stackError).pipe( + 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("StackPreparationError", "ArtifactIntegrityError", () => ({ + reason: "artifact" as const, + suggestion: + "Retry the stack preparation with --debug if the artifact cannot be prepared.", + })), + Match.tag( + "InvalidStackConfigError", + "StackVersionUnsupportedError", + "InvalidProjectRootError", + "StackSecretMismatchError", + "InvalidJwtSigningMaterialError", + () => ({ reason: "invalid-config" as const }), + ), + Match.tag("InvalidStackIdentityError", () => ({ reason: "flags" as const })), + Match.tag("StackStateInvalidError", "StackStateFormatUnsupportedError", () => ({ + reason: "invalid-config" as const, + })), + Match.tag("StackNotFoundError", "StackRuntimeMismatchError", () => ({ + reason: "flags" as const, + })), + Match.tag( + "StackOwnershipConflictError", + "StackNotRunningError", + "StackMustBeStoppedError", + "StackLifecycleConflictError", + "StackUpgradeRequiredError", + "StackRuntimeError", + "StackCleanupError", + () => ({ + reason: "lifecycle" as const, + suggestion: "Resolve the existing stack state before preparing it again.", + }), + ), + Match.orElse(() => ({ reason: "unknown" as const })), + ); + return new LegacyExperimentalStackPrepareError({ + ...classification, + message, + ...("suggestion" in classification ? { suggestion: classification.suggestion } : {}), + cause: error, + }); +}; diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts new file mode 100644 index 0000000000..55b9d69339 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts @@ -0,0 +1,109 @@ +import { Effect, Option } from "effect"; +import type { PrepareStackResult, 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, +} from "../stack.shared.ts"; +import { legacyLoadStackConfig } from "../stack-config.ts"; +import type { LegacyExperimentalStackPrepareFlags } from "./prepare.command.ts"; +import { LegacyExperimentalStackPrepareError, legacyStackPrepareError } from "./prepare.errors.ts"; + +const resultPayload = (id: string, result: PrepareStackResult) => ({ + id, + capabilities: result.capabilities, +}); + +const renderResult = (id: string, result: PrepareStackResult): string => { + const lines = [`Stack ${id} prepared.`]; + if (result.capabilities.length === 0) return `${lines[0]}\n`; + lines.push("Capabilities:"); + for (const capability of result.capabilities) + lines.push(` ${capability.capability} ${capability.version} (${capability.outcome})`); + return `${lines.join("\n")}\n`; +}; + +export const legacyValidateExperimentalStackPrepareTarget = ( + flags: Pick, +) => + Option.isSome(flags.stack) && Option.isSome(flags.stackId) + ? Effect.fail( + new LegacyExperimentalStackPrepareError({ + reason: "flags", + message: "--stack and --stack-id cannot be used together", + }), + ) + : Effect.void; + +export const legacyExperimentalStackPrepare = Effect.fn("legacy.experimental.stack.prepare")( + function* (flags: LegacyExperimentalStackPrepareFlags) { + const output = yield* Output; + const settings = yield* LegacyCliSettings; + const resolver = yield* LegacyExperimentalStackTargetResolver; + const stackApi = yield* LegacyExperimentalStackApi; + const legacyOutput = yield* Effect.serviceOption(LegacyOutputFlag); + if (Option.isSome(legacyOutput) && Option.isSome(legacyOutput.value)) + return yield* new LegacyExperimentalStackPrepareError({ + 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* legacyValidateExperimentalStackPrepareTarget(flags); + + const target = yield* resolver.resolve({ + projectRoot: settings.workdir, + ...(Option.isSome(flags.stack) ? { name: flags.stack.value } : {}), + ...(Option.isSome(flags.stackId) ? { id: flags.stackId.value } : {}), + runtime: flags.runtime, + }); + const config = yield* legacyLoadStackConfig(target.projectRoot).pipe( + Effect.mapError( + (error) => + new LegacyExperimentalStackPrepareError({ + reason: "invalid-config", + message: error.message, + cause: error, + }), + ), + ); + const disabledCapability = flags.capability.find((capability) => { + const selected = config.capabilities?.[capability]; + return selected !== undefined && "enabled" in selected && selected.enabled === false; + }); + if (disabledCapability !== undefined) + return yield* new LegacyExperimentalStackPrepareError({ + reason: "invalid-config", + message: `Capability ${disabledCapability} is disabled in config.toml.`, + suggestion: `Enable ${disabledCapability} in config.toml or drop --capability ${disabledCapability}.`, + }); + const runtime: StackRuntimePreference | undefined = target.runtime; + const stack = + target.id !== undefined + ? yield* stackApi.openStack(target.id).pipe(Effect.mapError(legacyStackPrepareError)) + : yield* stackApi + .createStack({ + projectRoot: target.projectRoot, + ...(target.name === undefined ? {} : { name: target.name }), + ...(runtime === undefined ? {} : { runtime }), + }) + .pipe(Effect.mapError(legacyStackPrepareError)); + + const task = yield* output.task("Preparing local Supabase stack..."); + const result = yield* stack + .prepare({ + config, + ...(flags.capability.length === 0 ? {} : { capabilities: flags.capability }), + }) + .pipe( + Effect.tapError((error) => task.fail(error.message)), + Effect.tap(() => task.clear()), + Effect.mapError(legacyStackPrepareError), + ); + const payload = resultPayload(stack.id, result); + if (output.format === "text") yield* output.raw(renderResult(stack.id, result)); + else yield* output.success("", payload); + return result; + }, +); diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts new file mode 100644 index 0000000000..79691e1ff7 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts @@ -0,0 +1,366 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary +import { mkdtempSync, mkdirSync, 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 { Deferred, Effect, Fiber, Layer, Option, Stream } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { StackIdSchema, StackPreparationError, type EffectStack } from "@supabase/stack/effect"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockLegacyCliSettings } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { + LegacyExperimentalStackApi, + LegacyExperimentalStackTargetResolver, +} from "../stack.shared.ts"; +import { + legacyExperimentalStackPrepare, + legacyValidateExperimentalStackPrepareTarget, +} from "./prepare.handler.ts"; +import { legacyExperimentalStackPrepareCommand } from "./prepare.command.ts"; +import { LegacyExperimentalStackPrepareError } from "./prepare.errors.ts"; +import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + actionability, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + +const project = (): string => { + const root = mkdtempSync(join(tmpdir(), "supabase-experimental-stack-prepare-")); + mkdirSync(join(root, "supabase"), { recursive: true }); + writeFileSync(join(root, "supabase", "config.toml"), 'project_id = "prepare-test"\n'); + return root; +}; + +const flags = (overrides: Partial[0]> = {}) => ({ + stack: Option.none(), + stackId: Option.none(), + runtime: "auto" as const, + capability: [], + ...overrides, +}); + +function fakeStack( + id: string, + prepare: (options: unknown) => Effect.Effect< + { + capabilities: ReadonlyArray<{ + capability: "database" | "rest"; + version: string; + outcome: "cached" | "downloaded" | "pulled"; + }>; + }, + StackPreparationError + >, +) { + return { + id: StackIdSchema.make(id), + status: () => Effect.die("status not used in prepare test"), + credentials: () => Effect.die("credentials not used in prepare test"), + prepare, + start: () => Effect.die("start not used in prepare test"), + stop: () => Effect.die("stop not used in prepare test"), + destroy: () => Effect.die("destroy not used in prepare test"), + logs: () => Effect.die("logs not used in prepare test"), + followLogs: () => Stream.empty, + } satisfies EffectStack; +} + +function setup(opts: { + root: string; + target: { projectRoot: string; name?: string; id?: string; runtime?: { kind: "native" } }; + stack: EffectStack; + onCreate?: (options: unknown) => void; + onOpen?: () => void; +}) { + const out = mockOutput(); + const { id, ...targetWithoutId } = opts.target; + const targetLayer = Layer.succeed(LegacyExperimentalStackTargetResolver, { + resolve: () => + Effect.succeed({ + ...targetWithoutId, + ...(id === undefined ? {} : { id: StackIdSchema.make(id) }), + }), + }); + const apiLayer = Layer.succeed(LegacyExperimentalStackApi, { + createStack: (options) => { + opts.onCreate?.(options); + return Effect.succeed(opts.stack); + }, + findStack: () => Effect.die("find not used in prepare test"), + listStacks: () => Effect.succeed([]), + openStack: () => { + opts.onOpen?.(); + return Effect.succeed(opts.stack); + }, + inspectStack: () => Effect.die("inspect not used in prepare test"), + }); + return { + out, + layer: Layer.mergeAll( + out.layer, + mockLegacyCliSettings({ workdir: opts.root }), + targetLayer, + apiLayer, + BunServices.layer, + ), + }; +} + +describe("experimental stack prepare", () => { + it.live("parses repeated capability choices through the command", () => { + let parsed: ReadonlyArray | undefined; + const configured = legacyExperimentalStackPrepareCommand.pipe( + Command.withHandler((flags) => + Effect.sync(() => { + parsed = flags.capability; + }), + ), + ); + return Effect.gen(function* () { + yield* Command.runWith(configured, { version: "0.0.0-test" })([ + "--capability", + "rest", + "--capability", + "auth", + ]); + expect(parsed).toEqual(["rest", "auth"]); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), + ); + }); + + it.effect("rejects mutually exclusive stack targets", () => + legacyValidateExperimentalStackPrepareTarget({ + stack: Option.some("feature-a"), + stackId: Option.some("a".repeat(64)), + }).pipe( + Effect.flip, + Effect.tap((failure) => + Effect.sync(() => expect(failure.message).toContain("cannot be used together")), + ), + ), + ); + + it.effect("rejects a disabled capability before package preparation", () => { + const root = project(); + writeFileSync( + join(root, "supabase", "config.toml"), + 'project_id = "prepare-test"\n[studio]\nenabled = false\n', + ); + const stack = fakeStack("f".repeat(64), () => Effect.die("prepare must not run")); + const setupResult = setup({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackPrepare(flags({ capability: ["studio"] })).pipe( + Effect.flip, + ); + expect(failure[ErrorActionabilityId]).toEqual(actionability.invalidConfig); + if (failure instanceof LegacyExperimentalStackPrepareError) + expect(failure.suggestion).toContain("Enable studio"); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("rejects the legacy output flag before resolving the target", () => { + const root = project(); + let resolved = false; + const setupResult = setup({ + root, + target: { projectRoot: root }, + stack: fakeStack("1".repeat(64), () => Effect.die("prepare must not run")), + }); + const target = Layer.succeed(LegacyExperimentalStackTargetResolver, { + resolve: () => + Effect.sync(() => { + resolved = true; + return { projectRoot: root }; + }), + }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackPrepare(flags()).pipe(Effect.flip); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + expect(resolved).toBe(false); + }).pipe( + Effect.provide( + Layer.mergeAll( + setupResult.layer, + target, + Layer.succeed(LegacyOutputFlag, Option.some("json")), + ), + ), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect( + "creates a stack handle and prepares selected capabilities from the project config", + () => { + const root = project(); + let createOptions: unknown; + let prepareOptions: unknown; + let stopped = false; + let destroyed = false; + const stack = fakeStack("a".repeat(64), (options) => { + prepareOptions = options; + return Effect.succeed({ + capabilities: [{ capability: "database", version: "16", outcome: "downloaded" }], + }); + }); + const setupResult = setup({ + root, + target: { projectRoot: root, name: "feature-a", runtime: { kind: "native" } }, + stack: { + ...stack, + stop: () => + Effect.sync(() => { + stopped = true; + }), + destroy: () => + Effect.sync(() => { + destroyed = true; + }), + }, + onCreate: (options) => { + createOptions = options; + }, + }); + return Effect.gen(function* () { + yield* legacyExperimentalStackPrepare( + flags({ + stack: Option.some("feature-a"), + runtime: "native", + capability: ["database"], + }), + ); + expect(createOptions).toEqual({ + projectRoot: root, + name: "feature-a", + runtime: { kind: "native" }, + }); + expect(prepareOptions).toMatchObject({ capabilities: ["database"] }); + expect(stopped).toBe(false); + expect(destroyed).toBe(false); + expect(setupResult.out.stdoutText).toContain("prepared"); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }, + ); + + it.effect("emits the selected stack and capabilities in JSON mode", () => { + const root = project(); + const stack = fakeStack("e".repeat(64), () => + Effect.succeed({ + capabilities: [{ capability: "database", version: "16", outcome: "cached" }], + }), + ); + const setupResult = setup({ root, target: { projectRoot: root }, stack }); + const output = mockOutput({ format: "json" }); + return Effect.gen(function* () { + yield* legacyExperimentalStackPrepare(flags()); + expect(output.messages.find((message) => message.type === "success")?.data).toEqual({ + id: "e".repeat(64), + capabilities: [{ capability: "database", version: "16", outcome: "cached" }], + }); + }).pipe( + Effect.provide(Layer.mergeAll(setupResult.layer, output.layer)), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect( + "opens an explicit stack id and omits capabilities for the package default selection", + () => { + const settingsRoot = project(); + const targetRoot = project(); + let opened = false; + let prepareOptions: unknown; + const stack = fakeStack("b".repeat(64), (options) => { + prepareOptions = options; + return Effect.succeed({ capabilities: [] }); + }); + const setupResult = setup({ + root: settingsRoot, + target: { projectRoot: targetRoot, id: "b".repeat(64) }, + stack, + onOpen: () => { + opened = true; + }, + }); + return Effect.gen(function* () { + yield* legacyExperimentalStackPrepare(flags({ stackId: Option.some("b".repeat(64)) })); + expect(opened).toBe(true); + expect(prepareOptions).toMatchObject({ config: expect.anything() }); + expect(prepareOptions).not.toHaveProperty("capabilities"); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring( + Effect.sync(() => { + rmSync(settingsRoot, { recursive: true, force: true }); + rmSync(targetRoot, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect("maps preparation failures without lifecycle cleanup", () => { + const root = project(); + const stack = fakeStack("c".repeat(64), () => + Effect.fail(new StackPreparationError({ message: "artifact failed" })), + ); + const setupResult = setup({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackPrepare(flags()).pipe(Effect.flip); + expect(failure).toBeInstanceOf(LegacyExperimentalStackPrepareError); + if (failure instanceof LegacyExperimentalStackPrepareError) { + expect(failure.reason).toBe("artifact"); + expect(failure.message).toBe("artifact failed"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.externalNetwork); + } + expect(setupResult.out.messages.filter((message) => message.type === "success")).toHaveLength( + 0, + ); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("interrupts caller-owned preparation without lifecycle cleanup", () => + Effect.gen(function* () { + const root = project(); + const started = yield* Deferred.make(); + let stopped = false; + let destroyed = false; + const stack = fakeStack("2".repeat(64), () => + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined); + return yield* Effect.never; + }), + ); + const setupResult = setup({ + root, + target: { projectRoot: root }, + stack: { + ...stack, + stop: () => Effect.sync(() => (stopped = true)), + destroy: () => Effect.sync(() => (destroyed = true)), + }, + }); + const fiber = yield* Effect.forkChild( + legacyExperimentalStackPrepare(flags()).pipe(Effect.provide(setupResult.layer)), + ); + yield* Deferred.await(started); + yield* Fiber.interrupt(fiber); + expect(stopped).toBe(false); + expect(destroyed).toBe(false); + rmSync(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 e57be40dcd..99688392b2 100644 --- a/apps/cli/src/commands/experimental/stack/stack.command.ts +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -7,6 +7,7 @@ import { legacyExperimentalStackStopCommand } from "./stop/stop.command.ts"; 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 { legacyExperimentalStackApiLayer, legacyExperimentalStackTargetResolverLayer, @@ -21,6 +22,7 @@ export const legacyExperimentalStackCommand = Command.make("stack").pipe( legacyExperimentalStackStatusCommand, legacyExperimentalStackListCommand, legacyExperimentalStackLogsCommand, + legacyExperimentalStackPrepareCommand, ]), Command.provide(legacyExperimentalStackTargetResolverLayer), Command.provide(legacyExperimentalStackApiLayer),