diff --git a/apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts b/apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts
new file mode 100644
index 0000000000..68a7b1c11b
--- /dev/null
+++ b/apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts
@@ -0,0 +1,470 @@
+import { mkdtempSync, rmSync } from "node:fs";
+import http from "node:http";
+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, FileSystem, Layer, Option, Path } from "effect";
+import { ChildProcessSpawner } from "effect/unstable/process";
+
+import { mockContainerCliSpawner } from "../../../tests/helpers/local-reset.ts";
+import { DebugLogger } from "../debug-logger.service.ts";
+import {
+ LocalDockerEngine,
+ dockerEndpointSocketPath,
+ isLocalDbRunning,
+ localDockerEngineLayer,
+} from "./local-db-running.ts";
+
+describe("dockerEndpointSocketPath", () => {
+ it("maps a unix:// endpoint to its filesystem path", () => {
+ expect(dockerEndpointSocketPath("unix:///var/run/docker.sock")).toBe("/var/run/docker.sock");
+ });
+
+ it("maps an npipe:// endpoint to its \\\\.\\pipe form", () => {
+ expect(dockerEndpointSocketPath("npipe:////./pipe/dockerDesktopLinuxEngine")).toBe(
+ "\\\\.\\pipe\\dockerDesktopLinuxEngine",
+ );
+ });
+
+ it("declines every endpoint the direct transport cannot address", () => {
+ expect(dockerEndpointSocketPath("tcp://localhost:2375")).toBeUndefined();
+ expect(dockerEndpointSocketPath("ssh://user@remote-host")).toBeUndefined();
+ expect(dockerEndpointSocketPath("fd://")).toBeUndefined();
+ expect(dockerEndpointSocketPath("unix://")).toBeUndefined();
+ });
+});
+
+function withDockerHost(
+ endpoint: string,
+ effect: Effect.Effect,
+): Effect.Effect {
+ return Effect.suspend(() => {
+ const previous = process.env["DOCKER_HOST"];
+ process.env["DOCKER_HOST"] = endpoint;
+ return effect.pipe(
+ Effect.ensuring(
+ Effect.sync(() => {
+ if (previous === undefined) delete process.env["DOCKER_HOST"];
+ else process.env["DOCKER_HOST"] = previous;
+ }),
+ ),
+ );
+ });
+}
+
+const ENGINE_IDENTITY_HEADERS = { "api-version": "1.55" };
+
+const makeEngineServer = (respond: (req: http.IncomingMessage, res: http.ServerResponse) => void) =>
+ Effect.acquireRelease(
+ Effect.callback<{
+ readonly socketPath: string;
+ readonly requestedUrls: Array;
+ readonly server: http.Server;
+ readonly dir: string;
+ }>((resume) => {
+ let settled = false;
+ const dir = mkdtempSync(join(tmpdir(), "ldbeng-"));
+ const socketPath = join(dir, "d.sock");
+ const requestedUrls: Array = [];
+ const server = http.createServer((req, res) => {
+ requestedUrls.push(req.url ?? "");
+ respond(req, res);
+ });
+ server.once("error", (cause) => {
+ if (settled) return;
+ settled = true;
+ rmSync(dir, { recursive: true, force: true });
+ resume(Effect.die(cause));
+ });
+ server.listen(socketPath, () => {
+ if (settled) return;
+ settled = true;
+ resume(Effect.succeed({ socketPath, requestedUrls, server, dir }));
+ });
+ return Effect.sync(() => {
+ settled = true;
+ server.close();
+ rmSync(dir, { recursive: true, force: true });
+ });
+ }),
+ ({ dir, server }) =>
+ Effect.callback((resume) => {
+ server.closeAllConnections?.();
+ server.close(() => {
+ rmSync(dir, { recursive: true, force: true });
+ resume(Effect.void);
+ });
+ }),
+ );
+
+const engineContainerExists = (containerId: string) =>
+ Effect.gen(function* () {
+ const engine = yield* LocalDockerEngine;
+ return yield* engine.containerExists(containerId);
+ }).pipe(Effect.provide(localDockerEngineLayer));
+
+describe("LocalDockerEngine (direct Engine-API transport)", () => {
+ describe.skipIf(process.platform === "win32")("over a per-test unix-socket Engine server", () => {
+ it.live("answers present for an Engine 200 inspect payload on the documented route", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const engine = yield* makeEngineServer((_req, res) => {
+ res.writeHead(200, { "content-type": "application/json", ...ENGINE_IDENTITY_HEADERS });
+ res.end(JSON.stringify({ Id: "abc123", State: { Status: "created" } }));
+ });
+ const answer = yield* withDockerHost(
+ `unix://${engine.socketPath}`,
+ engineContainerExists("supabase_db_engine-probe"),
+ );
+ expect(answer).toEqual(Option.some(true));
+ expect(engine.requestedUrls).toEqual(["/containers/supabase_db_engine-probe/json"]);
+ }),
+ ),
+ );
+
+ it.live("answers absent for an Engine 404", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const engine = yield* makeEngineServer((_req, res) => {
+ res.writeHead(404, { "content-type": "application/json", ...ENGINE_IDENTITY_HEADERS });
+ res.end(JSON.stringify({ message: "No such container" }));
+ });
+ const answer = yield* withDockerHost(
+ `unix://${engine.socketPath}`,
+ engineContainerExists("supabase_db_engine-probe"),
+ );
+ expect(answer).toEqual(Option.some(false));
+ }),
+ ),
+ );
+
+ it.live("gives no answer for a responder that does not identify as a Docker engine", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const engine = yield* makeEngineServer((req, res) => {
+ if (req.url?.includes("present") === true) {
+ res.writeHead(200, { "content-type": "application/json" });
+ res.end(JSON.stringify({ Id: "not-an-engine" }));
+ return;
+ }
+ if (req.url?.includes("server-header") === true) {
+ res.writeHead(404, { server: "Docker/28.5.2 (linux)" });
+ res.end(JSON.stringify({ message: "No such container" }));
+ return;
+ }
+ res.writeHead(404);
+ res.end();
+ });
+ const present = yield* withDockerHost(
+ `unix://${engine.socketPath}`,
+ engineContainerExists("present"),
+ );
+ const absent = yield* withDockerHost(
+ `unix://${engine.socketPath}`,
+ engineContainerExists("absent"),
+ );
+ const viaServerHeader = yield* withDockerHost(
+ `unix://${engine.socketPath}`,
+ engineContainerExists("server-header"),
+ );
+ expect(present).toEqual(Option.none());
+ expect(absent).toEqual(Option.none());
+ expect(viaServerHeader).toEqual(Option.some(false));
+ }),
+ ),
+ );
+
+ it.live("gives no answer for an empty or malformed Engine 200 body", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const engine = yield* makeEngineServer((req, res) => {
+ res.writeHead(200, { "content-type": "application/json", ...ENGINE_IDENTITY_HEADERS });
+ res.end(
+ req.url?.includes("empty") === true ? "" : JSON.stringify(["not", "an", "object"]),
+ );
+ });
+ const empty = yield* withDockerHost(
+ `unix://${engine.socketPath}`,
+ engineContainerExists("empty"),
+ );
+ const malformed = yield* withDockerHost(
+ `unix://${engine.socketPath}`,
+ engineContainerExists("malformed"),
+ );
+ expect(empty).toEqual(Option.none());
+ expect(malformed).toEqual(Option.none());
+ }),
+ ),
+ );
+
+ it.live(
+ "gives no answer for an abnormal Engine status, so the container CLI reproduces it",
+ () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const engine = yield* makeEngineServer((_req, res) => {
+ res.writeHead(500, {
+ "content-type": "application/json",
+ ...ENGINE_IDENTITY_HEADERS,
+ });
+ res.end(JSON.stringify({ message: "layer store corrupted" }));
+ });
+ const answer = yield* withDockerHost(
+ `unix://${engine.socketPath}`,
+ engineContainerExists("supabase_db_engine-probe"),
+ );
+ expect(answer).toEqual(Option.none());
+ }),
+ ),
+ );
+
+ it.live(
+ "gives no answer when the endpoint accepts the connection but never responds",
+ () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const engine = yield* makeEngineServer(() => {});
+ const answer = yield* withDockerHost(
+ `unix://${engine.socketPath}`,
+ engineContainerExists("supabase_db_engine-probe"),
+ );
+ expect(answer).toEqual(Option.none());
+ }),
+ ),
+ 15_000,
+ );
+
+ it.live("gives no answer for an oversized body", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const engine = yield* makeEngineServer((_req, res) => {
+ res.writeHead(200, { "content-type": "application/json", ...ENGINE_IDENTITY_HEADERS });
+ res.end(`{"Id":"${"a".repeat(80 * 1024)}"}`);
+ });
+ const answer = yield* withDockerHost(
+ `unix://${engine.socketPath}`,
+ engineContainerExists("supabase_db_engine-probe"),
+ );
+ expect(answer).toEqual(Option.none());
+ }),
+ ),
+ );
+
+ it.live("gives no answer when the peer resets after the headers", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const engine = yield* makeEngineServer((_req, res) => {
+ res.writeHead(200, {
+ "content-type": "application/json",
+ "content-length": "1000",
+ ...ENGINE_IDENTITY_HEADERS,
+ });
+ res.write('{"Id":');
+ res.socket?.destroy();
+ });
+ const answer = yield* withDockerHost(
+ `unix://${engine.socketPath}`,
+ engineContainerExists("supabase_db_engine-probe"),
+ );
+ expect(answer).toEqual(Option.none());
+ }),
+ ),
+ );
+
+ it.live("traces the request, the decline, and the fallback through DebugLogger", () => {
+ const lines: Array = [];
+ const recorder = Layer.succeed(DebugLogger, {
+ debug: (line: string) =>
+ Effect.sync(() => {
+ lines.push(`debug:${line}`);
+ }),
+ http: (method: string, url: string) =>
+ Effect.sync(() => {
+ lines.push(`http:${method} ${url}`);
+ }),
+ });
+ const traced = Effect.gen(function* () {
+ const engine = yield* LocalDockerEngine;
+ return yield* engine.containerExists("supabase_db_engine-probe");
+ }).pipe(Effect.provide(localDockerEngineLayer.pipe(Layer.provide(recorder))));
+ return Effect.scoped(
+ Effect.gen(function* () {
+ const engine = yield* makeEngineServer((_req, res) => {
+ res.writeHead(404, { "content-type": "application/json", ...ENGINE_IDENTITY_HEADERS });
+ res.end(JSON.stringify({ message: "No such container" }));
+ });
+ yield* withDockerHost(`unix://${engine.socketPath}`, traced);
+ expect(
+ lines.some(
+ (l) =>
+ l.startsWith("http:GET unix://") &&
+ l.includes("/containers/supabase_db_engine-probe/json"),
+ ),
+ ).toBe(true);
+ // Plainly, then with a password a URL parser mis-locates, then with
+ // no scheme for one to anchor on at all.
+ yield* withDockerHost("ssh://user:hunter2@remote-host", traced);
+ expect(
+ lines.some(
+ (l) => l.startsWith("debug:") && l.includes("not directly addressable (ssh)"),
+ ),
+ ).toBe(true);
+ expect(lines.some((l) => l.includes("hunter2") || l.includes("remote-host"))).toBe(false);
+ yield* withDockerHost("tcp://user:#hunter3@10.0.0.5:2376", traced);
+ expect(lines.some((l) => l.includes("not directly addressable (tcp)"))).toBe(true);
+ expect(lines.some((l) => l.includes("hunter3") || l.includes("10.0.0.5"))).toBe(false);
+ yield* withDockerHost("deploy:hunter4@10.0.0.5:2376", traced);
+ expect(lines.some((l) => l.includes("not directly addressable (no scheme)"))).toBe(true);
+ expect(lines.some((l) => l.includes("hunter4") || l.includes("deploy"))).toBe(false);
+ const missingDir = mkdtempSync(join(tmpdir(), "ldbgone-"));
+ yield* withDockerHost(`unix://${join(missingDir, "never-created.sock")}`, traced).pipe(
+ Effect.ensuring(
+ Effect.sync(() => rmSync(missingDir, { recursive: true, force: true })),
+ ),
+ );
+ expect(
+ lines.some((l) => l.startsWith("debug:") && l.includes("no definitive Engine answer")),
+ ).toBe(true);
+ }),
+ );
+ });
+
+ it.live(
+ "gives no answer at the wall-clock deadline when the peer trickles bytes forever",
+ () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const engine = yield* makeEngineServer((_req, res) => {
+ res.writeHead(200, {
+ "content-type": "application/json",
+ ...ENGINE_IDENTITY_HEADERS,
+ });
+ const drip = setInterval(() => {
+ res.write("a");
+ }, 500);
+ res.on("close", () => {
+ clearInterval(drip);
+ });
+ });
+ const answer = yield* withDockerHost(
+ `unix://${engine.socketPath}`,
+ engineContainerExists("supabase_db_engine-probe"),
+ );
+ expect(answer).toEqual(Option.none());
+ }),
+ ),
+ 15_000,
+ );
+ });
+
+ it.live("gives no answer when the local socket cannot be dialed", () => {
+ const missingDir = mkdtempSync(join(tmpdir(), "ldbgone-"));
+ return withDockerHost(
+ `unix://${join(missingDir, "never-created.sock")}`,
+ engineContainerExists("supabase_db_engine-probe"),
+ ).pipe(
+ Effect.ensuring(Effect.sync(() => rmSync(missingDir, { recursive: true, force: true }))),
+ Effect.map((answer) => {
+ expect(answer).toEqual(Option.none());
+ }),
+ );
+ });
+
+ it.live("gives no answer for an endpoint the transport cannot address (ssh)", () =>
+ withDockerHost(
+ "ssh://user@remote-host",
+ engineContainerExists("supabase_db_engine-probe"),
+ ).pipe(
+ Effect.map((answer) => {
+ expect(answer).toEqual(Option.none());
+ }),
+ ),
+ );
+});
+
+describe("isLocalDbRunning", () => {
+ const probe = (spawnerLayer: Layer.Layer) =>
+ Effect.gen(function* () {
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const workdir = mkdtempSync(join(tmpdir(), "ldbrun-"));
+ return yield* isLocalDbRunning(spawner, fs, path, workdir, "engine-probe").pipe(
+ Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true }))),
+ );
+ }).pipe(Effect.provide(spawnerLayer), Effect.provide(BunServices.layer));
+
+ it.live("trusts a definitive Engine answer without spawning the container CLI", () => {
+ const asked: Array = [];
+ const mock = mockContainerCliSpawner(() => ({ exitCode: 0 }));
+ return probe(mock.layer).pipe(
+ Effect.provideService(LocalDockerEngine, {
+ containerExists: (containerId) =>
+ Effect.sync(() => {
+ asked.push(containerId);
+ }).pipe(Effect.as(Option.some(true))),
+ }),
+ Effect.map((running) => {
+ expect(running).toBe(true);
+ expect(asked).toEqual(["supabase_db_engine-probe"]);
+ expect(mock.spawned).toEqual([]);
+ }),
+ );
+ });
+
+ it.live("trusts a definitive Engine 404 without spawning the container CLI", () => {
+ const mock = mockContainerCliSpawner(() => ({ exitCode: 0 }));
+ return probe(mock.layer).pipe(
+ Effect.provideService(LocalDockerEngine, {
+ containerExists: () => Effect.succeed(Option.some(false)),
+ }),
+ Effect.map((running) => {
+ expect(running).toBe(false);
+ expect(mock.spawned).toEqual([]);
+ }),
+ );
+ });
+
+ it.live("falls back to the container CLI when the Engine gives no answer", () => {
+ const mock = mockContainerCliSpawner(() => ({
+ exitCode: 1,
+ stderr: ["Error response from daemon: No such container: supabase_db_engine-probe"],
+ }));
+ return probe(mock.layer).pipe(
+ Effect.provideService(LocalDockerEngine, {
+ containerExists: () => Effect.succeed(Option.none()),
+ }),
+ Effect.map((running) => {
+ expect(running).toBe(false);
+ expect(mock.spawned.map((s) => s.args)).toEqual([
+ ["container", "inspect", "supabase_db_engine-probe"],
+ ]);
+ }),
+ );
+ });
+
+ it.live(
+ "composes: a real transport failure on the resolved endpoint falls through to the container CLI",
+ () => {
+ const mock = mockContainerCliSpawner(() => ({
+ exitCode: 1,
+ stderr: ["Error response from daemon: No such container: supabase_db_engine-probe"],
+ }));
+ const missingDir = mkdtempSync(join(tmpdir(), "ldbgone-"));
+ return withDockerHost(
+ `unix://${join(missingDir, "never-created.sock")}`,
+ probe(mock.layer).pipe(Effect.provide(localDockerEngineLayer)),
+ ).pipe(
+ Effect.ensuring(Effect.sync(() => rmSync(missingDir, { recursive: true, force: true }))),
+ Effect.map((running) => {
+ expect(running).toBe(false);
+ expect(mock.spawned.map((s) => s.args)).toEqual([
+ ["container", "inspect", "supabase_db_engine-probe"],
+ ]);
+ }),
+ );
+ },
+ );
+});
diff --git a/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts b/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts
index f2421a1498..663fd5cf7a 100644
--- a/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts
+++ b/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts
@@ -1,4 +1,6 @@
-import { Data, Effect, type FileSystem, Option, type Path, Stream } from "effect";
+import http from "node:http";
+
+import { Context, Data, Effect, type FileSystem, Layer, Option, type Path, Stream } from "effect";
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
import {
@@ -10,6 +12,9 @@ import { isContainerNotFoundMessage, spawnContainerCli } from "../container-cli.
import { readDbToml } from "../db-config.toml-read.ts";
import { resolveLocalProjectId, localDbContainerId } from "../docker-ids.ts";
import { SUGGEST_DOCKER_INSTALL, isDockerDaemonUnreachable } from "../docker-suggest.ts";
+import { redactHttpUrl } from "../../auth/http-debug.layer.ts";
+import { DebugLogger } from "../debug-logger.service.ts";
+import { resolveDockerDaemonEndpoint } from "../hostname.ts";
type Spawner = ChildProcessSpawner["Service"];
@@ -29,6 +34,246 @@ export class LocalDbRunningError extends Data.TaggedError("LocalDbRunningError")
}
}
+/**
+ * Direct Engine-API access to the locally addressable Docker daemon, so
+ * {@link isLocalDbRunning} does not depend on a spawnable, responsive
+ * `docker` CLI binary (issue #6110). `containerExists`: `Option.some(true)` —
+ * an Engine-identified 200 with a valid inspect payload; `Option.some(false)`
+ * — an Engine-identified 404; `Option.none()` — anything else (non-addressable
+ * endpoint, transport failure, silent socket, the 5s wall-clock bound
+ * expiring, non-Engine responder, abnormal
+ * status, malformed body), telling the caller to fall back to the container
+ * CLI, which preserves the established wording, daemon-down classification,
+ * and Podman fallback.
+ */
+export class LocalDockerEngine extends Context.Service<
+ LocalDockerEngine,
+ {
+ readonly containerExists: (containerId: string) => Effect.Effect>;
+ }
+>()("supabase/cli/LocalDockerEngine") {}
+
+/**
+ * The local socket path for a `unix://`/`npipe://` daemon endpoint
+ * (`npipe:////./pipe/X` -> `\\.\pipe\X`), as `node:http`'s `socketPath`
+ * accepts it. Every other scheme returns `undefined` — tcp/ssh/fd endpoints
+ * may need TLS material or transports only the `docker` CLI carries, so
+ * callers keep shelling out. Exported for tests (npipe is Windows-only).
+ */
+export function dockerEndpointSocketPath(endpoint: string): string | undefined {
+ if (endpoint.startsWith("unix://")) {
+ const socketPath = endpoint.slice("unix://".length);
+ return socketPath.length > 0 ? socketPath : undefined;
+ }
+ if (endpoint.startsWith("npipe://")) {
+ const pipePath = endpoint.slice("npipe://".length);
+ return pipePath.length > 0 ? pipePath.replaceAll("/", "\\") : undefined;
+ }
+ return undefined;
+}
+
+/**
+ * How the endpoint is named in {@link localDockerEngineLayer}'s decline line:
+ * by its scheme alone.
+ *
+ * That is the whole reason the probe declined — `tcp`, `ssh` and `fd` are not
+ * local sockets — and it is the one part of a `DOCKER_HOST` that cannot carry
+ * a credential. The rest can: `ssh://user:secret@host` is a supported spelling,
+ * and once a password holds an unencoded `@`, `/`, `?` or `#` there is no
+ * telling it apart from an ordinary host and path, so none of it is printed.
+ */
+function describeEndpoint(endpoint: string | undefined): string {
+ if (endpoint === undefined) {
+ return "unresolved context";
+ }
+ // Anchored on `://`, so a value with no scheme cannot report its own first
+ // segment as one — that segment is the username in `user:secret@host`.
+ const scheme = /^[a-zA-Z][a-zA-Z0-9+.-]*(?=:\/\/)/.exec(endpoint);
+ if (scheme === null) {
+ return "no scheme";
+ }
+ const name = scheme[0].toLowerCase();
+ // A socket scheme only reaches the decline branch with nothing after it, so
+ // naming it alone would read as a contradiction.
+ return name === "unix" || name === "npipe" ? `${name}, no socket path` : name;
+}
+
+/**
+ * Socket-inactivity deadline: a connected-but-silent endpoint degrades to the
+ * container-CLI fallback instead of parking the command (#6110's hang shape).
+ */
+const ENGINE_PROBE_TIMEOUT_MS = 2000;
+
+/**
+ * Absolute wall-clock deadline for one probe. The socket timeout above is
+ * inactivity-based, so a peer trickling bytes could evade it; past this bound
+ * the probe is interrupted (aborting the request) and falls back.
+ */
+const ENGINE_PROBE_DEADLINE_MS = 5000;
+
+/**
+ * Body bound (an inspect payload is a few KB); past it the probe stops
+ * reading and falls back.
+ */
+const ENGINE_MAX_RESPONSE_BYTES = 64 * 1024;
+
+/**
+ * Docker sets `Api-Version`/`Server: Docker/` on every response, 404s
+ * included (Podman's compat API sends `Api-Version` too). A 200/404 without
+ * them is some other service on the socket, not an answer — fall back.
+ */
+const isEngineResponse = (response: http.IncomingMessage): boolean =>
+ response.headers["api-version"] !== undefined ||
+ String(response.headers["server"] ?? "").startsWith("Docker/");
+
+/**
+ * `GET /containers//json` over a local socket / named pipe. Total: every
+ * terminal state settles exactly once, and anything that is not an
+ * Engine-identified 200/404 settles `Option.none()`. `agent: false` keeps the
+ * one-shot connection out of the process-global pool, so nothing outlives the
+ * probe.
+ */
+const inspectContainerOverSocket = (
+ socketPath: string,
+ containerId: string,
+): Effect.Effect> =>
+ Effect.callback((resume, signal) => {
+ let settled = false;
+ const settle = (result: Option.Option) => {
+ if (settled) return;
+ settled = true;
+ resume(Effect.succeed(result));
+ };
+ const settleNone = () => {
+ settle(Option.none());
+ };
+
+ let request: http.ClientRequest;
+ try {
+ request = http.request(
+ {
+ socketPath,
+ method: "GET",
+ path: `/containers/${encodeURIComponent(containerId)}/json`,
+ // HTTP/1.1 requires a Host header; "docker" is what Docker's SDKs send.
+ headers: { Host: "docker" },
+ agent: false,
+ timeout: ENGINE_PROBE_TIMEOUT_MS,
+ signal,
+ },
+ (response) => {
+ const chunks: Array = [];
+ let size = 0;
+ response.on("data", (chunk: Buffer) => {
+ size += chunk.length;
+ if (size > ENGINE_MAX_RESPONSE_BYTES) {
+ response.destroy();
+ settleNone();
+ return;
+ }
+ chunks.push(chunk);
+ });
+ response.on("error", settleNone);
+ // A dropped connection can surface as `close` without `end` (no
+ // `error` guaranteed); the latch no-ops the ordinary post-`end` close.
+ response.on("close", settleNone);
+ response.on("end", () => {
+ if (!isEngineResponse(response)) {
+ settleNone();
+ return;
+ }
+ const status = response.statusCode ?? 0;
+ if (status === 404) {
+ settle(Option.some(false));
+ return;
+ }
+ if (status !== 200) {
+ settleNone();
+ return;
+ }
+ // A 200 must carry a JSON-object inspect payload to count as "present".
+ let payload: unknown;
+ try {
+ payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
+ } catch {
+ payload = undefined;
+ }
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
+ settleNone();
+ return;
+ }
+ settle(Option.some(true));
+ });
+ },
+ );
+ request.on("error", settleNone);
+ // Node leaves the in-flight socket alive on `timeout` — destroy it. Also
+ // the universal backstop; deliberately no `request.on("close")`: under
+ // Bun that fires mid-response, between `data` and `end`.
+ request.on("timeout", () => {
+ request.destroy();
+ settleNone();
+ });
+ request.end();
+ } catch {
+ // A transport that cannot even be constructed is a transport failure too.
+ settleNone();
+ return;
+ }
+
+ return Effect.sync(() => {
+ settled = true;
+ request.destroy();
+ });
+ });
+
+/**
+ * Production {@link LocalDockerEngine}: resolves the endpoint the way the
+ * `docker` CLI itself would (`DOCKER_HOST` -> context store -> platform
+ * default), inside `Effect.suspend` so every execution sees the current
+ * environment. With `DebugLogger` provided (the db families provide it), the
+ * probe's socket and fallback decisions surface under `--debug` — otherwise
+ * this is the one HTTP call the debug side channel cannot see. An endpoint it
+ * cannot address is named by {@link describeEndpoint}, never printed.
+ */
+export const localDockerEngineLayer: Layer.Layer = Layer.effect(
+ LocalDockerEngine,
+ Effect.gen(function* () {
+ const debugLogger = yield* Effect.serviceOption(DebugLogger);
+ const debug = (line: string) =>
+ Option.isSome(debugLogger) ? debugLogger.value.debug(line) : Effect.void;
+ const httpLine = (url: string) =>
+ Option.isSome(debugLogger) ? debugLogger.value.http("GET", redactHttpUrl(url)) : Effect.void;
+ return LocalDockerEngine.of({
+ containerExists: (containerId) =>
+ Effect.suspend(() => {
+ const endpoint = resolveDockerDaemonEndpoint();
+ const socketPath =
+ endpoint === undefined ? undefined : dockerEndpointSocketPath(endpoint);
+ if (socketPath === undefined) {
+ return debug(
+ `local db engine probe: endpoint not directly addressable (${describeEndpoint(endpoint)}) — using the container CLI`,
+ ).pipe(Effect.as(Option.none()));
+ }
+ return httpLine(`${endpoint}/containers/${containerId}/json`).pipe(
+ Effect.andThen(inspectContainerOverSocket(socketPath, containerId)),
+ Effect.timeoutOrElse({
+ duration: ENGINE_PROBE_DEADLINE_MS,
+ orElse: () => Effect.succeed(Option.none()),
+ }),
+ Effect.tap((answer) =>
+ Option.isNone(answer)
+ ? debug(
+ "local db engine probe: no definitive Engine answer — falling back to the container CLI",
+ )
+ : Effect.void,
+ ),
+ );
+ }),
+ });
+ }),
+);
+
const decodeChunks = (chunks: ReadonlyArray): string => {
const total = chunks.reduce((size, chunk) => size + chunk.length, 0);
const bytes = new Uint8Array(total);
@@ -41,16 +286,18 @@ const decodeChunks = (chunks: ReadonlyArray): string => {
};
/**
- * Port of Go's `utils.AssertSupabaseDbIsRunning` (`internal/utils/misc.go:144`):
- * inspect the local Postgres container. Resolves `true` when it exists (the
- * stack is up) and `false` when the container-CLI reports a missing container —
- * Docker's "No such container"/"No such object" or Podman's own "no container with
- * name or ID ... found" wording, via the shared `isContainerNotFoundMessage`
- * matcher (`../container-cli.ts`) — Go's `ErrNotRunning`. Any other inspect
- * failure (e.g. the Docker daemon is
- * unreachable) fails with {@link LocalDbRunningError} instead of being
- * treated as "not running", matching Go, which returns the wrapped inspect
- * error rather than silently treating the database as stopped.
+ * Answers "does the local Postgres container exist?" (the stack-up probe run
+ * before any database bootstrap). Resolves `true` when it exists and `false`
+ * when it definitively does not; any other inspect failure (e.g. the Docker
+ * daemon is unreachable) fails with {@link LocalDbRunningError} instead
+ * of being silently treated as "not running".
+ *
+ * The probe asks the Engine API directly first ({@link LocalDockerEngine}),
+ * so a stalled `docker` CLI binary can no longer block it (issue #6110); only
+ * when the Engine gives no definitive answer does it fall back to the
+ * container-CLI spawn below, which preserves the Podman fallback and the
+ * daemon-down classification (via the shared `isContainerNotFoundMessage`
+ * matcher in `../container-cli.ts`).
*
* Shared by `db start` (`commands/db/start/start.handler.ts`) and `db reset`
* (`commands/db/reset/reset.handler.ts`) — hoisted out of the `db __db-bootstrap`
@@ -61,7 +308,7 @@ const decodeChunks = (chunks: ReadonlyArray): string => {
* itself no longer exists at all.
*
* `resolveDbToml` mirrors the seam's own best-effort read: the caller has
- * already run Go's `LoadConfig` validation before reaching this check, so here
+ * already run the config load/validation before reaching this check, so here
* we only want the resolved `projectId` and tolerate falling back to the
* workdir basename on an unreadable `.env` rather than re-throwing.
*/
@@ -71,7 +318,7 @@ export function isLocalDbRunning(
path: Path.Path,
workdir: string,
configuredProjectId: string | undefined,
-): Effect.Effect {
+): Effect.Effect {
return Effect.scoped(
Effect.gen(function* () {
// `warnOnUnresolvedEnv: false` — this doc comment's own `resolveDbToml` note:
@@ -92,6 +339,10 @@ export function isLocalDbRunning(
workdir,
);
const containerId = localDbContainerId(projectId);
+ // Engine probe first; `Option.none()` falls through to the CLI spawn below.
+ const engine = yield* LocalDockerEngine;
+ const engineAnswer = yield* engine.containerExists(containerId);
+ if (Option.isSome(engineAnswer)) return engineAnswer.value;
// Discard stdout (the inspect JSON) so the unconsumed pipe can never
// deadlock; only the exit code + stderr matter.
const child = yield* spawnContainerCli(spawner, ["container", "inspect", containerId], {
diff --git a/apps/cli/src/command-internal/hostname.ts b/apps/cli/src/command-internal/hostname.ts
index 108ae6cfc5..01a3d06a6c 100644
--- a/apps/cli/src/command-internal/hostname.ts
+++ b/apps/cli/src/command-internal/hostname.ts
@@ -100,26 +100,49 @@ function hostFromTcpEndpoint(endpoint: string): string | undefined {
}
}
+/**
+ * `docker/cli` `load.go`'s per-platform `DefaultDockerHost` — what the
+ * `"default"` context stands for, consulted only after `DOCKER_HOST` and the
+ * context store. Hoisted from `commands/start/services/vector.service.ts`.
+ */
+export function platformDefaultDockerHost(platform: NodeJS.Platform = process.platform): string {
+ return platform === "win32" ? "npipe:////./pipe/docker_engine" : "unix:///var/run/docker.sock";
+}
+
+/**
+ * The daemon endpoint the `docker` CLI itself would dial, without spawning
+ * it: `DOCKER_HOST`, else the active context's stored endpoint, with
+ * `"default"` meaning the platform socket. `undefined` for an unreadable
+ * non-default context — direct-transport callers must then fall back to the
+ * CLI. Consumed by {@link getHostname} and the Engine probe
+ * (`db-bootstrap/local-db-running.ts`).
+ */
+export function resolveDockerDaemonEndpoint(): string | undefined {
+ const dockerHost = process.env["DOCKER_HOST"];
+ if (dockerHost !== undefined && dockerHost.length > 0) {
+ return dockerHost;
+ }
+ const contextName = currentDockerContextName();
+ if (contextName === DEFAULT_CONTEXT_NAME) {
+ return platformDefaultDockerHost();
+ }
+ return dockerContextEndpointHost(contextName);
+}
+
/**
* Resolves the hostname used for local Supabase service connections, mirroring
* `utils.GetHostname`:
*
* 1. `SUPABASE_SERVICES_HOSTNAME` env override — set in dev containers or when
* the Docker daemon is not reachable on the container's own loopback.
- * 2. The Docker daemon host when `DOCKER_HOST` is a `tcp://host:port` endpoint
- * (`Docker.DaemonHost()` + `client.ParseHostURL` + `net.SplitHostPort`).
- * 3. Otherwise, the ACTIVE DOCKER CONTEXT's daemon endpoint, when it's a
- * `tcp://` one — `Docker.DaemonHost()` comes from a client built via
- * `command.NewDockerCli()` + `cli.Initialize()`, whose endpoint resolution walks `DOCKER_HOST` ->
- * `DOCKER_CONTEXT` -> the config file's `currentContext` -> the context
- * store (`docker/cli` `cli/command/cli.go`'s `getDockerEndPoint`/
- * `resolveContextName`) — not just `DOCKER_HOST`. The `docker`/`podman`
- * binary this module's callers shell out to for `ps`/`inspect` already
- * resolves the same active context itself, so without this step `status`
- * could correctly inspect a remote daemon while printing unusable
- * `127.0.0.1` API/DB/Studio URLs for it.
- * 4. `127.0.0.1` otherwise (the default unix-socket daemon, or an
- * unresolvable/malformed context).
+ * 2. The active Docker daemon endpoint's host, when that endpoint is a
+ * `tcp://host:port` one — resolved by {@link resolveDockerDaemonEndpoint}
+ * exactly the way the `docker`/`podman` binary this module's callers shell out
+ * to for `ps`/`inspect` resolves it itself, so `status` never correctly
+ * inspects a remote daemon while printing unusable `127.0.0.1`
+ * API/DB/Studio URLs for it.
+ * 3. `127.0.0.1` otherwise (the default unix-socket daemon, a non-tcp
+ * endpoint, or an unresolvable/malformed context).
*
* Shared across commands that connect to the local stack (`gen types`,
* `test db`, `status`, `stop`, and later `db reset` / `db dump`).
@@ -129,13 +152,9 @@ export function getHostname(): string {
if (override !== undefined && override.length > 0) {
return override;
}
- const dockerHost = process.env["DOCKER_HOST"];
- if (dockerHost !== undefined && dockerHost.length > 0) {
- return hostFromTcpEndpoint(dockerHost) ?? LOCAL_HOST;
- }
- const contextEndpoint = dockerContextEndpointHost(currentDockerContextName());
- if (contextEndpoint !== undefined) {
- const host = hostFromTcpEndpoint(contextEndpoint);
+ const endpoint = resolveDockerDaemonEndpoint();
+ if (endpoint !== undefined) {
+ const host = hostFromTcpEndpoint(endpoint);
if (host !== undefined) {
return host;
}
diff --git a/apps/cli/src/command-internal/hostname.unit.test.ts b/apps/cli/src/command-internal/hostname.unit.test.ts
index d98fc89709..1ef9f8c449 100644
--- a/apps/cli/src/command-internal/hostname.unit.test.ts
+++ b/apps/cli/src/command-internal/hostname.unit.test.ts
@@ -4,7 +4,12 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
-import { configureLoopbackProxyBypass, getHostname } from "./hostname.ts";
+import {
+ configureLoopbackProxyBypass,
+ getHostname,
+ platformDefaultDockerHost,
+ resolveDockerDaemonEndpoint,
+} from "./hostname.ts";
const LOOPBACK_NO_PROXY = "localhost,127.0.0.1,[::1]";
@@ -189,6 +194,75 @@ describe("getHostname", () => {
});
});
+describe("platformDefaultDockerHost", () => {
+ it("resolves the unix default off Windows", () => {
+ expect(platformDefaultDockerHost("darwin")).toBe("unix:///var/run/docker.sock");
+ expect(platformDefaultDockerHost("linux")).toBe("unix:///var/run/docker.sock");
+ });
+
+ it("resolves the named-pipe default on Windows", () => {
+ expect(platformDefaultDockerHost("win32")).toBe("npipe:////./pipe/docker_engine");
+ });
+});
+
+describe("resolveDockerDaemonEndpoint", () => {
+ let configDirs: Array = [];
+
+ afterEach(() => {
+ for (const dir of configDirs) rmSync(dir, { recursive: true, force: true });
+ configDirs = [];
+ });
+
+ function withDockerConfig(
+ options: Parameters[0],
+ env: Record,
+ run: () => T,
+ ): T {
+ const dir = writeDockerConfigDir(options);
+ configDirs.push(dir);
+ return withEnv(
+ { DOCKER_HOST: undefined, DOCKER_CONTEXT: undefined, DOCKER_CONFIG: dir, ...env },
+ run,
+ );
+ }
+
+ it("returns DOCKER_HOST verbatim, non-tcp schemes included", () => {
+ expect(
+ withEnv({ DOCKER_HOST: "unix:///custom/engine.sock" }, resolveDockerDaemonEndpoint),
+ ).toBe("unix:///custom/engine.sock");
+ expect(withEnv({ DOCKER_HOST: "tcp://docker-host:2375" }, resolveDockerDaemonEndpoint)).toBe(
+ "tcp://docker-host:2375",
+ );
+ });
+
+ it("maps the default context to the platform-default daemon endpoint", () => {
+ expect(withDockerConfig({}, {}, resolveDockerDaemonEndpoint)).toBe(platformDefaultDockerHost());
+ expect(
+ withDockerConfig(
+ { currentContext: "default", contexts: { default: "tcp://never-read:2375" } },
+ {},
+ resolveDockerDaemonEndpoint,
+ ),
+ ).toBe(platformDefaultDockerHost());
+ });
+
+ it("returns the active context's stored endpoint verbatim", () => {
+ expect(
+ withDockerConfig(
+ { currentContext: "remote", contexts: { remote: "tcp://remote-host:2375" } },
+ {},
+ resolveDockerDaemonEndpoint,
+ ),
+ ).toBe("tcp://remote-host:2375");
+ });
+
+ it("returns undefined for an unreadable non-default context, never the platform default", () => {
+ expect(
+ withDockerConfig({ currentContext: "ghost" }, {}, resolveDockerDaemonEndpoint),
+ ).toBeUndefined();
+ });
+});
+
describe("configureLoopbackProxyBypass", () => {
it.each([
["sets NO_PROXY when neither spelling is configured", {}, { NO_PROXY: LOOPBACK_NO_PROXY }],
diff --git a/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts b/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts
index 6a610e8aed..04fc9f26ad 100644
--- a/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts
+++ b/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts
@@ -13,6 +13,7 @@ import { pgDeltaNextEngineLayer } from "../commands/db/shared/pgdelta-engine.nex
import { pgDeltaNextAdapterLayer } from "../commands/db/shared/pgdelta-next-adapter.layer.ts";
import { pgDeltaNextShadowLayer } from "../commands/db/shared/pgdelta-next-shadow.layer.ts";
import { declarativeSeamLayer } from "../commands/db/shared/pgdelta.seam.layer.ts";
+import { localDockerEngineLayer } from "./db-bootstrap/local-db-running.ts";
/** The in-process pg-delta engine — the only implementation. */
const pgDeltaEngineLayer = pgDeltaNextEngineLayer;
@@ -43,11 +44,14 @@ export const migraRuntimeLayer = Layer.mergeAll(
pgDeltaSslProbeLayer,
);
const httpClient = httpClientLayer.pipe(Layer.provide(debugLoggerLayer));
+const localDockerEngine = localDockerEngineLayer.pipe(Layer.provide(debugLoggerLayer));
const seam = declarativeSeamLayer.pipe(
Layer.provide(pgDeltaCommandSettingsRuntimeLayer),
Layer.provide(dbConnectionLayer),
Layer.provide(dockerRunLayer),
Layer.provide(httpClient),
+ // Backs the seam's `isLocalDbRunning`/`startLocalDatabase` Engine-API probe.
+ Layer.provide(localDockerEngine),
);
const nextShadow = pgDeltaNextShadowLayer.pipe(
Layer.provide(dockerRunLayer),
@@ -71,4 +75,6 @@ export const pgDeltaCommandRuntimeLayer = Layer.mergeAll(
seam,
engine,
pgDeltaCommandSettingsRuntimeLayer,
+ // Exposed for handlers' own direct `isLocalDbRunning` calls (`db diff --use-pgadmin`).
+ localDockerEngine,
);
diff --git a/apps/cli/src/commands/db/diff/diff.integration.test.ts b/apps/cli/src/commands/db/diff/diff.integration.test.ts
index d3739af49b..81afa7bc67 100644
--- a/apps/cli/src/commands/db/diff/diff.integration.test.ts
+++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts
@@ -16,6 +16,7 @@ import {
mockCommandSettings,
mockDockerDaemonCliSpawner,
mockLinkedProjectCacheTracked,
+ mockLocalDockerEngineUnavailableLayer,
mockShadowContainerCliSpawner,
mockTelemetryStateTracked,
useShadowCacheDisabled,
@@ -430,6 +431,7 @@ function setup(workdir: string, opts: SetupOpts = {}) {
docker,
shadowDbConnection.layer,
dockerDaemon?.layer ?? shadowSpawner.layer,
+ mockLocalDockerEngineUnavailableLayer,
alwaysReadyHttpClientLayer,
resolver,
projectRefResolver,
diff --git a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md
index 288947e3c8..d4a4e41ba3 100644
--- a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md
+++ b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md
@@ -30,19 +30,19 @@ removed `DeclarativeSeam.execInherit` seam — see those commands' own
## Files Read
-| Path | Format | When |
-| ----------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------- |
-| `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations |
-| `/supabase/config.toml` | TOML | always, parsed up front before any destructive work (embedded defaults when absent); re-read for local bucket seeding |
-| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | always, resolved before the local prelude (config values, bootstrap config) |
-| `/.git/HEAD` (walked upward) | plain text | local path, for the `Finished … on branch .` line |
-| `~/.supabase//project-ref` | plain text | `--linked`, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set |
-| `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted |
-| seed files from `--sql-paths` or `[db.seed].sql_paths` | SQL | when seeding is enabled (not `--no-seed`); `--sql-paths` overrides config |
-| schema files from `[db.migrations].schema_paths` | SQL | when the `--experimental` schema-files branch is taken, either target (see Notes) |
-| `/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects |
-| `/supabase/roles.sql` | SQL | local PG15 path only, via the reused `startSetupLocalDatabase` pipeline — missing file tolerated |
-| `~/.docker/config.json` | JSON | via the `docker`/`podman` CLI itself, for registry auth — never read directly by this process |
+| Path | Format | When |
+| -------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
+| `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations |
+| `/supabase/config.toml` | TOML | always, parsed up front before any destructive work (embedded defaults when absent); re-read for local bucket seeding |
+| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | always, resolved before the local prelude (config values, bootstrap config) |
+| `/.git/HEAD` (walked upward) | plain text | local path, for the `Finished … on branch .` line |
+| `~/.supabase//project-ref` | plain text | `--linked`, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set |
+| `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted |
+| seed files from `--sql-paths` or `[db.seed].sql_paths` | SQL | when seeding is enabled (not `--no-seed`); `--sql-paths` overrides config |
+| schema files from `[db.migrations].schema_paths` | SQL | when the `--experimental` schema-files branch is taken, either target (see Notes) |
+| `/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects |
+| `/supabase/roles.sql` | SQL | local PG15 path only, via the reused `startSetupLocalDatabase` pipeline — missing file tolerated |
+| `~/.docker/config.json` + Docker context store (`contexts/meta//meta.json`) | JSON | resolving the daemon endpoint for the local path's running probe (in-process); also read by the `docker`/`podman` CLI itself for registry auth |
## Files Written
@@ -60,7 +60,7 @@ equivalent, PG15) or `InitSchema14`/`ApplyApiPrivileges` (PG14).
| Command | When | Purpose |
| ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `docker container inspect supabase_db_` | local path | `AssertSupabaseDbIsRunning` probe (Podman fallback) |
+| `docker container inspect supabase_db_` | local path | `AssertSupabaseDbIsRunning` probe (Podman fallback) — spawned only when the direct Engine-API request over the active local socket/named pipe gives no definitive answer |
| `docker container rm -f supabase_db_` / `docker volume rm -f ` | local path, PG15 | remove the existing container/volume before recreating (Podman fallback) |
| `docker network create` / `docker volume create` / `docker create` / `docker start` | local path, PG15 | recreate the Postgres container (same primitives `db start` uses) |
| `docker run --rm ` | local path, PG15, per enabled service | the one-shot `initSchema15` migrate jobs (`startSetupLocalDatabase`) |
@@ -121,27 +121,29 @@ the whole reset** (not just "skip buckets").
## API Routes
-| Method | Path | Auth | Request body | Response (used fields) |
-| ------ | ---- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| — | — | — | — | Connects to Postgres directly. The `--linked` resolver may call the Management API to mint a temporary login role; local bucket seeding calls the Storage gateway. |
+| Method | Path | Auth | Request body | Response (used fields) |
+| ------ | ---------------------------------------- | ---- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| — | — | — | — | Connects to Postgres directly. The `--linked` resolver may call the Management API to mint a temporary login role; local bucket seeding calls the Storage gateway. |
+| GET | `/containers/supabase_db_/json` | — | — | local path — the running probe's Docker Engine API request over the active context's local socket/named pipe (`Api-Version`/`Server` identity headers + status; a 200 body must parse as a JSON object; identity-gated 200 → running, 404 → absent; anything else → the spawned-CLI fallback) |
## Environment Variables
-| Variable | Purpose | Required? |
-| ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
-| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) |
-| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no |
-| `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) |
-| `SUPABASE_EXPERIMENTAL` | selects the schema-files apply branch on either target | no (also `--experimental`) |
-| `SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` | overrides `[experimental.pgdelta].enabled`; a truthy value flips the reset gate (`experimental && resolvedVersion === "" && !toml.pgDelta.enabled`) back to timestamped migrations even with `--experimental` set — switches between two different destructive code paths | no |
-| `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` | overrides `[db.migrations].schema_paths` (viper `AutomaticEnv`, beats the config-file value) for the schema-files apply branch — genuinely effective on both targets now | no (no dedicated flag — config-file-only otherwise) |
-| `SUPABASE_PROJECT_ID` | overrides the local container id; ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no |
-| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the image registry used to resolve the local path's container images (scoped for the whole run via `applyProjectEnv`) | no (project `.env` or shell) |
-| `SUPABASE_USE_SLIM_IMAGES` | resolves the local-reset Postgres image and the realtime/storage/auth migrate-job images from slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB, and flag-off `15.8.1.085` stay on docker.io | no (ambient shell only) |
-| `SUPABASE_DB_PORT` / `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | local-path container-recreate config overrides, same as `db start` | no |
-| `SUPABASE_API_PORT` / `SUPABASE_API_EXTERNAL_URL` / `SUPABASE_API_TLS_*` / `SUPABASE_API_ENABLED` | local-path bucket-seed step: override the matching `[api]` fields for the Storage gateway URL/TLS, same as `seed buckets` (shell or project dotenv; #6452) | no |
-| `SUPABASE_AUTH_JWT_SECRET` / `SUPABASE_AUTH_SERVICE_ROLE_KEY` | local path: override `auth.jwt_secret` / `auth.service_role_key` (shell or project dotenv, `encrypted:` decrypted) — feeds the recreated Postgres container (jwt secret), the fresh-database setup jobs (both) and the bucket-seed Storage service-role key, same as `seed buckets` | no |
-| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the recreated container/network onto an existing Docker network | no |
+| Variable | Purpose | Required? |
+| -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
+| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | local path: ambient shell environment only (project dotenv files deliberately never override Docker client keys) — resolves the daemon endpoint for the running probe (in-process) and steers the spawned `docker`/`podman` CLI itself | no |
+| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) |
+| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no |
+| `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) |
+| `SUPABASE_EXPERIMENTAL` | selects the schema-files apply branch on either target | no (also `--experimental`) |
+| `SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` | overrides `[experimental.pgdelta].enabled`; a truthy value flips the reset gate (`experimental && resolvedVersion === "" && !toml.pgDelta.enabled`) back to timestamped migrations even with `--experimental` set — switches between two different destructive code paths | no |
+| `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` | overrides `[db.migrations].schema_paths` (viper `AutomaticEnv`, beats the config-file value) for the schema-files apply branch — genuinely effective on both targets now | no (no dedicated flag — config-file-only otherwise) |
+| `SUPABASE_PROJECT_ID` | overrides the local container id; ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no |
+| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the image registry used to resolve the local path's container images (scoped for the whole run via `applyProjectEnv`) | no (project `.env` or shell) |
+| `SUPABASE_USE_SLIM_IMAGES` | resolves the local-reset Postgres image and the realtime/storage/auth migrate-job images from slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB, and flag-off `15.8.1.085` stay on docker.io | no (ambient shell only) |
+| `SUPABASE_DB_PORT` / `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | local-path container-recreate config overrides, same as `db start` | no |
+| `SUPABASE_API_PORT` / `SUPABASE_API_EXTERNAL_URL` / `SUPABASE_API_TLS_*` / `SUPABASE_API_ENABLED` | local-path bucket-seed step: override the matching `[api]` fields for the Storage gateway URL/TLS, same as `seed buckets` (shell or project dotenv; #6452) | no |
+| `SUPABASE_AUTH_JWT_SECRET` / `SUPABASE_AUTH_SERVICE_ROLE_KEY` | local path: override `auth.jwt_secret` / `auth.service_role_key` (shell or project dotenv, `encrypted:` decrypted) — feeds the recreated Postgres container (jwt secret), the fresh-database setup jobs (both) and the bucket-seed Storage service-role key, same as `seed buckets` | no |
+| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the recreated container/network onto an existing Docker network | no |
## Connection loss during migration apply
diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts
index c3dd5515db..6e75c9ec57 100644
--- a/apps/cli/src/commands/db/reset/reset.integration.test.ts
+++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts
@@ -19,6 +19,7 @@ import {
VALID_REF,
mockCommandSettings,
mockLinkedProjectCacheTracked,
+ mockLocalDockerEngineUnavailableLayer,
mockCommandPlatformApiService,
mockTelemetryStateTracked,
useTempWorkdir,
@@ -44,6 +45,7 @@ import { dockerRunLayer } from "../../../command-internal/docker-run.layer.ts";
import { DbConfigResolver } from "../../../command-internal/db-config.service.ts";
import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts";
import { DbConfigConnectTempRoleError } from "../../../command-internal/db-config.errors.ts";
+import { LocalDockerEngine } from "../../../command-internal/db-bootstrap/local-db-running.ts";
import { DbExecError } from "../../../command-internal/db-connection.errors.ts";
import {
DbConnection,
@@ -465,6 +467,7 @@ function setup(
mockCommandSettings({ workdir }),
BunServices.layer,
child.layer,
+ mockLocalDockerEngineUnavailableLayer,
mockRuntimeInfo({ platform: "linux" }),
mockProcessControl().layer,
alwaysReadyHttpClientLayer,
@@ -661,6 +664,34 @@ describe("db reset", () => {
},
);
+ it.live(
+ "refuses a local reset from the direct Engine answer without touching the container CLI",
+ () => {
+ const { layer, child } = setup(tmp.current, {
+ toml: 'project_id = "test"\n',
+ args: ["db", "reset", "--local"],
+ isLocal: true,
+ routeOpts: { running: true },
+ });
+ return Effect.gen(function* () {
+ const exit = yield* dbReset(DEFAULT_FLAGS).pipe(
+ Effect.provide(
+ Layer.succeed(LocalDockerEngine, {
+ containerExists: () => Effect.succeed(Option.some(false)),
+ }),
+ ),
+ Effect.provide(layer),
+ Effect.exit,
+ );
+ expect(Exit.isFailure(exit)).toBe(true);
+ if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("is not running.");
+ expect(
+ child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "inspect"),
+ ).toBe(false);
+ });
+ },
+ );
+
it.live(
"fails a local reset before the destructive recreate on a malformed config.toml",
() => {
diff --git a/apps/cli/src/commands/db/reset/reset.layers.ts b/apps/cli/src/commands/db/reset/reset.layers.ts
index 28d1f12400..44d1585ceb 100644
--- a/apps/cli/src/commands/db/reset/reset.layers.ts
+++ b/apps/cli/src/commands/db/reset/reset.layers.ts
@@ -1,4 +1,5 @@
import { Layer } from "effect";
+import { localDockerEngineLayer } from "../../../command-internal/db-bootstrap/local-db-running.ts";
import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts";
import { commandCredentialsLayer } from "../../../auth/command-credentials.layer.ts";
@@ -84,5 +85,7 @@ export const dbResetRuntimeLayer = Layer.mergeAll(
stdinLayer,
// Backs the native local recreate's PG15+ one-shot migrate jobs.
dockerRunLayer,
+ // Backs `isLocalDbRunning`'s direct Engine-API probe (+ its `--debug` trace).
+ localDockerEngineLayer.pipe(Layer.provide(debugLoggerLayer)),
commandRuntimeLayer(["db", "reset"]),
);
diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts
index 8877e5035a..95177d7db9 100644
--- a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts
+++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts
@@ -23,6 +23,7 @@ import {
import {
mockCommandSettings,
mockLinkedProjectCacheTracked,
+ mockLocalDockerEngineUnavailableLayer,
mockCommandPlatformApiService,
mockTelemetryStateTracked,
useTempWorkdir,
@@ -207,6 +208,7 @@ function setup(workdir: string, opts: SetupOpts = {}) {
cache.layer,
seam,
engine,
+ mockLocalDockerEngineUnavailableLayer,
resolver,
proxy,
dbConn,
diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts
index 95b10245c9..0d3f04bf6e 100644
--- a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts
+++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts
@@ -22,6 +22,7 @@ import {
import {
mockCommandSettings,
mockLinkedProjectCacheTracked,
+ mockLocalDockerEngineUnavailableLayer,
mockCommandPlatformApiService,
mockTelemetryStateTracked,
useShadowCacheDisabled,
@@ -244,6 +245,7 @@ function setup(workdir: string, opts: SetupOpts = {}) {
cache.layer,
seam,
engine,
+ mockLocalDockerEngineUnavailableLayer,
dbConn,
resolver,
mockCommandSettings({ workdir, projectId: opts.projectId ?? Option.some("test") }),
diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts
index e8cb0f617c..96f8d0ceea 100644
--- a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts
+++ b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts
@@ -10,6 +10,7 @@ import { afterEach, beforeEach, vi } from "vitest";
import {
mockCommandSettings,
+ mockLocalDockerEngineUnavailableLayer,
mockShadowContainerCliSpawner,
useShadowCacheDisabled,
} from "../../../../tests/helpers/command-mocks.ts";
@@ -117,6 +118,7 @@ function setup(
// `seam` itself resolves — `Layer.provide` fully resolves each requirement it can
// satisfy as it's applied, so `BunServices.layer` only ever fills in `FileSystem`/`Path`.
Layer.provide(shadowSpawner.layer),
+ Layer.provide(mockLocalDockerEngineUnavailableLayer),
Layer.provide(BunServices.layer),
);
diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts
index 0c30f776d8..cd3aa13c94 100644
--- a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts
+++ b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts
@@ -88,6 +88,8 @@ export const declarativeSeamLayer = Layer.effect(
cliSettings.workdir,
Option.getOrUndefined(cliSettings.projectId),
).pipe(
+ // Satisfies the probe's `LocalDockerEngine` requirement from the captured deps.
+ Effect.provideContext(context),
Effect.mapError(
(cause) =>
new DeclarativeShadowDbError({
diff --git a/apps/cli/src/commands/db/start/SIDE_EFFECTS.md b/apps/cli/src/commands/db/start/SIDE_EFFECTS.md
index 0414caeb8e..4e73105a01 100644
--- a/apps/cli/src/commands/db/start/SIDE_EFFECTS.md
+++ b/apps/cli/src/commands/db/start/SIDE_EFFECTS.md
@@ -7,9 +7,15 @@ Fully native. CLI-1954 removed the last Go delegation — the hidden `db __db-bo
`Finished` line, no `--exclude`, no `--ignore-health-check`.
The handler validates config, checks whether the local Postgres container is already
-running (`isLocalDbRunning` — a native `docker container inspect`, hoisted to
-`command-internal/db-bootstrap/local-db-running.ts` and shared with `db reset --local`'s
-own running-check), and otherwise natively brings up the container itself, reusing
+running (`isLocalDbRunning` in `command-internal/db-bootstrap/local-db-running.ts`,
+shared with `db reset --local`'s own running-check — a direct Docker Engine API
+`GET /containers//json` over the active context's unix socket / named pipe, falling
+back to a spawned `docker container inspect` whenever the Engine gives no definitive,
+Engine-identified answer, so a stalled `docker` CLI binary can no longer block the
+already-running check itself — issue #6110's silent pre-output hang. The bring-up that
+follows a definitive "absent" answer still shells out, starting with the volume-freshness
+probe, so a stalled `docker` CLI still blocks an actual bring-up), and otherwise natively
+brings up the container itself, reusing
`command-internal/db-bootstrap/`'s container-bootstrap primitives (the same ones `supabase
start` uses for its own Postgres bring-up, and `db reset --local`'s own recreate
composition reuses too — see that command's `SIDE_EFFECTS.md`):
@@ -74,7 +80,7 @@ volume was confirmed fresh this run).
| `/supabase/migrations/*.sql`, `supabase/seed.sql` | SQL | on a fresh volume with no `--from-backup`, via the standard migration-apply + seed pipeline |
| `/supabase/` (files/directories/globs) | SQL | on a fresh volume with no `--from-backup`, INSTEAD of `migrations/*.sql`, when `--experimental`/`SUPABASE_EXPERIMENTAL` is set and `[experimental.pgdelta] enabled` is false |
| `/supabase/.branches/_current_branch` | text | always, existence check before writing (see "Files Written") |
-| `~/.docker/config.json` | JSON | via the `docker`/`podman` CLI itself, for registry auth — never read directly by this process |
+| `~/.docker/config.json` + Docker context store (`contexts/meta//meta.json`) | JSON | resolving the daemon endpoint for the already-running probe (in-process); also read by the `docker`/`podman` CLI itself for registry auth |
## Files Written
@@ -88,11 +94,13 @@ volume was confirmed fresh this run).
## Subprocesses
Every step below shells out to `docker` (falling back to `podman`), matching every other
-native container command in this codebase — never `supabase-go`.
+native container command in this codebase — never `supabase-go` — except the
+already-running probe, which prefers a direct Engine-API request (see "API Routes") and
+only spawns on fallback.
| Command | When |
| -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `docker container inspect supabase_db_` | always — the already-running probe |
+| `docker container inspect supabase_db_` | the already-running probe — only when the direct Engine-API request (active local socket/named pipe) gives no definitive answer |
| `docker network create --label ... ` | when not already running, unless `--network-id` names a built-in network |
| `docker volume inspect supabase_db_` | when not already running — the pre-create fresh-volume probe |
| `docker image inspect` / `docker pull` (registry-fallback resolve) | when not already running — resolves the Postgres image |
@@ -105,9 +113,12 @@ native container command in this codebase — never `supabase-go`.
## API Routes
-| Method | Path | Auth | Request body | Response (used fields) |
-| ------ | ---- | ---- | ------------ | ---------------------- |
-| — | — | — | — | — |
+No platform (Management API) routes. The already-running probe issues one Docker Engine
+API request over the active context's local unix socket / named pipe:
+
+| Method | Path | Auth | Request body | Response (used fields) |
+| ------ | ---------------------------------------- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| GET | `/containers/supabase_db_/json` | — | — | `Api-Version`/`Server` identity headers + status; a 200 body must parse as a JSON object (identity-gated 200 → running, 404 → absent; anything else → the spawned-CLI fallback) |
## Environment Variables
@@ -128,7 +139,7 @@ native container command in this codebase — never `supabase-go`.
| `SUPABASE_AUTH_EXTERNAL_URL` / `SUPABASE_AUTH_SITE_URL` | auth migrate job env overrides | no |
| `SUPABASE_AUTH_JWT_EXPIRY` | Postgres's `JWT_EXP` env / signing | no |
| `SUPABASE_EXPERIMENTAL` (or `--experimental`) | fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` | no |
-| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file, installed into the process environment before any Docker work) to pick the Docker daemon this whole command talks to | no |
+| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read from the ambient shell environment to pick the Docker daemon this whole command talks to (project dotenv files deliberately never override Docker client keys) | no |
| `SUPABASE_USE_SLIM_IMAGES` | resolves the current Dockerfile pin (and majors 13/15's published slim PG15 pin, `15.14.1.167`) and PG15+ realtime/storage/auth migrate-job images from the slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); historical `.temp` pins, PG14, OrioleDB, and flag-off majors 13/15 (`15.8.1.085`) stay on docker.io | no |
`--network-id` (a global CLI flag, not an environment variable — `command-internal/global-flags.ts`)
diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts
index 6fe67a9fd6..6db72c90bd 100644
--- a/apps/cli/src/commands/db/start/start.integration.test.ts
+++ b/apps/cli/src/commands/db/start/start.integration.test.ts
@@ -16,6 +16,7 @@ import {
} from "../../../../tests/helpers/mocks.ts";
import {
mockCommandSettings,
+ mockLocalDockerEngineUnavailableLayer,
mockTelemetryStateTracked,
useTempWorkdir,
sequentialExecBatch,
@@ -27,6 +28,7 @@ import {
NetworkIdFlag,
} from "../../../command-internal/global-flags.ts";
import type { OutputFormat } from "../../../shared/output/types.ts";
+import { LocalDockerEngine } from "../../../command-internal/db-bootstrap/local-db-running.ts";
import { DbConnectError } from "../../../command-internal/db-connection.errors.ts";
import { DbConnection, type DbSession } from "../../../command-internal/db-connection.service.ts";
import { dockerRunLayer } from "../../../command-internal/docker-run.layer.ts";
@@ -323,6 +325,7 @@ function setup(opts: SetupOpts = {}) {
cliSettings,
telemetry.layer,
child.layer,
+ mockLocalDockerEngineUnavailableLayer,
alwaysReadyHttpClientLayer,
dbConnection,
dockerRunLayer.pipe(Layer.provide(child.layer), Layer.provide(mockProcessControl().layer)),
@@ -374,6 +377,25 @@ describe("db start", () => {
});
});
+ it.live(
+ "reports an already-running database from the direct Engine-API answer without touching the container CLI",
+ () => {
+ const { layer, out, child } = setup({});
+ return Effect.gen(function* () {
+ yield* dbStart(DEFAULT_FLAGS).pipe(
+ Effect.provide(
+ Layer.succeed(LocalDockerEngine, {
+ containerExists: () => Effect.succeed(Option.some(true)),
+ }),
+ ),
+ Effect.provide(layer),
+ );
+ expect(out.stderrText).toContain("Postgres database is already running.");
+ expect(child.spawned).toEqual([]);
+ });
+ },
+ );
+
it.live(
"starts the database on a fresh volume: creates the container, runs the SetupLocalDatabase-equivalent pipeline, and writes _current_branch",
() => {
diff --git a/apps/cli/src/commands/db/start/start.layers.ts b/apps/cli/src/commands/db/start/start.layers.ts
index 8a350c6f38..009249bc79 100644
--- a/apps/cli/src/commands/db/start/start.layers.ts
+++ b/apps/cli/src/commands/db/start/start.layers.ts
@@ -1,4 +1,5 @@
import { Layer } from "effect";
+import { localDockerEngineLayer } from "../../../command-internal/db-bootstrap/local-db-running.ts";
import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts";
import { commandSettingsLayer } from "../../../config/command-settings.layer.ts";
@@ -33,6 +34,8 @@ const httpClient = httpClientLayer.pipe(Layer.provide(debugLoggerLayer));
export const dbStartRuntimeLayer = Layer.mergeAll(
cliSettings,
telemetryStateLayer,
+ // Backs `isLocalDbRunning`'s direct Engine-API probe (+ its `--debug` trace).
+ localDockerEngineLayer.pipe(Layer.provide(debugLoggerLayer)),
commandRuntimeLayer(["db", "start"]),
dockerRunLayer,
dbConnectionLayer,
diff --git a/apps/cli/src/commands/start/services/vector.service.ts b/apps/cli/src/commands/start/services/vector.service.ts
index b3bbeabc2a..2b181c5dbc 100644
--- a/apps/cli/src/commands/start/services/vector.service.ts
+++ b/apps/cli/src/commands/start/services/vector.service.ts
@@ -40,6 +40,7 @@ import {
slimWgetWaitCommand,
} from "../../../command-internal/db-bootstrap/slim-runtime.ts";
import { usesSlimImageRuntime } from "../../../shared/services/slim-images.ts";
+import { platformDefaultDockerHost } from "../../../command-internal/hostname.ts";
import { renderStartVectorYaml } from "../lib/template-render.ts";
type Spawner = ChildProcessSpawner["Service"];
@@ -114,14 +115,6 @@ export function shouldMountRootDockerSocket(host: string): boolean {
);
}
-/**
- * The platform-default Docker host: `platform` defaults to
- * `process.platform`.
- */
-export function platformDefaultDockerHost(platform: NodeJS.Platform = process.platform): string {
- return platform === "win32" ? "npipe:////./pipe/docker_engine" : "unix:///var/run/docker.sock";
-}
-
export interface VectorDockerSocketPlan {
/** The `DOCKER_HOST` env override — empty for the `unix` scheme, which sets no override at all. */
readonly env: Readonly>;
diff --git a/apps/cli/src/commands/start/services/vector.service.unit.test.ts b/apps/cli/src/commands/start/services/vector.service.unit.test.ts
index e2b8d4d606..0f991a8666 100644
--- a/apps/cli/src/commands/start/services/vector.service.unit.test.ts
+++ b/apps/cli/src/commands/start/services/vector.service.unit.test.ts
@@ -7,7 +7,6 @@ import {
buildVectorContainerSpec,
buildVectorEntrypointScript,
parseDockerHostUrl,
- platformDefaultDockerHost,
resolveDockerDaemonHost,
resolveVectorDockerSocketPlan,
shouldMountRootDockerSocket,
@@ -146,17 +145,6 @@ describe("shouldMountRootDockerSocket", () => {
});
});
-describe("platformDefaultDockerHost", () => {
- test("resolves the unix default off Windows", () => {
- expect(platformDefaultDockerHost("darwin")).toBe("unix:///var/run/docker.sock");
- expect(platformDefaultDockerHost("linux")).toBe("unix:///var/run/docker.sock");
- });
-
- test("resolves the npipe default on Windows", () => {
- expect(platformDefaultDockerHost("win32")).toBe("npipe:////./pipe/docker_engine");
- });
-});
-
describe("resolveVectorDockerSocketPlan", () => {
test("tcp: proxies through host.docker.internal on the daemon's own port, no binds/securityOpt (start.go:422-426)", () => {
const plan = resolveVectorDockerSocketPlan("tcp://127.0.0.1:2376");
diff --git a/apps/cli/tests/helpers/command-mocks.ts b/apps/cli/tests/helpers/command-mocks.ts
index 7c81b0fe4c..88bf73cc78 100644
--- a/apps/cli/tests/helpers/command-mocks.ts
+++ b/apps/cli/tests/helpers/command-mocks.ts
@@ -46,6 +46,7 @@ import {
PGDATA_PATH,
} from "../../src/command-internal/db-bootstrap/pgdata-snapshot.ts";
import { projectRefLayer } from "../../src/config/project-ref.layer.ts";
+import { LocalDockerEngine } from "../../src/command-internal/db-bootstrap/local-db-running.ts";
import { LinkedProjectCache } from "../../src/telemetry/linked-project-cache.service.ts";
import { TelemetryState } from "../../src/telemetry/telemetry-state.service.ts";
import { CliArgs } from "../../src/shared/cli/cli-args.service.ts";
@@ -84,6 +85,15 @@ export const mockLinkedProjectCacheLayer = Layer.succeed(LinkedProjectCache, {
cache: () => Effect.void,
});
+/**
+ * Hermetic default: the Engine probe never answers, so tests exercise their
+ * mocked container-CLI spawner instead of dialing the machine's real Docker
+ * socket. Direct-path tests provide their own definitive `LocalDockerEngine`.
+ */
+export const mockLocalDockerEngineUnavailableLayer = Layer.succeed(LocalDockerEngine, {
+ containerExists: () => Effect.succeed(Option.none()),
+});
+
export const mockTelemetryStateLayer = Layer.succeed(TelemetryState, {
flush: Effect.void,
stitchLogin: () => Effect.void,