diff --git a/apps/cli/src/legacy/commands/gen/types/types.handler.ts b/apps/cli/src/legacy/commands/gen/types/types.handler.ts index c1e4f7bf32..6e5a945677 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -30,6 +30,8 @@ import { resolvePgmetaImage, } from "./types.shared.ts"; +type ChildProcessOptions = ChildProcess.CommandOptions; + const mapProjectTypesError = mapLegacyHttpError({ networkError: LegacyGenTypesNetworkError, statusError: LegacyGenTypesUnexpectedStatusError, @@ -307,13 +309,11 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le "node", "dist/server/server.js", ]; - const child = yield* spawner.spawn( - ChildProcess.make("docker", args, { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }), - ); + const child = yield* spawnContainerProcess(args, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); const [exitCode] = yield* Effect.all( [ @@ -336,12 +336,13 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le // We only need the exit code and stderr (Go uses Docker's ContainerInspect API, // which reads no stdout). Discard stdout so the inspect JSON can never fill the // pipe buffer and deadlock the unconsumed stream. - const child = yield* spawner.spawn( - ChildProcess.make("docker", ["container", "inspect", localDbContainerId(projectId)], { + const child = yield* spawnContainerProcess( + ["container", "inspect", localDbContainerId(projectId)], + { stdin: "ignore", stdout: "ignore", stderr: "pipe", - }), + }, ); const [exitCode, stderr] = yield* Effect.all([ child.exitCode.pipe(Effect.map(Number)), @@ -349,7 +350,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le ]); if (exitCode !== 0) { const message = stderr.trim(); - if (message.includes("No such container")) { + if (message.toLowerCase().includes("no such container")) { return yield* Effect.fail(new Error("supabase start is not running.")); } return yield* Effect.fail( @@ -363,6 +364,11 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le }), ); + const spawnContainerProcess = (args: ReadonlyArray, options: ChildProcessOptions) => + spawner + .spawn(ChildProcess.make("docker", args, options)) + .pipe(Effect.catch(() => spawner.spawn(ChildProcess.make("podman", args, options)))); + yield* Effect.gen(function* () { if (flags.local) { const loaded = yield* loadConfig(); diff --git a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts index 48016e3ddf..a66868941f 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { ChildProcessSpawner } from "effect/unstable/process"; import { CliOutput, Command } from "effect/unstable/cli"; -import { Deferred, Effect, Exit, Layer, Option, Sink, Stdio, Stream } from "effect"; +import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stdio, Stream } from "effect"; import { LEGACY_GLOBAL_FLAGS, LegacyDebugFlag, @@ -263,6 +263,78 @@ function mockSequentialChildProcessSpawner( }; } +function mockDockerMissingChildProcessSpawner( + steps: ReadonlyArray<{ + readonly exitCode?: number; + readonly stdout?: ReadonlyArray; + readonly stderr?: ReadonlyArray; + }>, +) { + const encoder = new TextEncoder(); + const spawned: Array<{ command: string; args: ReadonlyArray }> = []; + let stepIndex = 0; + + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const cmd = command._tag === "StandardCommand" ? command.command : ""; + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ command: cmd, args }); + + if (cmd === "docker") { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "docker not found", + }), + ); + } + + const step = steps[Math.min(stepIndex, steps.length - 1)]; + stepIndex += 1; + const exitDeferred = yield* Deferred.make(); + + yield* Effect.forkDetach( + Effect.gen(function* () { + yield* Effect.sleep("10 millis"); + yield* Deferred.succeed( + exitDeferred, + ChildProcessSpawner.ExitCode(step?.exitCode ?? 0), + ); + }), + ); + + const stdoutBytes = (step?.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); + const stderrBytes = (step?.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(3000 + spawned.length), + stdout: Stream.fromIterable(stdoutBytes), + stderr: Stream.fromIterable(stderrBytes), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), + ); + + return { + layer, + get spawned() { + return spawned; + }, + }; +} + async function withSslProbeServer( run: (port: number) => Promise, response: "N" | "S" = "N", @@ -697,6 +769,55 @@ describe("legacy gen types", () => { }), ); + it.live("falls back to podman when the docker executable is missing for local generation", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-podman-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + `port = ${port}`, + ].join("\n"), + ); + const child = mockDockerMissingChildProcessSpawner([ + { exitCode: 0 }, + { exitCode: 0, stdout: ["export type Database = {};"] }, + ]); + const { layer, out } = setup({ + workdir, + childLayer: child.layer, + }); + + await Effect.runPromise( + legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), + ); + + expect(out.stdoutText).toContain("export type Database = {};"); + expect(child.spawned[0]).toEqual({ + command: "docker", + args: ["container", "inspect", "supabase_db_demo"], + }); + expect(child.spawned[1]).toEqual({ + command: "podman", + args: ["container", "inspect", "supabase_db_demo"], + }); + expect(child.spawned[2]?.command).toBe("docker"); + expect(child.spawned[2]?.args).toContain("run"); + expect(child.spawned[3]?.command).toBe("podman"); + expect(child.spawned[3]?.args).toContain("run"); + expect(child.spawned[3]?.args).toContain("supabase_network_demo"); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + it.live("uses sanitized local docker ids and env-backed local db passwords", () => Effect.tryPromise({ try: () => @@ -1080,6 +1201,42 @@ describe("legacy gen types", () => { }); }); + it.live("keeps not-running parity when podman reports the local db container is missing", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-podman-missing-")); + writeConfig( + workdir, + ['project_id = "demo"', "", "[api]", 'schemas = ["public"]', "", "[db]", "port = 54321"].join( + "\n", + ), + ); + const child = mockDockerMissingChildProcessSpawner([ + { + exitCode: 1, + stderr: ['Error: inspecting object: no such container "supabase_db_demo"'], + }, + ]); + const { layer } = setup({ + workdir, + childLayer: child.layer, + }); + + return Effect.gen(function* () { + const exit = yield* legacyGenTypes(defaultFlags({ local: true })).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("supabase start is not running."); + } + expect(child.spawned).toEqual([ + { command: "docker", args: ["container", "inspect", "supabase_db_demo"] }, + { command: "podman", args: ["container", "inspect", "supabase_db_demo"] }, + ]); + }); + }); + it.live( "preserves inspect failure details when local db inspection fails for other reasons", () => {