Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c249464
feat(cli): add experimental stack start
jgoux Sep 7, 2026
c370b5a
fix(cli): validate experimental stack config listeners
jgoux Sep 7, 2026
ea97377
fix(cli): omit empty experimental stack listeners
jgoux Sep 7, 2026
2290b64
fix(cli): tighten experimental stack start config
jgoux Sep 8, 2026
02c669c
test(stack): stabilize ingress listener fixtures
jgoux Sep 8, 2026
99873ec
fix(cli): harden experimental stack config paths
jgoux Sep 8, 2026
e44ab9f
test(stack): stabilize ingress listener fixtures
jgoux Sep 8, 2026
9acb080
test(stack): stabilize remaining ingress ports
jgoux Sep 8, 2026
8c29ef7
fix(cli): scope stack config path service
jgoux Sep 8, 2026
cb78d99
fix(cli): clarify stack state recovery guidance
jgoux Sep 8, 2026
ef2ab0a
style(cli): format stack start handler
jgoux Sep 8, 2026
3055b18
test(cli): consume exported stack config errors
jgoux Sep 8, 2026
03ccaa6
chore(cli): extend experimental stack effect lint
jgoux Sep 8, 2026
bf1269f
fix(stack): explain signing key path restrictions
jgoux Sep 8, 2026
99d490c
fix(cli): retain the API listener for analytics
jgoux Sep 8, 2026
6a5fac6
feat(cli): add experimental stack stop
jgoux Sep 7, 2026
d0733f8
docs(cli): document experimental stack stop effects
jgoux Sep 7, 2026
08de239
fix(cli): refine experimental stack stop output
jgoux Sep 7, 2026
a2035be
fix(cli): classify invalid stack stop names
jgoux Sep 7, 2026
4b59856
test(cli): cover experimental stack stop parsing
jgoux Sep 7, 2026
b0a2677
test(cli): cover experimental stack stop errors
jgoux Sep 7, 2026
52f511d
chore(cli): annotate stack stop fixtures
jgoux Sep 8, 2026
63c9f23
test(cli): complete stack stop service mocks
jgoux Sep 8, 2026
c911669
feat(cli): expose stack discovery service
jgoux Sep 8, 2026
865c92f
test(cli): scope stop mock to available stack methods
jgoux Sep 8, 2026
ed30fd3
test(cli): isolate hidden flag parsing from runtime handlers
jgoux Sep 9, 2026
a5f62de
chore(cli): merge develop and update stack command wiring
jgoux Sep 9, 2026
099b60f
fix(cli): refine stack stop validation and error guidance
jgoux Sep 9, 2026
ca720ff
fix(cli): clarify stack stop ownership failures
jgoux Sep 9, 2026
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
3 changes: 2 additions & 1 deletion apps/cli/src/commands/experimental/stack/stack.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { commandSettingsLayer } from "../../../config/command-settings.layer.ts"
import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.ts";
import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts";
import { experimentalStackStartCommand } from "./start/start.command.ts";
import { experimentalStackStopCommand } from "./stop/stop.command.ts";
import { experimentalStackApiLayer, experimentalStackTargetResolverLayer } from "./stack.shared.ts";

