From 032e916b3a793535aaf8e9f4892f63196a45324f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:50:49 +0000 Subject: [PATCH 01/13] feat(cli): auto-download protoc-gen-openapi binary when not on PATH Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- packages/cli/cli/versions.yml | 11 ++ .../src/protobuf/ProtobufOpenAPIGenerator.ts | 28 +++- .../protobuf/ProtocGenOpenAPIDownloader.ts | 151 ++++++++++++++++++ 3 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts diff --git a/packages/cli/cli/versions.yml b/packages/cli/cli/versions.yml index 538cfe9ca631..37ccd35b3a69 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.35.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.34.0 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..3a3a1aa89057 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtobufOpenAPIGenerator.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtobufOpenAPIGenerator.ts @@ -3,6 +3,7 @@ import { createLoggingExecutable } from "@fern-api/logging-execa"; import { TaskContext } from "@fern-api/task-context"; import { access, cp, readFile, unlink, writeFile } from "fs/promises"; 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 +13,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,12 +123,23 @@ 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')." - ); + // Not on PATH — try auto-downloading + } + + 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")); @@ -135,11 +148,18 @@ export class ProtobufOpenAPIGenerator { 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}:${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..bf52471e91db --- /dev/null +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts @@ -0,0 +1,151 @@ +import { AbsoluteFilePath, join, RelativeFilePath } from "@fern-api/fs-utils"; +import { Logger } from "@fern-api/logger"; +import { createWriteStream } from "fs"; +import { access, chmod, mkdir, rename } from "fs/promises"; +import os from "os"; +import path from "path"; +import { Readable } from "stream"; +import { pipeline } from "stream/promises"; + +const PROTOC_GEN_OPENAPI_VERSION = "v0.1.12"; +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"; + +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 getCachedBinaryPath(): AbsoluteFilePath { + const { extension } = getPlatformInfo(); + const cacheDir = getCacheDir(); + return join(cacheDir, RelativeFilePath.of(`${BINARY_NAME}-${PROTOC_GEN_OPENAPI_VERSION}${extension}`)); +} + +function getSymlinkPath(): AbsoluteFilePath { + const { extension } = getPlatformInfo(); + const cacheDir = getCacheDir(); + return join(cacheDir, RelativeFilePath.of(`${BINARY_NAME}${extension}`)); +} + +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; + } +} + +/** + * Returns the directory containing the protoc-gen-openapi binary. + * If the binary is not cached, downloads it from GitHub Releases. + * Returns undefined if the download fails. + */ +export async function resolveProtocGenOpenAPI(logger: Logger): Promise { + const cachedBinaryPath = getCachedBinaryPath(); + const symlinkPath = getSymlinkPath(); + + if (await fileExists(cachedBinaryPath)) { + logger.debug(`Using cached protoc-gen-openapi at ${cachedBinaryPath}`); + // Ensure the unversioned symlink/copy exists + if (!(await fileExists(symlinkPath))) { + await copyBinaryFile(cachedBinaryPath, symlinkPath); + } + return getCacheDir(); + } + + const downloadUrl = getDownloadUrl(); + logger.debug(`Downloading protoc-gen-openapi from ${downloadUrl}`); + + try { + const cacheDir = getCacheDir(); + await mkdir(cacheDir, { recursive: true }); + + const tmpPath = AbsoluteFilePath.of(`${cachedBinaryPath}.tmp`); + const response = await fetch(downloadUrl, { redirect: "follow" }); + if (!response.ok) { + logger.debug(`Failed to download protoc-gen-openapi: ${response.status} ${response.statusText}`); + return undefined; + } + + if (response.body == null) { + logger.debug("Failed to download protoc-gen-openapi: empty response body"); + return undefined; + } + + const fileStream = createWriteStream(tmpPath); + await pipeline(Readable.fromWeb(response.body as ReadableStream), fileStream); + + await chmod(tmpPath, 0o755); + await rename(tmpPath, cachedBinaryPath); + + // Create the unversioned copy so buf can find it as "protoc-gen-openapi" + await copyBinaryFile(cachedBinaryPath, symlinkPath); + + logger.debug(`Downloaded protoc-gen-openapi ${PROTOC_GEN_OPENAPI_VERSION} to ${cachedBinaryPath}`); + return cacheDir; + } catch (error) { + logger.debug( + `Failed to download protoc-gen-openapi: ${error instanceof Error ? error.message : String(error)}` + ); + return undefined; + } +} + +async function copyBinaryFile(src: AbsoluteFilePath, dest: AbsoluteFilePath): Promise { + const { copyFile: fsCopyFile } = await import("fs/promises"); + await fsCopyFile(src, dest); + await chmod(dest, 0o755); +} From f5eaaab7555b8e6bc90bb3fea901e2a5b95f80a5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:58:42 +0000 Subject: [PATCH 02/13] fix: address compile error and review comments (path.delimiter, empty catch, stale binary) Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- .../src/protobuf/ProtobufOpenAPIGenerator.ts | 7 ++++-- .../protobuf/ProtocGenOpenAPIDownloader.ts | 23 +++++-------------- 2 files changed, 11 insertions(+), 19 deletions(-) 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 3a3a1aa89057..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,6 +2,7 @@ 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"; @@ -128,7 +129,9 @@ export class ProtobufOpenAPIGenerator { await which(["protoc-gen-openapi"]); protocGenOpenAPIOnPath = true; } catch (err) { - // Not on PATH — try auto-downloading + this.context.logger.debug( + `protoc-gen-openapi not found on PATH: ${err instanceof Error ? err.message : String(err)}` + ); } if (!protocGenOpenAPIOnPath) { @@ -151,7 +154,7 @@ export class ProtobufOpenAPIGenerator { // If we downloaded protoc-gen-openapi, prepend its directory to PATH so buf can find it const envOverride = this.protocGenOpenAPIBinDir != null - ? { PATH: `${this.protocGenOpenAPIBinDir}:${process.env.PATH ?? ""}` } + ? { PATH: `${this.protocGenOpenAPIBinDir}${path.delimiter}${process.env.PATH ?? ""}` } : undefined; const buf = createLoggingExecutable("buf", { diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts index bf52471e91db..3cd49b269874 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts @@ -1,11 +1,9 @@ import { AbsoluteFilePath, join, RelativeFilePath } from "@fern-api/fs-utils"; import { Logger } from "@fern-api/logger"; -import { createWriteStream } from "fs"; -import { access, chmod, mkdir, rename } from "fs/promises"; +import { writeFileSync } from "fs"; +import { access, chmod, copyFile, mkdir, rename } from "fs/promises"; import os from "os"; import path from "path"; -import { Readable } from "stream"; -import { pipeline } from "stream/promises"; const PROTOC_GEN_OPENAPI_VERSION = "v0.1.12"; const GITHUB_RELEASE_URL_BASE = "https://github.com/fern-api/protoc-gen-openapi/releases/download"; @@ -99,10 +97,7 @@ export async function resolveProtocGenOpenAPI(logger: Logger): Promise { - const { copyFile: fsCopyFile } = await import("fs/promises"); - await fsCopyFile(src, dest); + await copyFile(src, dest); await chmod(dest, 0o755); } From 247db1a23fc4c0720c4d39ba15ea6ac3b857a4f3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:22:35 +0000 Subject: [PATCH 03/13] fix: add versioning marker and filesystem lock for race-safe protoc-gen-openapi caching Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- .../protobuf/ProtocGenOpenAPIDownloader.ts | 150 +++++++++++++++--- 1 file changed, 125 insertions(+), 25 deletions(-) diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts index 3cd49b269874..aa058d7ccaf7 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts @@ -1,7 +1,7 @@ import { AbsoluteFilePath, join, RelativeFilePath } from "@fern-api/fs-utils"; import { Logger } from "@fern-api/logger"; import { writeFileSync } from "fs"; -import { access, chmod, copyFile, mkdir, rename } from "fs/promises"; +import { access, chmod, copyFile, mkdir, readFile, rename, rm, writeFile } from "fs/promises"; import os from "os"; import path from "path"; @@ -10,6 +10,8 @@ const GITHUB_RELEASE_URL_BASE = "https://github.com/fern-api/protoc-gen-openapi/ 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; @@ -60,18 +62,28 @@ function getCacheDir(): AbsoluteFilePath { return AbsoluteFilePath.of(path.join(homeDir, CACHE_DIR_NAME, BIN_DIR_NAME)); } -function getCachedBinaryPath(): AbsoluteFilePath { +function getVersionedBinaryPath(): AbsoluteFilePath { const { extension } = getPlatformInfo(); const cacheDir = getCacheDir(); return join(cacheDir, RelativeFilePath.of(`${BINARY_NAME}-${PROTOC_GEN_OPENAPI_VERSION}${extension}`)); } -function getSymlinkPath(): AbsoluteFilePath { +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}`; @@ -87,28 +99,99 @@ async function fileExists(filePath: AbsoluteFilePath): Promise { } /** - * Returns the directory containing the protoc-gen-openapi binary. - * If the binary is not cached, downloads it from GitHub Releases. - * Returns undefined if the download fails. + * 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 async () => { + try { + await rm(lockPath, { recursive: true }); + } catch { + // Lock directory already removed; harmless + } + }; + } 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 { + // Ignore — another process may have already removed it + } + await mkdir(lockPath, { recursive: false }); + return async () => { + try { + await rm(lockPath, { recursive: true }); + } catch { + // Lock directory already removed; harmless + } + }; +} + +/** + * 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.12`). + * 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 { - const cachedBinaryPath = getCachedBinaryPath(); - const symlinkPath = getSymlinkPath(); + const cacheDir = getCacheDir(); + await mkdir(cacheDir, { recursive: true }); - if (await fileExists(cachedBinaryPath)) { - logger.debug(`Using cached protoc-gen-openapi at ${cachedBinaryPath}`); - await copyBinaryFile(cachedBinaryPath, symlinkPath); + const releaseLock = await acquireLock(logger); + try { + return await resolveUnderLock(logger); + } finally { + await releaseLock(); + } +} + +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); + 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}`); try { - const cacheDir = getCacheDir(); - await mkdir(cacheDir, { recursive: true }); - - const tmpPath = AbsoluteFilePath.of(`${cachedBinaryPath}.tmp`); + const tmpDownloadPath = AbsoluteFilePath.of(`${versionedPath}.download`); const response = await fetch(downloadUrl, { redirect: "follow" }); if (!response.ok) { logger.debug(`Failed to download protoc-gen-openapi: ${response.status} ${response.statusText}`); @@ -116,16 +199,18 @@ export async function resolveProtocGenOpenAPI(logger: Logger): Promise { - await copyFile(src, dest); - await chmod(dest, 0o755); +async function readVersionMarker(markerPath: AbsoluteFilePath): Promise { + try { + return (await readFile(markerPath, "utf-8")).trim(); + } catch { + 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); } From 48331b9e4bf0f29ca0c45689433b161fe4a9ae5e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:29:37 +0000 Subject: [PATCH 04/13] fix: add logging to catch blocks and handle EEXIST race in lock force-break Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- .../protobuf/ProtocGenOpenAPIDownloader.ts | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts index aa058d7ccaf7..866494eb2abd 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts @@ -111,13 +111,7 @@ async function acquireLock(logger: Logger): Promise<() => Promise> { while (Date.now() < deadline) { try { await mkdir(lockPath, { recursive: false }); - return async () => { - try { - await rm(lockPath, { recursive: true }); - } catch { - // Lock directory already removed; harmless - } - }; + return createLockReleaser(lockPath, logger); } catch { logger.debug(`Waiting for lock on ${lockPath}...`); await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS)); @@ -128,15 +122,26 @@ async function acquireLock(logger: Logger): Promise<() => Promise> { logger.debug(`Lock timed out after ${LOCK_TIMEOUT_MS}ms, breaking stale lock`); try { await rm(lockPath, { recursive: true }); - } catch { - // Ignore — another process may have already removed it + } 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 }); } - 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 { - // Lock directory already removed; harmless + } catch (err) { + logger.debug(`Failed to release lock: ${err instanceof Error ? err.message : String(err)}`); } }; } From 41afd953b6cf3dfc6ed9c04d0bba449dd669b3ed Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:42:39 +0000 Subject: [PATCH 05/13] test: add end-to-end tests for ProtocGenOpenAPIDownloader Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- .../ProtocGenOpenAPIDownloader.test.ts | 372 ++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 packages/cli/workspace/lazy-fern-workspace/src/protobuf/__test__/ProtocGenOpenAPIDownloader.test.ts 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..68e8fa4d582b --- /dev/null +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/__test__/ProtocGenOpenAPIDownloader.test.ts @@ -0,0 +1,372 @@ +import { AbsoluteFilePath } from "@fern-api/fs-utils"; +import { Logger } from "@fern-api/logger"; +import { access, chmod, mkdir, readFile, rm, 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.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.12")); + 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.12"); + + // 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.12")); + 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.12"); + + // 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.12")); + 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.12"); + + // 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.12")); + 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.12"); + + 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.12"); + + // 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.12")); + const versionedStat = await stat(versionedPath); + expect(versionedStat.mode & 0o100).toBeTruthy(); + + vi.unstubAllGlobals(); + }); + }); +}); From 0da9fcac3f53c85b2666eae52cd5d450f922b4fe Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:54:09 +0000 Subject: [PATCH 06/13] fix: wrap resolveProtocGenOpenAPI in top-level try-catch to always return undefined on failure Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- .../protobuf/ProtocGenOpenAPIDownloader.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts index 866494eb2abd..857779f71456 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts @@ -161,14 +161,21 @@ function createLockReleaser(lockPath: string, logger: Logger): () => Promise { - const cacheDir = getCacheDir(); - await mkdir(cacheDir, { recursive: true }); - - const releaseLock = await acquireLock(logger); try { - return await resolveUnderLock(logger); - } finally { - await releaseLock(); + 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; } } From 36c4b88d2d507bcf7a40dba36cb57cdc4bb467ff Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:56:24 +0000 Subject: [PATCH 07/13] fix: biome formatting for catch block logger.debug Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- .../src/protobuf/ProtocGenOpenAPIDownloader.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts index 857779f71456..ae6e9b0ba63e 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts @@ -172,9 +172,7 @@ export async function resolveProtocGenOpenAPI(logger: Logger): Promise Date: Wed, 18 Mar 2026 19:51:14 +0000 Subject: [PATCH 08/13] fix: use async writeFile, clean up temp on failure, move unstubAllGlobals to afterEach Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- .../src/protobuf/ProtocGenOpenAPIDownloader.ts | 11 ++++++++--- .../__test__/ProtocGenOpenAPIDownloader.test.ts | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts index ae6e9b0ba63e..c23a0b231d42 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts @@ -1,6 +1,5 @@ import { AbsoluteFilePath, join, RelativeFilePath } from "@fern-api/fs-utils"; import { Logger } from "@fern-api/logger"; -import { writeFileSync } from "fs"; import { access, chmod, copyFile, mkdir, readFile, rename, rm, writeFile } from "fs/promises"; import os from "os"; import path from "path"; @@ -200,8 +199,8 @@ async function resolveUnderLock(logger: Logger): Promise { }); afterEach(async () => { + vi.unstubAllGlobals(); vi.restoreAllMocks(); await rm(tempHomeDir, { recursive: true, force: true }); }); From 9932cd7a6a69126e3dc7d48165e212a6b86a8c3f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 19:53:47 +0000 Subject: [PATCH 09/13] fix: use Uint8Array instead of Buffer for writeFile compatibility Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- .../src/protobuf/ProtocGenOpenAPIDownloader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts index c23a0b231d42..70962724a6f2 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts @@ -208,7 +208,7 @@ async function resolveUnderLock(logger: Logger): Promise Date: Wed, 18 Mar 2026 19:59:27 +0000 Subject: [PATCH 10/13] fix: add logging to empty catch blocks per REVIEW.md rules Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- .../src/protobuf/ProtocGenOpenAPIDownloader.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts index 70962724a6f2..711f7394fa29 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts @@ -183,7 +183,7 @@ async function resolveUnderLock(logger: Logger): Promise { +async function readVersionMarker(markerPath: AbsoluteFilePath, logger: Logger): Promise { try { return (await readFile(markerPath, "utf-8")).trim(); - } catch { + } catch (err) { + logger.debug(`Failed to read version marker: ${err instanceof Error ? err.message : String(err)}`); return undefined; } } From 61c3b5e4e073e44036e5f3ba3b701589539a22c1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:01:21 +0000 Subject: [PATCH 11/13] fix: biome formatting for cleanup catch block Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- .../src/protobuf/ProtocGenOpenAPIDownloader.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts index 711f7394fa29..cac748facb30 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts @@ -228,7 +228,9 @@ async function resolveUnderLock(logger: Logger): Promise Date: Wed, 18 Mar 2026 20:16:59 +0000 Subject: [PATCH 12/13] chore: bump protoc-gen-openapi version to v0.1.13 Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- .../protobuf/ProtocGenOpenAPIDownloader.ts | 4 ++-- .../ProtocGenOpenAPIDownloader.test.ts | 20 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts index cac748facb30..fb1df4918ab8 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts @@ -4,7 +4,7 @@ import { access, chmod, copyFile, mkdir, readFile, rename, rm, writeFile } from import os from "os"; import path from "path"; -const PROTOC_GEN_OPENAPI_VERSION = "v0.1.12"; +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"; @@ -148,7 +148,7 @@ function createLockReleaser(lockPath: string, logger: Logger): () => Promise { expect(fetchUrl).toContain("github.com/fern-api/protoc-gen-openapi/releases/download"); // Versioned binary should exist - const versionedPath = path.join(getCacheDir(), getVersionedBinaryName("v0.1.12")); + const versionedPath = path.join(getCacheDir(), getVersionedBinaryName("v0.1.13")); expect(await fileExists(versionedPath)).toBe(true); // Canonical binary should exist @@ -106,7 +106,7 @@ describe("ProtocGenOpenAPIDownloader", () => { 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.12"); + expect(markerContent.trim()).toBe("v0.1.13"); // Lock directory should be cleaned up const lockPath = path.join(getCacheDir(), "protoc-gen-openapi.lock"); @@ -122,7 +122,7 @@ describe("ProtocGenOpenAPIDownloader", () => { const cacheDir = getCacheDir(); await mkdir(cacheDir, { recursive: true }); - const versionedPath = path.join(cacheDir, getVersionedBinaryName("v0.1.12")); + const versionedPath = path.join(cacheDir, getVersionedBinaryName("v0.1.13")); await writeFile(versionedPath, FAKE_BINARY_CONTENT); await chmod(versionedPath, 0o755); @@ -131,7 +131,7 @@ describe("ProtocGenOpenAPIDownloader", () => { await chmod(canonicalPath, 0o755); const markerPath = path.join(cacheDir, "protoc-gen-openapi.version"); - await writeFile(markerPath, "v0.1.12"); + await writeFile(markerPath, "v0.1.13"); // fetch should NOT be called const mockFetch = vi.fn(); @@ -156,7 +156,7 @@ describe("ProtocGenOpenAPIDownloader", () => { const cacheDir = getCacheDir(); await mkdir(cacheDir, { recursive: true }); - const versionedPath = path.join(cacheDir, getVersionedBinaryName("v0.1.12")); + const versionedPath = path.join(cacheDir, getVersionedBinaryName("v0.1.13")); const newContent = "new-binary-content"; await writeFile(versionedPath, newContent); await chmod(versionedPath, 0o755); @@ -184,7 +184,7 @@ describe("ProtocGenOpenAPIDownloader", () => { // Marker should be updated const updatedMarker = await readFile(markerPath, "utf-8"); - expect(updatedMarker.trim()).toBe("v0.1.12"); + expect(updatedMarker.trim()).toBe("v0.1.13"); // Should log the update expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("Updated canonical")); @@ -196,13 +196,13 @@ describe("ProtocGenOpenAPIDownloader", () => { const cacheDir = getCacheDir(); await mkdir(cacheDir, { recursive: true }); - const versionedPath = path.join(cacheDir, getVersionedBinaryName("v0.1.12")); + 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.12"); + await writeFile(markerPath, "v0.1.13"); const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); @@ -308,7 +308,7 @@ describe("ProtocGenOpenAPIDownloader", () => { // 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.12"); + expect(marker.trim()).toBe("v0.1.13"); // Lock should be released const lockPath = path.join(cacheDir, "protoc-gen-openapi.lock"); @@ -363,7 +363,7 @@ describe("ProtocGenOpenAPIDownloader", () => { expect(canonicalStat.mode & 0o100).toBeTruthy(); // Versioned binary should also be executable - const versionedPath = path.join(getCacheDir(), getVersionedBinaryName("v0.1.12")); + const versionedPath = path.join(getCacheDir(), getVersionedBinaryName("v0.1.13")); const versionedStat = await stat(versionedPath); expect(versionedStat.mode & 0o100).toBeTruthy(); From 8793b8bb553b987cea60cd5a50d0861756cdfeaf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:40:08 +0000 Subject: [PATCH 13/13] test: add real e2e tests for protoc-gen-openapi auto-download from GitHub Releases Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- .../ProtocGenOpenAPIDownloader.test.ts | 127 +++++++++++++++++- 1 file changed, 126 insertions(+), 1 deletion(-) 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 index c42b288f95be..23164b87d70e 100644 --- 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 @@ -1,6 +1,6 @@ import { AbsoluteFilePath } from "@fern-api/fs-utils"; import { Logger } from "@fern-api/logger"; -import { access, chmod, mkdir, readFile, rm, writeFile } from "fs/promises"; +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"; @@ -371,3 +371,128 @@ describe("ProtocGenOpenAPIDownloader", () => { }); }); }); + +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); + }); +});