From 954334dc900e5cadc655933463f1293006fd7e0c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:29:51 +0000 Subject: [PATCH 1/8] feat(cli): auto-download buf binary when not on PATH Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- packages/cli/cli/versions.yml | 11 + .../src/protobuf/BufDownloader.ts | 262 +++++++++ .../src/protobuf/ProtobufIRGenerator.ts | 69 ++- .../src/protobuf/ProtobufOpenAPIGenerator.ts | 46 +- .../protobuf/__test__/BufDownloader.test.ts | 535 ++++++++++++++++++ .../lazy-fern-workspace/src/protobuf/utils.ts | 5 +- 6 files changed, 885 insertions(+), 43 deletions(-) create mode 100644 packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts create mode 100644 packages/cli/workspace/lazy-fern-workspace/src/protobuf/__test__/BufDownloader.test.ts diff --git a/packages/cli/cli/versions.yml b/packages/cli/cli/versions.yml index fc769853ab86..0444ccd3f690 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.37.0 + changelogEntry: + - summary: | + Auto-download `buf` CLI binary from GitHub Releases when it is not found + on PATH. This eliminates the need for users to pre-install `buf` before + running proto-based SDK generation. The binary is cached at `~/.fern/bin/` + with versioning, filesystem locking, and atomic file operations. + type: feat + createdAt: "2026-03-18" + irVersion: 65 + - version: 4.36.0 changelogEntry: - summary: | diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts new file mode 100644 index 000000000000..61a0fc000351 --- /dev/null +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts @@ -0,0 +1,262 @@ +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 BUF_VERSION = "v1.66.1"; +const GITHUB_RELEASE_URL_BASE = "https://github.com/bufbuild/buf/releases/download"; +const BINARY_NAME = "buf"; +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; +} + +/** + * Returns platform info matching buf's GitHub Release asset naming convention: + * - OS: Darwin, Linux, Windows (capitalized) + * - Arch: x86_64 (Intel), arm64 (macOS ARM), aarch64 (Linux ARM) + */ +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 = "x86_64"; + break; + case "arm64": + // buf uses "arm64" for macOS but "aarch64" for Linux + archName = platform === "linux" ? "aarch64" : "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}-${BUF_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}/${BUF_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 buf 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(`Buf 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 buf 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 buf 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 buf lock: ${err instanceof Error ? err.message : String(err)}`); + } + }; +} + +/** + * Resolves the buf binary, downloading it from GitHub Releases if needed. + * + * **Versioning**: Binaries are cached with a versioned filename (e.g. `buf-v1.66.1`). + * A `.version` marker file tracks which version the canonical `buf` binary corresponds to. + * When `BUF_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 absolute path to the buf binary, or `undefined` if download fails. + */ +export async function resolveBuf(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 buf: ${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 === BUF_VERSION && (await fileExists(canonicalPath))) { + logger.debug(`Using cached buf ${BUF_VERSION}`); + return canonicalPath; + } + // Version marker is stale or canonical binary is missing — refresh atomically + await atomicCopyBinary(versionedPath, canonicalPath); + await writeFile(versionMarkerPath, BUF_VERSION); + logger.debug(`Updated canonical buf to ${BUF_VERSION}`); + return canonicalPath; + } + + // Download the binary + const downloadUrl = getDownloadUrl(); + logger.debug(`Downloading buf from ${downloadUrl}`); + + const tmpDownloadPath = AbsoluteFilePath.of(`${versionedPath}.download`); + try { + const response = await fetch(downloadUrl, { redirect: "follow" }); + if (!response.ok) { + logger.debug(`Failed to download buf: ${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, BUF_VERSION); + + logger.debug(`Downloaded buf ${BUF_VERSION}`); + return canonicalPath; + } catch (error) { + logger.debug(`Failed to download buf: ${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 buf 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 buf 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/ProtobufIRGenerator.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtobufIRGenerator.ts index 92efc7cdbe30..193dd12d3d4a 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtobufIRGenerator.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtobufIRGenerator.ts @@ -4,6 +4,7 @@ import { TaskContext } from "@fern-api/task-context"; import { access, chmod, cp, unlink, writeFile } from "fs/promises"; import path from "path"; import tmp from "tmp-promise"; +import { resolveBuf } from "./BufDownloader.js"; import { detectAirGappedModeForProtobuf, @@ -21,6 +22,7 @@ import { export class ProtobufIRGenerator { private context: TaskContext; private isAirGapped: boolean | undefined; + private resolvedBufCommand: string | undefined; constructor({ context }: { context: TaskContext }) { this.context = context; @@ -56,11 +58,15 @@ export class ProtobufIRGenerator { absoluteFilepathToProtobufTarget: AbsoluteFilePath | undefined; deps: string[]; }): Promise { + // Resolve buf once at the start: check PATH first, then auto-download + await this.ensureBufResolved(); + // Detect air-gapped mode once at the start if we have dependencies if (deps.length > 0 && this.isAirGapped === undefined) { this.isAirGapped = await detectAirGappedModeForProtobuf( absoluteFilepathToProtobufRoot, - this.context.logger + this.context.logger, + this.resolvedBufCommand ); } @@ -122,20 +128,7 @@ export class ProtobufIRGenerator { return; } - // Use buf export to get all relevant .proto files - const which = createLoggingExecutable("which", { - cwd: protobufGeneratorConfigPath, - logger: undefined, - doNotPipeOutput: true - }); - - try { - await which(["buf"]); - } catch (err) { - this.context.failAndThrow( - "Missing required dependency; please install 'buf' to continue (e.g. 'brew install buf')." - ); - } + const bufCommand = this.resolvedBufCommand ?? "buf"; // Create a temporary buf config file to prevent conflicts // Try buf export with v1 first, then fall back to v2 if it fails @@ -149,7 +142,7 @@ export class ProtobufIRGenerator { try { const result = await runExeca( this.context.logger, - "buf", + bufCommand, [ "export", "--path", @@ -236,25 +229,12 @@ export class ProtobufIRGenerator { } private async doGenerateLocal({ cwd, deps }: { cwd: AbsoluteFilePath; deps: string[] }): Promise { - const which = createLoggingExecutable("which", { - cwd, - logger: undefined, - doNotPipeOutput: true - }); - - try { - await which(["buf"]); - } catch (err) { - this.context.failAndThrow( - "Missing required dependency; please install 'buf' to continue (e.g. 'brew install buf')." - ); - } - const bufYamlPath = join(cwd, RelativeFilePath.of("buf.yaml")); const configContent = getProtobufYamlV1(deps); - const buf = createLoggingExecutable("buf", { + const bufCommand = this.resolvedBufCommand ?? "buf"; + const buf = createLoggingExecutable(bufCommand, { cwd, logger: undefined, stdout: "ignore", @@ -296,6 +276,33 @@ export class ProtobufIRGenerator { return join(cwd, RelativeFilePath.of(PROTOBUF_GENERATOR_OUTPUT_FILEPATH)); } + private async ensureBufResolved(): Promise { + if (this.resolvedBufCommand != null) { + return; + } + + const which = createLoggingExecutable("which", { + cwd: AbsoluteFilePath.of(process.cwd()), + logger: undefined, + doNotPipeOutput: true + }); + + try { + await which(["buf"]); + this.resolvedBufCommand = "buf"; + } catch { + this.context.logger.debug("buf not found on PATH, attempting auto-download"); + const downloadedBufPath = await resolveBuf(this.context.logger); + if (downloadedBufPath != null) { + this.resolvedBufCommand = downloadedBufPath; + } else { + this.context.failAndThrow( + "Missing required dependency; please install 'buf' to continue (e.g. 'brew install buf')." + ); + } + } + } + private async generateRemote(): Promise { this.context.failAndThrow("Remote Protobuf generation is unimplemented."); } 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 54a54d51c94d..87c50232fa89 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtobufOpenAPIGenerator.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtobufOpenAPIGenerator.ts @@ -4,6 +4,7 @@ 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 { resolveBuf } from "./BufDownloader.js"; import { resolveProtocGenOpenAPI } from "./ProtocGenOpenAPIDownloader.js"; import { detectAirGappedModeForProtobuf, getProtobufYamlV1 } from "./utils.js"; @@ -15,6 +16,7 @@ export class ProtobufOpenAPIGenerator { private context: TaskContext; private isAirGapped: boolean | undefined; private protocGenOpenAPIBinDir: AbsoluteFilePath | undefined; + private resolvedBufCommand: string | undefined; constructor({ context }: { context: TaskContext }) { this.context = context; @@ -60,11 +62,15 @@ export class ProtobufOpenAPIGenerator { deps: string[]; existingBufLockContents?: string; }): Promise<{ absoluteFilepath: AbsoluteFilePath; bufLockContents: string | undefined }> { + // Resolve buf once at the start: check PATH first, then auto-download + await this.ensureBufResolved(); + // Detect air-gapped mode once at the start if we have dependencies if (deps.length > 0 && this.isAirGapped === undefined) { this.isAirGapped = await detectAirGappedModeForProtobuf( absoluteFilepathToProtobufRoot, - this.context.logger + this.context.logger, + this.resolvedBufCommand ); } @@ -116,14 +122,6 @@ export class ProtobufOpenAPIGenerator { doNotPipeOutput: true }); - try { - await which(["buf"]); - } catch (err) { - this.context.failAndThrow( - "Missing required dependency; please install 'buf' to continue (e.g. 'brew install buf')." - ); - } - let protocGenOpenAPIOnPath = false; try { await which(["protoc-gen-openapi"]); @@ -157,7 +155,8 @@ export class ProtobufOpenAPIGenerator { ? { PATH: `${this.protocGenOpenAPIBinDir}${path.delimiter}${process.env.PATH ?? ""}` } : undefined; - const buf = createLoggingExecutable("buf", { + const bufCommand = this.resolvedBufCommand ?? "buf"; + const buf = createLoggingExecutable(bufCommand, { cwd, logger: this.context.logger, stdout: "ignore", @@ -216,6 +215,33 @@ export class ProtobufOpenAPIGenerator { }; } + private async ensureBufResolved(): Promise { + if (this.resolvedBufCommand != null) { + return; + } + + const which = createLoggingExecutable("which", { + cwd: AbsoluteFilePath.of(process.cwd()), + logger: undefined, + doNotPipeOutput: true + }); + + try { + await which(["buf"]); + this.resolvedBufCommand = "buf"; + } catch { + this.context.logger.debug("buf not found on PATH, attempting auto-download"); + const downloadedBufPath = await resolveBuf(this.context.logger); + if (downloadedBufPath != null) { + this.resolvedBufCommand = downloadedBufPath; + } else { + this.context.failAndThrow( + "Missing required dependency; please install 'buf' to continue (e.g. 'brew install buf')." + ); + } + } + } + private async generateRemote(): Promise<{ absoluteFilepath: AbsoluteFilePath; bufLockContents: string | undefined; diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/__test__/BufDownloader.test.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/__test__/BufDownloader.test.ts new file mode 100644 index 000000000000..750cccbc0c3f --- /dev/null +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/__test__/BufDownloader.test.ts @@ -0,0 +1,535 @@ +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-buf\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("BufDownloader", () => { + 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" ? "buf.exe" : "buf"; + } + + function getVersionedBinaryName(version: string): string { + const ext = process.platform === "win32" ? ".exe" : ""; + return `buf-${version}${ext}`; + } + + describe("fresh download", () => { + it("downloads and caches binary on first invocation", 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 { resolveBuf } = await import("../BufDownloader.js"); + const result = await resolveBuf(logger); + + // Should return the full path to the canonical binary + const expectedPath = path.join(getCacheDir(), getBinaryName()); + expect(result).toBe(AbsoluteFilePath.of(expectedPath)); + + // Should have called fetch once + expect(mockFetch).toHaveBeenCalledOnce(); + const fetchUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(fetchUrl).toContain("bufbuild/buf/releases/download"); + + // Versioned binary should exist + const versionedPath = path.join(getCacheDir(), getVersionedBinaryName("v1.66.1")); + 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(), "buf.version"); + expect(await fileExists(markerPath)).toBe(true); + const markerContent = await readFile(markerPath, "utf-8"); + expect(markerContent.trim()).toBe("v1.66.1"); + + // Lock directory should be cleaned up + const lockPath = path.join(getCacheDir(), "buf.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("v1.66.1")); + 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, "buf.version"); + await writeFile(markerPath, "v1.66.1"); + + // fetch should NOT be called + const mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + + const { resolveBuf } = await import("../BufDownloader.js"); + const result = await resolveBuf(logger); + + expect(result).toBe(AbsoluteFilePath.of(canonicalPath)); + expect(mockFetch).not.toHaveBeenCalled(); + + // Debug log should indicate cache hit + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("Using cached buf")); + + vi.unstubAllGlobals(); + }); + }); + + describe("version upgrade", () => { + it("refreshes canonical binary when version marker is stale", async () => { + const cacheDir = getCacheDir(); + await mkdir(cacheDir, { recursive: true }); + + const versionedPath = path.join(cacheDir, getVersionedBinaryName("v1.66.1")); + 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, "buf.version"); + await writeFile(markerPath, "v1.65.0"); + + const mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + + const { resolveBuf } = await import("../BufDownloader.js"); + const result = await resolveBuf(logger); + + expect(result).toBe(AbsoluteFilePath.of(canonicalPath)); + 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("v1.66.1"); + + // Should log the update + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("Updated canonical buf")); + + 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("v1.66.1")); + await writeFile(versionedPath, FAKE_BINARY_CONTENT); + await chmod(versionedPath, 0o755); + + // Marker is correct but canonical is missing + const markerPath = path.join(cacheDir, "buf.version"); + await writeFile(markerPath, "v1.66.1"); + + const mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + + const { resolveBuf } = await import("../BufDownloader.js"); + const result = await resolveBuf(logger); + + const canonicalPath = path.join(cacheDir, getBinaryName()); + expect(result).toBe(AbsoluteFilePath.of(canonicalPath)); + expect(mockFetch).not.toHaveBeenCalled(); + + // Canonical should now exist + 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 { resolveBuf } = await import("../BufDownloader.js"); + const result = await resolveBuf(logger); + + expect(result).toBeUndefined(); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("Failed to download buf")); + + 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 { resolveBuf } = await import("../BufDownloader.js"); + const result = await resolveBuf(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 { resolveBuf } = await import("../BufDownloader.js"); + await resolveBuf(logger); + + // Lock directory should be cleaned up + const lockPath = path.join(getCacheDir(), "buf.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 { resolveBuf } = await import("../BufDownloader.js"); + + // Run 3 concurrent resolves + const results = await Promise.all([resolveBuf(logger), resolveBuf(logger), resolveBuf(logger)]); + + // All should succeed + const canonicalPath = path.join(getCacheDir(), getBinaryName()); + for (const result of results) { + expect(result).toBe(AbsoluteFilePath.of(canonicalPath)); + } + + // Canonical binary should exist and be valid + 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(getCacheDir(), "buf.version"); + const marker = await readFile(markerPath, "utf-8"); + expect(marker.trim()).toBe("v1.66.1"); + + // Lock should be released + const lockPath = path.join(getCacheDir(), "buf.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 { resolveBuf } = await import("../BufDownloader.js"); + const result = await resolveBuf(logger); + + const expectedPath = path.join(cacheDir, getBinaryName()); + expect(result).toBe(AbsoluteFilePath.of(expectedPath)); + 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 { resolveBuf } = await import("../BufDownloader.js"); + await resolveBuf(logger); + + // 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("v1.66.1")); + const versionedStat = await stat(versionedPath); + expect(versionedStat.mode & 0o100).toBeTruthy(); + + vi.unstubAllGlobals(); + }); + }); + + describe("download URL format", () => { + it("constructs correct URL for the current platform", 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 { resolveBuf } = await import("../BufDownloader.js"); + await resolveBuf(logger); + + expect(mockFetch).toHaveBeenCalledOnce(); + const fetchUrl = mockFetch.mock.calls[0]?.[0] as string; + + // URL should match buf's release naming convention + expect(fetchUrl).toContain("github.com/bufbuild/buf/releases/download/v1.66.1/buf-"); + + // OS name should be capitalized (Darwin, Linux, Windows) + const platform = process.platform; + if (platform === "darwin") { + expect(fetchUrl).toContain("buf-Darwin-"); + } else if (platform === "linux") { + expect(fetchUrl).toContain("buf-Linux-"); + } else if (platform === "win32") { + expect(fetchUrl).toContain("buf-Windows-"); + } + + // Arch should use buf's naming convention + const arch = process.arch; + if (arch === "x64") { + expect(fetchUrl).toContain("x86_64"); + } else if (arch === "arm64") { + if (platform === "linux") { + expect(fetchUrl).toContain("aarch64"); + } else { + expect(fetchUrl).toContain("arm64"); + } + } + + vi.unstubAllGlobals(); + }); + }); +}); + +describe("BufDownloader (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" ? "buf.exe" : "buf"; + } + + function getVersionedBinaryName(version: string): string { + const ext = process.platform === "win32" ? ".exe" : ""; + return `buf-${version}${ext}`; + } + + it( + "downloads real buf binary from GitHub Releases, caches it, and reuses on second call", + { timeout: 120_000 }, + async () => { + // Do NOT mock fetch — use the real network + const { resolveBuf } = await import("../BufDownloader.js"); + + // --- First call: should download from GitHub Releases --- + const result1 = await resolveBuf(logger); + + const expectedPath = path.join(getCacheDir(), getBinaryName()); + expect(result1).toBe(AbsoluteFilePath.of(expectedPath)); + + // Verify download log messages + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("Downloading buf from")); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("Downloaded buf")); + + // Verify cache structure + const cacheDir = getCacheDir(); + const canonicalPath = path.join(cacheDir, getBinaryName()); + const versionedPath = path.join(cacheDir, getVersionedBinaryName("v1.66.1")); + const markerPath = path.join(cacheDir, "buf.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 v1.66.1 + const markerContent = await readFile(markerPath, "utf-8"); + expect(markerContent.trim()).toBe("v1.66.1"); + + // Binary should be a real executable (buf is ~47-52MB) + const binaryStat = await stat(canonicalPath); + expect(binaryStat.size).toBeGreaterThan(10_000_000); + + // Binary should have executable permissions + expect(binaryStat.mode & 0o100).toBeTruthy(); + + // Lock should be cleaned up + const lockPath = path.join(cacheDir, "buf.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 resolveBuf(logger); + + expect(result2).toBe(AbsoluteFilePath.of(expectedPath)); + + // 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 buf"))).toBe(true); + expect(newMessages.some((msg) => msg.includes("Downloading"))).toBe(false); + } + ); + + it("downloaded binary is a valid ELF/Mach-O executable", { timeout: 120_000 }, async () => { + const { resolveBuf } = await import("../BufDownloader.js"); + const result = await resolveBuf(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 buffer = await readFile(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); + }); +}); diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/utils.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/utils.ts index 6e99d277fff8..2fca92c22911 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/utils.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/utils.ts @@ -76,7 +76,8 @@ async function performAirGapDetection(url: string, logger: Logger, timeoutMs: nu */ export async function detectAirGappedModeForProtobuf( absoluteFilepathToProtobufRoot: AbsoluteFilePath, - logger: Logger + logger: Logger, + bufCommand: string = "buf" ): Promise { const bufLockPath = join(absoluteFilepathToProtobufRoot, RelativeFilePath.of("buf.lock")); @@ -110,7 +111,7 @@ export async function detectAirGappedModeForProtobuf( // Try buf dep update with a timeout (30 seconds) try { - await runExeca(logger, "buf", ["dep", "update"], { + await runExeca(logger, bufCommand, ["dep", "update"], { cwd: tmpDir, stdio: "pipe", timeout: 30000 // 30 second timeout for detection From 9d22dc612d641938b18b91c3fbbba36ff5e7715f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:51:30 +0000 Subject: [PATCH 2/8] fix: resolve protoc-gen-openapi once, promote download logs to info Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- .../src/protobuf/BufDownloader.ts | 8 +-- .../src/protobuf/ProtobufOpenAPIGenerator.ts | 57 ++++++++++--------- .../protobuf/ProtocGenOpenAPIDownloader.ts | 8 +-- .../protobuf/__test__/BufDownloader.test.ts | 16 +++--- .../ProtocGenOpenAPIDownloader.test.ts | 16 +++--- 5 files changed, 53 insertions(+), 52 deletions(-) diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts index 61a0fc000351..90b4dd305a7d 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts @@ -191,19 +191,19 @@ async function resolveUnderLock(logger: Logger): Promise { - // Resolve buf once at the start: check PATH first, then auto-download + // Resolve buf and protoc-gen-openapi once at the start await this.ensureBufResolved(); + await this.ensureProtocGenOpenAPIResolved(); // Detect air-gapped mode once at the start if we have dependencies if (deps.length > 0 && this.isAirGapped === undefined) { @@ -116,33 +118,6 @@ export class ProtobufOpenAPIGenerator { }): Promise<{ absoluteFilepath: AbsoluteFilePath; bufLockContents: string | undefined }> { let bufLockContents: string | undefined = existingBufLockContents; - const which = createLoggingExecutable("which", { - cwd, - logger: undefined, - doNotPipeOutput: true - }); - - let protocGenOpenAPIOnPath = false; - try { - await which(["protoc-gen-openapi"]); - protocGenOpenAPIOnPath = true; - } catch (err) { - 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; @@ -215,6 +190,32 @@ export class ProtobufOpenAPIGenerator { }; } + private async ensureProtocGenOpenAPIResolved(): Promise { + if (this.protocGenOpenAPIResolved) { + return; + } + + const which = createLoggingExecutable("which", { + cwd: AbsoluteFilePath.of(process.cwd()), + logger: undefined, + doNotPipeOutput: true + }); + + try { + await which(["protoc-gen-openapi"]); + this.protocGenOpenAPIResolved = true; + } catch { + this.context.logger.debug("protoc-gen-openapi not found on PATH, attempting auto-download"); + 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')." + ); + } + this.protocGenOpenAPIResolved = true; + } + } + private async ensureBufResolved(): Promise { if (this.resolvedBufCommand != null) { return; 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 fb1df4918ab8..feacff1c4c7c 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts @@ -185,19 +185,19 @@ async function resolveUnderLock(logger: Logger): Promise { expect(result).toBe(AbsoluteFilePath.of(canonicalPath)); expect(mockFetch).not.toHaveBeenCalled(); - // Debug log should indicate cache hit - expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("Using cached buf")); + // Info log should indicate cache hit + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Using cached buf")); vi.unstubAllGlobals(); }); @@ -185,7 +185,7 @@ describe("BufDownloader", () => { expect(updatedMarker.trim()).toBe("v1.66.1"); // Should log the update - expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("Updated canonical buf")); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Updated buf")); vi.unstubAllGlobals(); }); @@ -468,8 +468,8 @@ describe("BufDownloader (real e2e)", () => { expect(result1).toBe(AbsoluteFilePath.of(expectedPath)); // Verify download log messages - expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("Downloading buf from")); - expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("Downloaded buf")); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Downloading buf")); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Downloaded buf")); // Verify cache structure const cacheDir = getCacheDir(); @@ -498,14 +498,14 @@ describe("BufDownloader (real e2e)", () => { expect(await fileExists(lockPath)).toBe(false); // --- Second call: should use cache (no re-download) --- - const debugCallsBefore = (logger.debug as ReturnType).mock.calls.length; + const infoCallsBefore = (logger.info as ReturnType).mock.calls.length; const result2 = await resolveBuf(logger); expect(result2).toBe(AbsoluteFilePath.of(expectedPath)); // Should log cache hit, not download - const debugCallsAfter = (logger.debug as ReturnType).mock.calls; - const newCalls = debugCallsAfter.slice(debugCallsBefore); + const infoCallsAfter = (logger.info as ReturnType).mock.calls; + const newCalls = infoCallsAfter.slice(infoCallsBefore); const newMessages = newCalls.map((call) => call[0] as string); expect(newMessages.some((msg) => msg.includes("Using cached buf"))).toBe(true); expect(newMessages.some((msg) => msg.includes("Downloading"))).toBe(false); 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 23164b87d70e..6026502f26e6 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 @@ -143,8 +143,8 @@ describe("ProtocGenOpenAPIDownloader", () => { expect(result).toBe(AbsoluteFilePath.of(cacheDir)); expect(mockFetch).not.toHaveBeenCalled(); - // Debug log should indicate cache hit - expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("Using cached")); + // Info log should indicate cache hit + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Using cached")); vi.unstubAllGlobals(); }); @@ -187,7 +187,7 @@ describe("ProtocGenOpenAPIDownloader", () => { expect(updatedMarker.trim()).toBe("v0.1.13"); // Should log the update - expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("Updated canonical")); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Updated protoc-gen-openapi")); vi.unstubAllGlobals(); }); @@ -430,8 +430,8 @@ describe("ProtocGenOpenAPIDownloader (real e2e)", () => { 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")); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Downloading protoc-gen-openapi")); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Downloaded protoc-gen-openapi")); // Verify cache structure const cacheDir = getCacheDir(); @@ -460,14 +460,14 @@ describe("ProtocGenOpenAPIDownloader (real e2e)", () => { expect(await fileExists(lockPath)).toBe(false); // --- Second call: should use cache (no re-download) --- - const debugCallsBefore = (logger.debug as ReturnType).mock.calls.length; + const infoCallsBefore = (logger.info 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 infoCallsAfter = (logger.info as ReturnType).mock.calls; + const newCalls = infoCallsAfter.slice(infoCallsBefore); 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); From 241181bd14252acf626aa101bd53b1c59a2f8c29 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:00:06 +0000 Subject: [PATCH 3/8] fix: add vi.resetModules() to first describe blocks in test files Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- .../src/protobuf/__test__/BufDownloader.test.ts | 3 +++ .../src/protobuf/__test__/ProtocGenOpenAPIDownloader.test.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/__test__/BufDownloader.test.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/__test__/BufDownloader.test.ts index 5dd52ead7e58..6004007dd8fd 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/__test__/BufDownloader.test.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/__test__/BufDownloader.test.ts @@ -37,6 +37,9 @@ describe("BufDownloader", () => { 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 use our temp directory vi.doMock("os", async () => { const actual = await vi.importActual("os"); 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 6026502f26e6..19dee5b140e9 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 @@ -37,6 +37,9 @@ describe("ProtocGenOpenAPIDownloader", () => { 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 use our temp directory vi.doMock("os", async () => { const actual = await vi.importActual("os"); From 49b7e8d6ab2fb9d4c296f2a7e502f4e68d651175 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:52:50 +0000 Subject: [PATCH 4/8] refactor: extract ensureBufCommand to shared utility in utils.ts Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- .../src/protobuf/ProtobufIRGenerator.ts | 23 +++------------ .../src/protobuf/ProtobufOpenAPIGenerator.ts | 24 +++------------- .../lazy-fern-workspace/src/protobuf/utils.ts | 28 ++++++++++++++++++- 3 files changed, 35 insertions(+), 40 deletions(-) diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtobufIRGenerator.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtobufIRGenerator.ts index 193dd12d3d4a..c5170f45410b 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtobufIRGenerator.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtobufIRGenerator.ts @@ -4,10 +4,10 @@ import { TaskContext } from "@fern-api/task-context"; import { access, chmod, cp, unlink, writeFile } from "fs/promises"; import path from "path"; import tmp from "tmp-promise"; -import { resolveBuf } from "./BufDownloader.js"; import { detectAirGappedModeForProtobuf, + ensureBufCommand, getProtobufYamlV1, PROTOBUF_EXPORT_CONFIG_V1, PROTOBUF_EXPORT_CONFIG_V2, @@ -281,25 +281,10 @@ export class ProtobufIRGenerator { return; } - const which = createLoggingExecutable("which", { - cwd: AbsoluteFilePath.of(process.cwd()), - logger: undefined, - doNotPipeOutput: true - }); - try { - await which(["buf"]); - this.resolvedBufCommand = "buf"; - } catch { - this.context.logger.debug("buf not found on PATH, attempting auto-download"); - const downloadedBufPath = await resolveBuf(this.context.logger); - if (downloadedBufPath != null) { - this.resolvedBufCommand = downloadedBufPath; - } else { - this.context.failAndThrow( - "Missing required dependency; please install 'buf' to continue (e.g. 'brew install buf')." - ); - } + this.resolvedBufCommand = await ensureBufCommand(this.context.logger); + } catch (error) { + this.context.failAndThrow(error instanceof Error ? error.message : String(error)); } } 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 cfc55ac3b787..821955e1dcd0 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtobufOpenAPIGenerator.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtobufOpenAPIGenerator.ts @@ -4,9 +4,8 @@ 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 { resolveBuf } from "./BufDownloader.js"; import { resolveProtocGenOpenAPI } from "./ProtocGenOpenAPIDownloader.js"; -import { detectAirGappedModeForProtobuf, getProtobufYamlV1 } from "./utils.js"; +import { detectAirGappedModeForProtobuf, ensureBufCommand, getProtobufYamlV1 } from "./utils.js"; const PROTOBUF_GENERATOR_CONFIG_FILENAME = "buf.gen.yaml"; const PROTOBUF_GENERATOR_OUTPUT_PATH = "output"; @@ -221,25 +220,10 @@ export class ProtobufOpenAPIGenerator { return; } - const which = createLoggingExecutable("which", { - cwd: AbsoluteFilePath.of(process.cwd()), - logger: undefined, - doNotPipeOutput: true - }); - try { - await which(["buf"]); - this.resolvedBufCommand = "buf"; - } catch { - this.context.logger.debug("buf not found on PATH, attempting auto-download"); - const downloadedBufPath = await resolveBuf(this.context.logger); - if (downloadedBufPath != null) { - this.resolvedBufCommand = downloadedBufPath; - } else { - this.context.failAndThrow( - "Missing required dependency; please install 'buf' to continue (e.g. 'brew install buf')." - ); - } + this.resolvedBufCommand = await ensureBufCommand(this.context.logger); + } catch (error) { + this.context.failAndThrow(error instanceof Error ? error.message : String(error)); } } diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/utils.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/utils.ts index 2fca92c22911..2daf3e3a99d5 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/utils.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/utils.ts @@ -1,8 +1,9 @@ import { AbsoluteFilePath, join, RelativeFilePath } from "@fern-api/fs-utils"; import { Logger } from "@fern-api/logger"; -import { runExeca } from "@fern-api/logging-execa"; +import { createLoggingExecutable, runExeca } from "@fern-api/logging-execa"; import { access, cp, rm } from "fs/promises"; import tmp from "tmp-promise"; +import { resolveBuf } from "./BufDownloader.js"; /** * Check if an error message indicates a network error. @@ -138,6 +139,31 @@ export async function detectAirGappedModeForProtobuf( } } +/** + * Resolves the buf command: checks PATH first, then auto-downloads from GitHub Releases. + * Returns the command string to use ("buf" if on PATH, or the full path to the cached binary). + * Throws via failAndThrow if buf cannot be found or downloaded. + */ +export async function ensureBufCommand(logger: Logger): Promise { + const which = createLoggingExecutable("which", { + cwd: AbsoluteFilePath.of(process.cwd()), + logger: undefined, + doNotPipeOutput: true + }); + + try { + await which(["buf"]); + return "buf"; + } catch { + logger.debug("buf not found on PATH, attempting auto-download"); + const downloadedBufPath = await resolveBuf(logger); + if (downloadedBufPath != null) { + return downloadedBufPath; + } + throw new Error("Missing required dependency; please install 'buf' to continue (e.g. 'brew install buf')."); + } +} + export const PROTOBUF_GENERATOR_CONFIG_FILENAME = "buf.gen.yaml"; export const PROTOBUF_GENERATOR_OUTPUT_PATH = "output"; export const PROTOBUF_GENERATOR_OUTPUT_FILEPATH = `${PROTOBUF_GENERATOR_OUTPUT_PATH}/ir.json`; From 7f76e72d4f1435a038de37c73c2e90bfb911ae27 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:05:17 +0000 Subject: [PATCH 5/8] fix: replace unguarded lock retry with bounded loop after timeout Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- .../src/protobuf/BufDownloader.ts | 19 ++++++++++++++----- .../protobuf/ProtocGenOpenAPIDownloader.ts | 19 ++++++++++++++----- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts index 90b4dd305a7d..492b0a0205d9 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts @@ -132,11 +132,20 @@ async function acquireLock(logger: Logger): Promise<() => Promise> { } 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 buf 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 }); + } catch { + // Another process grabbed the lock between our rm and mkdir — retry with remaining time + const remaining = Math.max(0, deadline + LOCK_TIMEOUT_MS - Date.now()); + const retryDeadline = Date.now() + Math.min(remaining, LOCK_TIMEOUT_MS); + while (Date.now() < retryDeadline) { + try { + await mkdir(lockPath, { recursive: false }); + return createLockReleaser(lockPath, logger); + } catch { + logger.debug(`Waiting for buf lock on ${lockPath} (post-break retry)...`); + await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS)); + } + } + throw new Error(`Failed to acquire buf lock after timeout and retry`); } return createLockReleaser(lockPath, logger); } 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 feacff1c4c7c..80261c6d894e 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts @@ -126,11 +126,20 @@ async function acquireLock(logger: Logger): Promise<() => Promise> { } 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 }); + } catch { + // Another process grabbed the lock between our rm and mkdir — retry with remaining time + const remaining = Math.max(0, deadline + LOCK_TIMEOUT_MS - Date.now()); + const retryDeadline = Date.now() + Math.min(remaining, LOCK_TIMEOUT_MS); + while (Date.now() < retryDeadline) { + try { + await mkdir(lockPath, { recursive: false }); + return createLockReleaser(lockPath, logger); + } catch { + logger.debug(`Waiting for lock on ${lockPath} (post-break retry)...`); + await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS)); + } + } + throw new Error(`Failed to acquire lock after timeout and retry`); } return createLockReleaser(lockPath, logger); } From d534c81010e5864f0649528e9f4c56466fd0dcb1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:19:36 +0000 Subject: [PATCH 6/8] chore: update url-form-encoded test snapshots after merge with main Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- .../test-definitions/url-form-encoded.json | 240 +++ .../test-definitions/url-form-encoded.json | 1647 ++++++++++++++++- 2 files changed, 1832 insertions(+), 55 deletions(-) diff --git a/packages/cli/generation/ir-generator-tests/src/dynamic-snippets/__test__/test-definitions/url-form-encoded.json b/packages/cli/generation/ir-generator-tests/src/dynamic-snippets/__test__/test-definitions/url-form-encoded.json index 2aa6317520b9..d3862cf66729 100644 --- a/packages/cli/generation/ir-generator-tests/src/dynamic-snippets/__test__/test-definitions/url-form-encoded.json +++ b/packages/cli/generation/ir-generator-tests/src/dynamic-snippets/__test__/test-definitions/url-form-encoded.json @@ -99,6 +99,198 @@ ], "extends": null, "additionalProperties": false + }, + "type_:TokenRequest": { + "type": "object", + "declaration": { + "name": { + "originalName": "TokenRequest", + "camelCase": { + "unsafeName": "tokenRequest", + "safeName": "tokenRequest" + }, + "snakeCase": { + "unsafeName": "token_request", + "safeName": "token_request" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_REQUEST", + "safeName": "TOKEN_REQUEST" + }, + "pascalCase": { + "unsafeName": "TokenRequest", + "safeName": "TokenRequest" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + } + }, + "properties": [ + { + "name": { + "name": { + "originalName": "client_id", + "camelCase": { + "unsafeName": "clientID", + "safeName": "clientID" + }, + "snakeCase": { + "unsafeName": "client_id", + "safeName": "client_id" + }, + "screamingSnakeCase": { + "unsafeName": "CLIENT_ID", + "safeName": "CLIENT_ID" + }, + "pascalCase": { + "unsafeName": "ClientID", + "safeName": "ClientID" + } + }, + "wireValue": "client_id" + }, + "typeReference": { + "type": "primitive", + "value": "STRING" + }, + "propertyAccess": null, + "variable": null + }, + { + "name": { + "name": { + "originalName": "client_secret", + "camelCase": { + "unsafeName": "clientSecret", + "safeName": "clientSecret" + }, + "snakeCase": { + "unsafeName": "client_secret", + "safeName": "client_secret" + }, + "screamingSnakeCase": { + "unsafeName": "CLIENT_SECRET", + "safeName": "CLIENT_SECRET" + }, + "pascalCase": { + "unsafeName": "ClientSecret", + "safeName": "ClientSecret" + } + }, + "wireValue": "client_secret" + }, + "typeReference": { + "type": "primitive", + "value": "STRING" + }, + "propertyAccess": null, + "variable": null + } + ], + "extends": null, + "additionalProperties": false + }, + "type_:TokenResponse": { + "type": "object", + "declaration": { + "name": { + "originalName": "TokenResponse", + "camelCase": { + "unsafeName": "tokenResponse", + "safeName": "tokenResponse" + }, + "snakeCase": { + "unsafeName": "token_response", + "safeName": "token_response" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_RESPONSE", + "safeName": "TOKEN_RESPONSE" + }, + "pascalCase": { + "unsafeName": "TokenResponse", + "safeName": "TokenResponse" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + } + }, + "properties": [ + { + "name": { + "name": { + "originalName": "access_token", + "camelCase": { + "unsafeName": "accessToken", + "safeName": "accessToken" + }, + "snakeCase": { + "unsafeName": "access_token", + "safeName": "access_token" + }, + "screamingSnakeCase": { + "unsafeName": "ACCESS_TOKEN", + "safeName": "ACCESS_TOKEN" + }, + "pascalCase": { + "unsafeName": "AccessToken", + "safeName": "AccessToken" + } + }, + "wireValue": "access_token" + }, + "typeReference": { + "type": "optional", + "value": { + "type": "primitive", + "value": "STRING" + } + }, + "propertyAccess": null, + "variable": null + }, + { + "name": { + "name": { + "originalName": "expires_in", + "camelCase": { + "unsafeName": "expiresIn", + "safeName": "expiresIn" + }, + "snakeCase": { + "unsafeName": "expires_in", + "safeName": "expires_in" + }, + "screamingSnakeCase": { + "unsafeName": "EXPIRES_IN", + "safeName": "EXPIRES_IN" + }, + "pascalCase": { + "unsafeName": "ExpiresIn", + "safeName": "ExpiresIn" + } + }, + "wireValue": "expires_in" + }, + "typeReference": { + "type": "optional", + "value": { + "type": "primitive", + "value": "INTEGER" + } + }, + "propertyAccess": null, + "variable": null + } + ], + "extends": null, + "additionalProperties": false } }, "headers": [], @@ -240,6 +432,54 @@ "type": "json" }, "examples": null + }, + "endpoint_.get_token": { + "auth": null, + "declaration": { + "name": { + "originalName": "get_token", + "camelCase": { + "unsafeName": "getToken", + "safeName": "getToken" + }, + "snakeCase": { + "unsafeName": "get_token", + "safeName": "get_token" + }, + "screamingSnakeCase": { + "unsafeName": "GET_TOKEN", + "safeName": "GET_TOKEN" + }, + "pascalCase": { + "unsafeName": "GetToken", + "safeName": "GetToken" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + } + }, + "location": { + "method": "POST", + "path": "/token" + }, + "request": { + "type": "body", + "pathParameters": [], + "body": { + "type": "typeReference", + "value": { + "type": "named", + "value": "type_:TokenRequest" + } + } + }, + "response": { + "type": "json" + }, + "examples": null } }, "pathParameters": [], diff --git a/packages/cli/generation/ir-generator-tests/src/ir/__test__/test-definitions/url-form-encoded.json b/packages/cli/generation/ir-generator-tests/src/ir/__test__/test-definitions/url-form-encoded.json index 1dd89280c453..eb70c4c8cdae 100644 --- a/packages/cli/generation/ir-generator-tests/src/ir/__test__/test-definitions/url-form-encoded.json +++ b/packages/cli/generation/ir-generator-tests/src/ir/__test__/test-definitions/url-form-encoded.json @@ -176,6 +176,286 @@ "v2Examples": null, "availability": null, "docs": null + }, + "type_:TokenRequest": { + "inline": null, + "name": { + "name": { + "originalName": "TokenRequest", + "camelCase": { + "unsafeName": "tokenRequest", + "safeName": "tokenRequest" + }, + "snakeCase": { + "unsafeName": "token_request", + "safeName": "token_request" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_REQUEST", + "safeName": "TOKEN_REQUEST" + }, + "pascalCase": { + "unsafeName": "TokenRequest", + "safeName": "TokenRequest" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + }, + "displayName": null, + "typeId": "type_:TokenRequest" + }, + "shape": { + "_type": "object", + "extends": [], + "properties": [ + { + "name": { + "name": { + "originalName": "client_id", + "camelCase": { + "unsafeName": "clientID", + "safeName": "clientID" + }, + "snakeCase": { + "unsafeName": "client_id", + "safeName": "client_id" + }, + "screamingSnakeCase": { + "unsafeName": "CLIENT_ID", + "safeName": "CLIENT_ID" + }, + "pascalCase": { + "unsafeName": "ClientID", + "safeName": "ClientID" + } + }, + "wireValue": "client_id" + }, + "valueType": { + "_type": "primitive", + "primitive": { + "v1": "STRING", + "v2": { + "type": "string", + "default": null, + "validation": null + } + } + }, + "propertyAccess": null, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + }, + "availability": null, + "docs": "Client identifier" + }, + { + "name": { + "name": { + "originalName": "client_secret", + "camelCase": { + "unsafeName": "clientSecret", + "safeName": "clientSecret" + }, + "snakeCase": { + "unsafeName": "client_secret", + "safeName": "client_secret" + }, + "screamingSnakeCase": { + "unsafeName": "CLIENT_SECRET", + "safeName": "CLIENT_SECRET" + }, + "pascalCase": { + "unsafeName": "ClientSecret", + "safeName": "ClientSecret" + } + }, + "wireValue": "client_secret" + }, + "valueType": { + "_type": "primitive", + "primitive": { + "v1": "STRING", + "v2": { + "type": "string", + "default": null, + "validation": null + } + } + }, + "propertyAccess": null, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + }, + "availability": null, + "docs": "Client secret" + } + ], + "extra-properties": false, + "extendedProperties": [] + }, + "referencedTypes": [], + "encoding": { + "json": {}, + "proto": null + }, + "source": null, + "userProvidedExamples": [], + "autogeneratedExamples": [], + "v2Examples": null, + "availability": null, + "docs": null + }, + "type_:TokenResponse": { + "inline": null, + "name": { + "name": { + "originalName": "TokenResponse", + "camelCase": { + "unsafeName": "tokenResponse", + "safeName": "tokenResponse" + }, + "snakeCase": { + "unsafeName": "token_response", + "safeName": "token_response" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_RESPONSE", + "safeName": "TOKEN_RESPONSE" + }, + "pascalCase": { + "unsafeName": "TokenResponse", + "safeName": "TokenResponse" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + }, + "displayName": null, + "typeId": "type_:TokenResponse" + }, + "shape": { + "_type": "object", + "extends": [], + "properties": [ + { + "name": { + "name": { + "originalName": "access_token", + "camelCase": { + "unsafeName": "accessToken", + "safeName": "accessToken" + }, + "snakeCase": { + "unsafeName": "access_token", + "safeName": "access_token" + }, + "screamingSnakeCase": { + "unsafeName": "ACCESS_TOKEN", + "safeName": "ACCESS_TOKEN" + }, + "pascalCase": { + "unsafeName": "AccessToken", + "safeName": "AccessToken" + } + }, + "wireValue": "access_token" + }, + "valueType": { + "_type": "container", + "container": { + "_type": "optional", + "optional": { + "_type": "primitive", + "primitive": { + "v1": "STRING", + "v2": { + "type": "string", + "default": null, + "validation": null + } + } + } + } + }, + "propertyAccess": null, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + }, + "availability": null, + "docs": null + }, + { + "name": { + "name": { + "originalName": "expires_in", + "camelCase": { + "unsafeName": "expiresIn", + "safeName": "expiresIn" + }, + "snakeCase": { + "unsafeName": "expires_in", + "safeName": "expires_in" + }, + "screamingSnakeCase": { + "unsafeName": "EXPIRES_IN", + "safeName": "EXPIRES_IN" + }, + "pascalCase": { + "unsafeName": "ExpiresIn", + "safeName": "ExpiresIn" + } + }, + "wireValue": "expires_in" + }, + "valueType": { + "_type": "container", + "container": { + "_type": "optional", + "optional": { + "_type": "primitive", + "primitive": { + "v1": "INTEGER", + "v2": { + "type": "integer", + "default": null, + "validation": null + } + } + } + } + }, + "propertyAccess": null, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + }, + "availability": null, + "docs": null + } + ], + "extra-properties": false, + "extendedProperties": [] + }, + "referencedTypes": [], + "encoding": { + "json": {}, + "proto": null + }, + "source": null, + "userProvidedExamples": [], + "autogeneratedExamples": [], + "v2Examples": null, + "availability": null, + "docs": null } }, "errors": {}, @@ -1125,61 +1405,1076 @@ "responseHeaders": [], "availability": null, "docs": null - } - ], - "audiences": null - } - }, - "constants": { - "errorInstanceIdKey": { - "name": { - "originalName": "errorInstanceId", - "camelCase": { - "unsafeName": "errorInstanceID", - "safeName": "errorInstanceID" - }, - "snakeCase": { - "unsafeName": "error_instance_id", - "safeName": "error_instance_id" - }, - "screamingSnakeCase": { - "unsafeName": "ERROR_INSTANCE_ID", - "safeName": "ERROR_INSTANCE_ID" }, - "pascalCase": { - "unsafeName": "ErrorInstanceID", - "safeName": "ErrorInstanceID" - } - }, - "wireValue": "errorInstanceId" - } - }, - "environments": null, - "errorDiscriminationStrategy": { - "type": "statusCode" - }, - "basePath": null, - "pathParameters": [], - "variables": [], - "serviceTypeReferenceInfo": { - "typesReferencedOnlyByService": { - "service_": [ - "type_:PostSubmitResponse" - ] - }, - "sharedTypes": [] - }, - "webhookGroups": {}, - "websocketChannels": {}, - "readmeConfig": null, - "sourceConfig": null, - "publishConfig": null, - "dynamic": { - "version": "1.0.0", - "types": { - "type_:PostSubmitResponse": { - "type": "object", - "declaration": { + { + "id": "endpoint_.get_token", + "name": { + "originalName": "get_token", + "camelCase": { + "unsafeName": "getToken", + "safeName": "getToken" + }, + "snakeCase": { + "unsafeName": "get_token", + "safeName": "get_token" + }, + "screamingSnakeCase": { + "unsafeName": "GET_TOKEN", + "safeName": "GET_TOKEN" + }, + "pascalCase": { + "unsafeName": "GetToken", + "safeName": "GetToken" + } + }, + "displayName": "Get OAuth token", + "auth": false, + "security": null, + "idempotent": false, + "baseUrl": null, + "v2BaseUrls": null, + "method": "POST", + "basePath": null, + "path": { + "head": "/token", + "parts": [] + }, + "fullPath": { + "head": "token", + "parts": [] + }, + "pathParameters": [], + "allPathParameters": [], + "queryParameters": [], + "headers": [], + "requestBody": { + "type": "reference", + "requestBodyType": { + "_type": "named", + "name": { + "originalName": "TokenRequest", + "camelCase": { + "unsafeName": "tokenRequest", + "safeName": "tokenRequest" + }, + "snakeCase": { + "unsafeName": "token_request", + "safeName": "token_request" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_REQUEST", + "safeName": "TOKEN_REQUEST" + }, + "pascalCase": { + "unsafeName": "TokenRequest", + "safeName": "TokenRequest" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + }, + "displayName": null, + "typeId": "type_:TokenRequest", + "default": null, + "inline": null + }, + "docs": null, + "contentType": "application/x-www-form-urlencoded", + "v2Examples": null + }, + "v2RequestBodies": null, + "sdkRequest": { + "shape": { + "type": "justRequestBody", + "value": { + "type": "typeReference", + "requestBodyType": { + "_type": "named", + "name": { + "originalName": "TokenRequest", + "camelCase": { + "unsafeName": "tokenRequest", + "safeName": "tokenRequest" + }, + "snakeCase": { + "unsafeName": "token_request", + "safeName": "token_request" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_REQUEST", + "safeName": "TOKEN_REQUEST" + }, + "pascalCase": { + "unsafeName": "TokenRequest", + "safeName": "TokenRequest" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + }, + "displayName": null, + "typeId": "type_:TokenRequest", + "default": null, + "inline": null + }, + "docs": null, + "contentType": null, + "v2Examples": null + } + }, + "requestParameterName": { + "originalName": "request", + "camelCase": { + "unsafeName": "request", + "safeName": "request" + }, + "snakeCase": { + "unsafeName": "request", + "safeName": "request" + }, + "screamingSnakeCase": { + "unsafeName": "REQUEST", + "safeName": "REQUEST" + }, + "pascalCase": { + "unsafeName": "Request", + "safeName": "Request" + } + }, + "streamParameter": null + }, + "response": { + "body": { + "type": "json", + "value": { + "type": "response", + "responseBodyType": { + "_type": "named", + "name": { + "originalName": "TokenResponse", + "camelCase": { + "unsafeName": "tokenResponse", + "safeName": "tokenResponse" + }, + "snakeCase": { + "unsafeName": "token_response", + "safeName": "token_response" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_RESPONSE", + "safeName": "TOKEN_RESPONSE" + }, + "pascalCase": { + "unsafeName": "TokenResponse", + "safeName": "TokenResponse" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + }, + "displayName": null, + "typeId": "type_:TokenResponse", + "default": null, + "inline": null + }, + "docs": "Token issued successfully", + "v2Examples": null + } + }, + "status-code": 200, + "isWildcardStatusCode": null, + "docs": "Token issued successfully" + }, + "v2Responses": null, + "errors": [], + "userSpecifiedExamples": [ + { + "example": { + "id": "df67dd0c", + "name": null, + "url": "/token", + "rootPathParameters": [], + "endpointPathParameters": [], + "servicePathParameters": [], + "endpointHeaders": [], + "serviceHeaders": [], + "queryParameters": [], + "request": { + "type": "reference", + "shape": { + "type": "named", + "typeName": { + "typeId": "type_:TokenRequest", + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + }, + "name": { + "originalName": "TokenRequest", + "camelCase": { + "unsafeName": "tokenRequest", + "safeName": "tokenRequest" + }, + "snakeCase": { + "unsafeName": "token_request", + "safeName": "token_request" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_REQUEST", + "safeName": "TOKEN_REQUEST" + }, + "pascalCase": { + "unsafeName": "TokenRequest", + "safeName": "TokenRequest" + } + }, + "displayName": null + }, + "shape": { + "type": "object", + "properties": [ + { + "name": { + "name": { + "originalName": "client_id", + "camelCase": { + "unsafeName": "clientID", + "safeName": "clientID" + }, + "snakeCase": { + "unsafeName": "client_id", + "safeName": "client_id" + }, + "screamingSnakeCase": { + "unsafeName": "CLIENT_ID", + "safeName": "CLIENT_ID" + }, + "pascalCase": { + "unsafeName": "ClientID", + "safeName": "ClientID" + } + }, + "wireValue": "client_id" + }, + "value": { + "shape": { + "type": "primitive", + "primitive": { + "type": "string", + "string": { + "original": "client_id" + } + } + }, + "jsonExample": "client_id" + }, + "originalTypeDeclaration": { + "typeId": "type_:TokenRequest", + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + }, + "name": { + "originalName": "TokenRequest", + "camelCase": { + "unsafeName": "tokenRequest", + "safeName": "tokenRequest" + }, + "snakeCase": { + "unsafeName": "token_request", + "safeName": "token_request" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_REQUEST", + "safeName": "TOKEN_REQUEST" + }, + "pascalCase": { + "unsafeName": "TokenRequest", + "safeName": "TokenRequest" + } + }, + "displayName": null + }, + "propertyAccess": null + }, + { + "name": { + "name": { + "originalName": "client_secret", + "camelCase": { + "unsafeName": "clientSecret", + "safeName": "clientSecret" + }, + "snakeCase": { + "unsafeName": "client_secret", + "safeName": "client_secret" + }, + "screamingSnakeCase": { + "unsafeName": "CLIENT_SECRET", + "safeName": "CLIENT_SECRET" + }, + "pascalCase": { + "unsafeName": "ClientSecret", + "safeName": "ClientSecret" + } + }, + "wireValue": "client_secret" + }, + "value": { + "shape": { + "type": "primitive", + "primitive": { + "type": "string", + "string": { + "original": "client_secret" + } + } + }, + "jsonExample": "client_secret" + }, + "originalTypeDeclaration": { + "typeId": "type_:TokenRequest", + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + }, + "name": { + "originalName": "TokenRequest", + "camelCase": { + "unsafeName": "tokenRequest", + "safeName": "tokenRequest" + }, + "snakeCase": { + "unsafeName": "token_request", + "safeName": "token_request" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_REQUEST", + "safeName": "TOKEN_REQUEST" + }, + "pascalCase": { + "unsafeName": "TokenRequest", + "safeName": "TokenRequest" + } + }, + "displayName": null + }, + "propertyAccess": null + } + ], + "extraProperties": null + } + }, + "jsonExample": { + "client_id": "client_id", + "client_secret": "client_secret" + } + }, + "response": { + "type": "ok", + "value": { + "type": "body", + "value": { + "shape": { + "type": "named", + "typeName": { + "typeId": "type_:TokenResponse", + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + }, + "name": { + "originalName": "TokenResponse", + "camelCase": { + "unsafeName": "tokenResponse", + "safeName": "tokenResponse" + }, + "snakeCase": { + "unsafeName": "token_response", + "safeName": "token_response" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_RESPONSE", + "safeName": "TOKEN_RESPONSE" + }, + "pascalCase": { + "unsafeName": "TokenResponse", + "safeName": "TokenResponse" + } + }, + "displayName": null + }, + "shape": { + "type": "object", + "properties": [ + { + "name": { + "name": { + "originalName": "access_token", + "camelCase": { + "unsafeName": "accessToken", + "safeName": "accessToken" + }, + "snakeCase": { + "unsafeName": "access_token", + "safeName": "access_token" + }, + "screamingSnakeCase": { + "unsafeName": "ACCESS_TOKEN", + "safeName": "ACCESS_TOKEN" + }, + "pascalCase": { + "unsafeName": "AccessToken", + "safeName": "AccessToken" + } + }, + "wireValue": "access_token" + }, + "value": { + "shape": { + "type": "container", + "container": { + "type": "optional", + "optional": { + "shape": { + "type": "primitive", + "primitive": { + "type": "string", + "string": { + "original": "access_token" + } + } + }, + "jsonExample": "access_token" + }, + "valueType": { + "_type": "primitive", + "primitive": { + "v1": "STRING", + "v2": { + "type": "string", + "default": null, + "validation": null + } + } + } + } + }, + "jsonExample": "access_token" + }, + "originalTypeDeclaration": { + "typeId": "type_:TokenResponse", + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + }, + "name": { + "originalName": "TokenResponse", + "camelCase": { + "unsafeName": "tokenResponse", + "safeName": "tokenResponse" + }, + "snakeCase": { + "unsafeName": "token_response", + "safeName": "token_response" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_RESPONSE", + "safeName": "TOKEN_RESPONSE" + }, + "pascalCase": { + "unsafeName": "TokenResponse", + "safeName": "TokenResponse" + } + }, + "displayName": null + }, + "propertyAccess": null + }, + { + "name": { + "name": { + "originalName": "expires_in", + "camelCase": { + "unsafeName": "expiresIn", + "safeName": "expiresIn" + }, + "snakeCase": { + "unsafeName": "expires_in", + "safeName": "expires_in" + }, + "screamingSnakeCase": { + "unsafeName": "EXPIRES_IN", + "safeName": "EXPIRES_IN" + }, + "pascalCase": { + "unsafeName": "ExpiresIn", + "safeName": "ExpiresIn" + } + }, + "wireValue": "expires_in" + }, + "value": { + "shape": { + "type": "container", + "container": { + "type": "optional", + "optional": { + "shape": { + "type": "primitive", + "primitive": { + "type": "integer", + "integer": 1 + } + }, + "jsonExample": 1 + }, + "valueType": { + "_type": "primitive", + "primitive": { + "v1": "INTEGER", + "v2": { + "type": "integer", + "default": null, + "validation": null + } + } + } + } + }, + "jsonExample": 1 + }, + "originalTypeDeclaration": { + "typeId": "type_:TokenResponse", + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + }, + "name": { + "originalName": "TokenResponse", + "camelCase": { + "unsafeName": "tokenResponse", + "safeName": "tokenResponse" + }, + "snakeCase": { + "unsafeName": "token_response", + "safeName": "token_response" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_RESPONSE", + "safeName": "TOKEN_RESPONSE" + }, + "pascalCase": { + "unsafeName": "TokenResponse", + "safeName": "TokenResponse" + } + }, + "displayName": null + }, + "propertyAccess": null + } + ], + "extraProperties": null + } + }, + "jsonExample": { + "access_token": "access_token", + "expires_in": 1 + } + } + } + }, + "docs": null + }, + "codeSamples": null + } + ], + "autogeneratedExamples": [ + { + "example": { + "id": "801af41a", + "url": "/token", + "name": null, + "endpointHeaders": [], + "endpointPathParameters": [], + "queryParameters": [], + "servicePathParameters": [], + "serviceHeaders": [], + "rootPathParameters": [], + "request": { + "type": "reference", + "shape": { + "type": "named", + "shape": { + "type": "object", + "properties": [ + { + "name": { + "name": { + "originalName": "client_id", + "camelCase": { + "unsafeName": "clientID", + "safeName": "clientID" + }, + "snakeCase": { + "unsafeName": "client_id", + "safeName": "client_id" + }, + "screamingSnakeCase": { + "unsafeName": "CLIENT_ID", + "safeName": "CLIENT_ID" + }, + "pascalCase": { + "unsafeName": "ClientID", + "safeName": "ClientID" + } + }, + "wireValue": "client_id" + }, + "originalTypeDeclaration": { + "name": { + "originalName": "TokenRequest", + "camelCase": { + "unsafeName": "tokenRequest", + "safeName": "tokenRequest" + }, + "snakeCase": { + "unsafeName": "token_request", + "safeName": "token_request" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_REQUEST", + "safeName": "TOKEN_REQUEST" + }, + "pascalCase": { + "unsafeName": "TokenRequest", + "safeName": "TokenRequest" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + }, + "displayName": null, + "typeId": "type_:TokenRequest" + }, + "value": { + "shape": { + "type": "primitive", + "primitive": { + "type": "string", + "string": { + "original": "client_id" + } + } + }, + "jsonExample": "client_id" + }, + "propertyAccess": null + }, + { + "name": { + "name": { + "originalName": "client_secret", + "camelCase": { + "unsafeName": "clientSecret", + "safeName": "clientSecret" + }, + "snakeCase": { + "unsafeName": "client_secret", + "safeName": "client_secret" + }, + "screamingSnakeCase": { + "unsafeName": "CLIENT_SECRET", + "safeName": "CLIENT_SECRET" + }, + "pascalCase": { + "unsafeName": "ClientSecret", + "safeName": "ClientSecret" + } + }, + "wireValue": "client_secret" + }, + "originalTypeDeclaration": { + "name": { + "originalName": "TokenRequest", + "camelCase": { + "unsafeName": "tokenRequest", + "safeName": "tokenRequest" + }, + "snakeCase": { + "unsafeName": "token_request", + "safeName": "token_request" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_REQUEST", + "safeName": "TOKEN_REQUEST" + }, + "pascalCase": { + "unsafeName": "TokenRequest", + "safeName": "TokenRequest" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + }, + "displayName": null, + "typeId": "type_:TokenRequest" + }, + "value": { + "shape": { + "type": "primitive", + "primitive": { + "type": "string", + "string": { + "original": "client_secret" + } + } + }, + "jsonExample": "client_secret" + }, + "propertyAccess": null + } + ], + "extraProperties": null + }, + "typeName": { + "name": { + "originalName": "TokenRequest", + "camelCase": { + "unsafeName": "tokenRequest", + "safeName": "tokenRequest" + }, + "snakeCase": { + "unsafeName": "token_request", + "safeName": "token_request" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_REQUEST", + "safeName": "TOKEN_REQUEST" + }, + "pascalCase": { + "unsafeName": "TokenRequest", + "safeName": "TokenRequest" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + }, + "displayName": null, + "typeId": "type_:TokenRequest" + } + }, + "jsonExample": { + "client_id": "client_id", + "client_secret": "client_secret" + } + }, + "response": { + "type": "ok", + "value": { + "type": "body", + "value": { + "shape": { + "type": "named", + "shape": { + "type": "object", + "properties": [ + { + "name": { + "name": { + "originalName": "access_token", + "camelCase": { + "unsafeName": "accessToken", + "safeName": "accessToken" + }, + "snakeCase": { + "unsafeName": "access_token", + "safeName": "access_token" + }, + "screamingSnakeCase": { + "unsafeName": "ACCESS_TOKEN", + "safeName": "ACCESS_TOKEN" + }, + "pascalCase": { + "unsafeName": "AccessToken", + "safeName": "AccessToken" + } + }, + "wireValue": "access_token" + }, + "originalTypeDeclaration": { + "name": { + "originalName": "TokenResponse", + "camelCase": { + "unsafeName": "tokenResponse", + "safeName": "tokenResponse" + }, + "snakeCase": { + "unsafeName": "token_response", + "safeName": "token_response" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_RESPONSE", + "safeName": "TOKEN_RESPONSE" + }, + "pascalCase": { + "unsafeName": "TokenResponse", + "safeName": "TokenResponse" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + }, + "displayName": null, + "typeId": "type_:TokenResponse" + }, + "value": { + "shape": { + "type": "container", + "container": { + "type": "optional", + "optional": { + "shape": { + "type": "primitive", + "primitive": { + "type": "string", + "string": { + "original": "access_token" + } + } + }, + "jsonExample": "access_token" + }, + "valueType": { + "_type": "primitive", + "primitive": { + "v1": "STRING", + "v2": { + "type": "string", + "default": null, + "validation": null + } + } + } + } + }, + "jsonExample": "access_token" + }, + "propertyAccess": null + }, + { + "name": { + "name": { + "originalName": "expires_in", + "camelCase": { + "unsafeName": "expiresIn", + "safeName": "expiresIn" + }, + "snakeCase": { + "unsafeName": "expires_in", + "safeName": "expires_in" + }, + "screamingSnakeCase": { + "unsafeName": "EXPIRES_IN", + "safeName": "EXPIRES_IN" + }, + "pascalCase": { + "unsafeName": "ExpiresIn", + "safeName": "ExpiresIn" + } + }, + "wireValue": "expires_in" + }, + "originalTypeDeclaration": { + "name": { + "originalName": "TokenResponse", + "camelCase": { + "unsafeName": "tokenResponse", + "safeName": "tokenResponse" + }, + "snakeCase": { + "unsafeName": "token_response", + "safeName": "token_response" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_RESPONSE", + "safeName": "TOKEN_RESPONSE" + }, + "pascalCase": { + "unsafeName": "TokenResponse", + "safeName": "TokenResponse" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + }, + "displayName": null, + "typeId": "type_:TokenResponse" + }, + "value": { + "shape": { + "type": "container", + "container": { + "type": "optional", + "optional": { + "shape": { + "type": "primitive", + "primitive": { + "type": "integer", + "integer": 1 + } + }, + "jsonExample": 1 + }, + "valueType": { + "_type": "primitive", + "primitive": { + "v1": "INTEGER", + "v2": { + "type": "integer", + "default": null, + "validation": null + } + } + } + } + }, + "jsonExample": 1 + }, + "propertyAccess": null + } + ], + "extraProperties": null + }, + "typeName": { + "name": { + "originalName": "TokenResponse", + "camelCase": { + "unsafeName": "tokenResponse", + "safeName": "tokenResponse" + }, + "snakeCase": { + "unsafeName": "token_response", + "safeName": "token_response" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_RESPONSE", + "safeName": "TOKEN_RESPONSE" + }, + "pascalCase": { + "unsafeName": "TokenResponse", + "safeName": "TokenResponse" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + }, + "displayName": null, + "typeId": "type_:TokenResponse" + } + }, + "jsonExample": { + "access_token": "access_token", + "expires_in": 1 + } + } + } + }, + "docs": null + } + } + ], + "pagination": null, + "transport": null, + "v2Examples": null, + "source": null, + "audiences": null, + "retries": null, + "apiPlayground": null, + "responseHeaders": [], + "availability": null, + "docs": null + } + ], + "audiences": null + } + }, + "constants": { + "errorInstanceIdKey": { + "name": { + "originalName": "errorInstanceId", + "camelCase": { + "unsafeName": "errorInstanceID", + "safeName": "errorInstanceID" + }, + "snakeCase": { + "unsafeName": "error_instance_id", + "safeName": "error_instance_id" + }, + "screamingSnakeCase": { + "unsafeName": "ERROR_INSTANCE_ID", + "safeName": "ERROR_INSTANCE_ID" + }, + "pascalCase": { + "unsafeName": "ErrorInstanceID", + "safeName": "ErrorInstanceID" + } + }, + "wireValue": "errorInstanceId" + } + }, + "environments": null, + "errorDiscriminationStrategy": { + "type": "statusCode" + }, + "basePath": null, + "pathParameters": [], + "variables": [], + "serviceTypeReferenceInfo": { + "typesReferencedOnlyByService": { + "service_": [ + "type_:PostSubmitResponse", + "type_:TokenRequest", + "type_:TokenResponse" + ] + }, + "sharedTypes": [] + }, + "webhookGroups": {}, + "websocketChannels": {}, + "readmeConfig": null, + "sourceConfig": null, + "publishConfig": null, + "dynamic": { + "version": "1.0.0", + "types": { + "type_:PostSubmitResponse": { + "type": "object", + "declaration": { "name": { "originalName": "PostSubmitResponse", "camelCase": { @@ -1275,6 +2570,198 @@ ], "extends": null, "additionalProperties": false + }, + "type_:TokenRequest": { + "type": "object", + "declaration": { + "name": { + "originalName": "TokenRequest", + "camelCase": { + "unsafeName": "tokenRequest", + "safeName": "tokenRequest" + }, + "snakeCase": { + "unsafeName": "token_request", + "safeName": "token_request" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_REQUEST", + "safeName": "TOKEN_REQUEST" + }, + "pascalCase": { + "unsafeName": "TokenRequest", + "safeName": "TokenRequest" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + } + }, + "properties": [ + { + "name": { + "name": { + "originalName": "client_id", + "camelCase": { + "unsafeName": "clientID", + "safeName": "clientID" + }, + "snakeCase": { + "unsafeName": "client_id", + "safeName": "client_id" + }, + "screamingSnakeCase": { + "unsafeName": "CLIENT_ID", + "safeName": "CLIENT_ID" + }, + "pascalCase": { + "unsafeName": "ClientID", + "safeName": "ClientID" + } + }, + "wireValue": "client_id" + }, + "typeReference": { + "type": "primitive", + "value": "STRING" + }, + "propertyAccess": null, + "variable": null + }, + { + "name": { + "name": { + "originalName": "client_secret", + "camelCase": { + "unsafeName": "clientSecret", + "safeName": "clientSecret" + }, + "snakeCase": { + "unsafeName": "client_secret", + "safeName": "client_secret" + }, + "screamingSnakeCase": { + "unsafeName": "CLIENT_SECRET", + "safeName": "CLIENT_SECRET" + }, + "pascalCase": { + "unsafeName": "ClientSecret", + "safeName": "ClientSecret" + } + }, + "wireValue": "client_secret" + }, + "typeReference": { + "type": "primitive", + "value": "STRING" + }, + "propertyAccess": null, + "variable": null + } + ], + "extends": null, + "additionalProperties": false + }, + "type_:TokenResponse": { + "type": "object", + "declaration": { + "name": { + "originalName": "TokenResponse", + "camelCase": { + "unsafeName": "tokenResponse", + "safeName": "tokenResponse" + }, + "snakeCase": { + "unsafeName": "token_response", + "safeName": "token_response" + }, + "screamingSnakeCase": { + "unsafeName": "TOKEN_RESPONSE", + "safeName": "TOKEN_RESPONSE" + }, + "pascalCase": { + "unsafeName": "TokenResponse", + "safeName": "TokenResponse" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + } + }, + "properties": [ + { + "name": { + "name": { + "originalName": "access_token", + "camelCase": { + "unsafeName": "accessToken", + "safeName": "accessToken" + }, + "snakeCase": { + "unsafeName": "access_token", + "safeName": "access_token" + }, + "screamingSnakeCase": { + "unsafeName": "ACCESS_TOKEN", + "safeName": "ACCESS_TOKEN" + }, + "pascalCase": { + "unsafeName": "AccessToken", + "safeName": "AccessToken" + } + }, + "wireValue": "access_token" + }, + "typeReference": { + "type": "optional", + "value": { + "type": "primitive", + "value": "STRING" + } + }, + "propertyAccess": null, + "variable": null + }, + { + "name": { + "name": { + "originalName": "expires_in", + "camelCase": { + "unsafeName": "expiresIn", + "safeName": "expiresIn" + }, + "snakeCase": { + "unsafeName": "expires_in", + "safeName": "expires_in" + }, + "screamingSnakeCase": { + "unsafeName": "EXPIRES_IN", + "safeName": "EXPIRES_IN" + }, + "pascalCase": { + "unsafeName": "ExpiresIn", + "safeName": "ExpiresIn" + } + }, + "wireValue": "expires_in" + }, + "typeReference": { + "type": "optional", + "value": { + "type": "primitive", + "value": "INTEGER" + } + }, + "propertyAccess": null, + "variable": null + } + ], + "extends": null, + "additionalProperties": false } }, "headers": [], @@ -1416,6 +2903,54 @@ "type": "json" }, "examples": null + }, + "endpoint_.get_token": { + "auth": null, + "declaration": { + "name": { + "originalName": "get_token", + "camelCase": { + "unsafeName": "getToken", + "safeName": "getToken" + }, + "snakeCase": { + "unsafeName": "get_token", + "safeName": "get_token" + }, + "screamingSnakeCase": { + "unsafeName": "GET_TOKEN", + "safeName": "GET_TOKEN" + }, + "pascalCase": { + "unsafeName": "GetToken", + "safeName": "GetToken" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + } + }, + "location": { + "method": "POST", + "path": "/token" + }, + "request": { + "type": "body", + "pathParameters": [], + "body": { + "type": "typeReference", + "value": { + "type": "named", + "value": "type_:TokenRequest" + } + } + }, + "response": { + "type": "json" + }, + "examples": null } }, "pathParameters": [], @@ -1436,7 +2971,9 @@ "websocket": null, "service": "service_", "types": [ - "type_:PostSubmitResponse" + "type_:PostSubmitResponse", + "type_:TokenRequest", + "type_:TokenResponse" ], "errors": [], "subpackages": [], From 9b565ef8af172ae7b09abc7f9e613beda107ce37 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:41:11 +0000 Subject: [PATCH 7/8] fix: correct remaining timeout calculation in lock retry logic Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- .../lazy-fern-workspace/src/protobuf/BufDownloader.ts | 4 ++-- .../src/protobuf/ProtocGenOpenAPIDownloader.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts index 492b0a0205d9..1f960b3c5f9e 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts @@ -134,8 +134,8 @@ async function acquireLock(logger: Logger): Promise<() => Promise> { await mkdir(lockPath, { recursive: false }); } catch { // Another process grabbed the lock between our rm and mkdir — retry with remaining time - const remaining = Math.max(0, deadline + LOCK_TIMEOUT_MS - Date.now()); - const retryDeadline = Date.now() + Math.min(remaining, LOCK_TIMEOUT_MS); + const remaining = Math.max(0, deadline - Date.now()); + const retryDeadline = Date.now() + remaining; while (Date.now() < retryDeadline) { try { await mkdir(lockPath, { recursive: false }); 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 80261c6d894e..7619675cbc8a 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts @@ -128,8 +128,8 @@ async function acquireLock(logger: Logger): Promise<() => Promise> { await mkdir(lockPath, { recursive: false }); } catch { // Another process grabbed the lock between our rm and mkdir — retry with remaining time - const remaining = Math.max(0, deadline + LOCK_TIMEOUT_MS - Date.now()); - const retryDeadline = Date.now() + Math.min(remaining, LOCK_TIMEOUT_MS); + const remaining = Math.max(0, deadline - Date.now()); + const retryDeadline = Date.now() + remaining; while (Date.now() < retryDeadline) { try { await mkdir(lockPath, { recursive: false }); From 3616a897b203a504a45bc44daa41203c5cc0b7b7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:51:59 +0000 Subject: [PATCH 8/8] fix: guarantee minimum retry budget after stale lock break Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> --- .../workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts | 2 +- .../src/protobuf/ProtocGenOpenAPIDownloader.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts index 1f960b3c5f9e..7976a8401593 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/BufDownloader.ts @@ -134,7 +134,7 @@ async function acquireLock(logger: Logger): Promise<() => Promise> { await mkdir(lockPath, { recursive: false }); } catch { // Another process grabbed the lock between our rm and mkdir — retry with remaining time - const remaining = Math.max(0, deadline - Date.now()); + const remaining = Math.max(LOCK_RETRY_INTERVAL_MS * 5, deadline - Date.now()); const retryDeadline = Date.now() + remaining; while (Date.now() < retryDeadline) { 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 index 7619675cbc8a..e1794e688c43 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts @@ -128,7 +128,7 @@ async function acquireLock(logger: Logger): Promise<() => Promise> { await mkdir(lockPath, { recursive: false }); } catch { // Another process grabbed the lock between our rm and mkdir — retry with remaining time - const remaining = Math.max(0, deadline - Date.now()); + const remaining = Math.max(LOCK_RETRY_INTERVAL_MS * 5, deadline - Date.now()); const retryDeadline = Date.now() + remaining; while (Date.now() < retryDeadline) { try {