Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/cli/src/command-internal/legacy-db-target-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([
"stack",
"stack-id",
"preparation",
"capability",
]);

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<typeof config>;

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,
),
),
);
101 changes: 101 additions & 0 deletions apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts
Original file line number Diff line number Diff line change
@@ -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,
});
};
109 changes: 109 additions & 0 deletions apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts
Original file line number Diff line number Diff line change
@@ -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<LegacyExperimentalStackPrepareFlags, "stack" | "stackId">,
) =>
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;
},
);
Loading
Loading