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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 92 additions & 12 deletions apps/cli/src/shared/legacy/go-proxy.layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import process from "node:process";
import { Effect, Layer } from "effect";
import * as ChildProcess from "effect/unstable/process/ChildProcess";
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
import { CLI_VERSION } from "../cli/version.ts";
import { ProcessControl } from "../runtime/process-control.service.ts";
import { LegacyGoProxy } from "./go-proxy.service.ts";

Expand All @@ -25,16 +26,30 @@ const PLATFORM_CANDIDATES: Partial<Record<string, Partial<Record<string, Readonl

const require = createRequire(import.meta.url);

function resolveBinary(): string {
/**
* Outcome of looking up `supabase-go`. The `notFound` variant carries the
* list of locations the resolver checked so the user-facing error can be
* specific about what was tried — no silent fallback that fork-bombs the
* shim against itself via PATH (CLI-1488).
*/
export type BinaryResolution =
| { readonly found: string }
| { readonly notFound: ReadonlyArray<string> };

function resolveBinary(): BinaryResolution {
const tried: string[] = [];

const envBin = process.env["SUPABASE_GO_BINARY"];
if (envBin) return envBin;
if (envBin) return { found: envBin };
tried.push("$SUPABASE_GO_BINARY (unset)");

const ext = process.platform === "win32" ? ".exe" : "";

// When running as a compiled standalone SFE (exec'd by the base shim via execFileSync),
// process.execPath is the SFE binary path. Look for supabase-go co-located next to it.
const colocated = path.join(path.dirname(process.execPath), `supabase-go${ext}`);
if (existsSync(colocated)) return colocated;
if (existsSync(colocated)) return { found: colocated };
tried.push(`${colocated} (not found alongside the shim)`);

// When running from source, resolve via installed npm packages.
// Guard with existsSync — in dev the workspace stub packages exist but their bin/ is empty.
Expand All @@ -43,14 +58,58 @@ function resolveBinary(): string {
try {
const pkgPath = path.dirname(require.resolve(`@supabase/cli-${suffix}/package.json`));
const bin = path.join(pkgPath, "bin", `supabase-go${ext}`);
if (existsSync(bin)) return bin;
if (existsSync(bin)) return { found: bin };
tried.push(`${bin} (npm package present, binary missing)`);
} catch {
// Package not installed — try next candidate.
tried.push(`@supabase/cli-${suffix} (npm package not installed)`);
}
}

// Fall back to `supabase` on PATH (useful in CI and development).
return "supabase";
return { notFound: tried };
}

/**
* Build a concrete `curl | tar` install snippet for the host platform, using
* the version baked into this shim at build time (`CLI_VERSION`). The release
* pipeline ships a `.tar.gz` for every (platform, arch) pair we support —
* including Windows — so the snippet is uniform across hosts. Returns null
* only when CLI_VERSION is the dev sentinel (we have no concrete URL) or the
* host arch isn't one the release pipeline targets.
*/
function reinstallTarballSnippet(): ReadonlyArray<string> | null {
if (CLI_VERSION === "0.0.0-dev") return null;
const archSuffix = process.arch === "x64" ? "amd64" : process.arch === "arm64" ? "arm64" : null;
if (archSuffix === null) return null;
// Map Node's `process.platform` to the release asset's OS slug. `win32` is
// historical (Win16 vs Win32); GitHub assets use the modern `windows` slug.
const osSlug = process.platform === "win32" ? "windows" : process.platform;
const asset = `supabase_${CLI_VERSION}_${osSlug}_${archSuffix}.tar.gz`;
return [
` mkdir -p "$HOME/.local/share/supabase"`,
` curl -sL https://github.com/supabase/cli/releases/download/v${CLI_VERSION}/${asset} \\`,
` | tar -xzf - -C "$HOME/.local/share/supabase"`,
` export PATH="$HOME/.local/share/supabase:$PATH"`,
];
}

export function formatGoBinaryNotFoundError(tried: ReadonlyArray<string>): string {
const snippet = reinstallTarballSnippet();
return [
"Could not find the `supabase-go` binary.",
"",
"The Supabase CLI ships as two co-located binaries: `supabase` (this shim)",
"and `supabase-go` (the Go CLI that the shim forwards to). The shim looked",
"for `supabase-go` in:",
"",
...tried.map((line) => ` • ${line}`),
"",
"To fix, do one of:",
" • Extract the release tarball into a directory and add the directory to",
" PATH (do not move `supabase` somewhere `supabase-go` doesn't follow).",
...(snippet === null ? [] : [" For example, on this host:", "", ...snippet, ""]),
" • Install via npm: `npm i -g supabase`.",
" • Set SUPABASE_GO_BINARY to the absolute path of `supabase-go`.",
].join("\n");
}

// ---------------------------------------------------------------------------
Expand All @@ -77,24 +136,45 @@ export function makeGoProxyLayer(opts?: {
env?: Record<string, string>;
globalArgs?: ReadonlyArray<string>;
/**
* Override the binary path. Primarily a test seam so specs don't have to
* mutate `process.env.SUPABASE_GO_BINARY`. In production, leave unset and
* let `resolveBinary()` pick the right artifact for the host platform.
* Override binary resolution. Primarily a test seam so specs don't have to
* mutate `process.env.SUPABASE_GO_BINARY` or stub the filesystem:
* - `string` — treat as the resolved Go binary path.
* - `{ notFound: [...] }` — simulate the not-found path; `.exec` will
* print the diagnostic and exit non-zero.
*
* In production, leave unset and let `resolveBinary()` pick the right
* artifact for the host platform.
*/
binary?: string;
binary?: string | BinaryResolution;
}): Layer.Layer<LegacyGoProxy, never, ProcessControl | ChildProcessSpawner> {
return Layer.effect(
LegacyGoProxy,
Effect.gen(function* () {
const processControl = yield* ProcessControl;
const spawner = yield* ChildProcessSpawner;
const binary = opts?.binary ?? resolveBinary();
const resolved: BinaryResolution =
typeof opts?.binary === "string"
? { found: opts.binary }
: (opts?.binary ?? resolveBinary());
const globalArgs = opts?.globalArgs ?? [];

return LegacyGoProxy.of({
exec: (args) =>
Effect.scoped(
Effect.gen(function* () {
if (!("found" in resolved)) {
// CLI-1488: never silently fall back to `supabase` on PATH —
// when the shim is on PATH and `supabase-go` is not co-located,
// that fallback resolves to the shim itself and fork-bombs.
// Print a specific diagnostic and exit non-zero instead.
yield* Effect.sync(() => {
process.stderr.write(`${formatGoBinaryNotFoundError(resolved.notFound)}\n`);
});
yield* processControl.exit(1);
return;
}
const binary = resolved.found;

// Hold the terminal-signals on the parent for the duration of
// the child's lifetime. Rationale:
//
Expand Down
140 changes: 138 additions & 2 deletions apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { describe, expect, it } from "@effect/vitest";
import { describe, expect, it, vi } from "@effect/vitest";
import { Deferred, Effect, Fiber, Layer, Sink, Stream } from "effect";
import { ChildProcessSpawner } from "effect/unstable/process";
import { type CliProcessSignal, ProcessControl } from "../runtime/process-control.service.ts";
import { LegacyGoProxy } from "./go-proxy.service.ts";
import { makeGoProxyLayer } from "./go-proxy.layer.ts";
import { formatGoBinaryNotFoundError, makeGoProxyLayer } from "./go-proxy.layer.ts";

/**
* Regression tests for the SIGINT propagation fix in go-proxy.layer.ts.
Expand Down Expand Up @@ -157,6 +157,107 @@ function mockSpawner(exit: ExitBehavior, spawnedBeforeExit?: Deferred.Deferred<v
*/
const TEST_BINARY = "/test/fake-supabase-go";

describe("formatGoBinaryNotFoundError", () => {
const TRIED = [
"$SUPABASE_GO_BINARY (unset)",
"/usr/local/bin/supabase-go (not found alongside the shim)",
"@supabase/cli-linux-x64 (npm package not installed)",
];

it("renders each tried location as a bullet and includes remediation hints", () => {
const message = formatGoBinaryNotFoundError(TRIED);
expect(message).toContain("Could not find the `supabase-go` binary");
expect(message).toContain(" • $SUPABASE_GO_BINARY (unset)");
expect(message).toContain(" • /usr/local/bin/supabase-go (not found alongside the shim)");
expect(message).toContain(" • @supabase/cli-linux-x64 (npm package not installed)");
expect(message).toContain("npm i -g supabase");
expect(message).toContain("SUPABASE_GO_BINARY");
});

it("omits the curl|tar snippet on dev builds (no CLI_VERSION baked in)", () => {
// The vitest run does not go through the production bundler, so
// CLI_VERSION resolves to the "0.0.0-dev" sentinel from version.ts and
// the snippet is suppressed — we have nothing concrete to point at.
const message = formatGoBinaryNotFoundError(TRIED);
expect(message).not.toContain("curl -sL");
// The prose remediation steps still appear so users have actionable hints.
expect(message).toContain("Extract the release tarball");
});
});

// The version- and platform-pinned curl|tar snippet exercised below
// instantiates a fresh module instance with a stubbed CLI_VERSION so we can
// assert against a known release version + asset filename. The fixture lives
// in a child `describe` so it doesn't bleed module mocks into other suites.
describe("formatGoBinaryNotFoundError - pinned snippet", () => {
const TRIED = ["$SUPABASE_GO_BINARY (unset)"];
const PINNED_VERSION = "2.100.0";

async function withMockedHost(
opts: { platform: NodeJS.Platform; arch: NodeJS.Architecture },
fn: (mod: typeof import("./go-proxy.layer.ts")) => void | Promise<void>,
): Promise<void> {
vi.resetModules();
vi.doMock("../cli/version.ts", () => ({ CLI_VERSION: PINNED_VERSION }));
const originalPlatform = process.platform;
const originalArch = process.arch;
Object.defineProperty(process, "platform", { value: opts.platform, configurable: true });
Object.defineProperty(process, "arch", { value: opts.arch, configurable: true });
try {
const mod = await import("./go-proxy.layer.ts");
await fn(mod);
} finally {
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
});
Object.defineProperty(process, "arch", { value: originalArch, configurable: true });
vi.doUnmock("../cli/version.ts");
vi.resetModules();
}
}

it("renders a copy-pasteable install snippet for linux x64", async () => {
await withMockedHost({ platform: "linux", arch: "x64" }, (mod) => {
const message = mod.formatGoBinaryNotFoundError(TRIED);
expect(message).toContain(
`https://github.com/supabase/cli/releases/download/v${PINNED_VERSION}/supabase_${PINNED_VERSION}_linux_amd64.tar.gz`,
);
expect(message).toContain(`mkdir -p "$HOME/.local/share/supabase"`);
expect(message).toContain(`export PATH="$HOME/.local/share/supabase:$PATH"`);
});
});

it("maps Node's win32 platform to the release asset's `windows` slug", async () => {
// Release pipeline publishes `.tar.gz` for every (platform, arch) pair,
// Windows included, so the snippet renders on win32 too — just with the
// modern `windows` slug instead of Node's historical `win32`.
await withMockedHost({ platform: "win32", arch: "x64" }, (mod) => {
const message = mod.formatGoBinaryNotFoundError(TRIED);
expect(message).toContain(
`https://github.com/supabase/cli/releases/download/v${PINNED_VERSION}/supabase_${PINNED_VERSION}_windows_amd64.tar.gz`,
);
// Never emit Node's internal `win32` token in the user-facing URL.
expect(message).not.toContain("win32");
});
});

it("maps darwin arm64 to the matching release asset", async () => {
await withMockedHost({ platform: "darwin", arch: "arm64" }, (mod) => {
expect(mod.formatGoBinaryNotFoundError(TRIED)).toContain(
`supabase_${PINNED_VERSION}_darwin_arm64.tar.gz`,
);
});
});

it("omits the snippet on unsupported architectures (no release asset)", async () => {
// ia32 has never been a release target — the snippet should not invent a URL.
await withMockedHost({ platform: "linux", arch: "ia32" }, (mod) => {
expect(mod.formatGoBinaryNotFoundError(TRIED)).not.toContain("curl -sL");
});
});
});

describe("makeGoProxyLayer", () => {
it.effect("passes detached:false and inherited stdio to the spawner", () => {
const spawner = mockSpawner({ kind: "success", code: 0 });
Expand Down Expand Up @@ -296,6 +397,41 @@ describe("makeGoProxyLayer", () => {
}).pipe(Effect.provide(layer));
});

// Regression guard for CLI-1488 — the previous `resolveBinary()` returned the
// literal string "supabase" when no Go binary was found, which when run from
// a PATH that contained the shim would fork-bomb the shim against itself
// (silent multi-minute hang in CI followed by SIGTERM). The layer must now
// refuse to spawn anything and surface a specific diagnostic + non-zero exit.
it.effect("prints a diagnostic and exits 1 when supabase-go cannot be resolved", () => {
const spawner = mockSpawner({ kind: "success", code: 0 });
const pc = mockProcessControl({ exitBehavior: "terminateDie" });
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const tried = [
"$SUPABASE_GO_BINARY (unset)",
"/usr/local/bin/supabase-go (not found alongside the shim)",
];
const layer = makeGoProxyLayer({ binary: { notFound: tried } }).pipe(
Layer.provide(Layer.mergeAll(spawner.layer, pc.layer)),
);
return Effect.gen(function* () {
const proxy = yield* LegacyGoProxy;
yield* proxy.exec(["db", "start"]).pipe(Effect.exit);

// Did NOT spawn anything — the whole point is to refuse the fork-bomb.
expect(spawner.spawned).toHaveLength(0);
// Exited with code 1 via ProcessControl.exit.
expect(pc.exitCalls).toEqual([1]);
// Wrote the diagnostic to stderr, including each tried location.
expect(stderr).toHaveBeenCalledTimes(1);
const written = String(stderr.mock.calls[0]![0]);
expect(written).toContain("Could not find the `supabase-go` binary");
expect(written).toContain("$SUPABASE_GO_BINARY (unset)");
expect(written).toContain("/usr/local/bin/supabase-go");
expect(written).toContain("SUPABASE_GO_BINARY");
stderr.mockRestore();
}).pipe(Effect.provide(layer));
});

it.effect("opens and closes a fresh hold scope per sequential exec call", () => {
const spawner = mockSpawner({ kind: "success", code: 0 });
const pc = mockProcessControl();
Expand Down
Loading