From 0e17152b7d048388253457668f203e036f0b62cc Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:08:56 +0200 Subject: [PATCH 1/6] test(stack): stabilize ingress listener fixtures --- .../supervisor/ingress.integration.test.ts | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/stack/src/supervisor/ingress.integration.test.ts b/packages/stack/src/supervisor/ingress.integration.test.ts index b5a3d2d6ac..c62043417c 100644 --- a/packages/stack/src/supervisor/ingress.integration.test.ts +++ b/packages/stack/src/supervisor/ingress.integration.test.ts @@ -195,6 +195,14 @@ describe("Supervisor ingress", () => { checkoutId: root, }; const stackId = yield* deriveStackId(stackIdentity); + const apiListener = yield* bindHostListener("127.0.0.1", 0, "api"); + if (apiListener.binding.kind !== "http") + return yield* Effect.die("API listener is not HTTP"); + const apiAddress = apiListener.binding.server.address(); + if (typeof apiAddress !== "object" || apiAddress === null) + return yield* Effect.die("API listener did not expose an address"); + const apiPort = apiAddress.port; + const internalApiListener = yield* bindHostListener("::1", apiPort, "api"); const compiled = yield* compileStack({ projectRoot: root, runtime: { kind: "native" }, @@ -227,7 +235,7 @@ describe("Supervisor ingress", () => { runtime: { kind: "native" }, desiredLifecycle: "running", definition: compiled.definition, - ports: [{ field: "api", port: 55432, intent: "automatic" }], + ports: [{ field: "api", port: apiPort, intent: "automatic" }], privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ ...binding, port: 30000 + index, @@ -236,14 +244,15 @@ describe("Supervisor ingress", () => { }); const listenerCloseCount = yield* Ref.make(0); const bindHost = (address: string, port: number, field: HostListener["field"]) => - bindHostListener(address, port, field).pipe( - Effect.map((listener) => ({ - ...listener, - close: listener.close.pipe( - Effect.andThen(Ref.update(listenerCloseCount, (count) => count + 1)), - ), - })), - ); + field !== "api" + ? bindHostListener(address, port, field) + : Effect.succeed({ + ...(address === "::1" ? internalApiListener : apiListener), + port: apiPort, + close: (address === "::1" ? internalApiListener : apiListener).close.pipe( + Effect.andThen(Ref.update(listenerCloseCount, (count) => count + 1)), + ), + }); const ingress = yield* makeSupervisorIngress({ stackId, stateRoot: root, From 4fe5c8b1375ee7ab878b610440dad5c3efc46cae Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:10:36 +0200 Subject: [PATCH 2/6] test(stack): stabilize ingress listener fixtures --- .../supervisor/ingress.integration.test.ts | 27 +++++++------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/packages/stack/src/supervisor/ingress.integration.test.ts b/packages/stack/src/supervisor/ingress.integration.test.ts index c62043417c..b5a3d2d6ac 100644 --- a/packages/stack/src/supervisor/ingress.integration.test.ts +++ b/packages/stack/src/supervisor/ingress.integration.test.ts @@ -195,14 +195,6 @@ describe("Supervisor ingress", () => { checkoutId: root, }; const stackId = yield* deriveStackId(stackIdentity); - const apiListener = yield* bindHostListener("127.0.0.1", 0, "api"); - if (apiListener.binding.kind !== "http") - return yield* Effect.die("API listener is not HTTP"); - const apiAddress = apiListener.binding.server.address(); - if (typeof apiAddress !== "object" || apiAddress === null) - return yield* Effect.die("API listener did not expose an address"); - const apiPort = apiAddress.port; - const internalApiListener = yield* bindHostListener("::1", apiPort, "api"); const compiled = yield* compileStack({ projectRoot: root, runtime: { kind: "native" }, @@ -235,7 +227,7 @@ describe("Supervisor ingress", () => { runtime: { kind: "native" }, desiredLifecycle: "running", definition: compiled.definition, - ports: [{ field: "api", port: apiPort, intent: "automatic" }], + ports: [{ field: "api", port: 55432, intent: "automatic" }], privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ ...binding, port: 30000 + index, @@ -244,15 +236,14 @@ describe("Supervisor ingress", () => { }); const listenerCloseCount = yield* Ref.make(0); const bindHost = (address: string, port: number, field: HostListener["field"]) => - field !== "api" - ? bindHostListener(address, port, field) - : Effect.succeed({ - ...(address === "::1" ? internalApiListener : apiListener), - port: apiPort, - close: (address === "::1" ? internalApiListener : apiListener).close.pipe( - Effect.andThen(Ref.update(listenerCloseCount, (count) => count + 1)), - ), - }); + bindHostListener(address, port, field).pipe( + Effect.map((listener) => ({ + ...listener, + close: listener.close.pipe( + Effect.andThen(Ref.update(listenerCloseCount, (count) => count + 1)), + ), + })), + ); const ingress = yield* makeSupervisorIngress({ stackId, stateRoot: root, From e659dbc59cac9d8bc8368707c428502b27101f6d Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:04:03 +0200 Subject: [PATCH 3/6] feat(cli): add experimental stack prepare command --- .../legacy-db-target-flags.ts | 1 + .../stack/prepare/SIDE_EFFECTS.md | 19 ++ .../stack/prepare/prepare.command.ts | 45 ++++ .../stack/prepare/prepare.errors.ts | 98 +++++++ .../stack/prepare/prepare.handler.ts | 99 +++++++ .../stack/prepare/prepare.integration.test.ts | 251 ++++++++++++++++++ .../experimental/stack/stack.command.ts | 2 + 7 files changed, 515 insertions(+) create mode 100644 apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md create mode 100644 apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts create mode 100644 apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts create mode 100644 apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts create mode 100644 apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts 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..d4dd3e3447 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts @@ -0,0 +1,98 @@ +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", + "InvalidStackIdentityError", + "InvalidProjectRootError", + "StackSecretMismatchError", + "InvalidJwtSigningMaterialError", + () => ({ 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..f87730ba86 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts @@ -0,0 +1,99 @@ +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 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..4944a4252e --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts @@ -0,0 +1,251 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +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 { 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"; + +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); + }, + 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( + "creates a durable stack 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( + "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(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 }))), + ); + }); +}); 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), From a56c039bf01673b563c0f3212d3c7dd94248ab5e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:14:07 +0200 Subject: [PATCH 4/6] fix(cli): classify experimental stack preparation failures --- .../stack/prepare/prepare.errors.ts | 5 +- .../stack/prepare/prepare.handler.ts | 10 ++ .../stack/prepare/prepare.integration.test.ts | 116 +++++++++++++++++- 3 files changed, 128 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts index d4dd3e3447..a89479c74e 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts @@ -65,12 +65,15 @@ export const legacyStackPrepareError = (error: unknown) => { Match.tag( "InvalidStackConfigError", "StackVersionUnsupportedError", - "InvalidStackIdentityError", "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, })), diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts index f87730ba86..55b9d69339 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts @@ -68,6 +68,16 @@ export const legacyExperimentalStackPrepare = Effect.fn("legacy.experimental.sta }), ), ); + 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 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 index 4944a4252e..96d6e776f6 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; 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 { 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"; @@ -19,6 +19,11 @@ import { 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-")); @@ -135,8 +140,60 @@ describe("experimental stack prepare", () => { ), ); + 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 durable stack and prepares selected capabilities from the project config", + "creates a stack handle and prepares selected capabilities from the project config", () => { const root = project(); let createOptions: unknown; @@ -191,6 +248,27 @@ describe("experimental stack prepare", () => { }, ); + 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", () => { @@ -239,6 +317,7 @@ describe("experimental stack prepare", () => { 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, @@ -248,4 +327,37 @@ describe("experimental stack prepare", () => { 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); + yield* Effect.never; + return { capabilities: [] }; + }), + ); + 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 }); + }), + ); }); From dacb385726af4b49f740c3cf4d9c6baa9397caa2 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:46:58 +0200 Subject: [PATCH 5/6] test(cli): complete prepare stack api mock --- .../experimental/stack/prepare/prepare.integration.test.ts | 2 ++ 1 file changed, 2 insertions(+) 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 index 96d6e776f6..84ae3f4d87 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts @@ -87,6 +87,8 @@ function setup(opts: { 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); From b00c55c4a3307e9f508a95cf4c23756d8fd62118 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:48:54 +0200 Subject: [PATCH 6/6] chore(cli): annotate stack prepare fixtures --- .../experimental/stack/prepare/prepare.integration.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 84ae3f4d87..79691e1ff7 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts @@ -1,5 +1,7 @@ +// 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"; @@ -339,8 +341,7 @@ describe("experimental stack prepare", () => { const stack = fakeStack("2".repeat(64), () => Effect.gen(function* () { yield* Deferred.succeed(started, undefined); - yield* Effect.never; - return { capabilities: [] }; + return yield* Effect.never; }), ); const setupResult = setup({