diff --git a/packages/cli/cli-v2/src/commands/docs/md/generate/__test__/command.test.ts b/packages/cli/cli-v2/src/commands/docs/md/generate/__test__/command.test.ts index 80d6088058c2..dffe807dacef 100644 --- a/packages/cli/cli-v2/src/commands/docs/md/generate/__test__/command.test.ts +++ b/packages/cli/cli-v2/src/commands/docs/md/generate/__test__/command.test.ts @@ -6,7 +6,7 @@ import { GenerateCommand } from "../command.js"; // The orchestrator (`runLibraryDocsGeneration`) is unit-tested in // `@fern-api/library-docs-generator`. These tests cover only the v2 // wrapper's responsibilities: workspace/docs validation and delegation -// to the orchestrator with the correct adapter inputs. +// to the (local-only) orchestrator with the correct adapter inputs. vi.mock("@fern-api/library-docs-generator", () => ({ runLibraryDocsGeneration: vi.fn() })); @@ -57,7 +57,7 @@ describe("GenerateCommand", () => { await expect(cmd.handle(context, {} as GenerateCommand.Args)).rejects.toThrow(CliError); }); - it("delegates to runLibraryDocsGeneration with adapter inputs", async () => { + it("delegates to runLibraryDocsGeneration with adapter inputs and no authentication", async () => { const cmd = new GenerateCommand(); const context = makeContext({ docs: { @@ -75,10 +75,14 @@ describe("GenerateCommand", () => { const call = (runLibraryDocsGeneration as ReturnType).mock.calls[0]?.[0]; expect(call).toBeDefined(); expect(call.library).toBe("my-sdk"); - expect(call.orgId).toBe("test-org"); - expect(call.tokenValue).toBe("tok-123"); // docsDirectoryPath should be the *directory* of docs.yml, not the file itself. expect(call.docsDirectoryPath).toBe("/tmp/proj/fern"); + // Generation is local-only: no token or org is threaded through and + // the command never prompts for authentication. + expect(call.tokenValue).toBeUndefined(); + expect(call.orgId).toBeUndefined(); + expect(context.getTokenOrPrompt).not.toHaveBeenCalled(); + expect(context.verifyOrgAccess).not.toHaveBeenCalled(); }); it("propagates errors thrown by the orchestrator", async () => { diff --git a/packages/cli/cli-v2/src/commands/docs/md/generate/command.ts b/packages/cli/cli-v2/src/commands/docs/md/generate/command.ts index 58bcb5275c2d..4a2f7fc064c9 100644 --- a/packages/cli/cli-v2/src/commands/docs/md/generate/command.ts +++ b/packages/cli/cli-v2/src/commands/docs/md/generate/command.ts @@ -40,9 +40,6 @@ export class GenerateCommand { }); } - const token = await context.getTokenOrPrompt(); - await context.verifyOrgAccess({ organization: workspace.org, token }); - const docsFilePath = workspace.docs.absoluteFilePath ?? workspace.absoluteFilePath ?? context.cwd; const docsDirectoryPath = AbsoluteFilePath.of(dirname(docsFilePath)); @@ -53,8 +50,6 @@ export class GenerateCommand { libraries, library: args.library, docsDirectoryPath, - orgId: workspace.org, - tokenValue: token.value, context: taskContext, wrapStep: withSpinner }); diff --git a/packages/cli/cli/changes/unreleased/local-only-library-docs-generation.yml b/packages/cli/cli/changes/unreleased/local-only-library-docs-generation.yml new file mode 100644 index 000000000000..40d3af1e542d --- /dev/null +++ b/packages/cli/cli/changes/unreleased/local-only-library-docs-generation.yml @@ -0,0 +1,5 @@ +- summary: | + `fern docs md generate` now always runs the library parser(s) locally using Docker. + The remote (server-side) generation path has been removed, so the command no longer + requires authentication, and the `--local` flag is deprecated as a no-op. + type: feat diff --git a/packages/cli/cli/src/cli.ts b/packages/cli/cli/src/cli.ts index b36ace990a01..fca9b0252c3c 100644 --- a/packages/cli/cli/src/cli.ts +++ b/packages/cli/cli/src/cli.ts @@ -2101,8 +2101,9 @@ function addDocsMdGenerateCommand(cli: Argv, cliContext: CliCo }) .option("local", { boolean: true, - default: false, - description: "Run the library parser(s) locally using Docker instead of Fern's servers" + default: true, + hidden: true, + deprecated: "Library docs generation always runs locally using Docker; --local is no longer needed." }), async (argv) => { cliContext.instrumentPostHogEvent({ @@ -2117,8 +2118,7 @@ function addDocsMdGenerateCommand(cli: Argv, cliContext: CliCo await generateLibraryDocs({ project, cliContext, - library: argv.library, - local: argv.local + library: argv.library }); } ); diff --git a/packages/cli/cli/src/commands/docs-md-generate/__test__/generateLibraryDocs.test.ts b/packages/cli/cli/src/commands/docs-md-generate/__test__/generateLibraryDocs.test.ts index bce1a8ddd00c..61c7f9bf9088 100644 --- a/packages/cli/cli/src/commands/docs-md-generate/__test__/generateLibraryDocs.test.ts +++ b/packages/cli/cli/src/commands/docs-md-generate/__test__/generateLibraryDocs.test.ts @@ -1,18 +1,13 @@ import { describe, expect, it, type Mock, vi } from "vitest"; // Per-library orchestration is unit-tested in `@fern-api/library-docs-generator`. -// These tests cover only the v1 wrapper: workspace validation, auth flow, -// and delegation to the orchestrator. -vi.mock("@fern-api/login", () => ({ - askToLogin: vi.fn() -})); - +// These tests cover only the v1 wrapper: workspace validation and delegation +// to the (local-only) orchestrator. vi.mock("@fern-api/library-docs-generator", () => ({ runLibraryDocsGeneration: vi.fn() })); import { runLibraryDocsGeneration } from "@fern-api/library-docs-generator"; -import { askToLogin } from "@fern-api/login"; import { type GenerateLibraryDocsOptions, generateLibraryDocs } from "../generateLibraryDocs.js"; function makeCliContext() { @@ -68,8 +63,7 @@ describe("generateLibraryDocs", () => { generateLibraryDocs({ project: project as unknown as GenerateLibraryDocsOptions["project"], cliContext: ctx as unknown as GenerateLibraryDocsOptions["cliContext"], - library: undefined, - local: false + library: undefined }) ).rejects.toThrow("No docs workspace found"); }); @@ -82,73 +76,31 @@ describe("generateLibraryDocs", () => { generateLibraryDocs({ project: project as unknown as GenerateLibraryDocsOptions["project"], cliContext: ctx as unknown as GenerateLibraryDocsOptions["cliContext"], - library: undefined, - local: false + library: undefined }) ).rejects.toThrow("No libraries configured"); }); - it("fails when authentication returns null", async () => { - const ctx = makeCliContext(); - const project = makeProject({ - libraries: { "my-sdk": { input: { git: "https://x" }, output: { path: "./d" }, lang: "python" } } - }); - (askToLogin as Mock).mockResolvedValue(null); - - await expect( - generateLibraryDocs({ - project: project as unknown as GenerateLibraryDocsOptions["project"], - cliContext: ctx as unknown as GenerateLibraryDocsOptions["cliContext"], - library: undefined, - local: false - }) - ).rejects.toThrow("Failed to authenticate"); - }); - - it("delegates to runLibraryDocsGeneration with the workspace's libraries and token", async () => { + it("delegates to runLibraryDocsGeneration without any authentication", async () => { const ctx = makeCliContext(); const project = makeProject({ libraries: { "my-sdk": { input: { git: "https://x" }, output: { path: "./d" }, lang: "python" } } }); - (askToLogin as Mock).mockResolvedValue({ type: "user", value: "tok-xyz" }); (runLibraryDocsGeneration as Mock).mockResolvedValue({ successful: 1 }); await generateLibraryDocs({ project: project as unknown as GenerateLibraryDocsOptions["project"], cliContext: ctx as unknown as GenerateLibraryDocsOptions["cliContext"], - library: "my-sdk", - local: false + library: "my-sdk" }); expect(runLibraryDocsGeneration).toHaveBeenCalledTimes(1); const call = (runLibraryDocsGeneration as Mock).mock.calls[0]?.[0]; expect(call).toBeDefined(); expect(call.library).toBe("my-sdk"); - expect(call.orgId).toBe("test-org"); - expect(call.tokenValue).toBe("tok-xyz"); expect(call.docsDirectoryPath).toBe("/home/user/project/fern"); - }); - - it("local mode: skips authentication and delegates with local: true and no token", async () => { - const ctx = makeCliContext(); - const project = makeProject({ - libraries: { "my-sdk": { input: { path: "./src" }, output: { path: "./d" }, lang: "python" } } - }); - (askToLogin as Mock).mockReset(); - (runLibraryDocsGeneration as Mock).mockReset(); - (runLibraryDocsGeneration as Mock).mockResolvedValue({ successful: 1 }); - - await generateLibraryDocs({ - project: project as unknown as GenerateLibraryDocsOptions["project"], - cliContext: ctx as unknown as GenerateLibraryDocsOptions["cliContext"], - library: undefined, - local: true - }); - - expect(askToLogin).not.toHaveBeenCalled(); - expect(runLibraryDocsGeneration).toHaveBeenCalledTimes(1); - const call = (runLibraryDocsGeneration as Mock).mock.calls[0]?.[0]; - expect(call.local).toBe(true); + // Generation is local-only: no token or org is threaded through. expect(call.tokenValue).toBeUndefined(); + expect(call.orgId).toBeUndefined(); }); }); diff --git a/packages/cli/cli/src/commands/docs-md-generate/generateLibraryDocs.ts b/packages/cli/cli/src/commands/docs-md-generate/generateLibraryDocs.ts index 016ba0af5100..2d32c22b8e3c 100644 --- a/packages/cli/cli/src/commands/docs-md-generate/generateLibraryDocs.ts +++ b/packages/cli/cli/src/commands/docs-md-generate/generateLibraryDocs.ts @@ -1,6 +1,4 @@ -import { FernToken } from "@fern-api/auth"; import { runLibraryDocsGeneration } from "@fern-api/library-docs-generator"; -import { askToLogin } from "@fern-api/login"; import { Project } from "@fern-api/project-loader"; import { CliError } from "@fern-api/task-context"; @@ -12,26 +10,16 @@ export interface GenerateLibraryDocsOptions { cliContext: CliContext; /** If specified, only generate docs for this library */ library: string | undefined; - /** Run the parser(s) locally in Docker instead of using Fern's servers. */ - local: boolean; } /** * Generate library documentation from source code. * * Loads the docs workspace and delegates to the shared orchestrator in - * `@fern-api/library-docs-generator`. By default it authenticates with FDR, - * starts server-side parsing, polls for completion, downloads the resulting IR - * from S3, and runs the local MDX generator. With `local`, it skips - * authentication and runs the parser Docker images directly on the user's - * machine. + * `@fern-api/library-docs-generator`, which runs the parser Docker images + * on the user's machine — no authentication or network calls are required. */ -export async function generateLibraryDocs({ - project, - cliContext, - library, - local -}: GenerateLibraryDocsOptions): Promise { +export async function generateLibraryDocs({ project, cliContext, library }: GenerateLibraryDocsOptions): Promise { const docsWorkspace = project.docsWorkspaces; if (docsWorkspace == null) { @@ -61,30 +49,12 @@ export async function generateLibraryDocs({ return; } - let tokenValue: string | undefined; - if (!local) { - const token: FernToken | null = await cliContext.runTask(async (context) => { - return askToLogin(context); - }); - - if (token == null) { - cliContext.failAndThrow("Failed to authenticate. Please run 'fern login' first.", undefined, { - code: CliError.Code.AuthError - }); - return; - } - tokenValue = token.value; - } - await cliContext.runTask(async (context) => { const { successful } = await runLibraryDocsGeneration({ libraries, library, docsDirectoryPath: docsWorkspace.absoluteFilePath, - orgId: project.config.organization, - tokenValue, - context, - local + context }); if (successful > 0) { diff --git a/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts b/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts index 6e7c0974707e..461d27b8448a 100644 --- a/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts +++ b/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts @@ -560,8 +560,8 @@ export type ParsedApiReferenceLayoutItem = /** * Parsed configuration for the source location of a library documentation source. - * A `git` input can be generated remotely or resolved locally with `--local`; - * a `path` input points at a local checkout and requires `--local`. + * A `git` input is cloned and resolved locally; a `path` input points at a + * local checkout. */ export type ParsedLibraryInputConfiguration = | { diff --git a/packages/cli/library-docs-generator/src/__test__/localGeneration.test.ts b/packages/cli/library-docs-generator/src/__test__/localGeneration.test.ts index b71342d89c13..b257f7d47009 100644 --- a/packages/cli/library-docs-generator/src/__test__/localGeneration.test.ts +++ b/packages/cli/library-docs-generator/src/__test__/localGeneration.test.ts @@ -20,7 +20,7 @@ import { runContainer } from "@fern-api/docker-utils"; import { runLibraryDocsGeneration } from "../orchestrate.js"; /** - * End-to-end coverage of the `fern docs md generate --local` path for a + * End-to-end coverage of the `fern docs md generate` path for a * local path and git input libraries. * * Only the Docker boundary (`runContainer`) is mocked — it emits a realistic @@ -29,9 +29,8 @@ import { runLibraryDocsGeneration } from "../orchestrate.js"; * source-path resolution, `LocalParserRunner`, IR validation, and the Python * MDX generator writing files to `output.path` on disk. * - * This is the local analogue of the git/remote render coverage in - * fern-platform's `library-docs-generate.spec.ts`, and closes the seam that - * let a `path` input be required by `--local` yet unhandled elsewhere. + * This is the local analogue of the git render coverage in + * fern-platform's `library-docs-generate.spec.ts`. */ type LoggerMock = { info: Mock; error: Mock; debug: Mock; warn: Mock; trace: Mock; log: Mock }; @@ -142,7 +141,7 @@ function pythonIr(): FdrAPI.libraryDocs.PythonLibraryDocsIr { } as unknown as FdrAPI.libraryDocs.PythonLibraryDocsIr; } -describe("runLibraryDocsGeneration — --local path input (end-to-end to disk)", () => { +describe("runLibraryDocsGeneration — path input (end-to-end to disk)", () => { let docsDir: string; beforeEach(() => { @@ -178,9 +177,7 @@ describe("runLibraryDocsGeneration — --local path input (end-to-end to disk)", const result = await runLibraryDocsGeneration({ libraries, docsDirectoryPath: AbsoluteFilePath.of(docsDir), - orgId: "smoke-test", - context: makeContext(), - local: true + context: makeContext() }); expect(result).toEqual({ successful: 1 }); @@ -245,9 +242,7 @@ describe("runLibraryDocsGeneration — --local path input (end-to-end to disk)", runLibraryDocsGeneration({ libraries, docsDirectoryPath: AbsoluteFilePath.of(docsDir), - orgId: "smoke-test", - context: makeContext(), - local: true + context: makeContext() }) ).resolves.toEqual({ successful: 1 }); diff --git a/packages/cli/library-docs-generator/src/__test__/orchestrate.test.ts b/packages/cli/library-docs-generator/src/__test__/orchestrate.test.ts index 64c242cc5138..b954cbdaebd1 100644 --- a/packages/cli/library-docs-generator/src/__test__/orchestrate.test.ts +++ b/packages/cli/library-docs-generator/src/__test__/orchestrate.test.ts @@ -1,9 +1,8 @@ import type { docsYml } from "@fern-api/configuration"; import { AbsoluteFilePath } from "@fern-api/fs-utils"; -import { CliError, type TaskContext, TaskResult } from "@fern-api/task-context"; - +import * as GitHub from "@fern-api/github"; +import { type TaskContext, TaskResult } from "@fern-api/task-context"; import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "vitest"; - import * as CppDocsGenerator from "../CppDocsGenerator.js"; import * as LocalParserRunner from "../LocalParserRunner.js"; import { runLibraryDocsGeneration, type StepWrapper } from "../orchestrate.js"; @@ -23,6 +22,11 @@ vi.mock("../LocalParserRunner.js", () => ({ runLocalParser: vi.fn() })); +vi.mock("@fern-api/github", async () => { + const actual = await vi.importActual("@fern-api/github"); + return { ...actual, cloneRepositoryAtRef: vi.fn(), resolveRepositorySubpath: vi.fn() }; +}); + type LoggerMock = { info: Mock; error: Mock; debug: Mock; warn: Mock; trace: Mock; log: Mock }; function makeLogger(): LoggerMock { @@ -55,90 +59,14 @@ function makeContext(logger: LoggerMock = makeLogger()): TaskContext & { logger: } as unknown as TaskContext & { logger: LoggerMock }; } -function pythonConfig(): docsYml.RawSchemas.LibraryConfiguration { +function pathConfig(): docsYml.RawSchemas.LibraryConfiguration { return { - input: { git: "https://github.com/acme/sdk" }, + input: { path: "./local-src" } as unknown as docsYml.RawSchemas.LibraryInputConfiguration, output: { path: "./docs" }, lang: "python" }; } -function cppConfig(): docsYml.RawSchemas.LibraryConfiguration { - return { - input: { git: "https://github.com/acme/cpp" }, - output: { path: "./docs" }, - lang: "cpp" - }; -} - -function makeStatus(status: string, extras: Record = {}) { - return { status, jobId: "job-1", progress: "", createdAt: "", updatedAt: "", ...extras }; -} - -/** - * Routes calls to `globalThis.fetch` based on URL substring so tests can - * script start → poll → result → S3 download responses for the library - * docs endpoints without standing up a real server. - */ -function makeMockFetch({ - startResponse, - statusResponses, - resultResponse, - irResponse -}: { - startResponse: { body: unknown; ok?: boolean }; - statusResponses: { body: unknown; ok?: boolean }[]; - resultResponse?: { body: unknown; ok?: boolean }; - irResponse?: unknown; -}) { - let statusIdx = 0; - const startCalls: unknown[] = []; - - const mockFn = vi.fn().mockImplementation(async (url: string, init?: RequestInit) => { - const urlStr = String(url); - - if (urlStr.includes("/library-docs/generate") && init?.method === "POST") { - startCalls.push(init.body ? JSON.parse(String(init.body)) : undefined); - if (startResponse.ok === false) { - return { - ok: false, - status: 401, - json: async () => startResponse.body, - text: async () => JSON.stringify(startResponse.body) - }; - } - return { ok: true, status: 200, json: async () => startResponse.body, text: async () => "" }; - } - - if (urlStr.includes("/library-docs/status/")) { - const resp = statusResponses[statusIdx++]; - if (!resp || resp.ok === false) { - return { - ok: false, - status: 500, - json: async () => resp?.body ?? {}, - text: async () => JSON.stringify(resp?.body ?? {}) - }; - } - return { ok: true, status: 200, json: async () => resp.body, text: async () => "" }; - } - - if (urlStr.includes("/library-docs/result/")) { - const resp = resultResponse ?? { body: { resultUrl: "https://s3.example.com/ir.json" }, ok: true }; - return { - ok: resp.ok !== false, - status: resp.ok !== false ? 200 : 500, - json: async () => resp.body, - text: async () => "" - }; - } - - return { ok: true, status: 200, json: async () => irResponse ?? { ir: mockPythonIr } }; - }); - - return { mockFn, startCalls }; -} - const mockPythonIr = { rootModule: { name: "sdk", path: "sdk", submodules: [], classes: [], functions: [], attributes: [] } }; @@ -150,24 +78,23 @@ const DOCS_DIR = AbsoluteFilePath.of("/tmp/docs"); describe("runLibraryDocsGeneration", () => { let originalFetch: typeof globalThis.fetch; + let fetchSpy: Mock; beforeEach(() => { vi.clearAllMocks(); - vi.useFakeTimers(); originalFetch = globalThis.fetch; - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ ir: mockPythonIr }), - text: async () => "" - }) as unknown as typeof fetch; + // Generation is local-only: any fetch call is a regression back to + // the removed remote path, so the spy rejects and tests assert it + // was never invoked. + fetchSpy = vi.fn().mockRejectedValue(new Error("unexpected network request")); + globalThis.fetch = fetchSpy as unknown as typeof fetch; (PythonDocsGenerator.generate as Mock).mockReturnValue({ pageCount: 1 }); (CppDocsGenerator.generateCpp as Mock).mockReturnValue({ pageCount: 1 }); + (GitHub.cloneRepositoryAtRef as Mock).mockResolvedValue("/tmp/clones/repo"); }); afterEach(() => { globalThis.fetch = originalFetch; - vi.useRealTimers(); }); it("rejects when libraries is empty", async () => { @@ -175,8 +102,6 @@ describe("runLibraryDocsGeneration", () => { runLibraryDocsGeneration({ libraries: {}, docsDirectoryPath: DOCS_DIR, - orgId: "org", - tokenValue: "tok", context: makeContext() }) ).rejects.toThrow(/No libraries configured/); @@ -185,275 +110,131 @@ describe("runLibraryDocsGeneration", () => { it("rejects when --library filter does not match any configured library", async () => { await expect( runLibraryDocsGeneration({ - libraries: { "my-sdk": pythonConfig() }, + libraries: { "my-sdk": pathConfig() }, library: "nonexistent", docsDirectoryPath: DOCS_DIR, - orgId: "org", - tokenValue: "tok", context: makeContext() }) ).rejects.toThrow(/Library 'nonexistent' not found/); }); - it("rejects 'path' input for remote generation (requires --local)", async () => { + it("parses a 'path' input library locally and generates without any network request (Python)", async () => { + (LocalParserRunner.runLocalParser as Mock).mockResolvedValue(mockPythonIr); + await expect( runLibraryDocsGeneration({ - libraries: { - "path-lib": { - input: { path: "./local" } as unknown as docsYml.RawSchemas.LibraryInputConfiguration, - output: { path: "./docs" }, - lang: "python" - } - }, + libraries: { "my-sdk": pathConfig() }, docsDirectoryPath: DOCS_DIR, - orgId: "org", - tokenValue: "tok", context: makeContext() }) - ).rejects.toThrow(/'path' input requires the --local flag/); + ).resolves.toEqual({ successful: 1 }); + + expect(LocalParserRunner.runLocalParser).toHaveBeenCalledTimes(1); + const call = (LocalParserRunner.runLocalParser as Mock).mock.calls[0]?.[0] as Record; + expect(call.language).toBe("PYTHON"); + expect(String(call.sourcePath)).toBe("/tmp/docs/local-src"); + expect(PythonDocsGenerator.generate).toHaveBeenCalledWith( + expect.objectContaining({ ir: mockPythonIr, slug: "my-sdk", title: "my-sdk" }) + ); + expect(fetchSpy).not.toHaveBeenCalled(); }); - it("local mode: parses a 'path' input library without a token and generates (Python)", async () => { + it("clones a git input locally, forwarding ref and subpath to the parser, without any network request", async () => { (LocalParserRunner.runLocalParser as Mock).mockResolvedValue(mockPythonIr); await expect( runLibraryDocsGeneration({ libraries: { "my-sdk": { - input: { path: "./local-src" } as unknown as docsYml.RawSchemas.LibraryInputConfiguration, + input: { + git: "https://github.com/acme/sdk", + ref: "release/2.0", + subpath: "packages/sdk" + }, output: { path: "./docs" }, lang: "python" } }, docsDirectoryPath: DOCS_DIR, - orgId: "org", - context: makeContext(), - local: true + context: makeContext() }) ).resolves.toEqual({ successful: 1 }); - expect(LocalParserRunner.runLocalParser).toHaveBeenCalledTimes(1); - const call = (LocalParserRunner.runLocalParser as Mock).mock.calls[0]?.[0] as Record; - expect(call.language).toBe("PYTHON"); - expect(String(call.sourcePath)).toBe("/tmp/docs/local-src"); - expect(PythonDocsGenerator.generate).toHaveBeenCalledWith( - expect.objectContaining({ ir: mockPythonIr, slug: "my-sdk", title: "my-sdk" }) - ); - }); - - it("happy path: start → poll → download IR → generate (Python)", async () => { - const { mockFn, startCalls } = makeMockFetch({ - startResponse: { body: { jobId: "job-1" } }, - statusResponses: [{ body: makeStatus("PENDING") }, { body: makeStatus("COMPLETED") }] - }); - globalThis.fetch = mockFn as unknown as typeof fetch; - - const promise = runLibraryDocsGeneration({ - libraries: { "my-sdk": pythonConfig() }, - docsDirectoryPath: DOCS_DIR, - orgId: "org", - tokenValue: "tok-123", - context: makeContext() + expect(GitHub.cloneRepositoryAtRef).toHaveBeenCalledWith({ + repositoryUrl: "https://github.com/acme/sdk", + ref: "release/2.0" }); - - await vi.advanceTimersByTimeAsync(3000); - await vi.advanceTimersByTimeAsync(3000); - await expect(promise).resolves.toEqual({ successful: 1 }); - - expect(startCalls.length).toBe(1); - const startCall = startCalls[0] as Record; - expect(startCall.language).toBe("PYTHON"); - expect(startCall.githubUrl).toBe("https://github.com/acme/sdk"); - expect(PythonDocsGenerator.generate).toHaveBeenCalledWith( - expect.objectContaining({ ir: mockPythonIr, slug: "my-sdk", title: "my-sdk" }) + expect(GitHub.resolveRepositorySubpath).toHaveBeenCalledWith( + expect.objectContaining({ repositoryRoot: "/tmp/clones/repo", subpath: "packages/sdk" }) ); - }); - - it("remote mode: forwards a git ref and subpath to the generation service", async () => { - const { mockFn, startCalls } = makeMockFetch({ - startResponse: { body: { jobId: "job-ref" } }, - statusResponses: [{ body: makeStatus("COMPLETED") }] - }); - globalThis.fetch = mockFn as unknown as typeof fetch; - - const promise = runLibraryDocsGeneration({ - libraries: { - "my-sdk": { - input: { - git: "https://github.com/acme/sdk", - ref: "release/2.0", - subpath: "packages/sdk" - }, - output: { path: "./docs" }, - lang: "python" - } - }, - docsDirectoryPath: DOCS_DIR, - orgId: "org", - tokenValue: "tok", - context: makeContext() - }); - await vi.advanceTimersByTimeAsync(3000); - await promise; - - expect((startCalls[0] as { config: unknown }).config).toEqual( + const call = (LocalParserRunner.runLocalParser as Mock).mock.calls[0]?.[0] as Record; + expect(String(call.sourcePath)).toBe("/tmp/clones/repo"); + expect(call.config).toEqual( expect.objectContaining({ - branch: "release/2.0", - packagePath: "packages/sdk" + packagePath: "packages/sdk", + sourceUrl: "https://github.com/acme/sdk", + branch: "release/2.0" }) ); + expect(fetchSpy).not.toHaveBeenCalled(); }); - it("sends the bearer token in the auth header", async () => { - const { mockFn } = makeMockFetch({ - startResponse: { body: { jobId: "job-auth" } }, - statusResponses: [{ body: makeStatus("COMPLETED") }] - }); - globalThis.fetch = mockFn as unknown as typeof fetch; - - const promise = runLibraryDocsGeneration({ - libraries: { "my-sdk": pythonConfig() }, - docsDirectoryPath: DOCS_DIR, - orgId: "org", - tokenValue: "tok-abc", - context: makeContext() - }); - - await vi.advanceTimersByTimeAsync(3000); - await promise; - - const fetchCalls = mockFn.mock.calls as Array<[string, RequestInit | undefined]>; - const authHeader = fetchCalls - .filter(([, init]) => init?.headers != null) - .map(([, init]) => (init?.headers as Record)?.Authorization) - .find(Boolean); - expect(authHeader).toBe("Bearer tok-abc"); - }); - - it("rejects when generation status is FAILED, preserving the server message", async () => { - const { mockFn } = makeMockFetch({ - startResponse: { body: { jobId: "job-fail" } }, - statusResponses: [ - { body: makeStatus("FAILED", { error: { code: "PARSE_FAILED", message: "Bad syntax" } }) } - ] - }); - globalThis.fetch = mockFn as unknown as typeof fetch; - - const promise = runLibraryDocsGeneration({ - libraries: { "my-sdk": pythonConfig() }, - docsDirectoryPath: DOCS_DIR, - orgId: "org", - tokenValue: "tok", - context: makeContext() - }); - // Attach a no-op handler so the rejection is never "unhandled" while - // we advance fake timers below. - promise.catch(() => undefined); - await vi.advanceTimersByTimeAsync(3000); - - await expect(promise).rejects.toThrow(/Bad syntax/); - await expect(promise).rejects.toBeInstanceOf(CliError); - }); - - it("rejects with a network error when startLibraryDocsGeneration HTTP-errors", async () => { - const { mockFn } = makeMockFetch({ - startResponse: { body: { error: "UnauthorizedError" }, ok: false }, - statusResponses: [] - }); - globalThis.fetch = mockFn as unknown as typeof fetch; + it("maps lang: cpp → CPP and calls generateCpp", async () => { + (LocalParserRunner.runLocalParser as Mock).mockResolvedValue(mockCppIr); await expect( runLibraryDocsGeneration({ - libraries: { "my-sdk": pythonConfig() }, + libraries: { "cpp-lib": { ...pathConfig(), lang: "cpp" } }, docsDirectoryPath: DOCS_DIR, - orgId: "org", - tokenValue: "tok", context: makeContext() }) - ).rejects.toThrow(/Failed to start generation/); - }); - - it("maps lang: cpp → CPP and calls generateCpp", async () => { - const { mockFn, startCalls } = makeMockFetch({ - startResponse: { body: { jobId: "job-cpp" } }, - statusResponses: [{ body: makeStatus("COMPLETED", { jobId: "job-cpp" }) }], - irResponse: { ir: mockCppIr } - }); - globalThis.fetch = mockFn as unknown as typeof fetch; - - const promise = runLibraryDocsGeneration({ - libraries: { "cpp-lib": cppConfig() }, - docsDirectoryPath: DOCS_DIR, - orgId: "org", - tokenValue: "tok", - context: makeContext() - }); - await vi.advanceTimersByTimeAsync(3000); - await promise; + ).resolves.toEqual({ successful: 1 }); - expect((startCalls[0] as Record).language).toBe("CPP"); + const call = (LocalParserRunner.runLocalParser as Mock).mock.calls[0]?.[0] as Record; + expect(call.language).toBe("CPP"); expect(CppDocsGenerator.generateCpp).toHaveBeenCalledWith( expect.objectContaining({ ir: mockCppIr, slug: "cpp-lib" }) ); }); it("respects the library filter — only the named library is generated", async () => { - const { mockFn, startCalls } = makeMockFetch({ - startResponse: { body: { jobId: "job-f" } }, - statusResponses: [{ body: makeStatus("COMPLETED") }] - }); - globalThis.fetch = mockFn as unknown as typeof fetch; - - const promise = runLibraryDocsGeneration({ - libraries: { - "sdk-a": pythonConfig(), - "sdk-b": { - input: { git: "https://github.com/acme/sdk-b" }, - output: { path: "./docs-b" }, - lang: "python" - } - }, - library: "sdk-a", - docsDirectoryPath: DOCS_DIR, - orgId: "org", - tokenValue: "tok", - context: makeContext() - }); - await vi.advanceTimersByTimeAsync(3000); - await promise; - - expect(startCalls.length).toBe(1); - expect((startCalls[0] as Record).githubUrl).toBe("https://github.com/acme/sdk"); - }); + (LocalParserRunner.runLocalParser as Mock).mockResolvedValue(mockPythonIr); - it("times out when polling exceeds the deadline", async () => { - const { mockFn } = makeMockFetch({ - startResponse: { body: { jobId: "job-timeout" } }, - statusResponses: Array.from({ length: 100 }, () => ({ body: makeStatus("PARSING") })) - }); - globalThis.fetch = mockFn as unknown as typeof fetch; + await expect( + runLibraryDocsGeneration({ + libraries: { + "sdk-a": pathConfig(), + "sdk-b": { + input: { path: "./other-src" } as unknown as docsYml.RawSchemas.LibraryInputConfiguration, + output: { path: "./docs-b" }, + lang: "python" + } + }, + library: "sdk-a", + docsDirectoryPath: DOCS_DIR, + context: makeContext() + }) + ).resolves.toEqual({ successful: 1 }); - const promise = runLibraryDocsGeneration({ - libraries: { "my-sdk": pythonConfig() }, - docsDirectoryPath: DOCS_DIR, - orgId: "org", - tokenValue: "tok", - context: makeContext() - }); - promise.catch(() => undefined); + expect(LocalParserRunner.runLocalParser).toHaveBeenCalledTimes(1); + const call = (LocalParserRunner.runLocalParser as Mock).mock.calls[0]?.[0] as Record; + expect(String(call.sourcePath)).toBe("/tmp/docs/local-src"); + }); - // Advance well past the 3-minute deadline. - await vi.advanceTimersByTimeAsync(4 * 60 * 1000); + it("rejects when the parser produces an IR without the expected root node", async () => { + (LocalParserRunner.runLocalParser as Mock).mockResolvedValue({}); - await expect(promise).rejects.toThrow(/timed out/); + await expect( + runLibraryDocsGeneration({ + libraries: { "my-sdk": pathConfig() }, + docsDirectoryPath: DOCS_DIR, + context: makeContext() + }) + ).rejects.toThrow(/rootModule/); }); it("invokes wrapStep around each long-running step", async () => { - const { mockFn } = makeMockFetch({ - startResponse: { body: { jobId: "job-wrap" } }, - statusResponses: [{ body: makeStatus("COMPLETED") }] - }); - globalThis.fetch = mockFn as unknown as typeof fetch; + (LocalParserRunner.runLocalParser as Mock).mockResolvedValue(mockPythonIr); const messages: string[] = []; const wrapStep: StepWrapper = async ({ message, operation }) => { @@ -461,19 +242,13 @@ describe("runLibraryDocsGeneration", () => { return operation(); }; - const promise = runLibraryDocsGeneration({ - libraries: { "my-sdk": pythonConfig() }, + await runLibraryDocsGeneration({ + libraries: { "my-sdk": pathConfig() }, docsDirectoryPath: DOCS_DIR, - orgId: "org", - tokenValue: "tok", context: makeContext(), wrapStep }); - await vi.advanceTimersByTimeAsync(3000); - await promise; - expect(messages.some((m) => m.includes("starting generation"))).toBe(true); - expect(messages.some((m) => m.includes("generating documentation"))).toBe(true); - expect(messages.some((m) => m.includes("downloading generated IR"))).toBe(true); + expect(messages.some((m) => m.includes("parsing library source locally"))).toBe(true); }); }); diff --git a/packages/cli/library-docs-generator/src/index.ts b/packages/cli/library-docs-generator/src/index.ts index a6d85f45d979..9513252c3897 100644 --- a/packages/cli/library-docs-generator/src/index.ts +++ b/packages/cli/library-docs-generator/src/index.ts @@ -5,12 +5,7 @@ */ export { type CppGenerateOptions, type CppGenerateResult, generateCpp } from "./CppDocsGenerator.js"; -export { - createLibraryDocsClient, - type LibraryDocsClient, - runLibraryDocsGeneration, - type StepWrapper -} from "./orchestrate.js"; +export { runLibraryDocsGeneration, type StepWrapper } from "./orchestrate.js"; export { type GenerateOptions, type GenerateResult, generate } from "./PythonDocsGenerator.js"; export type { CppLibraryDocsIr } from "./types/CppLibraryDocsIr.js"; export { diff --git a/packages/cli/library-docs-generator/src/orchestrate.ts b/packages/cli/library-docs-generator/src/orchestrate.ts index 0e474aa1814c..cbb677bfad9a 100644 --- a/packages/cli/library-docs-generator/src/orchestrate.ts +++ b/packages/cli/library-docs-generator/src/orchestrate.ts @@ -12,89 +12,8 @@ import { type LocalParserConfig, runLocalParser } from "./LocalParserRunner.js"; import { generate } from "./PythonDocsGenerator.js"; import type { CppLibraryDocsIr } from "./types/CppLibraryDocsIr.js"; -const POLL_INTERVAL_MS = 3000; -const POLL_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes - export type LibraryLanguage = "PYTHON" | "CPP"; -/** - * Lightweight client interface for the library docs endpoints. - * - * Mirrors the oRPC contract in `@fern-api/fdr-sdk` so callers stay decoupled - * from the concrete HTTP transport. Once the published fdr-sdk includes - * `createLibraryDocsClient`, this interface and the fetch-based - * implementation below can be replaced with a direct import. - */ -export interface LibraryDocsClient { - startLibraryDocsGeneration(input: { - orgId: string; - githubUrl: string; - language: LibraryLanguage; - config?: { - branch?: string | null; - packagePath?: string | null; - title?: string | null; - slug?: string | null; - doxyfileContent?: string | null; - } | null; - }): Promise<{ jobId: string }>; - getLibraryDocsGenerationStatus(input: { jobId: string }): Promise<{ - jobId: string; - status: string; - progress: string; - error?: { code: string; message: string }; - createdAt: string; - updatedAt: string; - }>; - getLibraryDocsResult(input: { jobId: string }): Promise<{ - jobId: string; - resultUrl: string; - }>; -} - -/** - * Build a {@link LibraryDocsClient} backed by plain `fetch`. - * - * The base URL and auth mirror what `createFdrService` uses so that - * env-var overrides (`DEFAULT_FDR_ORIGIN`, `FERN_FDR_ORIGIN`) keep - * working. - */ -export function createLibraryDocsClient({ token }: { token: string }): LibraryDocsClient { - const defaultOrigin = process.env.DEFAULT_FDR_ORIGIN ?? "https://registry.buildwithfern.com"; - const baseUrl = process.env.FERN_FDR_ORIGIN ?? process.env.OVERRIDE_FDR_ORIGIN ?? defaultOrigin; - const docsBase = `${baseUrl}/v2/registry/docs`; - - async function request(method: string, path: string, body?: unknown): Promise { - const response = await fetch(`${docsBase}${path}`, { - method, - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json" - }, - body: body != null ? JSON.stringify(body) : undefined - }); - - if (!response.ok) { - const text = await response.text().catch(() => ""); - throw new CliError({ message: `HTTP ${response.status}: ${text}`, code: CliError.Code.NetworkError }); - } - - return (await response.json()) as T; - } - - return { - startLibraryDocsGeneration(input) { - return request("POST", "/library-docs/generate", input); - }, - getLibraryDocsGenerationStatus(input) { - return request("GET", `/library-docs/status/${input.jobId}`); - }, - getLibraryDocsResult(input) { - return request("GET", `/library-docs/result/${input.jobId}`); - } - }; -} - function isGitLibraryInput( input: docsYml.RawSchemas.LibraryInputConfiguration ): input is docsYml.RawSchemas.GitLibraryInputSchema { @@ -124,10 +43,8 @@ const defaultWrapStep: StepWrapper = ({ operation }) => operation(); * Iterates over the configured libraries, producing the library-docs IR and * running the local MDX generator for each. * - * By default the IR is produced remotely via the FDR library-docs endpoints - * (start generation, poll for completion, download the resulting IR). When - * `local` is set, the parser Docker images are run directly on the user's - * machine and no network calls or authentication are required. + * The IR is produced by running the parser Docker images directly on the + * user's machine — no network calls or authentication are required. * * All libraries are attempted (via `Promise.allSettled`) so a single failure * does not abort generation for the remaining ones. If any library failed, @@ -138,24 +55,16 @@ export async function runLibraryDocsGeneration({ libraries, library, docsDirectoryPath, - orgId, - tokenValue, context, - wrapStep = defaultWrapStep, - local = false + wrapStep = defaultWrapStep }: { libraries: Record; /** Optional library name to filter to a single entry. */ library?: string; /** Absolute path of the directory containing docs.yml — used to resolve input/output paths. */ docsDirectoryPath: AbsoluteFilePath; - orgId: string; - /** The raw bearer token value. Required unless `local` is set. */ - tokenValue?: string; context: TaskContext; wrapStep?: StepWrapper; - /** Run parser Docker images locally instead of using Fern's servers. */ - local?: boolean; }): Promise<{ successful: number }> { if (Object.keys(libraries).length === 0) { throw new CliError({ @@ -177,19 +86,6 @@ export async function runLibraryDocsGeneration({ const librariesToGenerate = library != null ? { [library]: libraries[library] } : libraries; - let client: LibraryDocsClient | undefined; - if (!local) { - if (tokenValue == null) { - throw new CliError({ - message: - "Authentication is required for remote library docs generation.\n\n" + - " Run 'fern login', or pass --local to parse libraries locally with Docker.", - code: CliError.Code.AuthError - }); - } - client = createLibraryDocsClient({ token: tokenValue }); - } - const results = await Promise.allSettled( Object.entries(librariesToGenerate).map(async ([name, config]) => { if (config == null) { @@ -199,14 +95,11 @@ export async function runLibraryDocsGeneration({ }); } await generateSingleLibrary({ - client, context, name, config, docsDirectoryPath, - orgId, - wrapStep, - local + wrapStep }); }) ); @@ -232,23 +125,17 @@ export async function runLibraryDocsGeneration({ } async function generateSingleLibrary({ - client, context, name, config, docsDirectoryPath, - orgId, - wrapStep, - local + wrapStep }: { - client: LibraryDocsClient | undefined; context: TaskContext; name: string; config: docsYml.RawSchemas.LibraryConfiguration; docsDirectoryPath: AbsoluteFilePath; - orgId: string; wrapStep: StepWrapper; - local: boolean; }): Promise { const resolvedOutputPath = resolve(docsDirectoryPath, config.output.path); @@ -282,19 +169,15 @@ async function generateSingleLibrary({ }); } - let ir: unknown; - if (local) { - ir = await generateIrLocally({ context, name, config, docsDirectoryPath, language, doxyfileContent, wrapStep }); - } else if (client != null) { - ir = await generateIrRemotely({ client, name, config, language, orgId, doxyfileContent, wrapStep }); - } else { - // Unreachable in practice (runLibraryDocsGeneration constructs a client for the remote - // path), but keeps the nullable `client` honest without a non-null assertion. - throw new CliError({ - message: `Library '${name}': authentication is required for remote generation. Re-run with --local or run 'fern login'.`, - code: CliError.Code.AuthError - }); - } + const ir = await generateIrLocally({ + context, + name, + config, + docsDirectoryPath, + language, + doxyfileContent, + wrapStep + }); if (language === "CPP") { const cppIr = ir as CppLibraryDocsIr; @@ -325,60 +208,6 @@ async function generateSingleLibrary({ } } -/** - * Produces the library-docs IR via the FDR endpoints: start generation, poll - * for completion, and download the resulting IR. - */ -async function generateIrRemotely({ - client, - name, - config, - language, - orgId, - doxyfileContent, - wrapStep -}: { - client: LibraryDocsClient; - name: string; - config: docsYml.RawSchemas.LibraryConfiguration; - language: LibraryLanguage; - orgId: string; - doxyfileContent: string | undefined; - wrapStep: StepWrapper; -}): Promise { - if (!isGitLibraryInput(config.input)) { - throw new CliError({ - message: `Library '${name}': 'path' input requires the --local flag. Use 'git' input for remote generation.`, - code: CliError.Code.ConfigError - }); - } - const gitInput = config.input; - - const jobId = await wrapStep({ - message: `Library '${name}': starting generation from ${gitInput.git}${gitInput.ref != null ? ` (ref: ${gitInput.ref})` : ""}`, - operation: () => - startGeneration(client, { - name, - orgId, - githubUrl: gitInput.git, - language, - packagePath: gitInput.subpath, - ref: gitInput.ref, - doxyfileContent - }) - }); - - await wrapStep({ - message: `Library '${name}': generating documentation`, - operation: () => pollForCompletion(client, jobId, name) - }); - - return wrapStep({ - message: `Library '${name}': downloading generated IR`, - operation: () => downloadIr(client, jobId, name, language) - }); -} - /** * Produces the library-docs IR by running the parser Docker image locally. * Local paths are resolved relative to the docs directory; git inputs are @@ -438,122 +267,9 @@ async function generateIrLocally({ return ir; } -async function startGeneration( - client: LibraryDocsClient, - opts: { - name: string; - orgId: string; - githubUrl: string; - language: LibraryLanguage; - packagePath?: string; - ref?: string; - doxyfileContent?: string; - } -): Promise { - try { - const result = await client.startLibraryDocsGeneration({ - orgId: opts.orgId, - githubUrl: opts.githubUrl, - language: opts.language, - config: { - // FDR's library-docs API accepts any git ref (branch, tag, or SHA) via `branch`. - branch: opts.ref, - packagePath: opts.packagePath, - title: opts.name, - slug: opts.name, - doxyfileContent: opts.doxyfileContent - } - }); - return result.jobId; - } catch (error) { - throw new CliError({ - message: `Failed to start generation for library '${opts.name}': ${extractErrorMessage(error)}`, - code: CliError.Code.NetworkError - }); - } -} - -async function pollForCompletion(client: LibraryDocsClient, jobId: string, libraryName: string): Promise { - const deadline = Date.now() + POLL_TIMEOUT_MS; - - while (Date.now() < deadline) { - await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); - - let status; - try { - status = await client.getLibraryDocsGenerationStatus({ jobId }); - } catch (error) { - throw new CliError({ - message: - `Failed to check generation status for library '${libraryName}': ` + extractErrorMessage(error), - code: CliError.Code.NetworkError - }); - } - - switch (status.status) { - case "PENDING": - case "PARSING": - break; - case "COMPLETED": - return; - case "FAILED": - throw new CliError({ - message: - `Generation failed for library '${libraryName}': ` + - `${status.error?.message ?? "Unknown error"} (${status.error?.code ?? "UNKNOWN"})`, - code: CliError.Code.InternalError - }); - default: - throw new CliError({ - message: `Unexpected generation status for library '${libraryName}': ${status.status}`, - code: CliError.Code.InternalError - }); - } - } - - throw new CliError({ - message: `Generation timed out for library '${libraryName}' after ${POLL_TIMEOUT_MS / 1000}s`, - code: CliError.Code.NetworkError - }); -} - -async function downloadIr( - client: LibraryDocsClient, - jobId: string, - libraryName: string, - language: LibraryLanguage -): Promise { - let resultUrl: string; - try { - const result = await client.getLibraryDocsResult({ jobId }); - resultUrl = result.resultUrl; - } catch (error) { - throw new CliError({ - message: `Failed to fetch generation result for library '${libraryName}': ` + extractErrorMessage(error), - code: CliError.Code.NetworkError - }); - } - - const irFetchResponse = await fetch(resultUrl); - if (!irFetchResponse.ok) { - throw new CliError({ - message: `Failed to download IR for library '${libraryName}': HTTP ${irFetchResponse.status}`, - code: CliError.Code.NetworkError - }); - } - - const irWrapper = (await irFetchResponse.json()) as { ir?: unknown }; - const ir = irWrapper.ir; - - validateLibraryIr(ir, language, libraryName); - - return ir; -} - /** - * Asserts that a parsed IR has the root node expected for its language. Shared - * by the remote (downloaded) and local (parser-produced) paths so both surface - * the same actionable error before the MDX generator runs. + * Asserts that a parsed IR has the root node expected for its language so an + * actionable error surfaces before the MDX generator runs. */ function validateLibraryIr(ir: unknown, language: LibraryLanguage, libraryName: string): void { if (ir == null) {