export const experimentalStackCommand = Command.make("stack").pipe(
Command.withDescription("Manage an experimental managed local Supabase stack."),
Command.withShortDescription("Manage a managed local stack"),
Command.withSubcommands([experimentalStackStartCommand]),
Command.withSubcommands([experimentalStackStartCommand, experimentalStackStopCommand]),
Command.provide(experimentalStackTargetResolverLayer),
Command.provide(experimentalStackApiLayer),
Command.provide(commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer))),
Expand Down
58 changes: 50 additions & 8 deletions apps/cli/src/commands/experimental/stack/stack.shared.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Context, Data, Effect, FileSystem, Layer, Path, Crypto } from "effect";
import { Context, Data, Effect, FileSystem, Layer, Option, Path, Crypto } from "effect";
import {
createStack,
findStack,
inspectStack,
isStackId,
openStack,
Expand All @@ -26,6 +27,7 @@ interface ExperimentalStackTarget {
export class ExperimentalStackTargetError extends Data.TaggedError("ExperimentalStackTargetError")<{
readonly message: string;
readonly reason: "flags" | "invalid-config";
readonly suggestion?: string;
readonly cause?: unknown;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
Expand Down Expand Up @@ -55,6 +57,12 @@ export class ExperimentalStackTargetResolver extends Context.Service<
export class ExperimentalStackApi extends Context.Service<
ExperimentalStackApi,
{
readonly findStack: (
...args: Parameters<typeof findStack>
) => Effect.Effect<
Effect.Success<ReturnType<typeof findStack>>,
Effect.Error<ReturnType<typeof findStack>>
>;
readonly createStack: (
...args: Parameters<typeof createStack>
) => Effect.Effect<
Expand All @@ -76,6 +84,45 @@ export class ExperimentalStackApi extends Context.Service<
}
>()("supabase/experimental-stack/StackApi") {}

export const validateExperimentalStackTarget = (input: {
readonly stack?: string;
readonly stackId?: string;
}): Effect.Effect<void, ExperimentalStackTargetError> =>
Effect.gen(function* () {
if (input.stack !== undefined && input.stackId !== undefined) {
return yield* new ExperimentalStackTargetError({
message: "--stack and --stack-id cannot be used together",
reason: "flags",
});
}
});

export const validateExperimentalStackId = (
id: string,
): Effect.Effect<StackId, ExperimentalStackTargetError> =>
isStackId(id)
? Effect.succeed(id)
: Effect.fail(
new ExperimentalStackTargetError({
message: "--stack-id must be a lowercase SHA-256 stack id",
reason: "flags",
}),
);

export const rejectExperimentalStackOutput = (
outputFlag: Option.Option<Option.Option<string>>,
): Effect.Effect<void, ExperimentalStackTargetError> =>
Option.isSome(outputFlag) && Option.isSome(outputFlag.value)
? Effect.fail(
new ExperimentalStackTargetError({
message: "The legacy -o/--output flag is not supported here; use --output-format json.",
reason: "flags",
suggestion:
"Use --output-format json, --output-format text, or --output-format stream-json.",
}),
)
: Effect.void;

export const experimentalStackApiLayer = Layer.effect(
ExperimentalStackApi,
Effect.gen(function* () {
Expand All @@ -91,6 +138,7 @@ export const experimentalStackApiLayer = Layer.effect(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcess),
);
return {
findStack: (...args: Parameters<typeof findStack>) => provideServices(findStack(...args)),
createStack: (...args: Parameters<typeof createStack>) =>
provideServices(createStack(...args)),
openStack: (...args: Parameters<typeof openStack>) => provideServices(openStack(...args)),
Expand All @@ -104,13 +152,7 @@ export const experimentalStackApiLayer = Layer.effect(
export const experimentalStackTargetResolverLayer = Layer.succeed(ExperimentalStackTargetResolver, {
resolve: (input) =>
Effect.gen(function* () {
if (input.id !== undefined && !isStackId(input.id)) {
return yield* new ExperimentalStackTargetError({
message: "--stack-id must be a lowercase SHA-256 stack id",
reason: "flags",
});
}
const id = input.id;
const id = input.id === undefined ? undefined : yield* validateExperimentalStackId(input.id);
const stackApi = yield* ExperimentalStackApi;
const inspection =
id === undefined
Expand Down
65 changes: 52 additions & 13 deletions apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// This is a compiled CLI boundary test. It deliberately starts the native owner through the
// built binary, then uses the package's public Promise API only to inspect and destroy that exact
// stack after the CLI process has exited.
// This is a compiled CLI boundary test. It deliberately starts and stops the native stack through
// the built binary, then uses the package's public Promise API to inspect and destroy that exact
// stack.
// oxlint-disable-next-line effecttsgo/process-env -- package runtime composition is scoped below.

// oxlint-disable-next-line effecttsgo/node-builtin-import -- compiled CLI fixture requires host process/filesystem APIs
Expand Down Expand Up @@ -50,15 +50,14 @@ enabled = false
enabled = false
`;

// oxlint-disable-next-line effecttsgo/async-function -- subprocess cleanup is a foreign Promise boundary
async function inspectAndDestroyStack(home: string, stackId: string) {
// oxlint-disable-next-line effecttsgo/async-function -- subprocess inspection is a foreign Promise boundary
async function inspectStackState(home: string, stackId: string) {
const script = `
import { inspectStack, openStack, StackIdSchema } from "@supabase/stack";
const id = StackIdSchema.make(process.argv.at(-1));
const inspection = await inspectStack(id);
const stack = await openStack(id);
const status = await stack.status();
await stack.destroy();
console.log(JSON.stringify({
owner: inspection.owner,
projectRoot: inspection.descriptor.projectRoot,
Expand Down Expand Up @@ -88,6 +87,25 @@ async function inspectAndDestroyStack(home: string, stackId: string) {
};
}

// oxlint-disable-next-line effecttsgo/async-function -- subprocess cleanup is a foreign Promise boundary
async function destroyStack(home: string, stackId: string) {
const script = `
import { openStack, StackIdSchema } from "@supabase/stack";
const stack = await openStack(StackIdSchema.make(process.argv.at(-1)));
await stack.destroy();
`;
await execFile("bun", ["--bun", "-e", script, stackId], {
cwd: process.cwd(),
env: {
...process.env,
SUPABASE_HOME: home,
SUPABASE_NO_KEYRING: "1",
SUPABASE_TELEMETRY_DISABLED: "1",
},
timeout: CLEANUP_TIMEOUT_MS,
});
}

describe("experimental stack start (compiled e2e)", () => {
let home: ReturnType<typeof makeTempHome> | undefined;
let projectDir: string | undefined;
Expand All @@ -104,7 +122,7 @@ describe("experimental stack start (compiled e2e)", () => {
const discovered = candidates.filter((entry) => /^[0-9a-f]{64}$/u.test(entry));
const ownedId = stackId ?? (discovered.length === 1 ? discovered[0] : undefined);
if (ownedId !== undefined) {
await inspectAndDestroyStack(home.dir, ownedId);
await destroyStack(home.dir, ownedId);
cleanupComplete = true;
} else if (discovered.length > 1) {
throw new Error(`Could not identify one owned stack for cleanup: ${discovered.join(", ")}`);
Expand All @@ -122,7 +140,7 @@ describe("experimental stack start (compiled e2e)", () => {
}, CLEANUP_TIMEOUT_MS);

test.skipIf(!nativeSupported)(
"starts a detached native owner and leaves a ready database after CLI exit",
"starts and stops a native stack while preserving its database",
{ timeout: START_TIMEOUT_MS + CLEANUP_TIMEOUT_MS },
// oxlint-disable-next-line effecttsgo/async-function -- compiled CLI e2e callback is a Promise boundary
async () => {
Expand All @@ -149,13 +167,34 @@ describe("experimental stack start (compiled e2e)", () => {
if (idText === undefined || homeDir === undefined || projectRoot === undefined)
throw new Error("compiled start did not return a stack id");

const observed = await inspectAndDestroyStack(homeDir.dir, idText);
stackDestroyed = true;
expect(observed.owner).toBe("running");
const running = await inspectStackState(homeDir.dir, idText);
expect(running.owner).toBe("running");
expect(running.projectRoot).toBe(await realpath(projectRoot));
expect(running.runtime).toEqual({ kind: "native" });
expect(running.lifecycle).toBe("running");
expect(running.database).toBe("ready");
const databasePath = path.join(homeDir.dir, "managed", "stacks", idText, "data", "database");
await access(path.join(databasePath, "PG_VERSION"));

await rm(path.join(projectRoot, "supabase", "config.toml"));
const stop = await runSupabase(["experimental", "stack", "stop", "--stack-id", idText], {
cwd: projectRoot,
home: homeDir.dir,
exitTimeoutMs: CLEANUP_TIMEOUT_MS,
});
expect(stop.exitCode, `stdout:\n${stop.stdout}\nstderr:\n${stop.stderr}`).toBe(0);

const observed = await inspectStackState(homeDir.dir, idText);
expect(observed.owner).toBe("absent");
expect(observed.projectRoot).toBe(await realpath(projectRoot));
expect(observed.runtime).toEqual({ kind: "native" });
expect(observed.lifecycle).toBe("running");
expect(observed.database).toBe("ready");
expect(observed.lifecycle).toBe("stopped");
expect(observed.database).toBe("stopped");

await access(path.join(databasePath, "PG_VERSION"));

await destroyStack(homeDir.dir, idText);
stackDestroyed = true;

await expect(access(path.join(homeDir.dir, "managed", "stacks", idText))).rejects.toThrow();
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,6 @@ import {
ErrorActionabilityId,
} from "../../../../shared/telemetry/error-actionability.ts";

export class ExperimentalStackTargetFlagsError extends Data.TaggedError(
"ExperimentalStackTargetFlagsError",
)<{ readonly message: string }> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return actionability.provideFlags;
}
}

export class ExperimentalStackStartError extends Data.TaggedError("ExperimentalStackStartError")<{
readonly reason:
| "invalid-config"
Expand Down
53 changes: 28 additions & 25 deletions apps/cli/src/commands/experimental/stack/start/start.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,16 @@ import { Output } from "../../../../shared/output/output.service.ts";
import { OutputFlag } from "../../../../command-internal/global-flags.ts";
import { CommandSettings } from "../../../../config/command-settings.service.ts";
import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts";
import { ExperimentalStackApi, ExperimentalStackTargetResolver } from "../stack.shared.ts";
import {
ExperimentalStackApi,
ExperimentalStackTargetError,
ExperimentalStackTargetResolver,
rejectExperimentalStackOutput,
validateExperimentalStackTarget,
} from "../stack.shared.ts";
import { loadStackConfig } from "../stack-config.ts";
import type { ExperimentalStackStartFlags } from "./start.command.ts";
import { ExperimentalStackStartError, ExperimentalStackTargetFlagsError } from "./start.errors.ts";
import { ExperimentalStackStartError } from "./start.errors.ts";

const statusPayload = (status: StackStatus) => ({
id: status.id,
Expand Down Expand Up @@ -49,16 +55,13 @@ const eagerlyActivate = <
value: T,
): T => (value.enabled === false ? value : Object.assign({}, value, { activation: "eager" }));

const validateExperimentalStackStartTarget = (
flags: Pick<ExperimentalStackStartFlags, "stack" | "stackId">,
) =>
Option.isSome(flags.stack) && Option.isSome(flags.stackId)
? Effect.fail(
new ExperimentalStackTargetFlagsError({
message: "--stack and --stack-id cannot be used together",
}),
)
: Effect.void;
const mapTargetError = (error: ExperimentalStackTargetError) =>
new ExperimentalStackStartError({
reason: error.reason,
message: error.message,
...(error.suggestion === undefined ? {} : { suggestion: error.suggestion }),
cause: error,
});

export const experimentalStackStart = Effect.fn("experimental.stack.start")(function* (
flags: ExperimentalStackStartFlags,
Expand All @@ -70,20 +73,20 @@ export const experimentalStackStart = Effect.fn("experimental.stack.start")(func
const resolver = yield* ExperimentalStackTargetResolver;
const stackApi = yield* ExperimentalStackApi;
const outputFlag = yield* Effect.serviceOption(OutputFlag);
if (Option.isSome(outputFlag) && Option.isSome(outputFlag.value))
return yield* new ExperimentalStackStartError({
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* validateExperimentalStackStartTarget(flags);
yield* rejectExperimentalStackOutput(outputFlag).pipe(Effect.mapError(mapTargetError));
yield* validateExperimentalStackTarget({
stack: Option.getOrUndefined(flags.stack),
stackId: Option.getOrUndefined(flags.stackId),
}).pipe(Effect.mapError(mapTargetError));

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 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,
})
.pipe(Effect.mapError(mapTargetError));
const config = yield* loadStackConfig(target.projectRoot).pipe(
Effect.mapError(
(error) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ function handlerLayer(opts: {
),
});
const apiLayer = Layer.succeed(ExperimentalStackApi, {
findStack: () => Effect.succeed(Option.none()),
createStack: (options) => {
opts.onCreate?.(options);
return Effect.succeed(opts.stack);
Expand Down Expand Up @@ -197,6 +198,7 @@ describe("experimental stack start targeting", () => {

it.effect("classifies an existing stack runtime mismatch as provided flags", () => {
const api = Layer.succeed(ExperimentalStackApi, {
findStack: () => Effect.succeed(Option.none()),
createStack: () => Effect.die("unused"),
openStack: () => Effect.die("unused"),
inspectStack: () =>
Expand Down Expand Up @@ -503,6 +505,7 @@ describe("experimental stack start targeting", () => {
},
}),
Layer.succeed(ExperimentalStackApi, {
findStack: () => Effect.succeed(Option.none()),
createStack: () => {
created = true;
return Effect.die("create should not run");
Expand Down
32 changes: 32 additions & 0 deletions apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# `supabase experimental stack stop`

This command stops the managed stack identified by the current project, an optional `--stack`
name, or `--stack-id`. It uses the public `@supabase/stack` API to stop the owner while
preserving the stack's persistent state and data volumes. It never destroys the stack.

## Files read and written

The stack package reads and updates its durable state under `<SUPABASE_HOME or ~/.supabase>`
and the selected stack's lifecycle state. The CLI reads its normal workdir settings. The
command does not load `supabase/config.toml`, so a missing or invalid project config does not
prevent stopping a stack addressed with `--stack-id`. Implicit and named stacks still depend on
workdir discovery, so removing an ancestor config can change which stack is selected; use an
explicit `--workdir` when needed.

No project files, credentials, or runtime configuration files are written. The package owns
the supervisor teardown and state transition; the CLI does not remove containers, volumes,
or stack state itself. If persisted state says the stack is running but its owner is
unreachable, the package may launch a short-lived Supervisor to arbitrate teardown before
returning. That process is package-owned and is not managed directly by the CLI.

## Output and telemetry

Text mode reports the selected stack and stopped outcome. Structured modes include the selected
stack id and stopped outcome. If no current stack exists, the command succeeds with an explicit
no-stack result. Exit status is `0` for a successful stop or no current stack, `1` for a
missing named stack or any typed stop failure, and `130` if the command is interrupted before
the stop completes. Standard command instrumentation records command
metadata; stack data and credentials are not emitted as telemetry properties.

Telemetry state is flushed to `<SUPABASE_HOME or ~/.supabase>/telemetry.json`
after both successful and failed command runs.
Loading