diff --git a/packages/cli/cli/versions.yml b/packages/cli/cli/versions.yml index f8b5b29e48ae..fc769853ab86 100644 --- a/packages/cli/cli/versions.yml +++ b/packages/cli/cli/versions.yml @@ -1,4 +1,15 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 4.36.0 + changelogEntry: + - summary: | + Auto-download `protoc-gen-openapi` binary from GitHub Releases when it is + not found on PATH. This eliminates the need to install Go and compile + `protoc-gen-openapi` from source on every CI run, reducing proto-based + SDK generation setup time by ~40 seconds. + type: feat + createdAt: "2026-03-18" + irVersion: 65 + - version: 4.35.0 changelogEntry: - summary: | @@ -11,7 +22,6 @@ type: feat createdAt: "2026-03-17" irVersion: 65 - - version: 4.34.1 changelogEntry: - summary: | diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtobufOpenAPIGenerator.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtobufOpenAPIGenerator.ts index dec1d2079fa5..54a54d51c94d 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtobufOpenAPIGenerator.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtobufOpenAPIGenerator.ts @@ -2,7 +2,9 @@ import { AbsoluteFilePath, join, RelativeFilePath, relative } from "@fern-api/fs import { createLoggingExecutable } from "@fern-api/logging-execa"; import { TaskContext } from "@fern-api/task-context"; import { access, cp, readFile, unlink, writeFile } from "fs/promises"; +import path from "path"; import tmp from "tmp-promise"; +import { resolveProtocGenOpenAPI } from "./ProtocGenOpenAPIDownloader.js"; import { detectAirGappedModeForProtobuf, getProtobufYamlV1 } from "./utils.js"; const PROTOBUF_GENERATOR_CONFIG_FILENAME = "buf.gen.yaml"; @@ -12,6 +14,7 @@ const PROTOBUF_GENERATOR_OUTPUT_FILEPATH = `${PROTOBUF_GENERATOR_OUTPUT_PATH}/op export class ProtobufOpenAPIGenerator { private context: TaskContext; private isAirGapped: boolean | undefined; + private protocGenOpenAPIBinDir: AbsoluteFilePath | undefined; constructor({ context }: { context: TaskContext }) { this.context = context; @@ -121,25 +124,45 @@ export class ProtobufOpenAPIGenerator { ); } + let protocGenOpenAPIOnPath = false; try { await which(["protoc-gen-openapi"]); + protocGenOpenAPIOnPath = true; } catch (err) { - this.context.failAndThrow( - "Missing required dependency; please install 'protoc-gen-openapi' to continue (e.g. 'brew install go && go install github.com/fern-api/protoc-gen-openapi/cmd/protoc-gen-openapi@latest')." + this.context.logger.debug( + `protoc-gen-openapi not found on PATH: ${err instanceof Error ? err.message : String(err)}` ); } + if (!protocGenOpenAPIOnPath) { + if (this.protocGenOpenAPIBinDir == null) { + this.protocGenOpenAPIBinDir = await resolveProtocGenOpenAPI(this.context.logger); + } + if (this.protocGenOpenAPIBinDir == null) { + this.context.failAndThrow( + "Missing required dependency; please install 'protoc-gen-openapi' to continue (e.g. 'brew install go && go install github.com/fern-api/protoc-gen-openapi/cmd/protoc-gen-openapi@latest')." + ); + } + } + const bufYamlPath = join(cwd, RelativeFilePath.of("buf.yaml")); const bufLockPath = join(cwd, RelativeFilePath.of("buf.lock")); let cleanupBufLock = false; const configContent = getProtobufYamlV1(deps); + // If we downloaded protoc-gen-openapi, prepend its directory to PATH so buf can find it + const envOverride = + this.protocGenOpenAPIBinDir != null + ? { PATH: `${this.protocGenOpenAPIBinDir}${path.delimiter}${process.env.PATH ?? ""}` } + : undefined; + const buf = createLoggingExecutable("buf", { cwd, logger: this.context.logger, stdout: "ignore", - stderr: "pipe" + stderr: "pipe", + ...(envOverride != null ? { env: { ...process.env, ...envOverride } } : {}) }); try { diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts new file mode 100644 index 000000000000..fb1df4918ab8 --- /dev/null +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts @@ -0,0 +1,258 @@ +import { AbsoluteFilePath, join, RelativeFilePath } from "@fern-api/fs-utils"; +import { Logger } from "@fern-api/logger"; +import { access, chmod, copyFile, mkdir, readFile, rename, rm, writeFile } from "fs/promises"; +import os from "os"; +import path from "path"; + +const PROTOC_GEN_OPENAPI_VERSION = "v0.1.13"; +const GITHUB_RELEASE_URL_BASE = "https://github.com/fern-api/protoc-gen-openapi/releases/download"; +const BINARY_NAME = "protoc-gen-openapi"; +const CACHE_DIR_NAME = ".fern"; +const BIN_DIR_NAME = "bin"; +const LOCK_TIMEOUT_MS = 120_000; +const LOCK_RETRY_INTERVAL_MS = 200; + +interface PlatformInfo { + os: string; + arch: string; + extension: string; +} + +function getPlatformInfo(): PlatformInfo { + const platform = os.platform(); + const arch = os.arch(); + + let osName: string; + switch (platform) { + case "linux": + osName = "linux"; + break; + case "darwin": + osName = "darwin"; + break; + case "win32": + osName = "windows"; + break; + default: + throw new Error(`Unsupported platform: ${platform}`); + } + + let archName: string; + switch (arch) { + case "x64": + archName = "amd64"; + break; + case "arm64": + archName = "arm64"; + break; + default: + throw new Error(`Unsupported architecture: ${arch}`); + } + + return { + os: osName, + arch: archName, + extension: osName === "windows" ? ".exe" : "" + }; +} + +function getCacheDir(): AbsoluteFilePath { + const homeDir = os.homedir(); + return AbsoluteFilePath.of(path.join(homeDir, CACHE_DIR_NAME, BIN_DIR_NAME)); +} + +function getVersionedBinaryPath(): AbsoluteFilePath { + const { extension } = getPlatformInfo(); + const cacheDir = getCacheDir(); + return join(cacheDir, RelativeFilePath.of(`${BINARY_NAME}-${PROTOC_GEN_OPENAPI_VERSION}${extension}`)); +} + +function getCanonicalBinaryPath(): AbsoluteFilePath { + const { extension } = getPlatformInfo(); + const cacheDir = getCacheDir(); + return join(cacheDir, RelativeFilePath.of(`${BINARY_NAME}${extension}`)); +} + +function getVersionMarkerPath(): AbsoluteFilePath { + const cacheDir = getCacheDir(); + return join(cacheDir, RelativeFilePath.of(`${BINARY_NAME}.version`)); +} + +function getLockDirPath(): string { + const cacheDir = getCacheDir(); + return path.join(cacheDir, `${BINARY_NAME}.lock`); +} + +function getDownloadUrl(): string { + const { os: osName, arch, extension } = getPlatformInfo(); + return `${GITHUB_RELEASE_URL_BASE}/${PROTOC_GEN_OPENAPI_VERSION}/${BINARY_NAME}-${osName}-${arch}${extension}`; +} + +async function fileExists(filePath: AbsoluteFilePath): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +/** + * Acquires an exclusive filesystem lock using mkdir (atomic on all platforms). + * Returns a release function that removes the lock directory. + * If the lock cannot be acquired within LOCK_TIMEOUT_MS, force-breaks it + * (assumes the holder crashed) and retries once. + */ +async function acquireLock(logger: Logger): Promise<() => Promise> { + const lockPath = getLockDirPath(); + const deadline = Date.now() + LOCK_TIMEOUT_MS; + + while (Date.now() < deadline) { + try { + await mkdir(lockPath, { recursive: false }); + return createLockReleaser(lockPath, logger); + } catch { + logger.debug(`Waiting for lock on ${lockPath}...`); + await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS)); + } + } + + // Timeout — force-break the presumed-stale lock and retry once + logger.debug(`Lock timed out after ${LOCK_TIMEOUT_MS}ms, breaking stale lock`); + try { + await rm(lockPath, { recursive: true }); + } catch (err) { + logger.debug(`Failed to remove stale lock: ${err instanceof Error ? err.message : String(err)}`); + } + try { + await mkdir(lockPath, { recursive: false }); + } catch (err) { + // Another process grabbed the lock between our rm and mkdir — wait briefly and retry + logger.debug(`Failed to re-acquire lock after break: ${err instanceof Error ? err.message : String(err)}`); + await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS)); + await mkdir(lockPath, { recursive: false }); + } + return createLockReleaser(lockPath, logger); +} + +function createLockReleaser(lockPath: string, logger: Logger): () => Promise { + return async () => { + try { + await rm(lockPath, { recursive: true }); + } catch (err) { + logger.debug(`Failed to release lock: ${err instanceof Error ? err.message : String(err)}`); + } + }; +} + +/** + * Resolves the protoc-gen-openapi binary, downloading it from GitHub Releases if needed. + * + * **Versioning**: Binaries are cached with a versioned filename (e.g. `protoc-gen-openapi-v0.1.13`). + * A `.version` marker file tracks which version the canonical `protoc-gen-openapi` binary corresponds to. + * When `PROTOC_GEN_OPENAPI_VERSION` is bumped, the canonical binary is atomically replaced on the + * next invocation. + * + * **Race conditions**: An exclusive filesystem lock (mkdir-based) is held during download and + * file operations to prevent concurrent processes from corrupting the cache. All file replacements + * use write-to-temp + atomic rename. + * + * @returns The directory containing the binary (for PATH injection), or `undefined` if download fails. + */ +export async function resolveProtocGenOpenAPI(logger: Logger): Promise { + try { + const cacheDir = getCacheDir(); + await mkdir(cacheDir, { recursive: true }); + + const releaseLock = await acquireLock(logger); + try { + return await resolveUnderLock(logger); + } finally { + await releaseLock(); + } + } catch (error) { + logger.debug(`Failed to resolve protoc-gen-openapi: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } +} + +async function resolveUnderLock(logger: Logger): Promise { + const versionedPath = getVersionedBinaryPath(); + const canonicalPath = getCanonicalBinaryPath(); + const versionMarkerPath = getVersionMarkerPath(); + + // Fast path: versioned binary already downloaded + if (await fileExists(versionedPath)) { + const currentMarker = await readVersionMarker(versionMarkerPath, logger); + if (currentMarker === PROTOC_GEN_OPENAPI_VERSION && (await fileExists(canonicalPath))) { + logger.debug(`Using cached protoc-gen-openapi ${PROTOC_GEN_OPENAPI_VERSION}`); + return getCacheDir(); + } + // Version marker is stale or canonical binary is missing — refresh atomically + await atomicCopyBinary(versionedPath, canonicalPath); + await writeFile(versionMarkerPath, PROTOC_GEN_OPENAPI_VERSION); + logger.debug(`Updated canonical protoc-gen-openapi to ${PROTOC_GEN_OPENAPI_VERSION}`); + return getCacheDir(); + } + + // Download the binary + const downloadUrl = getDownloadUrl(); + logger.debug(`Downloading protoc-gen-openapi from ${downloadUrl}`); + + const tmpDownloadPath = AbsoluteFilePath.of(`${versionedPath}.download`); + try { + const response = await fetch(downloadUrl, { redirect: "follow" }); + if (!response.ok) { + logger.debug(`Failed to download protoc-gen-openapi: ${response.status} ${response.statusText}`); + return undefined; + } + + const arrayBuffer = await response.arrayBuffer(); + await writeFile(tmpDownloadPath, new Uint8Array(arrayBuffer)); + await chmod(tmpDownloadPath, 0o755); + + // Atomic rename to versioned path + await rename(tmpDownloadPath, versionedPath); + + // Atomic copy to canonical path + update version marker + await atomicCopyBinary(versionedPath, canonicalPath); + await writeFile(versionMarkerPath, PROTOC_GEN_OPENAPI_VERSION); + + logger.debug(`Downloaded protoc-gen-openapi ${PROTOC_GEN_OPENAPI_VERSION}`); + return getCacheDir(); + } catch (error) { + logger.debug( + `Failed to download protoc-gen-openapi: ${error instanceof Error ? error.message : String(error)}` + ); + // Clean up partial download if it exists + try { + await rm(tmpDownloadPath, { force: true }); + } catch (cleanupErr) { + logger.debug( + `Failed to clean up partial download: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}` + ); + } + return undefined; + } +} + +async function readVersionMarker(markerPath: AbsoluteFilePath, logger: Logger): Promise { + try { + return (await readFile(markerPath, "utf-8")).trim(); + } catch (err) { + logger.debug(`Failed to read version marker: ${err instanceof Error ? err.message : String(err)}`); + return undefined; + } +} + +/** + * Atomically copies src to dest by writing to a temp file first, then renaming. + * rename() is atomic on the same filesystem, so no other process can observe a + * partially-written binary. + */ +async function atomicCopyBinary(src: AbsoluteFilePath, dest: AbsoluteFilePath): Promise { + const tmpDest = AbsoluteFilePath.of(`${dest}.tmp`); + await copyFile(src, tmpDest); + await chmod(tmpDest, 0o755); + await rename(tmpDest, dest); +} diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/__test__/ProtocGenOpenAPIDownloader.test.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/__test__/ProtocGenOpenAPIDownloader.test.ts new file mode 100644 index 000000000000..23164b87d70e --- /dev/null +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/__test__/ProtocGenOpenAPIDownloader.test.ts @@ -0,0 +1,498 @@ +import { AbsoluteFilePath } from "@fern-api/fs-utils"; +import { Logger } from "@fern-api/logger"; +import { access, chmod, mkdir, readFile, rm, stat, writeFile } from "fs/promises"; +import path from "path"; +import tmp from "tmp-promise"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const FAKE_BINARY_CONTENT = "#!/bin/sh\necho fake-protoc-gen-openapi\n"; + +function createMockLogger(): Logger { + return { + disable: vi.fn(), + enable: vi.fn(), + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + log: vi.fn() + }; +} + +async function fileExists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +describe("ProtocGenOpenAPIDownloader", () => { + let tempHomeDir: string; + let logger: Logger; + + beforeEach(async () => { + tempHomeDir = (await tmp.dir({ unsafeCleanup: true })).path; + logger = createMockLogger(); + + // Mock os.homedir() to use our temp directory + vi.doMock("os", async () => { + const actual = await vi.importActual("os"); + return { + ...actual, + default: { + ...actual, + homedir: () => tempHomeDir, + platform: actual.platform, + arch: actual.arch + } + }; + }); + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + await rm(tempHomeDir, { recursive: true, force: true }); + }); + + function getCacheDir(): string { + return path.join(tempHomeDir, ".fern", "bin"); + } + + function getBinaryName(): string { + return process.platform === "win32" ? "protoc-gen-openapi.exe" : "protoc-gen-openapi"; + } + + function getVersionedBinaryName(version: string): string { + const ext = process.platform === "win32" ? ".exe" : ""; + return `protoc-gen-openapi-${version}${ext}`; + } + + describe("fresh download", () => { + it("downloads and caches binary on first invocation", async () => { + // Mock fetch to return our fake binary + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + arrayBuffer: () => Promise.resolve(new TextEncoder().encode(FAKE_BINARY_CONTENT).buffer) + }); + vi.stubGlobal("fetch", mockFetch); + + const { resolveProtocGenOpenAPI } = await import("../ProtocGenOpenAPIDownloader.js"); + const result = await resolveProtocGenOpenAPI(logger); + + // Should return the cache directory + expect(result).toBe(AbsoluteFilePath.of(getCacheDir())); + + // Should have called fetch once + expect(mockFetch).toHaveBeenCalledOnce(); + const fetchUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(fetchUrl).toContain("protoc-gen-openapi"); + expect(fetchUrl).toContain("github.com/fern-api/protoc-gen-openapi/releases/download"); + + // Versioned binary should exist + const versionedPath = path.join(getCacheDir(), getVersionedBinaryName("v0.1.13")); + expect(await fileExists(versionedPath)).toBe(true); + + // Canonical binary should exist + const canonicalPath = path.join(getCacheDir(), getBinaryName()); + expect(await fileExists(canonicalPath)).toBe(true); + + // Version marker should exist with correct content + const markerPath = path.join(getCacheDir(), "protoc-gen-openapi.version"); + expect(await fileExists(markerPath)).toBe(true); + const markerContent = await readFile(markerPath, "utf-8"); + expect(markerContent.trim()).toBe("v0.1.13"); + + // Lock directory should be cleaned up + const lockPath = path.join(getCacheDir(), "protoc-gen-openapi.lock"); + expect(await fileExists(lockPath)).toBe(false); + + vi.unstubAllGlobals(); + }); + }); + + describe("cache hit (fast path)", () => { + it("returns immediately when versioned binary and marker already exist", async () => { + // Pre-populate cache + const cacheDir = getCacheDir(); + await mkdir(cacheDir, { recursive: true }); + + const versionedPath = path.join(cacheDir, getVersionedBinaryName("v0.1.13")); + await writeFile(versionedPath, FAKE_BINARY_CONTENT); + await chmod(versionedPath, 0o755); + + const canonicalPath = path.join(cacheDir, getBinaryName()); + await writeFile(canonicalPath, FAKE_BINARY_CONTENT); + await chmod(canonicalPath, 0o755); + + const markerPath = path.join(cacheDir, "protoc-gen-openapi.version"); + await writeFile(markerPath, "v0.1.13"); + + // fetch should NOT be called + const mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + + const { resolveProtocGenOpenAPI } = await import("../ProtocGenOpenAPIDownloader.js"); + const result = await resolveProtocGenOpenAPI(logger); + + expect(result).toBe(AbsoluteFilePath.of(cacheDir)); + expect(mockFetch).not.toHaveBeenCalled(); + + // Debug log should indicate cache hit + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("Using cached")); + + vi.unstubAllGlobals(); + }); + }); + + describe("version upgrade", () => { + it("refreshes canonical binary when version marker is stale", async () => { + // Pre-populate cache with "old" version marker but current versioned binary + const cacheDir = getCacheDir(); + await mkdir(cacheDir, { recursive: true }); + + const versionedPath = path.join(cacheDir, getVersionedBinaryName("v0.1.13")); + const newContent = "new-binary-content"; + await writeFile(versionedPath, newContent); + await chmod(versionedPath, 0o755); + + const canonicalPath = path.join(cacheDir, getBinaryName()); + await writeFile(canonicalPath, "old-binary-content"); + await chmod(canonicalPath, 0o755); + + // Marker says old version + const markerPath = path.join(cacheDir, "protoc-gen-openapi.version"); + await writeFile(markerPath, "v0.1.11"); + + const mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + + const { resolveProtocGenOpenAPI } = await import("../ProtocGenOpenAPIDownloader.js"); + const result = await resolveProtocGenOpenAPI(logger); + + expect(result).toBe(AbsoluteFilePath.of(cacheDir)); + expect(mockFetch).not.toHaveBeenCalled(); + + // Canonical binary should now have the new content + const canonicalContent = await readFile(canonicalPath, "utf-8"); + expect(canonicalContent).toBe(newContent); + + // Marker should be updated + const updatedMarker = await readFile(markerPath, "utf-8"); + expect(updatedMarker.trim()).toBe("v0.1.13"); + + // Should log the update + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("Updated canonical")); + + vi.unstubAllGlobals(); + }); + + it("refreshes canonical binary when canonical is missing but versioned exists", async () => { + const cacheDir = getCacheDir(); + await mkdir(cacheDir, { recursive: true }); + + const versionedPath = path.join(cacheDir, getVersionedBinaryName("v0.1.13")); + await writeFile(versionedPath, FAKE_BINARY_CONTENT); + await chmod(versionedPath, 0o755); + + // Marker is correct but canonical is missing + const markerPath = path.join(cacheDir, "protoc-gen-openapi.version"); + await writeFile(markerPath, "v0.1.13"); + + const mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + + const { resolveProtocGenOpenAPI } = await import("../ProtocGenOpenAPIDownloader.js"); + const result = await resolveProtocGenOpenAPI(logger); + + expect(result).toBe(AbsoluteFilePath.of(cacheDir)); + expect(mockFetch).not.toHaveBeenCalled(); + + // Canonical should now exist + const canonicalPath = path.join(cacheDir, getBinaryName()); + expect(await fileExists(canonicalPath)).toBe(true); + + vi.unstubAllGlobals(); + }); + }); + + describe("download failure", () => { + it("returns undefined when download returns non-200", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found" + }); + vi.stubGlobal("fetch", mockFetch); + + const { resolveProtocGenOpenAPI } = await import("../ProtocGenOpenAPIDownloader.js"); + const result = await resolveProtocGenOpenAPI(logger); + + expect(result).toBeUndefined(); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("Failed to download")); + + vi.unstubAllGlobals(); + }); + + it("returns undefined when fetch throws a network error", async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error("network error")); + vi.stubGlobal("fetch", mockFetch); + + const { resolveProtocGenOpenAPI } = await import("../ProtocGenOpenAPIDownloader.js"); + const result = await resolveProtocGenOpenAPI(logger); + + expect(result).toBeUndefined(); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("network error")); + + vi.unstubAllGlobals(); + }); + }); + + describe("lock safety", () => { + it("releases lock even when download fails", async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error("download failed")); + vi.stubGlobal("fetch", mockFetch); + + const { resolveProtocGenOpenAPI } = await import("../ProtocGenOpenAPIDownloader.js"); + await resolveProtocGenOpenAPI(logger); + + // Lock directory should be cleaned up + const lockPath = path.join(getCacheDir(), "protoc-gen-openapi.lock"); + expect(await fileExists(lockPath)).toBe(false); + + vi.unstubAllGlobals(); + }); + + it("concurrent calls do not corrupt the cache", async () => { + let fetchCallCount = 0; + const mockFetch = vi.fn().mockImplementation(async () => { + fetchCallCount++; + // Simulate download latency + await new Promise((resolve) => setTimeout(resolve, 50)); + return { + ok: true, + status: 200, + statusText: "OK", + arrayBuffer: () => + Promise.resolve(new TextEncoder().encode(`binary-content-${fetchCallCount}`).buffer) + }; + }); + vi.stubGlobal("fetch", mockFetch); + + const { resolveProtocGenOpenAPI } = await import("../ProtocGenOpenAPIDownloader.js"); + + // Run 3 concurrent resolves + const results = await Promise.all([ + resolveProtocGenOpenAPI(logger), + resolveProtocGenOpenAPI(logger), + resolveProtocGenOpenAPI(logger) + ]); + + // All should succeed + const cacheDir = getCacheDir(); + for (const result of results) { + expect(result).toBe(AbsoluteFilePath.of(cacheDir)); + } + + // Canonical binary should exist and be valid + const canonicalPath = path.join(cacheDir, getBinaryName()); + expect(await fileExists(canonicalPath)).toBe(true); + const content = await readFile(canonicalPath, "utf-8"); + expect(content).toContain("binary-content"); + + // Version marker should be correct + const markerPath = path.join(cacheDir, "protoc-gen-openapi.version"); + const marker = await readFile(markerPath, "utf-8"); + expect(marker.trim()).toBe("v0.1.13"); + + // Lock should be released + const lockPath = path.join(cacheDir, "protoc-gen-openapi.lock"); + expect(await fileExists(lockPath)).toBe(false); + + vi.unstubAllGlobals(); + }); + }); + + describe("cache directory creation", () => { + it("creates cache directory if it does not exist", async () => { + const cacheDir = getCacheDir(); + expect(await fileExists(cacheDir)).toBe(false); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + arrayBuffer: () => Promise.resolve(new TextEncoder().encode(FAKE_BINARY_CONTENT).buffer) + }); + vi.stubGlobal("fetch", mockFetch); + + const { resolveProtocGenOpenAPI } = await import("../ProtocGenOpenAPIDownloader.js"); + const result = await resolveProtocGenOpenAPI(logger); + + expect(result).toBe(AbsoluteFilePath.of(cacheDir)); + expect(await fileExists(cacheDir)).toBe(true); + + vi.unstubAllGlobals(); + }); + }); + + describe("binary permissions", () => { + it("sets executable permissions on downloaded binary", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + arrayBuffer: () => Promise.resolve(new TextEncoder().encode(FAKE_BINARY_CONTENT).buffer) + }); + vi.stubGlobal("fetch", mockFetch); + + const { resolveProtocGenOpenAPI } = await import("../ProtocGenOpenAPIDownloader.js"); + await resolveProtocGenOpenAPI(logger); + + const { stat } = await import("fs/promises"); + + // Canonical binary should be executable + const canonicalPath = path.join(getCacheDir(), getBinaryName()); + const canonicalStat = await stat(canonicalPath); + // Check owner execute bit (0o100) + expect(canonicalStat.mode & 0o100).toBeTruthy(); + + // Versioned binary should also be executable + const versionedPath = path.join(getCacheDir(), getVersionedBinaryName("v0.1.13")); + const versionedStat = await stat(versionedPath); + expect(versionedStat.mode & 0o100).toBeTruthy(); + + vi.unstubAllGlobals(); + }); + }); +}); + +describe("ProtocGenOpenAPIDownloader (real e2e)", () => { + let tempHomeDir: string; + let logger: Logger; + + beforeEach(async () => { + tempHomeDir = (await tmp.dir({ unsafeCleanup: true })).path; + logger = createMockLogger(); + + // Reset module registry so vi.doMock takes effect on next dynamic import + vi.resetModules(); + + // Mock os.homedir() to isolate cache to a temp directory + vi.doMock("os", async () => { + const actual = await vi.importActual("os"); + return { + ...actual, + default: { + ...actual, + homedir: () => tempHomeDir, + platform: actual.platform, + arch: actual.arch + } + }; + }); + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + await rm(tempHomeDir, { recursive: true, force: true }); + }); + + function getCacheDir(): string { + return path.join(tempHomeDir, ".fern", "bin"); + } + + function getBinaryName(): string { + return process.platform === "win32" ? "protoc-gen-openapi.exe" : "protoc-gen-openapi"; + } + + function getVersionedBinaryName(version: string): string { + const ext = process.platform === "win32" ? ".exe" : ""; + return `protoc-gen-openapi-${version}${ext}`; + } + + it( + "downloads real binary from GitHub Releases, caches it, and reuses on second call", + { timeout: 60_000 }, + async () => { + // Do NOT mock fetch — use the real network + const { resolveProtocGenOpenAPI } = await import("../ProtocGenOpenAPIDownloader.js"); + + // --- First call: should download from GitHub Releases --- + const result1 = await resolveProtocGenOpenAPI(logger); + + expect(result1).toBe(AbsoluteFilePath.of(getCacheDir())); + + // Verify download log messages + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("Downloading protoc-gen-openapi from")); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("Downloaded protoc-gen-openapi")); + + // Verify cache structure + const cacheDir = getCacheDir(); + const canonicalPath = path.join(cacheDir, getBinaryName()); + const versionedPath = path.join(cacheDir, getVersionedBinaryName("v0.1.13")); + const markerPath = path.join(cacheDir, "protoc-gen-openapi.version"); + + // All cache files should exist + expect(await fileExists(canonicalPath)).toBe(true); + expect(await fileExists(versionedPath)).toBe(true); + expect(await fileExists(markerPath)).toBe(true); + + // Version marker should contain v0.1.13 + const markerContent = await readFile(markerPath, "utf-8"); + expect(markerContent.trim()).toBe("v0.1.13"); + + // Binary should be a real executable (not empty or truncated) + const binaryStat = await stat(canonicalPath); + expect(binaryStat.size).toBeGreaterThan(1_000_000); // Real binary is ~11MB + + // Binary should have executable permissions + expect(binaryStat.mode & 0o100).toBeTruthy(); + + // Lock should be cleaned up + const lockPath = path.join(cacheDir, "protoc-gen-openapi.lock"); + expect(await fileExists(lockPath)).toBe(false); + + // --- Second call: should use cache (no re-download) --- + const debugCallsBefore = (logger.debug as ReturnType).mock.calls.length; + const result2 = await resolveProtocGenOpenAPI(logger); + + expect(result2).toBe(AbsoluteFilePath.of(cacheDir)); + + // Should log cache hit, not download + const debugCallsAfter = (logger.debug as ReturnType).mock.calls; + const newCalls = debugCallsAfter.slice(debugCallsBefore); + const newMessages = newCalls.map((call) => call[0] as string); + expect(newMessages.some((msg) => msg.includes("Using cached"))).toBe(true); + expect(newMessages.some((msg) => msg.includes("Downloading"))).toBe(false); + } + ); + + it("downloaded binary is a valid ELF/Mach-O executable", { timeout: 60_000 }, async () => { + const { resolveProtocGenOpenAPI } = await import("../ProtocGenOpenAPIDownloader.js"); + const result = await resolveProtocGenOpenAPI(logger); + expect(result).toBeDefined(); + + const canonicalPath = path.join(getCacheDir(), getBinaryName()); + + // Read first 4 bytes to verify it's a real binary (ELF or Mach-O magic) + const { readFile: readFileFn } = await import("fs/promises"); + const buffer = await readFileFn(canonicalPath); + expect(buffer.length).toBeGreaterThan(4); + + // ELF magic: 0x7f 'E' 'L' 'F' | Mach-O magic: 0xFEEDFACE/0xFEEDFACF/0xCFFA.. + const elfMagic = buffer[0] === 0x7f && buffer[1] === 0x45 && buffer[2] === 0x4c && buffer[3] === 0x46; + const machoMagic = + (buffer[0] === 0xfe && buffer[1] === 0xed && buffer[2] === 0xfa) || + (buffer[0] === 0xcf && buffer[1] === 0xfa && buffer[2] === 0xed); + const peMagic = buffer[0] === 0x4d && buffer[1] === 0x5a; // MZ header for Windows PE + + expect(elfMagic || machoMagic || peMagic).toBe(true); + }); +});