Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: {
// `pull` does not exist yet.
const launchConfig =
stackCheck.value.launch === undefined
? toStartStackConfig([], "auto")
? toStartStackConfig([], undefined)
: withServiceVersions(
toStartStackConfig(
stackCheck.value.launch.excludedServices?.filter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio
const serviceVersionContext = yield* resolveServiceVersionContext([], undefined);
const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot);
const stackConfig = withServiceVersions(
toStartStackConfig([], "auto"),
toStartStackConfig([], undefined),
serviceVersionContext.runtimeVersions,
);
const stackLayer = yield* daemonLayer({
Expand All @@ -65,7 +65,6 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio
name: opts.stack,
edgeRuntime: opts.edgeRuntime,
launch: {
mode: "auto",
versions: serviceVersionContext.pinnedBaseline,
excludedServices: [],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ import {
} from "../../config/service-version-resolution.ts";

describe("service version overrides", () => {
test("parses and normalizes repeated flag overrides", async () => {
test("parses repeated flag overrides as exact image tags", async () => {
await expect(
Effect.runPromise(
parseServiceVersionOverrides(["postgrest=v14.5", "mailpit=1.30.2", "auth=2.180.0"]),
),
).resolves.toEqual({
postgrest: "14.5",
mailpit: "v1.30.2",
postgrest: "v14.5",
mailpit: "1.30.2",
auth: "2.180.0",
});
});
Expand All @@ -27,8 +27,8 @@ describe("service version overrides", () => {
const candidateBaseline = {
...DEFAULT_VERSIONS,
postgres: "17.6.1.090",
postgrest: "14.5",
auth: "2.187.0",
postgrest: "v14.5",
auth: "v2.187.0",
};

const layer = Layer.mergeAll(
Expand Down Expand Up @@ -68,12 +68,12 @@ describe("service version overrides", () => {
runtimeVersions: {
...candidateBaseline,
postgres: "17.4.1.045",
auth: "2.170.0",
auth: "v2.170.0",
storage: "1.40.0",
},
activeOverrides: [
{ service: "postgres", version: "17.4.1.045", source: "flag" },
{ service: "auth", version: "2.170.0", source: "flag" },
{ service: "auth", version: "v2.170.0", source: "flag" },
{ service: "storage", version: "1.40.0", source: "local" },
],
availableUpdates: [],
Expand Down
16 changes: 10 additions & 6 deletions apps/cli/src/next/commands/start/start.command.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect, Layer, Context } from "effect";
import { Effect, Layer, Context, Option } from "effect";
import { loadProjectConfig } from "@supabase/config";
import {
DEFAULT_MANAGED_STACK_NAME,
Expand Down Expand Up @@ -79,9 +79,10 @@ export const serviceVersionFlag = Flag.string("service-version").pipe(

const modeFlag = Flag.choice("mode", startModes).pipe(
Flag.withDescription(
'Stack startup mode. "auto" prefers native binaries and falls back to Docker, "native" requires native-compatible services, and "docker" forces Docker for all services.',
'Stack startup mode. "native" requires native-compatible services and "docker" requires a usable Docker or Podman runtime.',
),
Flag.withDefault("auto" as StartMode),
Flag.optional,
Flag.map(Option.getOrUndefined),
);

interface StartVersionStateShape {
Expand Down Expand Up @@ -124,7 +125,7 @@ export type StartFlags = CliCommand.Command.Config.Infer<typeof flags>;
export const startCommand = Command.make("start", flags).pipe(
Command.withDescription(
"Start the local Supabase development stack.\n\n" +
"Starts the full local Supabase stack. Use --mode auto (default) to prefer native binaries and fall back to Docker, --mode native to require native-compatible services, or --mode docker to force Docker-backed startup.\n\n" +
"Starts the full local Supabase stack. By default, a usable Docker or Podman runtime selects Docker mode; otherwise the stack uses native mode. Use --mode to require one explicitly.\n\n" +
"Named CLI stacks persist managed runtime state under the Supabase home directory. Use --exclude to skip optional services. Use --detach to run in the background.",
),
Command.withShortDescription("Start local Supabase stack"),
Expand Down Expand Up @@ -219,7 +220,7 @@ export const startCommand = Command.make("start", flags).pipe(
portDocument: portIntents,
});
const launch = {
mode: flags.mode,
...(flags.mode === undefined ? {} : { mode: flags.mode }),
versions: serviceVersionContext.pinnedBaseline,
excludedServices: flags.exclude,
...(existingSummary?.lastNotifiedUpdateFingerprint === undefined
Expand All @@ -242,12 +243,15 @@ export const startCommand = Command.make("start", flags).pipe(
cwd: runtimeInfo.cwd,
name: flags.stack,
});
if (summary.launch === undefined) {
return yield* Effect.die("Managed stack started without persisted launch settings");
}

return {
stackLayer,
startVersionState: StartVersionState.of({
launch: {
mode: flags.mode,
mode: summary.launch.mode,
versions: serviceVersionContext.pinnedBaseline,
excludedServices: flags.exclude,
},
Expand Down
3 changes: 1 addition & 2 deletions apps/cli/src/next/commands/start/start.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ export const start = Effect.fnUntraced(function* (flags: StartFlags) {
yield* updateManagedLaunch({
...lifecycleInput,
launch: {
mode: launch.mode,
versions: launch.versions,
excludedServices: launch.excludedServices,
lastNotifiedUpdateFingerprint: serviceVersionContext.updateFingerprint,
Expand All @@ -68,7 +67,7 @@ export const start = Effect.fnUntraced(function* (flags: StartFlags) {
}

yield* analytics.capture("cli_stack_started", {
mode: flags.mode,
mode: launch.mode,
detach: flags.detach,
stack: flags.stack,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ describe("start handler", () => {
);
return start({
stack: fixture.stackName,
mode: "auto",
mode: "docker",
exclude: [],
serviceVersion: [],
detach: false,
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/next/commands/status/status.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const resolveConfiguredSummary = Effect.fnUntraced(function* (input: {
return yield* resolveStackSummary({
...input,
portDocument: managedPortIntents(
toStartStackConfig(excluded, current.launch?.mode ?? "auto"),
toStartStackConfig(excluded, current.launch?.mode),
loaded ?? undefined,
),
});
Expand Down
7 changes: 1 addition & 6 deletions apps/cli/src/next/commands/update/update.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,19 +103,14 @@ export const update = Effect.fnUntraced(function* (flags: UpdateFlags) {
);

if (Option.isSome(existingSummary)) {
const persistedLaunch = existingSummary.value.launch ?? {
mode: "auto" as const,
excludedServices: [] as const,
};
yield* updateManagedLaunch({
cacheRoot: cliConfig.supabaseHome,
cwd: runtimeInfo.cwd,
workspacePath: projectHome.projectRoot,
stackName: flags.stack,
launch: {
mode: persistedLaunch.mode,
versions: serviceVersionContext.candidateBaseline,
excludedServices: persistedLaunch.excludedServices,
excludedServices: existingSummary.value.launch?.excludedServices ?? [],
...(existingSummary.value.lastNotifiedUpdateFingerprint === undefined
? {}
: {
Expand Down
7 changes: 3 additions & 4 deletions apps/cli/src/next/config/stack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,16 @@ export const excludedStackServices = [
export type ExcludedStackService = (typeof excludedStackServices)[number];
export const isExcludedStackService = (value: string): value is ExcludedStackService =>
excludedStackServices.some((candidate) => candidate === value);
export const startModes = ["native", "auto", "docker"] as const;
export const startModes = ["native", "docker"] as const;
export type StartMode = (typeof startModes)[number];

export function toStartStackConfig(
exclude: ReadonlyArray<ExcludedStackService>,
mode: StartMode,
mode?: StartMode,
): StackConfig {
const excluded = new Set(exclude);
return {
mode,
startupMode: "lazy",
...(mode === undefined ? {} : { mode }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Disable Docker-only defaults when falling back to native mode

When supabase start is run without --mode and neither Docker nor Podman is usable, runtime selection falls back to native mode, but this generated config still enables realtime, storage, imgproxy, mailpit, pgmeta, studio, analytics, vector, and pooler. validateResolvedConfig consequently rejects the advertised fallback because all of those services are Docker-only, so the default Dockerless start cannot reach the native Postgres/Auth/PostgREST stack unless the user manually excludes every incompatible service.

Useful? React with 👍 / 👎.

realtime: excluded.has("realtime") ? false : {},
storage: excluded.has("storage") ? false : {},
imgproxy: excluded.has("imgproxy") || excluded.has("storage") ? false : {},
Expand Down
25 changes: 13 additions & 12 deletions apps/cli/src/next/config/stack-config.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,29 @@ import { describe, expect, it } from "vitest";
import { toStartStackConfig, withServiceVersions } from "./stack-config.ts";

describe("toStartStackConfig", () => {
it("uses lazy service startup with the requested runtime mode", () => {
expect(toStartStackConfig([], "auto")).toMatchObject({
mode: "auto",
startupMode: "lazy",
it("leaves mode unset so the stack package can select the usable runtime", () => {
expect(toStartStackConfig([], undefined)).not.toHaveProperty("mode");
});

it("uses the requested runtime mode and catalog service defaults", () => {
expect(toStartStackConfig([], "docker")).toMatchObject({
mode: "docker",
});
expect(toStartStackConfig([], "docker")).toMatchObject({
mode: "docker",
startupMode: "lazy",
});
expect(toStartStackConfig([], "native")).toMatchObject({
mode: "native",
startupMode: "lazy",
});
});

it("dedupes excluded services when building stack config", () => {
expect(toStartStackConfig(["auth", "auth"], "auto")).toMatchObject({
mode: "auto",
expect(toStartStackConfig(["auth", "auth"], "docker")).toMatchObject({
mode: "docker",
auth: false,
});
expect(toStartStackConfig(["auth", "postgrest"], "auto")).toMatchObject({
mode: "auto",
expect(toStartStackConfig(["auth", "postgrest"], "docker")).toMatchObject({
mode: "docker",
auth: false,
postgrest: false,
});
Expand All @@ -33,7 +34,7 @@ describe("toStartStackConfig", () => {
describe("withServiceVersions", () => {
it("injects linked service versions without re-enabling excluded services", () => {
expect(
withServiceVersions(toStartStackConfig([], "auto"), {
withServiceVersions(toStartStackConfig([], "docker"), {
postgres: "17.6.1.090",
postgrest: "14.5",
auth: "2.187.0",
Expand All @@ -49,7 +50,7 @@ describe("withServiceVersions", () => {
});

expect(
withServiceVersions(toStartStackConfig(["auth", "storage"], "auto"), {
withServiceVersions(toStartStackConfig(["auth", "storage"], "docker"), {
postgres: "17.6.1.090",
auth: "2.187.0",
storage: "1.39.2",
Expand Down
39 changes: 39 additions & 0 deletions apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,45 @@ describe("stack e2e cleanup manager", () => {
expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]);
});

it("keeps cleanup best-effort when the associated home cannot be disposed", async () => {
const calls: Array<string> = [];
const manager = createStackE2eCleanupManager(
cleanupEnvironment(calls, {
captureSnapshot: () => ({
managedStacksRootExists: true,
documentFiles: [],
stackDirs: [],
trackedPids: [],
}),
}),
);

manager.registerHome({
dir: "/tmp/home",
dispose: () => {
calls.push("dispose-home");
throw permissionError("home is not removable");
},
});
manager.registerStackProject({
dir: "/tmp/project",
cleanup: async () => {
calls.push("cleanup-project");
},
});
manager.associateHome("/tmp/project", "/tmp/home");

const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
await expect(manager.drain()).resolves.toBeUndefined();
expect(warn).toHaveBeenCalledWith(expect.stringContaining("/tmp/home"));
expect(warn).toHaveBeenCalledWith(expect.stringContaining("home is not removable"));
} finally {
warn.mockRestore();
}
expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]);
});

it("canonicalizes symlinked project and home paths before matching stack state", async () => {
const root = mkdtempSync(join(tmpdir(), "stack-e2e-cleanup-"));
const project = join(root, "project");
Expand Down
4 changes: 4 additions & 0 deletions apps/cli/src/shared/telemetry/error-actionability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -983,6 +983,9 @@ const externalActionabilityByTag: Record<string, ErrorActionabilityAdapter> = {
? { ...actionability.invalidConfig, fingerprint_suffix: "port_allocation" }
: actionability.unknown,
BinaryNotFoundError: () => actionability.invalidConfig,
BinaryManifestError: () => actionability.externalNetwork,
BinaryRuntimeError: () => actionability.externalNetwork,
BinaryHostCompatibilityError: () => actionability.invalidConfig,
DownloadError: () => actionability.externalNetwork,
ChecksumMismatchError: () => ({
...actionability.externalNetwork,
Expand Down Expand Up @@ -1052,6 +1055,7 @@ const externalActionabilityByTag: Record<string, ErrorActionabilityAdapter> = {
...actionability.stopStack,
fingerprint_suffix: "managed_attached",
}),
ManagedStackLaunchMissingError: () => actionability.startStack,
ManagedWorkspaceRepairConflictError: () => ({
...actionability.invalidInput,
fingerprint_suffix: "managed_workspace_repair",
Expand Down
7 changes: 6 additions & 1 deletion apps/cli/tests/helpers/running-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ import { CliConfig } from "../../src/next/config/cli-config.service.ts";
import { ProjectHome } from "../../src/next/config/project-home.service.ts";
import { RuntimeInfo } from "../../src/shared/runtime/runtime-info.service.ts";

const launch = { mode: "auto" as const, versions: { postgres: "17.6.1" }, excludedServices: [] };
const launch = {
mode: "docker" as const,
containerRuntime: "docker" as const,
versions: { postgres: "17.6.1" },
excludedServices: [],
};
const portDocument: ManagedPortIntentDocument = {
activeFields: ["apiPort", "dbPort"],
document: {},
Expand Down
6 changes: 5 additions & 1 deletion apps/cli/tests/helpers/stack-e2e-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,11 @@ export function createStackE2eCleanupManager(
failures.push(cleanupErrorDetail(project.dir, error));
} finally {
if (home !== undefined) {
home.dispose();
try {
home.dispose();
} catch (error) {
failures.push(cleanupErrorDetail(home.dir, error));
}
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/process-compose/tests/helpers/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ const isOneShotSupervisor = (args: ReadonlyArray<string>): boolean => {
typeof config === "object" &&
config !== null &&
"command" in config &&
config.command === "bash" &&
"args" in config &&
Array.isArray(config.args) &&
config.args[0] === "-c"
((config.command === "bash" && config.args[0] === "-c") ||
((config.command === "docker" || config.command === "podman") && config.args[0] === "exec"))
);
} catch {
return false;
Expand Down
11 changes: 9 additions & 2 deletions packages/stack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ console.log((await stack.getInfo()).url);

`createStack` resolves configuration, reserves ports, and builds a scoped
handle. `stack.start()` starts services; disposing the handle stops them and
releases its lease.
releases its lease. When `mode` is omitted, creation uses Docker mode with a
usable Docker or Podman service and otherwise selects native mode. An explicit
mode never falls back to the other one.

## Managed stack

Expand Down Expand Up @@ -47,7 +49,7 @@ const runtime =
projectDir: projectRoot,
name: "default",
portIntents,
launch: { mode: "auto", versions: {}, excludedServices: [] },
launch: { mode: "docker", versions: {}, excludedServices: [] },
});
```

Expand All @@ -56,5 +58,10 @@ const runtime =
`stopDaemon` and the discovery helpers delegate to the managed lifecycle
facade. No CLI metadata file or PID polling is involved.

After a managed supervisor claims a stack, its persisted Docker, Podman, or
native selection remains pinned even if startup later fails. Retry after
restoring or starting that runtime; delete and recreate the stack to choose a
different execution mode. Deletion removes the stack's managed data.

For the end-to-end lifecycle, identity, ports, service execution, transport,
compiled-Bun re-entry, and testing boundary, see [How `@supabase/stack` works](docs/architecture.md).
Loading
Loading