From c359e124c8c31dffe2dfd53c8c7a7795644ab730 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 17:06:54 +0000 Subject: [PATCH 1/3] fix(cli): refuse to spawn supabase-go via PATH fallback (CLI-1488) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LegacyGoProxy.resolveBinary() previously returned the literal string "supabase" as a last-resort fallback when no co-located supabase-go nor any @supabase/cli- npm package could be found. When a user installs the v2.99.0 release tarball by moving only the `supabase` shim onto PATH and leaving `supabase-go` behind, that fallback resolves to the shim itself — every legacy command then spawns the shim recursively (stdio inherited, parent-side SIGTERM masked by holdSignals), producing the silent multi-minute CI hang that ends in SIGKILL from the runner. Replace the fallback with a structured BinaryResolution result and print a specific diagnostic listing every location the resolver checked, with concrete remediation steps, then exit non-zero before spawning anything. https://claude.ai/code/session_01Lb1v3o9Dabe6qjH565ki4k --- apps/cli/src/shared/legacy/go-proxy.layer.ts | 79 ++++++++++++++++--- .../shared/legacy/go-proxy.layer.unit.test.ts | 58 +++++++++++++- 2 files changed, 123 insertions(+), 14 deletions(-) diff --git a/apps/cli/src/shared/legacy/go-proxy.layer.ts b/apps/cli/src/shared/legacy/go-proxy.layer.ts index 1e7cd5f0e..20516a9b8 100644 --- a/apps/cli/src/shared/legacy/go-proxy.layer.ts +++ b/apps/cli/src/shared/legacy/go-proxy.layer.ts @@ -25,16 +25,30 @@ const PLATFORM_CANDIDATES: Partial }; + +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. @@ -43,14 +57,34 @@ 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 }; +} + +export function formatGoBinaryNotFoundError(tried: ReadonlyArray): string { + 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).", + " • Install via npm: `npm i -g supabase`.", + " • Set SUPABASE_GO_BINARY to the absolute path of `supabase-go`.", + "", + "Docs: https://supabase.com/docs/guides/local-development/cli/getting-started", + ].join("\n"); } // --------------------------------------------------------------------------- @@ -77,24 +111,45 @@ export function makeGoProxyLayer(opts?: { env?: Record; globalArgs?: ReadonlyArray; /** - * 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 { 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: // diff --git a/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts b/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts index 88bda5fee..498a3f832 100644 --- a/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts +++ b/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts @@ -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. @@ -157,6 +157,25 @@ function mockSpawner(exit: ExitBehavior, spawnedBeforeExit?: Deferred.Deferred { + it("renders each tried location as a bullet and includes remediation hints", () => { + const message = formatGoBinaryNotFoundError([ + "$SUPABASE_GO_BINARY (unset)", + "/usr/local/bin/supabase-go (not found alongside the shim)", + "@supabase/cli-linux-x64 (npm package not installed)", + ]); + 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"); + expect(message).toContain( + "https://supabase.com/docs/guides/local-development/cli/getting-started", + ); + }); +}); + describe("makeGoProxyLayer", () => { it.effect("passes detached:false and inherited stdio to the spawner", () => { const spawner = mockSpawner({ kind: "success", code: 0 }); @@ -296,6 +315,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(); From 62d31844d2ad3aa90713e84bb29287f827811fac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 17:19:52 +0000 Subject: [PATCH 2/3] fix(cli): replace docs link with version-pinned reinstall snippet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Docs: ..." line on the not-found diagnostic pointed at a page that doesn't explain the two-binary tarball layout, so it added no context for the user actually stuck. Replace it with a concrete `curl | tar` snippet that uses the CLI_VERSION baked in at build time and the host platform/arch — the asset filename is fully constructed so the user can paste it as-is. Skip the snippet on Windows (different asset format) and on dev builds where CLI_VERSION resolves to the "0.0.0-dev" sentinel. https://claude.ai/code/session_01Lb1v3o9Dabe6qjH565ki4k --- apps/cli/src/shared/legacy/go-proxy.layer.ts | 26 ++++++- .../shared/legacy/go-proxy.layer.unit.test.ts | 71 ++++++++++++++++--- 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/shared/legacy/go-proxy.layer.ts b/apps/cli/src/shared/legacy/go-proxy.layer.ts index 20516a9b8..9dfcf9034 100644 --- a/apps/cli/src/shared/legacy/go-proxy.layer.ts +++ b/apps/cli/src/shared/legacy/go-proxy.layer.ts @@ -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"; @@ -67,7 +68,29 @@ function resolveBinary(): BinaryResolution { 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`). Returns + * null on Windows (different asset format) or when the version is the dev + * sentinel — in those cases the diagnostic falls back to the generic + * prose-only remediation steps. + */ +function reinstallTarballSnippet(): ReadonlyArray | null { + if (CLI_VERSION === "0.0.0-dev") return null; + if (process.platform !== "linux" && process.platform !== "darwin") return null; + const archSuffix = os.arch() === "x64" ? "amd64" : os.arch() === "arm64" ? "arm64" : null; + if (archSuffix === null) return null; + const asset = `supabase_${CLI_VERSION}_${process.platform}_${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 { + const snippet = reinstallTarballSnippet(); return [ "Could not find the `supabase-go` binary.", "", @@ -80,10 +103,9 @@ export function formatGoBinaryNotFoundError(tried: ReadonlyArray): strin "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`.", - "", - "Docs: https://supabase.com/docs/guides/local-development/cli/getting-started", ].join("\n"); } diff --git a/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts b/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts index 498a3f832..d238ade1c 100644 --- a/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts +++ b/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts @@ -158,21 +158,76 @@ function mockSpawner(exit: ExitBehavior, spawnedBeforeExit?: Deferred.Deferred { + 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([ - "$SUPABASE_GO_BINARY (unset)", - "/usr/local/bin/supabase-go (not found alongside the shim)", - "@supabase/cli-linux-x64 (npm package not installed)", - ]); + 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"); - expect(message).toContain( - "https://supabase.com/docs/guides/local-development/cli/getting-started", - ); + }); + + 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"; + + it("renders a copy-pasteable install snippet for linux x64", async () => { + vi.resetModules(); + vi.doMock("../cli/version.ts", () => ({ CLI_VERSION: PINNED_VERSION })); + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + Object.defineProperty(process, "arch", { value: "x64", configurable: true }); + try { + const mod = await import("./go-proxy.layer.ts"); + 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"`); + } finally { + vi.doUnmock("../cli/version.ts"); + vi.resetModules(); + } + }); + + it("omits the snippet on Windows (different asset format than tar.gz)", async () => { + vi.resetModules(); + vi.doMock("../cli/version.ts", () => ({ CLI_VERSION: PINNED_VERSION })); + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + try { + const mod = await import("./go-proxy.layer.ts"); + expect(mod.formatGoBinaryNotFoundError(TRIED)).not.toContain("curl -sL"); + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + vi.doUnmock("../cli/version.ts"); + vi.resetModules(); + } }); }); From 8061252ada31790faac5c6abaa7123bf1aa21ffb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 17:28:21 +0000 Subject: [PATCH 3/3] fix(cli): render the reinstall snippet on Windows too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release pipeline publishes `supabase__windows_.tar.gz` for every Windows target (in addition to .zip), so the `curl | tar` remediation snippet is just as valid on Windows as on linux/darwin. Drop the platform skip and map Node's historical `win32` to the release asset's `windows` slug. Switch from `os.arch()` to `process.arch` (same value) so the host mock in the unit tests can shape both axes via Object.defineProperty — ESM module namespaces aren't spy-able, so the os module couldn't be intercepted directly. https://claude.ai/code/session_01Lb1v3o9Dabe6qjH565ki4k --- apps/cli/src/shared/legacy/go-proxy.layer.ts | 17 +++-- .../shared/legacy/go-proxy.layer.unit.test.ts | 71 +++++++++++++------ 2 files changed, 59 insertions(+), 29 deletions(-) diff --git a/apps/cli/src/shared/legacy/go-proxy.layer.ts b/apps/cli/src/shared/legacy/go-proxy.layer.ts index 9dfcf9034..eb12c5125 100644 --- a/apps/cli/src/shared/legacy/go-proxy.layer.ts +++ b/apps/cli/src/shared/legacy/go-proxy.layer.ts @@ -70,17 +70,20 @@ function resolveBinary(): BinaryResolution { /** * Build a concrete `curl | tar` install snippet for the host platform, using - * the version baked into this shim at build time (`CLI_VERSION`). Returns - * null on Windows (different asset format) or when the version is the dev - * sentinel — in those cases the diagnostic falls back to the generic - * prose-only remediation steps. + * 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 | null { if (CLI_VERSION === "0.0.0-dev") return null; - if (process.platform !== "linux" && process.platform !== "darwin") return null; - const archSuffix = os.arch() === "x64" ? "amd64" : os.arch() === "arm64" ? "arm64" : null; + const archSuffix = process.arch === "x64" ? "amd64" : process.arch === "arm64" ? "arm64" : null; if (archSuffix === null) return null; - const asset = `supabase_${CLI_VERSION}_${process.platform}_${archSuffix}.tar.gz`; + // 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} \\`, diff --git a/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts b/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts index d238ade1c..8fc4713d7 100644 --- a/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts +++ b/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts @@ -193,41 +193,68 @@ describe("formatGoBinaryNotFoundError - pinned snippet", () => { const TRIED = ["$SUPABASE_GO_BINARY (unset)"]; const PINNED_VERSION = "2.100.0"; - it("renders a copy-pasteable install snippet for linux x64", async () => { - vi.resetModules(); - vi.doMock("../cli/version.ts", () => ({ CLI_VERSION: PINNED_VERSION })); - Object.defineProperty(process, "platform", { value: "linux", configurable: true }); - Object.defineProperty(process, "arch", { value: "x64", configurable: true }); - try { - const mod = await import("./go-proxy.layer.ts"); - 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"`); - } finally { - vi.doUnmock("../cli/version.ts"); - vi.resetModules(); - } - }); - - it("omits the snippet on Windows (different asset format than tar.gz)", async () => { + async function withMockedHost( + opts: { platform: NodeJS.Platform; arch: NodeJS.Architecture }, + fn: (mod: typeof import("./go-proxy.layer.ts")) => void | Promise, + ): Promise { vi.resetModules(); vi.doMock("../cli/version.ts", () => ({ CLI_VERSION: PINNED_VERSION })); const originalPlatform = process.platform; - Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + 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"); - expect(mod.formatGoBinaryNotFoundError(TRIED)).not.toContain("curl -sL"); + 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"); + }); }); });