diff --git a/packages/cli/cli/changes/unreleased/add-sdk-gen-api-runtime-bundles.yml b/packages/cli/cli/changes/unreleased/add-sdk-gen-api-runtime-bundles.yml new file mode 100644 index 000000000000..05ea83df327f --- /dev/null +++ b/packages/cli/cli/changes/unreleased/add-sdk-gen-api-runtime-bundles.yml @@ -0,0 +1,3 @@ +- summary: | + Send generator-compatible Fern runtime bundles with sdk-gen-api build requests. + type: internal diff --git a/packages/cli/generation/local-generation/local-workspace-runner/src/getGeneratorConfig.ts b/packages/cli/generation/local-generation/local-workspace-runner/src/getGeneratorConfig.ts index 7467b8ae9c07..5f9bcb8467f5 100644 --- a/packages/cli/generation/local-generation/local-workspace-runner/src/getGeneratorConfig.ts +++ b/packages/cli/generation/local-generation/local-workspace-runner/src/getGeneratorConfig.ts @@ -1,627 +1,5 @@ -import { GeneratorInvocation, generatorsYml } from "@fern-api/configuration"; -import { isGithubSelfhosted } from "@fern-api/configuration-loader"; -import { AbsoluteFilePath } from "@fern-api/fs-utils"; -import { parseRepository } from "@fern-api/github"; -import { CliError } from "@fern-api/task-context"; -import { FernFiddle } from "@fern-fern/fiddle-sdk"; -import { FernGeneratorExec } from "@fern-fern/generator-exec-sdk"; -import { EnvironmentVariable } from "@fern-fern/generator-exec-sdk/api"; -import * as path from "path"; - -const DEFAULT_OUTPUT_VERSION = "0.0.1"; - -export function getLicensePathFromConfig( - generatorInvocation: GeneratorInvocation -): { type: "basic"; value: string } | { type: "custom"; value: string } | undefined { - if ( - generatorInvocation.raw?.github != null && - typeof generatorInvocation.raw.github === "object" && - "license" in generatorInvocation.raw.github - ) { - const githubConfig = generatorInvocation.raw.github as { license?: string | { custom: string } }; - - if (githubConfig.license != null) { - if (typeof githubConfig.license === "string") { - return { type: "basic", value: githubConfig.license }; - } else if (typeof githubConfig.license === "object" && "custom" in githubConfig.license) { - return { type: "custom", value: githubConfig.license.custom }; - } - } - } - - if (generatorInvocation.raw?.metadata?.license != null) { - const license = generatorInvocation.raw.metadata.license; - if (typeof license === "string") { - return { type: "basic", value: license }; - } else if (typeof license === "object" && "custom" in license) { - return { type: "custom", value: license.custom }; - } - } - - return undefined; -} - -function extractLicenseInfo( - generatorInvocation: GeneratorInvocation, - absolutePathToFernConfig?: AbsoluteFilePath -): FernGeneratorExec.LicenseConfig | undefined { - const licenseConfig = getLicensePathFromConfig(generatorInvocation); - - if (licenseConfig == null) { - return undefined; - } - - if (licenseConfig.type === "basic") { - if (licenseConfig.value === "MIT" || licenseConfig.value === "Apache-2.0") { - return FernGeneratorExec.LicenseConfig.basic({ - id: - licenseConfig.value === "MIT" - ? FernGeneratorExec.LicenseId.Mit - : FernGeneratorExec.LicenseId.Apache2 - }); - } - } else if (licenseConfig.type === "custom") { - return FernGeneratorExec.LicenseConfig.custom({ - filename: path.basename(licenseConfig.value) - }); - } - - return undefined; -} - -export declare namespace getGeneratorConfig { - export interface Args { - workspaceName: string; - organization: string; - outputVersion?: string | undefined; - customConfig: unknown; - generatorInvocation: generatorsYml.GeneratorInvocation; - absolutePathToSnippet: AbsoluteFilePath | undefined; - absolutePathToSnippetTemplates: AbsoluteFilePath | undefined; - absolutePathToFernConfig: AbsoluteFilePath | undefined; - writeUnitTests: boolean; - generateOauthClients: boolean; - generatePaginatedClients: boolean; - whiteLabel?: boolean; - /** - * When true, publishV2/publish output modes will create a real `publish` output config - * so the generator actually publishes to the registry. When false (default), these modes - * are converted to a dummy github config to prevent accidental publishing during - * `fern generate --local`. - */ - publishToRegistry?: boolean; - paths: { - snippetPath: AbsoluteFilePath | undefined; - snippetTemplatePath: AbsoluteFilePath | undefined; - irPath: AbsoluteFilePath; - outputDirectory: AbsoluteFilePath; - }; - } -} - -export function getGithubPublishConfig( - githubPublishInfo: FernFiddle.GithubPublishInfo | undefined -): FernGeneratorExec.GithubPublishInfo | undefined { - return githubPublishInfo != null - ? FernFiddle.GithubPublishInfo._visit(githubPublishInfo, { - npm: (value) => { - const token = (value.token ?? "").trim(); - const useOidc = token === "" || token === "OIDC"; - const hasToken = token !== ""; - return FernGeneratorExec.GithubPublishInfo.npm({ - registryUrl: value.registryUrl, - packageName: value.packageName, - tokenEnvironmentVariable: EnvironmentVariable( - useOidc - ? "" - : token.startsWith("${") && token.endsWith("}") - ? token.slice(2, -1).trim() - : "" - ), - shouldGeneratePublishWorkflow: useOidc || hasToken - }); - }, - maven: (value) => - FernGeneratorExec.GithubPublishInfo.maven({ - registryUrl: value.registryUrl, - coordinate: value.coordinate, - usernameEnvironmentVariable: EnvironmentVariable(value.credentials?.username ?? ""), - passwordEnvironmentVariable: EnvironmentVariable(value.credentials?.password ?? ""), - signature: - value.signature != null - ? { - keyIdEnvironmentVariable: EnvironmentVariable(value.signature.keyId ?? ""), - passwordEnvironmentVariable: EnvironmentVariable(value.signature.password ?? ""), - secretKeyEnvironmentVariable: EnvironmentVariable(value.signature.secretKey ?? "") - } - : undefined - }), - pypi: (value) => { - const password = (value.credentials?.password ?? "").trim(); - const useOidc = password === "" || password === "OIDC"; - const hasCredentials = value.credentials != null; - return FernGeneratorExec.GithubPublishInfo.pypi({ - registryUrl: value.registryUrl, - packageName: value.packageName, - usernameEnvironmentVariable: EnvironmentVariable("PYPI_USERNAME"), - passwordEnvironmentVariable: EnvironmentVariable(useOidc ? "OIDC" : "PYPI_PASSWORD"), - shouldGeneratePublishWorkflow: useOidc || hasCredentials, - pypiMetadata: value.pypiMetadata - }); - }, - rubygems: (value) => - FernGeneratorExec.GithubPublishInfo.rubygems({ - registryUrl: value.registryUrl, - packageName: value.packageName, - apiKeyEnvironmentVariable: EnvironmentVariable(value.apiKey ?? "") - }), - postman: (value) => - FernGeneratorExec.GithubPublishInfo.postman({ - apiKeyEnvironmentVariable: EnvironmentVariable(value.apiKey ?? ""), - workspaceIdEnvironmentVariable: EnvironmentVariable(value.workspaceId ?? "") - }), - nuget: (value) => { - const apiKey = (value.apiKey ?? "").trim(); - const useOidc = apiKey === "" || apiKey === "OIDC"; - return FernGeneratorExec.GithubPublishInfo.nuget({ - registryUrl: value.registryUrl, - packageName: value.packageName, - apiKeyEnvironmentVariable: EnvironmentVariable( - useOidc - ? "" - : apiKey.startsWith("${") && apiKey.endsWith("}") - ? apiKey.slice(2, -1).trim() - : "" - ), - shouldGeneratePublishWorkflow: useOidc ? true : undefined - }); - }, - crates: (value) => - FernGeneratorExec.GithubPublishInfo.crates({ - registryUrl: value.registryUrl, - packageName: value.packageName, - tokenEnvironmentVariable: EnvironmentVariable(value.token ?? "") - }), - _other: () => undefined - }) - : undefined; -} - -export function getGeneratorConfig({ - generatorInvocation, - customConfig, - workspaceName, - organization, - outputVersion = DEFAULT_OUTPUT_VERSION, - absolutePathToSnippet, - absolutePathToSnippetTemplates, - absolutePathToFernConfig, - writeUnitTests, - generateOauthClients, - generatePaginatedClients, - whiteLabel, - publishToRegistry, - paths -}: getGeneratorConfig.Args): FernGeneratorExec.GeneratorConfig { - const licenseInfo = extractLicenseInfo(generatorInvocation, absolutePathToFernConfig); - const { snippetPath, snippetTemplatePath, irPath, outputDirectory } = paths; - const output = generatorInvocation.outputMode._visit({ - publish: (value) => { - if (publishToRegistry === true) { - const publishTarget = getPublishTargetFromPublishMode(value); - return { - ...newRealPublishOutputConfig(outputVersion, publishTarget, paths), - snippetFilepath: snippetPath, - publishingMetadata: generatorInvocation.publishMetadata - }; - } - return { - ...newDummyPublishOutputConfig(outputVersion, value, generatorInvocation, paths), - snippetFilepath: snippetPath, - publishingMetadata: generatorInvocation.publishMetadata - }; - }, - publishV2: (value) => { - if (publishToRegistry === true) { - const publishTarget = getPublishTargetFromPublishModeV2(value); - return { - ...newRealPublishOutputConfig(outputVersion, publishTarget, paths), - snippetFilepath: snippetPath, - publishingMetadata: generatorInvocation.publishMetadata - }; - } - return { - ...newDummyPublishOutputConfig(outputVersion, value, generatorInvocation, paths), - snippetFilepath: snippetPath, - publishingMetadata: generatorInvocation.publishMetadata - }; - }, - downloadFiles: () => { - const outputConfig: FernGeneratorExec.GeneratorOutputConfig = { - mode: FernGeneratorExec.OutputMode.downloadFiles(), - path: outputDirectory, - snippetFilepath: snippetPath, - publishingMetadata: generatorInvocation.publishMetadata - }; - return outputConfig; - }, - github: (value) => { - const outputConfig: FernGeneratorExec.GeneratorOutputConfig = { - mode: FernGeneratorExec.OutputMode.github({ - repoUrl: `https://github.com/${value.owner}/${value.repo}`, - version: outputVersion, - publishInfo: getGithubPublishConfig(value.publishInfo), - installationToken: undefined // Don't attempt to clone the repository when generating locally. - }), - path: outputDirectory, - publishingMetadata: generatorInvocation.publishMetadata - }; - if (absolutePathToSnippet !== undefined) { - outputConfig.snippetFilepath = snippetPath; - } - if (absolutePathToSnippetTemplates !== undefined) { - outputConfig.snippetTemplateFilepath = snippetTemplatePath; - } - return outputConfig; - }, - githubV2: (value) => { - const repoUrl = value._visit({ - commitAndRelease: (value) => `https://github.com/${value.owner}/${value.repo}`, - push: (value) => `https://github.com/${value.owner}/${value.repo}`, - pullRequest: (value) => `https://github.com/${value.owner}/${value.repo}`, - _other: () => { - throw new CliError({ - message: "Encountered unknown github mode", - code: CliError.Code.InternalError - }); - } - }); - const outputConfig: FernGeneratorExec.GeneratorOutputConfig = { - mode: FernGeneratorExec.OutputMode.github({ - repoUrl, - version: outputVersion, - publishInfo: getGithubPublishConfig(value.publishInfo) - }), - path: outputDirectory, - publishingMetadata: generatorInvocation.publishMetadata - }; - if (absolutePathToSnippet !== undefined) { - outputConfig.snippetFilepath = snippetPath; - } - if (absolutePathToSnippetTemplates !== undefined) { - outputConfig.snippetTemplateFilepath = snippetTemplatePath; - } - return outputConfig; - }, - _other: () => { - throw new CliError({ - message: "Output type did not match any of the types supported by Fern", - code: CliError.Code.InternalError - }); - } - }); - const publishConfig = getPublishConfigForGithubOidc(generatorInvocation, outputVersion); - return { - irFilepath: irPath, - output, - publish: publishConfig, - customConfig: customConfig, - workspaceName, - organization, - environment: FernGeneratorExec.GeneratorEnvironment.local(), - dryRun: false, - whitelabel: whiteLabel ?? false, - writeUnitTests, - generateOauthClients, - generatePaginatedClients, - license: licenseInfo - }; -} - -function newDummyPublishOutputConfig( - version: string, - multipleOutputMode: FernFiddle.PublishOutputMode | FernFiddle.PublishOutputModeV2, - generatorInvocation: GeneratorInvocation, - paths: { - outputDirectory: AbsoluteFilePath; - } -): FernGeneratorExec.GeneratorOutputConfig { - const { outputDirectory } = paths; - let outputMode: - | FernFiddle.NpmOutput - | FernFiddle.MavenOutput - | FernFiddle.PypiOutput - | FernFiddle.RubyGemsOutput - | FernFiddle.PostmanOutput - | FernFiddle.NugetOutput - | FernFiddle.CratesOutput - | undefined; - if ("registryOverrides" in multipleOutputMode) { - outputMode = multipleOutputMode.registryOverrides.maven ?? multipleOutputMode.registryOverrides.npm; - } else if (outputMode != null) { - outputMode = multipleOutputMode._visit< - | FernFiddle.NpmOutput - | FernFiddle.MavenOutput - | FernFiddle.PypiOutput - | FernFiddle.RubyGemsOutput - | FernFiddle.PostmanOutput - | FernFiddle.NugetOutput - | FernFiddle.CratesOutput - | undefined - >({ - mavenOverride: (value) => value, - npmOverride: (value) => value, - pypiOverride: (value) => value, - rubyGemsOverride: (value) => value, - postman: (value) => value, - nugetOverride: (value) => value, - cratesOverride: (value) => value, - _other: () => undefined - }); - } - - let repoUrl = ""; - if (generatorInvocation.raw?.github != null) { - if (isGithubSelfhosted(generatorInvocation.raw.github)) { - const parsed = parseRepository(generatorInvocation.raw.github.uri); - repoUrl = parsed.repoUrl; - } else { - repoUrl = generatorInvocation.raw?.github.repository; - } - } - - return { - mode: FernGeneratorExec.OutputMode.github({ - repoUrl, - version - }), - path: outputDirectory - }; -} - -function newRealPublishOutputConfig( - version: string, - publishTarget: FernGeneratorExec.GeneratorPublishTarget | undefined, - paths: { outputDirectory: AbsoluteFilePath } -): FernGeneratorExec.GeneratorOutputConfig { - const { outputDirectory } = paths; - const { registries, registriesV2 } = buildRegistriesFromPublishTarget(publishTarget); - return { - mode: FernGeneratorExec.OutputMode.publish({ - registries, - registriesV2, - publishTarget, - version - }), - path: outputDirectory - }; -} - -/** - * Populate the deprecated registries/registriesV2 fields from the publishTarget. - * Some generators (e.g. TypeScript) read registryUrl/token from registriesV2.npm - * instead of publishTarget, so both must be set. - */ -function buildRegistriesFromPublishTarget(publishTarget: FernGeneratorExec.GeneratorPublishTarget | undefined): { - registries: FernGeneratorExec.GeneratorRegistriesConfig; - registriesV2: FernGeneratorExec.GeneratorRegistriesConfigV2; -} { - const registries: FernGeneratorExec.GeneratorRegistriesConfig = structuredClone(emptyRegistriesConfig); - const registriesV2: FernGeneratorExec.GeneratorRegistriesConfigV2 = structuredClone(emptyRegistriesConfigV2); - - if (publishTarget == null) { - return { registries, registriesV2 }; - } - - switch (publishTarget.type) { - case "npm": - registries.npm = { registryUrl: publishTarget.registryUrl, token: publishTarget.token, scope: "" }; - registriesV2.npm = { - registryUrl: publishTarget.registryUrl, - token: publishTarget.token, - packageName: publishTarget.packageName - }; - break; - case "maven": - registries.maven = { - registryUrl: publishTarget.registryUrl, - username: publishTarget.username, - password: publishTarget.password, - group: "", - signature: publishTarget.signature - }; - registriesV2.maven = { - registryUrl: publishTarget.registryUrl, - username: publishTarget.username, - password: publishTarget.password, - coordinate: publishTarget.coordinate, - signature: publishTarget.signature - }; - break; - case "pypi": - registriesV2.pypi = { - registryUrl: publishTarget.registryUrl, - username: publishTarget.username, - password: publishTarget.password, - packageName: publishTarget.packageName, - pypiMetadata: publishTarget.pypiMetadata - }; - break; - case "rubygems": - registriesV2.rubygems = { - registryUrl: publishTarget.registryUrl, - apiKey: publishTarget.apiKey, - packageName: publishTarget.packageName - }; - break; - case "nuget": - registriesV2.nuget = { - registryUrl: publishTarget.registryUrl, - apiKey: publishTarget.apiKey, - packageName: publishTarget.packageName - }; - break; - case "crates": - registriesV2.crates = { - registryUrl: publishTarget.registryUrl, - token: publishTarget.token, - packageName: publishTarget.packageName - }; - break; - default: - break; - } - - return { registries, registriesV2 }; -} - -function getPublishTargetFromPublishMode( - mode: FernFiddle.PublishOutputMode -): FernGeneratorExec.GeneratorPublishTarget | undefined { - if ("registryOverrides" in mode) { - const overrides = mode.registryOverrides; - if (overrides.npm != null) { - return FernGeneratorExec.GeneratorPublishTarget.npm({ - registryUrl: overrides.npm.registryUrl, - token: overrides.npm.token, - packageName: overrides.npm.packageName - }); - } - if (overrides.maven != null) { - return FernGeneratorExec.GeneratorPublishTarget.maven({ - registryUrl: overrides.maven.registryUrl, - username: overrides.maven.username ?? "", - password: overrides.maven.password ?? "", - coordinate: overrides.maven.coordinate ?? "", - signature: undefined - }); - } - } - return undefined; -} - -function getPublishTargetFromPublishModeV2( - mode: FernFiddle.PublishOutputModeV2 -): FernGeneratorExec.GeneratorPublishTarget | undefined { - return mode._visit({ - npmOverride: (value) => - value != null - ? FernGeneratorExec.GeneratorPublishTarget.npm({ - registryUrl: value.registryUrl, - token: value.token, - packageName: value.packageName - }) - : undefined, - mavenOverride: (value) => - value != null - ? FernGeneratorExec.GeneratorPublishTarget.maven({ - registryUrl: value.registryUrl, - username: value.username, - password: value.password, - coordinate: value.coordinate, - signature: value.signature ?? undefined - }) - : undefined, - pypiOverride: (value) => - value != null - ? FernGeneratorExec.GeneratorPublishTarget.pypi({ - registryUrl: value.registryUrl, - username: value.username, - password: value.password, - packageName: value.coordinate, - pypiMetadata: value.pypiMetadata ?? undefined - }) - : undefined, - rubyGemsOverride: (value) => - value != null - ? FernGeneratorExec.GeneratorPublishTarget.rubygems({ - registryUrl: value.registryUrl, - apiKey: value.apiKey, - packageName: value.packageName - }) - : undefined, - nugetOverride: (value) => - value != null - ? FernGeneratorExec.GeneratorPublishTarget.nuget({ - registryUrl: value.registryUrl, - apiKey: value.apiKey, - packageName: value.packageName - }) - : undefined, - cratesOverride: (value) => - value != null - ? FernGeneratorExec.GeneratorPublishTarget.crates({ - registryUrl: value.registryUrl, - token: value.token, - packageName: value.packageName - }) - : undefined, - postman: (value) => - FernGeneratorExec.GeneratorPublishTarget.postman({ - apiKey: value.apiKey, - workspaceId: value.workspaceId - }), - _other: () => undefined - }); -} - -/** - * For GitHub output modes, extract OIDC publish config so the Python generator - * can detect `registriesV2.pypi.password == "OIDC"` and activate its OIDC workflow. - */ -function getPublishConfigForGithubOidc( - generatorInvocation: GeneratorInvocation, - version: string -): FernGeneratorExec.GeneratorPublishConfig | undefined { - const publishInfo: FernFiddle.GithubPublishInfo | undefined = generatorInvocation.outputMode._visit< - FernFiddle.GithubPublishInfo | undefined - >({ - publish: () => undefined, - publishV2: () => undefined, - downloadFiles: () => undefined, - github: (value) => value.publishInfo, - githubV2: (value) => - value._visit({ - push: (v) => v.publishInfo, - commitAndRelease: (v) => v.publishInfo, - pullRequest: (v) => v.publishInfo, - _other: () => undefined - }), - _other: () => undefined - }); - if (publishInfo == null || publishInfo.type !== "pypi") { - return undefined; - } - const password = (publishInfo.credentials?.password ?? "").trim(); - if (password !== "OIDC" && password !== "") { - return undefined; - } - const registriesV2 = structuredClone(emptyRegistriesConfigV2); - registriesV2.pypi = { - registryUrl: publishInfo.registryUrl, - username: "__token__", - password: "OIDC", - packageName: publishInfo.packageName, - pypiMetadata: publishInfo.pypiMetadata - }; - return { - registries: structuredClone(emptyRegistriesConfig), - registriesV2, - publishTarget: undefined, - version - }; -} - -const emptyRegistriesConfig: FernGeneratorExec.GeneratorRegistriesConfig = { - maven: { registryUrl: "", username: "", password: "", group: "", signature: undefined }, - npm: { registryUrl: "", token: "", scope: "" } -}; - -const emptyRegistriesConfigV2: FernGeneratorExec.GeneratorRegistriesConfigV2 = { - maven: { registryUrl: "", username: "", password: "", coordinate: "", signature: undefined }, - npm: { registryUrl: "", token: "", packageName: "" }, - pypi: { registryUrl: "", username: "", password: "", packageName: "", pypiMetadata: undefined }, - rubygems: { registryUrl: "", apiKey: "", packageName: "" }, - nuget: { registryUrl: "", apiKey: "", packageName: "" }, - crates: { registryUrl: "", token: "", packageName: "" } -}; +export { + getGeneratorConfig, + getGithubPublishConfig, + getLicensePathFromConfig +} from "@fern-api/remote-workspace-runner"; diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/package.json b/packages/cli/generation/remote-generation/remote-workspace-runner/package.json index 6d305ef0b55c..78bf6caf4cd2 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/package.json +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/package.json @@ -39,6 +39,7 @@ "@fern-api/cli-logger": "workspace:*", "@fern-api/cli-source-resolver": "workspace:*", "@fern-api/configuration": "workspace:*", + "@fern-api/configuration-loader": "workspace:*", "@fern-api/core": "workspace:*", "@fern-api/core-utils": "workspace:*", "@fern-api/docs-resolver": "workspace:*", @@ -46,6 +47,7 @@ "@fern-api/fdr-sdk": "catalog:", "@fern-api/fs-utils": "workspace:*", "@fern-api/generator-cli": "workspace:*", + "@fern-api/github": "workspace:*", "@fern-api/ir-generator": "workspace:*", "@fern-api/ir-migrations": "workspace:*", "@fern-api/ir-sdk": "workspace:*", @@ -58,6 +60,7 @@ "@fern-api/venus-api-sdk": "catalog:", "@fern-api/workspace-loader": "workspace:*", "@fern-fern/fiddle-sdk": "catalog:", + "@fern-fern/generator-exec-sdk": "catalog:", "axios": "catalog:", "chalk": "catalog:", "form-data": "catalog:", diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/fernSdkGenApi.test.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/fernSdkGenApi.test.ts index 9a629c086c29..1951d080f64f 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/fernSdkGenApi.test.ts +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/fernSdkGenApi.test.ts @@ -1,7 +1,9 @@ import { generatorsYml } from "@fern-api/configuration"; import { FernFiddle } from "@fern-fern/fiddle-sdk"; import axios from "axios"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import FormData from "form-data"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { gunzipSync, gzipSync } from "zlib"; import { createFernSdkGenApiBatchRequest, createFernSdkGenApiRequest, @@ -13,6 +15,43 @@ import { mapFernSdkGenApiOutput, runFernSdkGenApiBuild } from "../fernSdkGenApi.js"; +import { getGithubPublishConfig } from "../getGeneratorConfig.js"; +import { prepareFernSdkGenApiRuntimeBundle } from "../prepareFernSdkGenApiRuntimeBundle.js"; + +const migrationMocks = vi.hoisted(() => ({ + getIrVersionForGenerator: vi.fn(), + migrateForGenerator: vi.fn(), + migrateToVersionForGenerator: vi.fn() +})); + +vi.mock("@fern-api/core", async (importOriginal) => ({ + ...(await importOriginal()), + getIrVersionForGenerator: migrationMocks.getIrVersionForGenerator +})); + +vi.mock("@fern-api/ir-migrations", async (importOriginal) => ({ + ...(await importOriginal()), + migrateIntermediateRepresentationForGenerator: migrationMocks.migrateForGenerator, + migrateIntermediateRepresentationToVersionForGenerator: migrationMocks.migrateToVersionForGenerator +})); + +beforeEach(() => { + migrationMocks.getIrVersionForGenerator.mockReset().mockResolvedValue(undefined); + migrationMocks.migrateForGenerator.mockReset().mockImplementation(({ intermediateRepresentation }) => + Promise.resolve({ + ...intermediateRepresentation, + migrated: "generator" + }) + ); + migrationMocks.migrateToVersionForGenerator + .mockReset() + .mockImplementation(({ intermediateRepresentation, irVersion }) => + Promise.resolve({ + ...intermediateRepresentation, + migrated: irVersion + }) + ); +}); afterEach(() => { vi.restoreAllMocks(); @@ -29,11 +68,59 @@ function invocation(overrides: Record = {}): generatorsYml.Gene smartCasing: true, smartCasingDigitWordBoundary: false, disableExamples: false, - outputMode: { type: "downloadFiles" }, + outputMode: FernFiddle.OutputMode.downloadFiles({}), ...overrides } as unknown as generatorsYml.GeneratorInvocation; } +const context = { + logger: { debug: vi.fn(), info: vi.fn() }, + failAndThrow: (message: string) => { + throw new Error(message); + } +} as never; + +const validSourceArchive = gzipSync(Buffer.from("archive")); +const validRuntimeBundle = gzipSync(Buffer.from("runtime-bundle")); + +function createPreflightBatch({ + runtimeBundles, + specsTarGzBuffer = validSourceArchive, + generatorInvocation = invocation(), + generatorInvocations +}: { + runtimeBundles: Buffer[]; + specsTarGzBuffer?: Buffer; + generatorInvocation?: generatorsYml.GeneratorInvocation; + generatorInvocations?: generatorsYml.GeneratorInvocation[]; +}): { + builds: Array>; + post: ReturnType; + get: ReturnType; +} { + vi.stubEnv("FERN_SDK_GEN_API_ORIGIN", "https://sdk-gen-api.test"); + const post = vi.spyOn(axios, "post").mockRejectedValue(new Error("axios should not be called")); + const get = vi.spyOn(axios, "get").mockRejectedValue(new Error("axios should not be called")); + const batch = new FernSdkGenApiBatch(runtimeBundles.length); + const builds = runtimeBundles.map((runtimeBundle, index) => + batch.run({ + apiName: "Petstore", + organization: "acme", + cliVersion: "0.0.0", + generatorInvocation: generatorInvocations?.[index] ?? generatorInvocation, + sdkVersion: "1.2.3", + token: { value: "token" } as never, + specsTarGzBuffer, + runtimeBundle, + absolutePathToPreview: undefined, + context, + targetIdSeed: index.toString() + }) + ); + + return { builds, post, get }; +} + describe("isEligibleForFernSdkGenApi", () => { it("selects first-party SDK generators in every supported language", () => { const generators = [ @@ -75,6 +162,17 @@ describe("isEligibleForFernSdkGenApi", () => { ).toBe(true); }); + it("passes a known generator language mismatch to canonical compatibility validation", () => { + // Eligibility gates route prerequisites; batch preflight owns canonical diagnostics. + expect( + isEligibleForFernSdkGenApi({ + generatorInvocation: invocation({ language: "python" }), + sdkVersion: "1.2.3", + specsTarGzBuffer: Buffer.from("archive") + }) + ).toBe(true); + }); + it("rejects non-SDK generators and unresolved SDK versions", () => { expect( isEligibleForFernSdkGenApi({ @@ -153,9 +251,11 @@ describe("isEligibleForFernSdkGenApi", () => { cliVersion: "0.0.0", generatorInvocation: invocation(), sdkVersion: "1.2.3", - specsTarGzBuffer: Buffer.from("archive") + specsTarGzBuffer: Buffer.from("archive"), + runtimeBundle: Buffer.from("bundle") }); + expect(request.protocolVersion).toBe(2); expect(request.apiInputs).toEqual([{ id: "default", specIndexes: "all" }]); expect(request.targets[0]).toMatchObject({ language: "typescript", @@ -168,6 +268,7 @@ describe("isEligibleForFernSdkGenApi", () => { } }); expect(request.targets[0]?.invocation).not.toHaveProperty("audiences"); + expect(request.targets[0]).not.toHaveProperty("runtimeBundle"); }); it("preserves an explicitly selected audience list", () => { @@ -180,7 +281,8 @@ describe("isEligibleForFernSdkGenApi", () => { { generatorInvocation: invocation(), sdkVersion: "1.2.3", - audiences: ["public"] + audiences: ["public"], + runtimeBundle: Buffer.from("bundle") } ] }); @@ -199,7 +301,8 @@ describe("isEligibleForFernSdkGenApi", () => { version: "4.64.1" }), sdkVersion: "1.2.3", - specsTarGzBuffer: Buffer.from("archive") + specsTarGzBuffer: Buffer.from("archive"), + runtimeBundle: Buffer.from("bundle") }); expect(request.targets[0]?.language).toBe("python"); @@ -231,7 +334,8 @@ describe("isEligibleForFernSdkGenApi", () => { ) }), sdkVersion: "1.2.3", - specsTarGzBuffer: Buffer.from("archive") + specsTarGzBuffer: Buffer.from("archive"), + runtimeBundle: Buffer.from("bundle") }); expect(request.targets[0]).toMatchObject({ @@ -267,7 +371,8 @@ describe("isEligibleForFernSdkGenApi", () => { ) }), sdkVersion: "1.2.3", - specsTarGzBuffer: Buffer.from("archive") + specsTarGzBuffer: Buffer.from("archive"), + runtimeBundle: Buffer.from("bundle") }); expect(request.targets[0]).toMatchObject({ @@ -290,7 +395,8 @@ describe("isEligibleForFernSdkGenApi", () => { { generatorInvocation: invocation(), sdkVersion: "1.2.3", - targetIdSeed: "0" + targetIdSeed: "0", + runtimeBundle: Buffer.from("typescript-bundle") }, { generatorInvocation: invocation({ @@ -299,12 +405,14 @@ describe("isEligibleForFernSdkGenApi", () => { version: "4.64.1" }), sdkVersion: "1.2.3", - targetIdSeed: "1" + targetIdSeed: "1", + runtimeBundle: Buffer.from("python-bundle") }, { generatorInvocation: invocation(), sdkVersion: "2.0.0", - targetIdSeed: "2" + targetIdSeed: "2", + runtimeBundle: Buffer.from("second-typescript-bundle") } ] }); @@ -313,19 +421,24 @@ describe("isEligibleForFernSdkGenApi", () => { expect(new Set(request.targets.map((target) => target.targetId)).size).toBe(3); }); - it("changes the idempotency key when generator configuration or output changes", () => { - const createRequest = (generatorInvocation: generatorsYml.GeneratorInvocation) => + it("changes the idempotency key when generator configuration, output, or bundle changes", () => { + const createRequest = ( + generatorInvocation: generatorsYml.GeneratorInvocation, + runtimeBundle = Buffer.from("bundle") + ) => createFernSdkGenApiRequest({ apiName: "Petstore", organization: "acme", cliVersion: "0.0.0", generatorInvocation, sdkVersion: "1.2.3", - specsTarGzBuffer: Buffer.from("archive") + specsTarGzBuffer: Buffer.from("archive"), + runtimeBundle }); const original = createRequest(invocation()); const configured = createRequest(invocation({ config: { packageJson: { name: "@acme/sdk" } } })); + const changedBundle = createRequest(invocation(), Buffer.from("changed-bundle")); const github = createRequest( invocation({ outputMode: FernFiddle.OutputMode.githubV2( @@ -336,11 +449,215 @@ describe("isEligibleForFernSdkGenApi", () => { expect(configured.idempotencyKey).not.toBe(original.idempotencyKey); expect(github.idempotencyKey).not.toBe(original.idempotencyKey); + expect(changedBundle.idempotencyKey).not.toBe(original.idempotencyKey); + }); + + it("creates a generator-compatible gzip bundle with enriched IR and no publish secrets", async () => { + const generatorInvocation = invocation({ + config: { packageJson: { name: "@acme/sdk" } }, + outputMode: FernFiddle.OutputMode.publishV2( + FernFiddle.PublishOutputModeV2.npmOverride({ + registryUrl: "https://registry.npmjs.org", + packageName: "@acme/sdk", + token: "raw-publish-secret" + }) + ) + }); + + const compressed = await prepareFernSdkGenApiRuntimeBundle({ + apiName: "Petstore", + organization: "acme", + generatorInvocation, + sdkVersion: "1.2.3", + intermediateRepresentation: { + apiName: "Petstore", + fdrApiDefinitionId: "definition-id", + publishConfig: { type: "filesystem" } + } as never, + irVersionOverride: undefined, + context + }); + const bundle = JSON.parse(gunzipSync(compressed).toString("utf8")); + + expect(bundle).toMatchObject({ + config: { + irFilepath: "/tmp/fern-runtime/ir.json", + workspaceName: "Petstore", + organization: "acme", + customConfig: { packageJson: { name: "@acme/sdk" } }, + output: { + path: "/fern/output", + mode: { + type: "github", + version: "1.2.3" + } + }, + writeUnitTests: false, + generateOauthClients: false, + generatePaginatedClients: false + }, + ir: { + apiName: "Petstore", + fdrApiDefinitionId: "definition-id", + publishConfig: { type: "filesystem" }, + migrated: "generator" + } + }); + expect(gunzipSync(compressed).toString("utf8")).not.toContain("raw-publish-secret"); + }); + + it("enables only explicitly supplied runtime entitlements", async () => { + const compressed = await prepareFernSdkGenApiRuntimeBundle({ + apiName: "Petstore", + organization: "acme", + generatorInvocation: invocation(), + sdkVersion: "1.2.3", + intermediateRepresentation: { apiName: "Petstore" } as never, + irVersionOverride: undefined, + generateOauthClients: true, + generatePaginatedClients: true, + context + }); + const bundle = JSON.parse(gunzipSync(compressed).toString("utf8")); + + expect(bundle.config).toMatchObject({ + writeUnitTests: false, + generateOauthClients: true, + generatePaginatedClients: true + }); + }); + + it("preserves trusted OIDC markers while omitting actual GitHub publish credentials", () => { + for (const marker of ["OIDC", ""] as const) { + const npm = getGithubPublishConfig( + FernFiddle.GithubPublishInfo.npm({ + registryUrl: "https://registry.npmjs.org", + packageName: "@acme/sdk", + token: marker + }), + { omitPublishCredentials: true } + ); + const nuget = getGithubPublishConfig( + FernFiddle.GithubPublishInfo.nuget({ + registryUrl: "https://api.nuget.org/v3/index.json", + packageName: "Acme.Sdk", + apiKey: marker + }), + { omitPublishCredentials: true } + ); + const pypi = getGithubPublishConfig( + FernFiddle.GithubPublishInfo.pypi({ + registryUrl: "https://upload.pypi.org/legacy/", + packageName: "acme-sdk", + credentials: { username: "__token__", password: marker } + }), + { omitPublishCredentials: true } + ); + + expect(npm?.type === "npm" ? npm.tokenEnvironmentVariable : undefined).toBe(""); + expect(nuget?.type === "nuget" ? nuget.apiKeyEnvironmentVariable : undefined).toBe(""); + expect(pypi?.type === "pypi" ? pypi.passwordEnvironmentVariable : undefined).toBe("OIDC"); + } + + const npmWithSecret = getGithubPublishConfig( + FernFiddle.GithubPublishInfo.npm({ + registryUrl: "https://registry.npmjs.org", + packageName: "@acme/sdk", + token: "actual-secret" + }), + { omitPublishCredentials: true } + ); + const nugetWithSecret = getGithubPublishConfig( + FernFiddle.GithubPublishInfo.nuget({ + registryUrl: "https://api.nuget.org/v3/index.json", + packageName: "Acme.Sdk", + apiKey: "actual-secret" + }), + { omitPublishCredentials: true } + ); + const pypiWithSecret = getGithubPublishConfig( + FernFiddle.GithubPublishInfo.pypi({ + registryUrl: "https://upload.pypi.org/legacy/", + packageName: "acme-sdk", + credentials: { username: "actual-user", password: "actual-secret" } + }), + { omitPublishCredentials: true } + ); + expect(npmWithSecret?.type === "npm" ? npmWithSecret.tokenEnvironmentVariable : undefined).toBe(""); + expect(nugetWithSecret?.type === "nuget" ? nugetWithSecret.apiKeyEnvironmentVariable : undefined).toBe(""); + expect(pypiWithSecret?.type === "pypi" ? pypiWithSecret.usernameEnvironmentVariable : undefined).toBe(""); + expect(pypiWithSecret?.type === "pypi" ? pypiWithSecret.passwordEnvironmentVariable : undefined).toBe(""); + }); + + it("omits credentials for registries without trusted-publishing markers", () => { + const options = { omitPublishCredentials: true }; + const maven = getGithubPublishConfig( + FernFiddle.GithubPublishInfo.maven({ + registryUrl: "https://repo.example.com", + coordinate: "com.acme:sdk", + credentials: { username: "user", password: "secret" }, + signature: { keyId: "key", password: "secret", secretKey: "private" } + }), + options + ); + const rubygems = getGithubPublishConfig( + FernFiddle.GithubPublishInfo.rubygems({ + registryUrl: "https://rubygems.org", + packageName: "acme-sdk", + apiKey: "secret" + }), + options + ); + const crates = getGithubPublishConfig( + FernFiddle.GithubPublishInfo.crates({ + registryUrl: "https://crates.io", + packageName: "acme-sdk", + token: "secret" + }), + options + ); + + expect(maven).toMatchObject({ + usernameEnvironmentVariable: "", + passwordEnvironmentVariable: "" + }); + expect(maven?.type === "maven" ? maven.signature : undefined).toBeUndefined(); + expect(rubygems?.type === "rubygems" ? rubygems.apiKeyEnvironmentVariable : undefined).toBe(""); + expect(crates?.type === "crates" ? crates.tokenEnvironmentVariable : undefined).toBe(""); + }); + + it("uses the override and exact generator identity when migrating a runtime bundle", async () => { + const generatorInvocation = invocation({ version: "3.86.7" }); + migrationMocks.getIrVersionForGenerator.mockResolvedValue(66); + + await prepareFernSdkGenApiRuntimeBundle({ + apiName: "Petstore", + organization: "acme", + generatorInvocation, + sdkVersion: "1.2.3", + intermediateRepresentation: { apiName: "Petstore" } as never, + irVersionOverride: "v55", + context + }); + + expect(migrationMocks.getIrVersionForGenerator).toHaveBeenCalledWith(generatorInvocation); + expect(migrationMocks.migrateToVersionForGenerator).toHaveBeenCalledWith( + expect.objectContaining({ + irVersion: "v55", + targetGenerator: { + name: "fernapi/fern-typescript-sdk", + version: "3.86.7" + } + }) + ); + expect(migrationMocks.migrateForGenerator).not.toHaveBeenCalled(); }); it("submits and polls a multi-language group once", async () => { process.env.FERN_SDK_GEN_API_ORIGIN = "https://sdk-gen-api.test"; - const specsTarGzBuffer = Buffer.from("archive"); + const specsTarGzBuffer = validSourceArchive; + const typescriptRuntimeBundle = gzipSync(Buffer.from("typescript-runtime-bundle")); + const pythonRuntimeBundle = gzipSync(Buffer.from("python-runtime-bundle")); const typescript = invocation(); const python = invocation({ name: "fernapi/fern-python-sdk", @@ -356,9 +673,15 @@ describe("isEligibleForFernSdkGenApi", () => { { generatorInvocation: typescript, sdkVersion: "1.2.3", - targetIdSeed: "0" + targetIdSeed: "2", + runtimeBundle: typescriptRuntimeBundle }, - { generatorInvocation: python, sdkVersion: "1.2.3", targetIdSeed: "1" } + { + generatorInvocation: python, + sdkVersion: "1.2.3", + targetIdSeed: "10", + runtimeBundle: pythonRuntimeBundle + } ] }); const post = vi.spyOn(axios, "post").mockResolvedValue({ data: { buildId: "build-1" } } as never); @@ -371,17 +694,12 @@ describe("isEligibleForFernSdkGenApi", () => { status: "succeeded", logs: [], result: { - artifactUrl: `https://example.test/${target.targetId}.zip` + artifactUrl: `https://example.test/${target.targetId}.zip`, + actualVersion: target.language } })) } } as never); - const context = { - logger: { debug: vi.fn(), info: vi.fn() }, - failAndThrow: (message: string) => { - throw new Error(message); - } - } as never; const common = { apiName: "Petstore", organization: "acme", @@ -394,23 +712,50 @@ describe("isEligibleForFernSdkGenApi", () => { }; const batch = new FernSdkGenApiBatch(2); - const results = await Promise.all([ - batch.run({ - ...common, - generatorInvocation: typescript, - targetIdSeed: "0" - }), - batch.run({ ...common, generatorInvocation: python, targetIdSeed: "1" }) - ]); + const pythonResult = batch.run({ + ...common, + generatorInvocation: python, + runtimeBundle: pythonRuntimeBundle, + targetIdSeed: "10" + }); + const typescriptResult = batch.run({ + ...common, + generatorInvocation: typescript, + runtimeBundle: typescriptRuntimeBundle, + targetIdSeed: "2" + }); + const results = await Promise.all([pythonResult, typescriptResult]); expect(post).toHaveBeenCalledTimes(1); expect(get).toHaveBeenCalledTimes(1); - expect(results.map((result) => result.actualVersion)).toEqual(["1.2.3", "1.2.3"]); + expect(results.map((result) => result.actualVersion)).toEqual(["python", "typescript"]); + const submittedForm = post.mock.calls[0]?.[1]; + expect(submittedForm).toBeInstanceOf(FormData); + if (!(submittedForm instanceof FormData)) { + throw new Error("Expected sdk-gen-api request to use multipart form data"); + } + const multipartBuffer = submittedForm.getBuffer(); + const multipartBody = multipartBuffer.toString("utf8"); + const requestMatch = multipartBody.match(/name="request"\r\n\r\n([^\r\n]+)/); + expect(requestMatch?.[1]).toBeDefined(); + const submittedRequest = JSON.parse(requestMatch?.[1] ?? "{}"); + expect(submittedRequest.targets).toEqual(request.targets); + expect(submittedRequest.idempotencyKey).toBe(request.idempotencyKey); + expect(multipartBody.match(/name="bundles"/g)).toHaveLength(2); + request.targets.forEach((target, index) => { + const filename = `${target.targetId}.json.gz`; + const bundle = index === 0 ? typescriptRuntimeBundle : pythonRuntimeBundle; + expect(multipartBody).toContain(`name="bundles"; filename="${filename}"\r\nContent-Type: application/gzip`); + expect(multipartBuffer.indexOf(filename)).toBeLessThan(multipartBuffer.indexOf(bundle)); + }); + expect(multipartBuffer.indexOf(typescriptRuntimeBundle)).toBeLessThan( + multipartBuffer.indexOf(pythonRuntimeBundle) + ); }); it("stops polling when the build fails before a target reaches a terminal state", async () => { process.env.FERN_SDK_GEN_API_ORIGIN = "https://sdk-gen-api.test"; - const specsTarGzBuffer = Buffer.from("archive"); + const specsTarGzBuffer = validSourceArchive; const generatorInvocation = invocation(); const request = createFernSdkGenApiRequest({ apiName: "Petstore", @@ -418,7 +763,8 @@ describe("isEligibleForFernSdkGenApi", () => { cliVersion: "0.0.0", generatorInvocation, sdkVersion: "1.2.3", - specsTarGzBuffer + specsTarGzBuffer, + runtimeBundle: validRuntimeBundle }); vi.spyOn(axios, "post").mockResolvedValue({ data: { buildId: "build-1" } } as never); const get = vi.spyOn(axios, "get").mockResolvedValue({ @@ -428,13 +774,6 @@ describe("isEligibleForFernSdkGenApi", () => { targets: [{ targetId: request.targets[0]?.targetId, status: "queued", logs: [] }] } } as never); - const context = { - logger: { debug: vi.fn(), info: vi.fn() }, - failAndThrow: (message: string) => { - throw new Error(message); - } - } as never; - await expect( runFernSdkGenApiBuild({ apiName: "Petstore", @@ -444,12 +783,154 @@ describe("isEligibleForFernSdkGenApi", () => { sdkVersion: "1.2.3", token: { value: "token" } as never, specsTarGzBuffer, + runtimeBundle: validRuntimeBundle, absolutePathToPreview: undefined, context }) ).rejects.toThrow("build ended with status failed"); expect(get).toHaveBeenCalledTimes(1); }); + + it.each([ + ["UNKNOWN_GENERATOR", invocation({ name: "fernapi/not-a-generator" })], + ["GENERATOR_LANGUAGE_MISMATCH", invocation({ language: "python" })], + ["INVALID_GENERATOR_VERSION", invocation({ version: "latest" })] + ])("rejects %s before submission", async (code, generatorInvocation) => { + const { builds, post, get } = createPreflightBatch({ + runtimeBundles: [validRuntimeBundle], + generatorInvocation + }); + + await expect(Promise.all(builds)).rejects.toThrow(code); + expect(post).not.toHaveBeenCalled(); + expect(get).not.toHaveBeenCalled(); + }); + + it("recommends sdk-config migration at the generator cutover", async () => { + const { builds, post, get } = createPreflightBatch({ + runtimeBundles: [validRuntimeBundle], + generatorInvocation: invocation({ version: "4.0.0" }) + }); + + await expect(Promise.all(builds)).rejects.toThrow( + "SDK_CONFIG_V1_REQUIRED; generator=fernapi/fern-typescript-sdk; language=typescript; requestedVersion=4.0.0; cutoverVersion=4.0.0; receivedConfigKind=legacy-fern; expectedConfigKind=sdk-config-v1" + ); + await expect(Promise.all(builds)).rejects.toThrow("fern sdk-config migrate"); + expect(post).not.toHaveBeenCalled(); + expect(get).not.toHaveBeenCalled(); + }); + + it("requires sdk-config for MCP at its first core-backed version", async () => { + const { builds, post, get } = createPreflightBatch({ + runtimeBundles: [validRuntimeBundle], + generatorInvocation: invocation({ + name: "fernapi/fern-mcp-server", + language: "mcp", + version: "0.1.0" + }) + }); + + await expect(Promise.all(builds)).rejects.toThrow( + "SDK_CONFIG_V1_REQUIRED; generator=fernapi/fern-mcp-server; language=mcp; requestedVersion=0.1.0; cutoverVersion=0.1.0" + ); + expect(post).not.toHaveBeenCalled(); + expect(get).not.toHaveBeenCalled(); + }); + + it("rejects an incompatible later batch target before submitting any target", async () => { + const { builds, post, get } = createPreflightBatch({ + runtimeBundles: [validRuntimeBundle, validRuntimeBundle], + generatorInvocations: [invocation(), invocation({ version: "4.0.0" })] + }); + + await expect(Promise.all(builds)).rejects.toThrow("SDK_CONFIG_V1_REQUIRED"); + expect(post).not.toHaveBeenCalled(); + expect(get).not.toHaveBeenCalled(); + }); + + it("rejects more than 64 bundles before submission", async () => { + const { builds, post } = createPreflightBatch({ + runtimeBundles: Array.from({ length: 65 }, () => Buffer.alloc(0)) + }); + await expect(Promise.all(builds)).rejects.toThrow("at most 64 runtime bundles"); + expect(post).not.toHaveBeenCalled(); + }); + + it("rejects a bundle larger than 5 MiB before submission", async () => { + const { builds, post } = createPreflightBatch({ + runtimeBundles: [Buffer.alloc(5 * 1024 * 1024 + 1)] + }); + await expect(Promise.all(builds)).rejects.toThrow("exceeding the 5 MiB compressed limit"); + expect(post).not.toHaveBeenCalled(); + }); + + it("rejects a source archive larger than 25 MiB compressed before submission", async () => { + const { builds, post } = createPreflightBatch({ + runtimeBundles: [validRuntimeBundle], + specsTarGzBuffer: Buffer.alloc(25 * 1024 * 1024 + 1) + }); + await expect(Promise.all(builds)).rejects.toThrow("source archive is 25.00 MiB"); + expect(post).not.toHaveBeenCalled(); + }); + + it("rejects a source archive larger than 25 MiB decompressed before submission", async () => { + const { builds, post } = createPreflightBatch({ + runtimeBundles: [validRuntimeBundle], + specsTarGzBuffer: gzipSync(Buffer.alloc(25 * 1024 * 1024 + 1)) + }); + await expect(Promise.all(builds)).rejects.toThrow("source archive is 25.00 MiB decompressed"); + expect(post).not.toHaveBeenCalled(); + }); + + it("rejects a bundle larger than 25 MiB decompressed before submission", async () => { + const { builds, post } = createPreflightBatch({ + runtimeBundles: [gzipSync(Buffer.alloc(25 * 1024 * 1024 + 1))] + }); + await expect(Promise.all(builds)).rejects.toThrow("runtime bundle 0 is 25.00 MiB decompressed"); + expect(post).not.toHaveBeenCalled(); + }); + + it("rejects more than 25 MiB of bundles before submission", async () => { + const { builds, post } = createPreflightBatch({ + runtimeBundles: Array.from({ length: 6 }, () => Buffer.alloc(5 * 1024 * 1024)) + }); + await expect(Promise.all(builds)).rejects.toThrow("exceeding the 25 MiB compressed limit"); + expect(post).not.toHaveBeenCalled(); + }); + + it("rejects more than 100 MiB of bundles decompressed before submission", async () => { + const { builds, post } = createPreflightBatch({ + runtimeBundles: Array.from({ length: 5 }, () => gzipSync(Buffer.alloc(21 * 1024 * 1024))) + }); + await expect(Promise.all(builds)).rejects.toThrow("exceeding the 100 MiB decompressed limit"); + expect(post).not.toHaveBeenCalled(); + }); + + it("rejects malformed source gzip before submission", async () => { + const { builds, post } = createPreflightBatch({ + runtimeBundles: [validRuntimeBundle], + specsTarGzBuffer: Buffer.from("not-gzip") + }); + await expect(Promise.all(builds)).rejects.toThrow("source archive is malformed gzip"); + expect(post).not.toHaveBeenCalled(); + }); + + it("rejects malformed runtime bundle gzip before submission", async () => { + const { builds, post } = createPreflightBatch({ + runtimeBundles: [Buffer.from("not-gzip")] + }); + await expect(Promise.all(builds)).rejects.toThrow("runtime bundle 0 is malformed gzip"); + expect(post).not.toHaveBeenCalled(); + }); + + it("rejects a multipart body larger than 60 MiB before submission", async () => { + const { builds, post } = createPreflightBatch({ + runtimeBundles: [validRuntimeBundle], + generatorInvocation: invocation({ config: { padding: "x".repeat(61 * 1024 * 1024) } }) + }); + await expect(Promise.all(builds)).rejects.toThrow("exceeding the 60 MiB limit"); + expect(post).not.toHaveBeenCalled(); + }); }); describe("sdk-gen-api environment configuration", () => { @@ -555,7 +1036,8 @@ describe("fernapi/fern-mcp-server target", () => { cliVersion: "0.0.0", generatorInvocation: mcpInvocation({ config }), sdkVersion: "0.0.1", - specsTarGzBuffer: Buffer.from("archive") + specsTarGzBuffer: Buffer.from("archive"), + runtimeBundle: Buffer.from("bundle") }); const target = request.targets[0]; @@ -577,7 +1059,8 @@ describe("fernapi/fern-mcp-server target", () => { cliVersion: "0.0.0", generatorInvocation: mcpInvocation(), sdkVersion: "0.0.1", - specsTarGzBuffer: Buffer.from("archive") + specsTarGzBuffer: Buffer.from("archive"), + runtimeBundle: Buffer.from("bundle") }); expect(request.targets[0]?.package).toBeUndefined(); @@ -594,7 +1077,8 @@ describe("fernapi/fern-mcp-server target", () => { }) }), sdkVersion: "0.0.1", - specsTarGzBuffer: Buffer.from("archive") + specsTarGzBuffer: Buffer.from("archive"), + runtimeBundle: Buffer.from("bundle") }); expect(request.targets[0]?.requestedOutput).toEqual({ diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/sdkGenClientCompatibility.test.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/sdkGenClientCompatibility.test.ts new file mode 100644 index 000000000000..5ecf34c761d4 --- /dev/null +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/sdkGenClientCompatibility.test.ts @@ -0,0 +1,221 @@ +// cspell:ignore kotlin +import { describe, expect, it } from "vitest"; +import { + type GenerationConfigKind, + type GenerationConfigRoute, + GeneratorConfigCompatibilityError, + type GeneratorLanguage, + getGeneratorLanguage, + validateGeneratorConfigCompatibility +} from "../sdk-gen-client/index.js"; + +interface LanguageBoundary { + generatorId: string; + language: GeneratorLanguage; + below: string; + cutover: string; + above: string; +} + +const LANGUAGE_BOUNDARIES: readonly LanguageBoundary[] = [ + { + generatorId: "fernapi/fern-typescript-sdk", + language: "typescript", + below: "3.999.999", + cutover: "4.0.0", + above: "4.0.1" + }, + { + generatorId: "fernapi/fern-python-sdk", + language: "python", + below: "5.999.999", + cutover: "6.0.0", + above: "6.0.1" + }, + { + generatorId: "fernapi/fern-java-sdk", + language: "java", + below: "4.999.999", + cutover: "5.0.0", + above: "5.0.1" + }, + { + generatorId: "fernapi/fern-kotlin-sdk", + language: "kotlin", + below: "4.999.999", + cutover: "5.0.0", + above: "5.0.1" + }, + { + generatorId: "fernapi/fern-go-sdk", + language: "go", + below: "1.999.999", + cutover: "2.0.0", + above: "2.0.1" + }, + { + generatorId: "fernapi/fern-csharp-sdk", + language: "csharp", + below: "2.999.999", + cutover: "3.0.0", + above: "3.0.1" + }, + { + generatorId: "fernapi/fern-php-sdk", + language: "php", + below: "2.999.999", + cutover: "3.0.0", + above: "3.0.1" + }, + { + generatorId: "fernapi/fern-ruby-sdk", + language: "ruby", + below: "1.999.999", + cutover: "2.0.0", + above: "2.0.1" + }, + { + generatorId: "fernapi/fern-rust-sdk", + language: "rust", + below: "0.999.999", + cutover: "1.0.0", + above: "1.0.1" + }, + { + generatorId: "fernapi/fern-swift-sdk", + language: "swift", + below: "0.999.999", + cutover: "1.0.0", + above: "1.0.1" + }, + { + generatorId: "fernapi/fern-cli", + language: "cli", + below: "0.999.999", + cutover: "1.0.0", + above: "1.0.1" + }, + { + generatorId: "fernapi/fern-mcp-server", + language: "mcp", + below: "0.0.999", + cutover: "0.1.0", + above: "0.1.1" + } +]; + +const GENERATOR_ALIASES: ReadonlyArray = [ + ["fernapi/fern-typescript", "typescript", "4.0.0"], + ["fernapi/fern-typescript-sdk", "typescript", "4.0.0"], + ["fernapi/fern-typescript-node-sdk", "typescript", "4.0.0"], + ["fernapi/fern-typescript-browser-sdk", "typescript", "4.0.0"], + ["fernapi/fern-python-sdk", "python", "6.0.0"], + ["fernapi/fern-java-sdk", "java", "5.0.0"], + ["fernapi/fern-kotlin-sdk", "kotlin", "5.0.0"], + ["fernapi/fern-go-sdk", "go", "2.0.0"], + ["fernapi/fern-csharp-sdk", "csharp", "3.0.0"], + ["fernapi/fern-php-sdk", "php", "3.0.0"], + ["fernapi/fern-ruby-sdk", "ruby", "2.0.0"], + ["fernapi/fern-ruby-sdk-v2", "ruby", "2.0.0"], + ["fernapi/fern-rust-sdk", "rust", "1.0.0"], + ["fernapi/fern-swift-sdk", "swift", "1.0.0"], + ["fernapi/fern-cli", "cli", "1.0.0"], + ["fernapi/fern-cli-generator", "cli", "1.0.0"], + ["fernapi/fern-mcp-server", "mcp", "0.1.0"] +]; + +describe("validateGeneratorConfigCompatibility", () => { + describe.each(LANGUAGE_BOUNDARIES)("$language cutover", (boundary) => { + it("routes cutover-1 to the Fern runtime bundle", () => { + expect(validate(boundary, boundary.below, "legacy-fern")).toEqual({ + generatorId: boundary.generatorId, + language: boundary.language, + requestedVersion: boundary.below, + cutoverVersion: boundary.cutover, + configKind: "legacy-fern", + payloadKind: "fern-runtime-bundle" + }); + }); + + it("rejects SDK Config at cutover-1", () => { + const error = captureError(() => validate(boundary, boundary.below, "sdk-config-v1")); + expect(error).toMatchObject({ + code: "LEGACY_FERN_CONFIG_REQUIRED", + generatorId: boundary.generatorId, + language: boundary.language, + requestedVersion: boundary.below, + cutoverVersion: boundary.cutover, + receivedConfigKind: "sdk-config-v1", + expectedConfigKind: "legacy-fern", + expectedLanguage: boundary.language, + retryable: false, + recommendedAction: "USE_LEGACY_FERN_CONFIG" + }); + }); + + it("routes cutover to SDK Config IR v1", () => { + expect(validate(boundary, boundary.cutover, "sdk-config-v1").payloadKind).toBe("sdk-config-ir-v1"); + }); + + it("rejects legacy Fern configuration at cutover", () => { + const error = captureError(() => validate(boundary, boundary.cutover, "legacy-fern")); + expect(error).toMatchObject({ + code: "SDK_CONFIG_V1_REQUIRED", + generatorId: boundary.generatorId, + language: boundary.language, + requestedVersion: boundary.cutover, + cutoverVersion: boundary.cutover, + receivedConfigKind: "legacy-fern", + expectedConfigKind: "sdk-config-v1", + expectedLanguage: boundary.language, + retryable: false, + recommendedAction: "USE_SDK_CONFIG_V1" + }); + }); + + it("routes cutover+1 to SDK Config IR v1", () => { + expect(validate(boundary, boundary.above, "sdk-config-v1").payloadKind).toBe("sdk-config-ir-v1"); + }); + }); + + it.each(GENERATOR_ALIASES)("maps alias %s to %s", (generatorId, language, cutoverVersion) => { + expect(getGeneratorLanguage(generatorId)).toBe(language); + expect( + validateGeneratorConfigCompatibility({ + generatorId, + language, + requestedVersion: cutoverVersion, + configKind: "sdk-config-v1" + }) + ).toMatchObject({ language, cutoverVersion, payloadKind: "sdk-config-ir-v1" }); + }); + + it("does not expose a language for an unknown generator", () => { + expect(getGeneratorLanguage("acme/custom-generator")).toBeUndefined(); + }); +}); + +function validate( + boundary: LanguageBoundary, + requestedVersion: string, + configKind: GenerationConfigKind +): GenerationConfigRoute { + return validateGeneratorConfigCompatibility({ + generatorId: boundary.generatorId, + language: boundary.language, + requestedVersion, + configKind + }); +} + +function captureError(action: () => unknown): GeneratorConfigCompatibilityError { + try { + action(); + } catch (error: unknown) { + if (error instanceof GeneratorConfigCompatibilityError) { + return error; + } + throw error; + } + throw new Error("Expected compatibility validation to throw"); +} diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/sdkGenClientDiagnostics.test.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/sdkGenClientDiagnostics.test.ts new file mode 100644 index 000000000000..af2926f6d410 --- /dev/null +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/sdkGenClientDiagnostics.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from "vitest"; +import { compareSemver, parseExactSemver } from "../sdk-gen-client/exactSemver.js"; +import { + createGeneratorPolicies, + GeneratorConfigPolicyInvariantError +} from "../sdk-gen-client/generatorConfigPolicy.js"; +import { + GeneratorConfigCompatibilityError, + type ValidateGeneratorConfigCompatibilityInput, + validateGeneratorConfigCompatibility +} from "../sdk-gen-client/index.js"; + +const VALID_INPUT: ValidateGeneratorConfigCompatibilityInput = { + generatorId: "fernapi/fern-typescript-sdk", + language: "typescript", + requestedVersion: "4.0.0", + configKind: "sdk-config-v1" +}; + +describe("generator configuration diagnostics", () => { + it.each([ + "", + "AUTO", + "latest", + "4", + "4.0", + "v4.0.0", + "^4.0.0", + "~4.0.0", + ">=4.0.0", + "4.x", + "4.0.0 || 5.0.0", + " 4.0.0", + "4.0.0 ", + "04.0.0", + "4.00.0", + "4.0.00", + "4.0.0.0", + "4.0.0-01" + ])("rejects non-exact or malformed version %j", (requestedVersion) => { + const error = captureError(() => validateGeneratorConfigCompatibility({ ...VALID_INPUT, requestedVersion })); + + expect(error).toMatchObject({ + code: "INVALID_GENERATOR_VERSION", + generatorId: VALID_INPUT.generatorId, + language: VALID_INPUT.language, + requestedVersion, + cutoverVersion: "4.0.0", + receivedConfigKind: "sdk-config-v1", + expectedConfigKind: null, + expectedLanguage: "typescript", + retryable: false, + recommendedAction: "USE_EXACT_GENERATOR_VERSION" + }); + }); + + it.each([ + ["missing", undefined], + ["null", null], + ["object", { kind: "sdk-config-v1" }], + ["arbitrary string", "sdk-config-v2"], + ["number", 1] + ])("rejects a %s config kind at runtime", (_label, receivedConfigKind) => { + const malformedInput = { + ...VALID_INPUT, + configKind: receivedConfigKind + } as unknown as ValidateGeneratorConfigCompatibilityInput; + const error = captureError(() => validateGeneratorConfigCompatibility(malformedInput)); + + expect(error).toMatchObject({ + code: "INVALID_CONFIG_KIND", + generatorId: VALID_INPUT.generatorId, + language: VALID_INPUT.language, + requestedVersion: VALID_INPUT.requestedVersion, + cutoverVersion: "4.0.0", + receivedConfigKind, + expectedConfigKind: "sdk-config-v1", + expectedLanguage: "typescript", + retryable: false, + recommendedAction: "USE_SUPPORTED_CONFIG_KIND" + }); + }); + + it("rejects a physically missing config kind property", () => { + const { configKind: _configKind, ...inputWithoutConfigKind } = VALID_INPUT; + const error = captureError(() => + validateGeneratorConfigCompatibility(inputWithoutConfigKind as ValidateGeneratorConfigCompatibilityInput) + ); + + expect(Object.hasOwn(error, "receivedConfigKind")).toBe(true); + expect(error).toMatchObject({ + code: "INVALID_CONFIG_KIND", + receivedConfigKind: undefined, + retryable: false, + recommendedAction: "USE_SUPPORTED_CONFIG_KIND" + }); + }); + + it("compares exact prerelease, build, and large numeric versions safely", () => { + expect( + validateGeneratorConfigCompatibility({ + ...VALID_INPUT, + requestedVersion: "4.0.0-rc.1", + configKind: "legacy-fern" + }).payloadKind + ).toBe("fern-runtime-bundle"); + + expect( + validateGeneratorConfigCompatibility({ + ...VALID_INPUT, + requestedVersion: "4.0.0+build.123" + }).payloadKind + ).toBe("sdk-config-ir-v1"); + + expect( + validateGeneratorConfigCompatibility({ + ...VALID_INPUT, + requestedVersion: "900719925474099300000.0.0" + }).payloadKind + ).toBe("sdk-config-ir-v1"); + }); + + it("follows SemVer prerelease precedence", () => { + const versions = [ + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0" + ]; + + for (let index = 1; index < versions.length; index += 1) { + expect(compareSemver(parse(versions[index - 1] ?? ""), parse(versions[index] ?? ""))).toBeLessThan(0); + } + }); + + it.each([ + "1.0.0-", + "1.0.0+", + "1.0.0-alpha..1", + "1.0.0+build..1", + "1.0.0-alpha_1" + ])("rejects malformed prerelease or build version %s", (version) => { + expect(parseExactSemver(version)).toBeNull(); + }); + + it("returns stable diagnostics for an unknown generator", () => { + const error = captureError(() => + validateGeneratorConfigCompatibility({ + ...VALID_INPUT, + generatorId: "acme/custom-generator" + }) + ); + + expect(error).toMatchObject({ + name: "GeneratorConfigCompatibilityError", + code: "UNKNOWN_GENERATOR", + generatorId: "acme/custom-generator", + language: "typescript", + requestedVersion: "4.0.0", + cutoverVersion: null, + receivedConfigKind: "sdk-config-v1", + expectedConfigKind: null, + expectedLanguage: null, + retryable: false, + recommendedAction: "USE_KNOWN_GENERATOR_ID" + }); + }); + + it("returns stable diagnostics for a language mismatch", () => { + const error = captureError(() => + validateGeneratorConfigCompatibility({ + ...VALID_INPUT, + generatorId: "fernapi/fern-typescript-node-sdk", + language: "python" + }) + ); + + expect(error).toMatchObject({ + code: "GENERATOR_LANGUAGE_MISMATCH", + generatorId: "fernapi/fern-typescript-node-sdk", + language: "python", + requestedVersion: "4.0.0", + cutoverVersion: "4.0.0", + receivedConfigKind: "sdk-config-v1", + expectedConfigKind: null, + expectedLanguage: "typescript", + retryable: false, + recommendedAction: "USE_GENERATOR_LANGUAGE" + }); + }); + + it("fails malformed internal cutovers with a typed invariant diagnostic", () => { + const error = capturePolicyError(() => + createGeneratorPolicies([["fernapi/invalid-sdk", { language: "typescript", cutoverVersion: "AUTO" }]]) + ); + + expect(error).toMatchObject({ + name: "GeneratorConfigPolicyInvariantError", + code: "INVALID_GENERATOR_CUTOVER_POLICY", + generatorId: "fernapi/invalid-sdk", + language: "typescript", + cutoverVersion: "AUTO", + retryable: false, + recommendedAction: "FIX_GENERATOR_CUTOVER_POLICY" + }); + }); +}); + +function captureError(action: () => unknown): GeneratorConfigCompatibilityError { + try { + action(); + } catch (error: unknown) { + if (error instanceof GeneratorConfigCompatibilityError) { + return error; + } + throw error; + } + throw new Error("Expected compatibility validation to throw"); +} + +function parse(version: string) { + const parsed = parseExactSemver(version); + if (parsed == null) { + throw new Error(`Expected an exact semantic version: ${version}`); + } + return parsed; +} + +function capturePolicyError(action: () => unknown): GeneratorConfigPolicyInvariantError { + try { + action(); + } catch (error: unknown) { + if (error instanceof GeneratorConfigPolicyInvariantError) { + return error; + } + throw error; + } + throw new Error("Expected policy validation to throw"); +} diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/createAndStartJob.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/createAndStartJob.ts index 9ae77f73627b..0d081817c23e 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/src/createAndStartJob.ts +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/createAndStartJob.ts @@ -1,12 +1,8 @@ import { stripCliConfigKeys } from "@fern-api/api-workspace-commons"; import { FernToken } from "@fern-api/auth"; import { fernConfigJson, generatorsYml } from "@fern-api/configuration"; -import { createFiddleService, getFiddleOrigin, getIrVersionForGenerator } from "@fern-api/core"; +import { createFiddleService, getFiddleOrigin } from "@fern-api/core"; import { AbsoluteFilePath, dirname, join, RelativeFilePath, stringifyLargeObject } from "@fern-api/fs-utils"; -import { - migrateIntermediateRepresentationForGenerator, - migrateIntermediateRepresentationToVersionForGenerator -} from "@fern-api/ir-migrations"; import { IntermediateRepresentation } from "@fern-api/ir-sdk"; import { CliError, TaskContext } from "@fern-api/task-context"; import { FernDefinition, FernWorkspace } from "@fern-api/workspace-loader"; @@ -18,6 +14,7 @@ import yaml from "js-yaml"; import urlJoin from "url-join"; import { promisify } from "util"; import { gzip } from "zlib"; +import { migrateIntermediateRepresentationForInvocation } from "./migrateIntermediateRepresentationForInvocation.js"; import { retryWithRateLimit, TooManyRequestsError } from "./retryWithRateLimit.js"; const gzipAsync = promisify(gzip); @@ -395,29 +392,12 @@ async function startJob({ irVersionOverride: string | undefined; specsTarGzBuffer: Buffer | undefined; }): Promise { - const irVersionFromFdr = await getIrVersionForGenerator(generatorInvocation).then((version) => - version == null ? undefined : "v" + version.toString() - ); - const resolvedIrVersionOverride = irVersionOverride ?? irVersionFromFdr; - const migratedIntermediateRepresentation = - resolvedIrVersionOverride == null - ? await migrateIntermediateRepresentationForGenerator({ - intermediateRepresentation, - context, - targetGenerator: { - name: generatorInvocation.name, - version: generatorInvocation.version - } - }) - : await migrateIntermediateRepresentationToVersionForGenerator({ - intermediateRepresentation, - context, - irVersion: resolvedIrVersionOverride, - targetGenerator: { - name: generatorInvocation.name, - version: generatorInvocation.version - } - }); + const migratedIntermediateRepresentation = await migrateIntermediateRepresentationForInvocation({ + intermediateRepresentation, + generatorInvocation, + context, + irVersionOverride + }); const formData = new FormData(); diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/fernSdkGenApi.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/fernSdkGenApi.ts index d6d67b6a78a1..1651a2b44d36 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/src/fernSdkGenApi.ts +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/fernSdkGenApi.ts @@ -1,3 +1,4 @@ +// cspell:ignore kotlin import { stripCliConfigKeys } from "@fern-api/api-workspace-commons"; import { FernToken } from "@fern-api/auth"; import { generatorsYml } from "@fern-api/configuration"; @@ -9,26 +10,30 @@ import axios, { AxiosError } from "axios"; import { createHash } from "crypto"; import FormData from "form-data"; import path from "path"; +import { gunzipSync } from "zlib"; import { downloadFilesForTask } from "./RemoteTaskHandler.js"; +import { + GeneratorConfigCompatibilityError, + type GeneratorLanguage, + getGeneratorLanguage, + validateGeneratorConfigCompatibility +} from "./sdk-gen-client/index.js"; const POLL_INTERVAL_MS = 2_000; const POLL_TIMEOUT_MS = 15 * 60 * 1_000; const REQUEST_TIMEOUT_MS = 60_000; +const MAX_BUNDLES = 64; +const MAX_SOURCE_COMPRESSED_BYTES = 25 * 1024 * 1024; +const MAX_SOURCE_DECOMPRESSED_BYTES = 25 * 1024 * 1024; +const MAX_BUNDLE_COMPRESSED_BYTES = 5 * 1024 * 1024; +const MAX_BUNDLE_DECOMPRESSED_BYTES = 25 * 1024 * 1024; +const MAX_TOTAL_BUNDLE_COMPRESSED_BYTES = 25 * 1024 * 1024; +const MAX_TOTAL_BUNDLE_DECOMPRESSED_BYTES = 100 * 1024 * 1024; +const MAX_MULTIPART_BODY_BYTES = 60 * 1024 * 1024; const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); +const TARGET_ID_SEED_COLLATOR = new Intl.Collator("en", { numeric: true }); -export type FernSdkGenApiLanguage = - | "typescript" - | "python" - | "java" - | "kotlin" - | "go" - | "csharp" - | "php" - | "ruby" - | "rust" - | "swift" - | "cli" - | "mcp"; +export type FernSdkGenApiLanguage = GeneratorLanguage; export type FernSdkGenApiPublishRegistry = | "npm" @@ -67,31 +72,6 @@ export type FernSdkGenApiRequestedOutput = } | { type: "publish"; publish: FernSdkGenApiPublishConfig }; -/** - * First-party Fern SDK generators that can be represented by the shared SDK Config IR target - * languages. Keep aliases here because existing generators.yml files remain valid during the - * backend migration. - */ -const FERN_SDK_GENERATOR_LANGUAGES: Readonly> = { - "fernapi/fern-typescript": "typescript", - "fernapi/fern-typescript-sdk": "typescript", - "fernapi/fern-typescript-node-sdk": "typescript", - "fernapi/fern-typescript-browser-sdk": "typescript", - "fernapi/fern-python-sdk": "python", - "fernapi/fern-java-sdk": "java", - "fernapi/fern-kotlin-sdk": "kotlin", - "fernapi/fern-go-sdk": "go", - "fernapi/fern-csharp-sdk": "csharp", - "fernapi/fern-php-sdk": "php", - "fernapi/fern-ruby-sdk": "ruby", - "fernapi/fern-ruby-sdk-v2": "ruby", - "fernapi/fern-rust-sdk": "rust", - "fernapi/fern-swift-sdk": "swift", - "fernapi/fern-cli": "cli", - "fernapi/fern-cli-generator": "cli", - "fernapi/fern-mcp-server": "mcp" -}; - interface FernBuildStatus { buildId: string; status: "queued" | "running" | "succeeded" | "failed" | "partial_failure"; @@ -105,7 +85,7 @@ interface FernBuildStatus { } export interface FernSdkGenApiRequest { - protocolVersion: 1; + protocolVersion: 2; apiName: string; cliVersion?: string; idempotencyKey: string; @@ -160,7 +140,7 @@ export function getFernSdkGenApiOrigin(): string | undefined { } export function getFernSdkGenApiLanguage(generatorName: string): FernSdkGenApiLanguage | undefined { - return FERN_SDK_GENERATOR_LANGUAGES[generatorName]; + return getGeneratorLanguage(generatorName); } interface FernSdkGenApiOutputMapping { @@ -439,13 +419,7 @@ export function isEligibleForFernSdkGenApi( // Fiddle currently replaces AUTO after generation. Until that step moves into the shared // pipeline, forwarding AUTO would write the literal placeholder into generated packages. const hasConcreteVersion = sdkVersion != null && sdkVersion.trim().length > 0 && !isAutoVersion(sdkVersion); - return ( - language != null && - (generatorInvocation.language == null || generatorInvocation.language === language) && - hasConcreteVersion && - specsTarGzBuffer != null && - whitelabel == null - ); + return language != null && hasConcreteVersion && specsTarGzBuffer != null && whitelabel == null; } export interface FernSdkGenApiBuildParameters { @@ -456,6 +430,7 @@ export interface FernSdkGenApiBuildParameters { sdkVersion: string; token: FernToken; specsTarGzBuffer: Buffer; + runtimeBundle: Buffer; absolutePathToPreview: AbsoluteFilePath | undefined; context: InteractiveTaskContext; targetIdSeed?: string; @@ -537,10 +512,11 @@ export class FernSdkGenApiBatch { } private async dispatch(): Promise { + const orderedParticipants = [...this.participants].sort(compareFernSdkGenApiParticipants); try { - const results = await executeFernSdkGenApiBuild(this.participants); + const results = await executeFernSdkGenApiBuild(orderedParticipants); results.forEach((result, index) => { - const participant = this.participants[index]; + const participant = orderedParticipants[index]; if (result.status === "fulfilled") { participant?.resolve(result.value); } else { @@ -573,6 +549,7 @@ async function executeFernSdkGenApiBuild( if (first == null) { throw new Error("Cannot submit an empty Fern sdk-gen-api build"); } + assertGeneratorConfigCompatibility(participants); let origin: string | undefined; try { origin = getFernSdkGenApiOrigin(); @@ -594,6 +571,7 @@ async function executeFernSdkGenApiBuild( } assertSameBatchInput(participants); + validateProtocolInputs(participants, first); const request = createFernSdkGenApiBatchRequest({ apiName: first.apiName, organization: first.organization, @@ -603,7 +581,8 @@ async function executeFernSdkGenApiBuild( generatorInvocation: participant.generatorInvocation, sdkVersion: participant.sdkVersion, targetIdSeed: participant.targetIdSeed, - audiences: participant.audiences + audiences: participant.audiences, + runtimeBundle: participant.runtimeBundle })) }); @@ -613,6 +592,25 @@ async function executeFernSdkGenApiBuild( filename: "specs.tar.gz", contentType: "application/gzip" }); + participants.forEach((participant, index) => { + const target = request.targets[index]; + if (target == null) { + throw new Error(`Cannot pair sdk-gen-api runtime bundle at index ${index} with a target`); + } + // sdk-gen-api correlates bundles by this filename; ordering is only deterministic batching. + form.append("bundles", participant.runtimeBundle, { + filename: `${target.targetId}.json.gz`, + contentType: "application/gzip" + }); + }); + const multipartBodyLength = form.getLengthSync(); + if (multipartBodyLength > MAX_MULTIPART_BODY_BYTES) { + return first.context.failAndThrow( + `sdk-gen-api multipart request is ${formatMiB(multipartBodyLength)}, exceeding the 60 MiB limit; reduce source or target bundle size`, + undefined, + { code: CliError.Code.ConfigError } + ); + } let buildId: string; try { @@ -624,7 +622,7 @@ async function executeFernSdkGenApiBuild( Authorization: `Bearer ${first.token.value}`, "X-Fern-Organization-Id": first.organization }, - maxBodyLength: 30 * 1024 * 1024, + maxBodyLength: MAX_MULTIPART_BODY_BYTES, timeout: REQUEST_TIMEOUT_MS }); buildId = response.data.buildId; @@ -702,6 +700,179 @@ async function executeFernSdkGenApiBuild( } } +function assertGeneratorConfigCompatibility(participants: FernSdkGenApiBuildParameters[]): void { + for (const participant of participants) { + const { generatorInvocation } = participant; + const language = generatorInvocation.language ?? getFernSdkGenApiLanguage(generatorInvocation.name); + if (language == null) { + participant.context.failAndThrow( + `Cannot submit SDK generation to sdk-gen-api: generator language is unknown for ${generatorInvocation.name}`, + undefined, + { code: CliError.Code.ConfigError } + ); + } + try { + validateGeneratorConfigCompatibility({ + generatorId: generatorInvocation.name, + language, + requestedVersion: generatorInvocation.version, + configKind: "legacy-fern" + }); + } catch (error) { + if (!(error instanceof GeneratorConfigCompatibilityError)) { + throw error; + } + participant.context.failAndThrow(formatGeneratorConfigCompatibilityError(error), undefined, { + code: CliError.Code.ConfigError + }); + } + } +} + +function formatGeneratorConfigCompatibilityError(error: GeneratorConfigCompatibilityError): string { + const diagnostic = [ + error.code, + `generator=${error.generatorId}`, + `language=${error.language}`, + `requestedVersion=${error.requestedVersion}`, + `cutoverVersion=${error.cutoverVersion ?? "n/a"}`, + `receivedConfigKind=${String(error.receivedConfigKind)}`, + `expectedConfigKind=${error.expectedConfigKind ?? "n/a"}`, + `expectedLanguage=${error.expectedLanguage ?? "n/a"}`, + `retryable=${error.retryable}`, + `recommendedAction=${error.recommendedAction}` + ].join("; "); + const remediation = + error.code === "SDK_CONFIG_V1_REQUIRED" + ? " Run `fern sdk-config migrate` to create SDK Config v1 for this target." + : ""; + return `Cannot submit SDK generation to sdk-gen-api: ${error.message} [${diagnostic}].${remediation}`; +} + +function compareFernSdkGenApiParticipants( + left: FernSdkGenApiBuildParameters, + right: FernSdkGenApiBuildParameters +): number { + const seedComparison = TARGET_ID_SEED_COLLATOR.compare(left.targetIdSeed ?? "", right.targetIdSeed ?? ""); + if (seedComparison !== 0) { + return seedComparison; + } + const generatorComparison = left.generatorInvocation.name.localeCompare(right.generatorInvocation.name); + if (generatorComparison !== 0) { + return generatorComparison; + } + const versionComparison = left.generatorInvocation.version.localeCompare(right.generatorInvocation.version); + if (versionComparison !== 0) { + return versionComparison; + } + return left.sdkVersion.localeCompare(right.sdkVersion); +} + +function validateProtocolInputs( + participants: FernSdkGenApiBuildParameters[], + first: FernSdkGenApiBuildParameters +): void { + if (participants.length > MAX_BUNDLES) { + first.context.failAndThrow( + `sdk-gen-api supports at most ${MAX_BUNDLES} runtime bundles per build; received ${participants.length}`, + undefined, + { code: CliError.Code.ConfigError } + ); + } + if (first.specsTarGzBuffer.length > MAX_SOURCE_COMPRESSED_BYTES) { + first.context.failAndThrow( + `sdk-gen-api source archive is ${formatMiB(first.specsTarGzBuffer.length)}, exceeding the 25 MiB compressed limit`, + undefined, + { code: CliError.Code.ConfigError } + ); + } + const oversizedBundleIndex = participants.findIndex( + (participant) => participant.runtimeBundle.length > MAX_BUNDLE_COMPRESSED_BYTES + ); + if (oversizedBundleIndex >= 0) { + const participant = participants[oversizedBundleIndex]; + first.context.failAndThrow( + `sdk-gen-api runtime bundle ${participant?.targetIdSeed ?? oversizedBundleIndex.toString()} is ${formatMiB(participant?.runtimeBundle.length ?? 0)}, exceeding the 5 MiB compressed limit`, + undefined, + { code: CliError.Code.ConfigError } + ); + } + const totalBundleBytes = participants.reduce((total, participant) => total + participant.runtimeBundle.length, 0); + if (totalBundleBytes > MAX_TOTAL_BUNDLE_COMPRESSED_BYTES) { + first.context.failAndThrow( + `sdk-gen-api runtime bundles total ${formatMiB(totalBundleBytes)}, exceeding the 25 MiB compressed limit; reduce the number or size of targets`, + undefined, + { code: CliError.Code.ConfigError } + ); + } + getBoundedGzipSize({ + buffer: first.specsTarGzBuffer, + maxBytes: MAX_SOURCE_DECOMPRESSED_BYTES, + label: "source archive", + context: first.context + }); + let totalDecompressedBundleBytes = 0; + for (const [index, participant] of participants.entries()) { + totalDecompressedBundleBytes += getBoundedGzipSize({ + buffer: participant.runtimeBundle, + maxBytes: MAX_BUNDLE_DECOMPRESSED_BYTES, + label: `runtime bundle ${participant.targetIdSeed ?? index.toString()}`, + context: first.context + }); + if (totalDecompressedBundleBytes > MAX_TOTAL_BUNDLE_DECOMPRESSED_BYTES) { + first.context.failAndThrow( + `sdk-gen-api runtime bundles total ${formatMiB(totalDecompressedBundleBytes)} decompressed, exceeding the 100 MiB decompressed limit; reduce the number or size of targets`, + undefined, + { code: CliError.Code.ConfigError } + ); + } + } +} + +function getBoundedGzipSize({ + buffer, + maxBytes, + label, + context +}: { + buffer: Buffer; + maxBytes: number; + label: string; + context: InteractiveTaskContext; +}): number { + let decompressed: Buffer; + try { + decompressed = gunzipSync(buffer, { maxOutputLength: maxBytes + 1 }); + } catch (error) { + if (isMaxOutputLengthError(error)) { + return context.failAndThrow( + `sdk-gen-api ${label} exceeds the ${formatMiB(maxBytes)} decompressed limit`, + undefined, + { code: CliError.Code.ConfigError } + ); + } + return context.failAndThrow(`sdk-gen-api ${label} is malformed gzip; regenerate it and retry`, error, { + code: CliError.Code.ConfigError + }); + } + if (decompressed.length > maxBytes) { + return context.failAndThrow( + `sdk-gen-api ${label} is ${formatMiB(decompressed.length)} decompressed, exceeding the ${formatMiB(maxBytes)} decompressed limit`, + undefined, + { code: CliError.Code.ConfigError } + ); + } + return decompressed.length; +} + +function isMaxOutputLengthError(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ERR_BUFFER_TOO_LARGE"; +} + +function formatMiB(bytes: number): string { + return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`; +} + function isTerminal(status: FernBuildStatus, targetId: string): boolean { const target = status.targets.find((candidate) => candidate.targetId === targetId); return target?.status === "failed" || target?.status === "succeeded"; @@ -789,7 +960,8 @@ export function createFernSdkGenApiRequest({ cliVersion, generatorInvocation, sdkVersion, - specsTarGzBuffer + specsTarGzBuffer, + runtimeBundle }: { apiName: string; organization: string; @@ -797,13 +969,14 @@ export function createFernSdkGenApiRequest({ generatorInvocation: generatorsYml.GeneratorInvocation; sdkVersion: string; specsTarGzBuffer: Buffer; + runtimeBundle: Buffer; }): FernSdkGenApiRequest { return createFernSdkGenApiBatchRequest({ apiName, organization, cliVersion, specsTarGzBuffer, - targets: [{ generatorInvocation, sdkVersion }] + targets: [{ generatorInvocation, sdkVersion, runtimeBundle }] }); } @@ -823,6 +996,7 @@ export function createFernSdkGenApiBatchRequest({ sdkVersion: string; targetIdSeed?: string; audiences?: string[]; + runtimeBundle: Buffer; }>; }): FernSdkGenApiRequest { if (targets.length === 0) { @@ -875,21 +1049,25 @@ export function createFernSdkGenApiBatchRequest({ }; }); const apiInputs: FernSdkGenApiRequest["apiInputs"] = [{ id: "default", specIndexes: "all" }]; + const runtimeBundleHashes = targets.map((target) => + createHash("sha256").update(target.runtimeBundle).digest("hex") + ); const idempotencyKey = createHash("sha256") .update(specsTarGzBuffer) .update( JSON.stringify({ - protocolVersion: 1, + protocolVersion: 2, organization, apiName, apiInputs, - targets: requestTargets + targets: requestTargets, + runtimeBundleHashes }) ) .digest("hex"); return { - protocolVersion: 1, + protocolVersion: 2, apiName, ...(cliVersion ? { cliVersion } : {}), idempotencyKey, diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/getGeneratorConfig.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/getGeneratorConfig.ts new file mode 100644 index 000000000000..35833547e7cc --- /dev/null +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/getGeneratorConfig.ts @@ -0,0 +1,665 @@ +import { GeneratorInvocation, generatorsYml } from "@fern-api/configuration"; +import { isGithubSelfhosted } from "@fern-api/configuration-loader"; +import { AbsoluteFilePath } from "@fern-api/fs-utils"; +import { parseRepository } from "@fern-api/github"; +import { CliError } from "@fern-api/task-context"; +import { FernFiddle } from "@fern-fern/fiddle-sdk"; +import { FernGeneratorExec } from "@fern-fern/generator-exec-sdk"; +import { EnvironmentVariable } from "@fern-fern/generator-exec-sdk/api"; +import * as path from "path"; + +const DEFAULT_OUTPUT_VERSION = "0.0.1"; + +export function getLicensePathFromConfig( + generatorInvocation: GeneratorInvocation +): { type: "basic"; value: string } | { type: "custom"; value: string } | undefined { + if ( + generatorInvocation.raw?.github != null && + typeof generatorInvocation.raw.github === "object" && + "license" in generatorInvocation.raw.github + ) { + const githubConfig = generatorInvocation.raw.github as { license?: string | { custom: string } }; + + if (githubConfig.license != null) { + if (typeof githubConfig.license === "string") { + return { type: "basic", value: githubConfig.license }; + } else if (typeof githubConfig.license === "object" && "custom" in githubConfig.license) { + return { type: "custom", value: githubConfig.license.custom }; + } + } + } + + if (generatorInvocation.raw?.metadata?.license != null) { + const license = generatorInvocation.raw.metadata.license; + if (typeof license === "string") { + return { type: "basic", value: license }; + } else if (typeof license === "object" && "custom" in license) { + return { type: "custom", value: license.custom }; + } + } + + return undefined; +} + +function extractLicenseInfo( + generatorInvocation: GeneratorInvocation, + absolutePathToFernConfig?: AbsoluteFilePath +): FernGeneratorExec.LicenseConfig | undefined { + const licenseConfig = getLicensePathFromConfig(generatorInvocation); + + if (licenseConfig == null) { + return undefined; + } + + if (licenseConfig.type === "basic") { + if (licenseConfig.value === "MIT" || licenseConfig.value === "Apache-2.0") { + return FernGeneratorExec.LicenseConfig.basic({ + id: + licenseConfig.value === "MIT" + ? FernGeneratorExec.LicenseId.Mit + : FernGeneratorExec.LicenseId.Apache2 + }); + } + } else if (licenseConfig.type === "custom") { + return FernGeneratorExec.LicenseConfig.custom({ + filename: path.basename(licenseConfig.value) + }); + } + + return undefined; +} + +export declare namespace getGeneratorConfig { + export interface Args { + workspaceName: string; + organization: string; + outputVersion?: string | undefined; + customConfig: unknown; + generatorInvocation: generatorsYml.GeneratorInvocation; + absolutePathToSnippet: AbsoluteFilePath | undefined; + absolutePathToSnippetTemplates: AbsoluteFilePath | undefined; + absolutePathToFernConfig: AbsoluteFilePath | undefined; + writeUnitTests: boolean; + generateOauthClients: boolean; + generatePaginatedClients: boolean; + whiteLabel?: boolean; + /** + * When true, publishV2/publish output modes will create a real `publish` output config + * so the generator actually publishes to the registry. When false (default), these modes + * are converted to a dummy github config to prevent accidental publishing during + * `fern generate --local`. + */ + publishToRegistry?: boolean; + /** Preserve publish mode metadata while omitting credentials from serialized configs. */ + omitPublishCredentials?: boolean; + paths: { + snippetPath: AbsoluteFilePath | undefined; + snippetTemplatePath: AbsoluteFilePath | undefined; + irPath: AbsoluteFilePath; + outputDirectory: AbsoluteFilePath; + }; + } +} + +export function getGithubPublishConfig( + githubPublishInfo: FernFiddle.GithubPublishInfo | undefined, + options: { omitPublishCredentials?: boolean } = {} +): FernGeneratorExec.GithubPublishInfo | undefined { + const omitPublishCredentials = options.omitPublishCredentials ?? false; + return githubPublishInfo != null + ? FernFiddle.GithubPublishInfo._visit(githubPublishInfo, { + npm: (value) => { + const token = (value.token ?? "").trim(); + const oidcMarker = getTrustedOidcMarker(token); + const hasToken = token !== ""; + return FernGeneratorExec.GithubPublishInfo.npm({ + registryUrl: value.registryUrl, + packageName: value.packageName, + tokenEnvironmentVariable: EnvironmentVariable( + omitPublishCredentials + ? oidcMarker != null + ? "" + : "" + : oidcMarker != null + ? "" + : token.startsWith("${") && token.endsWith("}") + ? token.slice(2, -1).trim() + : "" + ), + shouldGeneratePublishWorkflow: oidcMarker != null || hasToken + }); + }, + maven: (value) => + FernGeneratorExec.GithubPublishInfo.maven({ + registryUrl: value.registryUrl, + coordinate: value.coordinate, + usernameEnvironmentVariable: EnvironmentVariable( + omitPublishCredentials ? "" : (value.credentials?.username ?? "") + ), + passwordEnvironmentVariable: EnvironmentVariable( + omitPublishCredentials ? "" : (value.credentials?.password ?? "") + ), + signature: + value.signature != null && !omitPublishCredentials + ? { + keyIdEnvironmentVariable: EnvironmentVariable(value.signature.keyId ?? ""), + passwordEnvironmentVariable: EnvironmentVariable(value.signature.password ?? ""), + secretKeyEnvironmentVariable: EnvironmentVariable(value.signature.secretKey ?? "") + } + : undefined + }), + pypi: (value) => { + const password = (value.credentials?.password ?? "").trim(); + const oidcMarker = getTrustedOidcMarker(password); + const hasCredentials = value.credentials != null; + return FernGeneratorExec.GithubPublishInfo.pypi({ + registryUrl: value.registryUrl, + packageName: value.packageName, + usernameEnvironmentVariable: EnvironmentVariable(omitPublishCredentials ? "" : "PYPI_USERNAME"), + passwordEnvironmentVariable: EnvironmentVariable( + omitPublishCredentials + ? oidcMarker != null + ? "OIDC" + : "" + : oidcMarker != null + ? "OIDC" + : "PYPI_PASSWORD" + ), + shouldGeneratePublishWorkflow: oidcMarker != null || hasCredentials, + pypiMetadata: value.pypiMetadata + }); + }, + rubygems: (value) => + FernGeneratorExec.GithubPublishInfo.rubygems({ + registryUrl: value.registryUrl, + packageName: value.packageName, + apiKeyEnvironmentVariable: EnvironmentVariable(omitPublishCredentials ? "" : (value.apiKey ?? "")) + }), + postman: (value) => + FernGeneratorExec.GithubPublishInfo.postman({ + apiKeyEnvironmentVariable: EnvironmentVariable( + omitPublishCredentials ? "" : (value.apiKey ?? "") + ), + workspaceIdEnvironmentVariable: EnvironmentVariable( + omitPublishCredentials ? "" : (value.workspaceId ?? "") + ) + }), + nuget: (value) => { + const apiKey = (value.apiKey ?? "").trim(); + const oidcMarker = getTrustedOidcMarker(apiKey); + return FernGeneratorExec.GithubPublishInfo.nuget({ + registryUrl: value.registryUrl, + packageName: value.packageName, + apiKeyEnvironmentVariable: EnvironmentVariable( + omitPublishCredentials + ? oidcMarker != null + ? "" + : "" + : oidcMarker != null + ? "" + : apiKey.startsWith("${") && apiKey.endsWith("}") + ? apiKey.slice(2, -1).trim() + : "" + ), + shouldGeneratePublishWorkflow: oidcMarker != null ? true : undefined + }); + }, + crates: (value) => + FernGeneratorExec.GithubPublishInfo.crates({ + registryUrl: value.registryUrl, + packageName: value.packageName, + tokenEnvironmentVariable: EnvironmentVariable(omitPublishCredentials ? "" : (value.token ?? "")) + }), + _other: () => undefined + }) + : undefined; +} + +function getTrustedOidcMarker(value: string): "OIDC" | "" | undefined { + if (value === "OIDC" || value === "") { + return value; + } + return undefined; +} + +export function getGeneratorConfig({ + generatorInvocation, + customConfig, + workspaceName, + organization, + outputVersion = DEFAULT_OUTPUT_VERSION, + absolutePathToSnippet, + absolutePathToSnippetTemplates, + absolutePathToFernConfig, + writeUnitTests, + generateOauthClients, + generatePaginatedClients, + whiteLabel, + publishToRegistry, + omitPublishCredentials, + paths +}: getGeneratorConfig.Args): FernGeneratorExec.GeneratorConfig { + const licenseInfo = extractLicenseInfo(generatorInvocation, absolutePathToFernConfig); + const { snippetPath, snippetTemplatePath, irPath, outputDirectory } = paths; + const output = generatorInvocation.outputMode._visit({ + publish: (value) => { + if (publishToRegistry === true) { + const publishTarget = getPublishTargetFromPublishMode(value, omitPublishCredentials === true); + return { + ...newRealPublishOutputConfig(outputVersion, publishTarget, paths), + snippetFilepath: snippetPath, + publishingMetadata: generatorInvocation.publishMetadata + }; + } + return { + ...newDummyPublishOutputConfig(outputVersion, value, generatorInvocation, paths), + snippetFilepath: snippetPath, + publishingMetadata: generatorInvocation.publishMetadata + }; + }, + publishV2: (value) => { + if (publishToRegistry === true) { + const publishTarget = getPublishTargetFromPublishModeV2(value, omitPublishCredentials === true); + return { + ...newRealPublishOutputConfig(outputVersion, publishTarget, paths), + snippetFilepath: snippetPath, + publishingMetadata: generatorInvocation.publishMetadata + }; + } + return { + ...newDummyPublishOutputConfig(outputVersion, value, generatorInvocation, paths), + snippetFilepath: snippetPath, + publishingMetadata: generatorInvocation.publishMetadata + }; + }, + downloadFiles: () => { + const outputConfig: FernGeneratorExec.GeneratorOutputConfig = { + mode: FernGeneratorExec.OutputMode.downloadFiles(), + path: outputDirectory, + snippetFilepath: snippetPath, + publishingMetadata: generatorInvocation.publishMetadata + }; + return outputConfig; + }, + github: (value) => { + const outputConfig: FernGeneratorExec.GeneratorOutputConfig = { + mode: FernGeneratorExec.OutputMode.github({ + repoUrl: `https://github.com/${value.owner}/${value.repo}`, + version: outputVersion, + publishInfo: getGithubPublishConfig(value.publishInfo, { omitPublishCredentials }), + installationToken: undefined // Don't attempt to clone the repository when generating locally. + }), + path: outputDirectory, + publishingMetadata: generatorInvocation.publishMetadata + }; + if (absolutePathToSnippet !== undefined) { + outputConfig.snippetFilepath = snippetPath; + } + if (absolutePathToSnippetTemplates !== undefined) { + outputConfig.snippetTemplateFilepath = snippetTemplatePath; + } + return outputConfig; + }, + githubV2: (value) => { + const repoUrl = value._visit({ + commitAndRelease: (value) => `https://github.com/${value.owner}/${value.repo}`, + push: (value) => `https://github.com/${value.owner}/${value.repo}`, + pullRequest: (value) => `https://github.com/${value.owner}/${value.repo}`, + _other: () => { + throw new CliError({ + message: "Encountered unknown github mode", + code: CliError.Code.InternalError + }); + } + }); + const outputConfig: FernGeneratorExec.GeneratorOutputConfig = { + mode: FernGeneratorExec.OutputMode.github({ + repoUrl, + version: outputVersion, + publishInfo: getGithubPublishConfig(value.publishInfo, { omitPublishCredentials }) + }), + path: outputDirectory, + publishingMetadata: generatorInvocation.publishMetadata + }; + if (absolutePathToSnippet !== undefined) { + outputConfig.snippetFilepath = snippetPath; + } + if (absolutePathToSnippetTemplates !== undefined) { + outputConfig.snippetTemplateFilepath = snippetTemplatePath; + } + return outputConfig; + }, + _other: () => { + throw new CliError({ + message: "Output type did not match any of the types supported by Fern", + code: CliError.Code.InternalError + }); + } + }); + const publishConfig = getPublishConfigForGithubOidc(generatorInvocation, outputVersion); + return { + irFilepath: irPath, + output, + publish: publishConfig, + customConfig: customConfig, + workspaceName, + organization, + environment: FernGeneratorExec.GeneratorEnvironment.local(), + dryRun: false, + whitelabel: whiteLabel ?? false, + writeUnitTests, + generateOauthClients, + generatePaginatedClients, + license: licenseInfo + }; +} + +function newDummyPublishOutputConfig( + version: string, + multipleOutputMode: FernFiddle.PublishOutputMode | FernFiddle.PublishOutputModeV2, + generatorInvocation: GeneratorInvocation, + paths: { + outputDirectory: AbsoluteFilePath; + } +): FernGeneratorExec.GeneratorOutputConfig { + const { outputDirectory } = paths; + let outputMode: + | FernFiddle.NpmOutput + | FernFiddle.MavenOutput + | FernFiddle.PypiOutput + | FernFiddle.RubyGemsOutput + | FernFiddle.PostmanOutput + | FernFiddle.NugetOutput + | FernFiddle.CratesOutput + | undefined; + if ("registryOverrides" in multipleOutputMode) { + outputMode = multipleOutputMode.registryOverrides.maven ?? multipleOutputMode.registryOverrides.npm; + } else if (outputMode != null) { + outputMode = multipleOutputMode._visit< + | FernFiddle.NpmOutput + | FernFiddle.MavenOutput + | FernFiddle.PypiOutput + | FernFiddle.RubyGemsOutput + | FernFiddle.PostmanOutput + | FernFiddle.NugetOutput + | FernFiddle.CratesOutput + | undefined + >({ + mavenOverride: (value) => value, + npmOverride: (value) => value, + pypiOverride: (value) => value, + rubyGemsOverride: (value) => value, + postman: (value) => value, + nugetOverride: (value) => value, + cratesOverride: (value) => value, + _other: () => undefined + }); + } + + let repoUrl = ""; + if (generatorInvocation.raw?.github != null) { + if (isGithubSelfhosted(generatorInvocation.raw.github)) { + const parsed = parseRepository(generatorInvocation.raw.github.uri); + repoUrl = parsed.repoUrl; + } else { + repoUrl = generatorInvocation.raw?.github.repository; + } + } + + return { + mode: FernGeneratorExec.OutputMode.github({ + repoUrl, + version + }), + path: outputDirectory + }; +} + +function newRealPublishOutputConfig( + version: string, + publishTarget: FernGeneratorExec.GeneratorPublishTarget | undefined, + paths: { outputDirectory: AbsoluteFilePath } +): FernGeneratorExec.GeneratorOutputConfig { + const { outputDirectory } = paths; + const { registries, registriesV2 } = buildRegistriesFromPublishTarget(publishTarget); + return { + mode: FernGeneratorExec.OutputMode.publish({ + registries, + registriesV2, + publishTarget, + version + }), + path: outputDirectory + }; +} + +/** + * Populate the deprecated registries/registriesV2 fields from the publishTarget. + * Some generators (e.g. TypeScript) read registryUrl/token from registriesV2.npm + * instead of publishTarget, so both must be set. + */ +function buildRegistriesFromPublishTarget(publishTarget: FernGeneratorExec.GeneratorPublishTarget | undefined): { + registries: FernGeneratorExec.GeneratorRegistriesConfig; + registriesV2: FernGeneratorExec.GeneratorRegistriesConfigV2; +} { + const registries: FernGeneratorExec.GeneratorRegistriesConfig = structuredClone(emptyRegistriesConfig); + const registriesV2: FernGeneratorExec.GeneratorRegistriesConfigV2 = structuredClone(emptyRegistriesConfigV2); + + if (publishTarget == null) { + return { registries, registriesV2 }; + } + + switch (publishTarget.type) { + case "npm": + registries.npm = { registryUrl: publishTarget.registryUrl, token: publishTarget.token, scope: "" }; + registriesV2.npm = { + registryUrl: publishTarget.registryUrl, + token: publishTarget.token, + packageName: publishTarget.packageName + }; + break; + case "maven": + registries.maven = { + registryUrl: publishTarget.registryUrl, + username: publishTarget.username, + password: publishTarget.password, + group: "", + signature: publishTarget.signature + }; + registriesV2.maven = { + registryUrl: publishTarget.registryUrl, + username: publishTarget.username, + password: publishTarget.password, + coordinate: publishTarget.coordinate, + signature: publishTarget.signature + }; + break; + case "pypi": + registriesV2.pypi = { + registryUrl: publishTarget.registryUrl, + username: publishTarget.username, + password: publishTarget.password, + packageName: publishTarget.packageName, + pypiMetadata: publishTarget.pypiMetadata + }; + break; + case "rubygems": + registriesV2.rubygems = { + registryUrl: publishTarget.registryUrl, + apiKey: publishTarget.apiKey, + packageName: publishTarget.packageName + }; + break; + case "nuget": + registriesV2.nuget = { + registryUrl: publishTarget.registryUrl, + apiKey: publishTarget.apiKey, + packageName: publishTarget.packageName + }; + break; + case "crates": + registriesV2.crates = { + registryUrl: publishTarget.registryUrl, + token: publishTarget.token, + packageName: publishTarget.packageName + }; + break; + default: + break; + } + + return { registries, registriesV2 }; +} + +function getPublishTargetFromPublishMode( + mode: FernFiddle.PublishOutputMode, + omitPublishCredentials: boolean +): FernGeneratorExec.GeneratorPublishTarget | undefined { + if ("registryOverrides" in mode) { + const overrides = mode.registryOverrides; + if (overrides.npm != null) { + return FernGeneratorExec.GeneratorPublishTarget.npm({ + registryUrl: overrides.npm.registryUrl, + token: omitPublishCredentials ? "" : overrides.npm.token, + packageName: overrides.npm.packageName + }); + } + if (overrides.maven != null) { + return FernGeneratorExec.GeneratorPublishTarget.maven({ + registryUrl: overrides.maven.registryUrl, + username: omitPublishCredentials ? "" : (overrides.maven.username ?? ""), + password: omitPublishCredentials ? "" : (overrides.maven.password ?? ""), + coordinate: overrides.maven.coordinate ?? "", + signature: undefined + }); + } + } + return undefined; +} + +function getPublishTargetFromPublishModeV2( + mode: FernFiddle.PublishOutputModeV2, + omitPublishCredentials: boolean +): FernGeneratorExec.GeneratorPublishTarget | undefined { + return mode._visit({ + npmOverride: (value) => + value != null + ? FernGeneratorExec.GeneratorPublishTarget.npm({ + registryUrl: value.registryUrl, + token: omitPublishCredentials ? "" : value.token, + packageName: value.packageName + }) + : undefined, + mavenOverride: (value) => + value != null + ? FernGeneratorExec.GeneratorPublishTarget.maven({ + registryUrl: value.registryUrl, + username: omitPublishCredentials ? "" : value.username, + password: omitPublishCredentials ? "" : value.password, + coordinate: value.coordinate, + signature: omitPublishCredentials ? undefined : (value.signature ?? undefined) + }) + : undefined, + pypiOverride: (value) => + value != null + ? FernGeneratorExec.GeneratorPublishTarget.pypi({ + registryUrl: value.registryUrl, + username: omitPublishCredentials ? "" : value.username, + password: omitPublishCredentials ? "" : value.password, + packageName: value.coordinate, + pypiMetadata: value.pypiMetadata ?? undefined + }) + : undefined, + rubyGemsOverride: (value) => + value != null + ? FernGeneratorExec.GeneratorPublishTarget.rubygems({ + registryUrl: value.registryUrl, + apiKey: omitPublishCredentials ? "" : value.apiKey, + packageName: value.packageName + }) + : undefined, + nugetOverride: (value) => + value != null + ? FernGeneratorExec.GeneratorPublishTarget.nuget({ + registryUrl: value.registryUrl, + apiKey: omitPublishCredentials ? "" : value.apiKey, + packageName: value.packageName + }) + : undefined, + cratesOverride: (value) => + value != null + ? FernGeneratorExec.GeneratorPublishTarget.crates({ + registryUrl: value.registryUrl, + token: omitPublishCredentials ? "" : value.token, + packageName: value.packageName + }) + : undefined, + postman: (value) => + FernGeneratorExec.GeneratorPublishTarget.postman({ + apiKey: omitPublishCredentials ? "" : value.apiKey, + workspaceId: omitPublishCredentials ? "" : value.workspaceId + }), + _other: () => undefined + }); +} + +/** + * For GitHub output modes, extract OIDC publish config so the Python generator + * can detect `registriesV2.pypi.password == "OIDC"` and activate its OIDC workflow. + */ +function getPublishConfigForGithubOidc( + generatorInvocation: GeneratorInvocation, + version: string +): FernGeneratorExec.GeneratorPublishConfig | undefined { + const publishInfo: FernFiddle.GithubPublishInfo | undefined = generatorInvocation.outputMode._visit< + FernFiddle.GithubPublishInfo | undefined + >({ + publish: () => undefined, + publishV2: () => undefined, + downloadFiles: () => undefined, + github: (value) => value.publishInfo, + githubV2: (value) => + value._visit({ + push: (v) => v.publishInfo, + commitAndRelease: (v) => v.publishInfo, + pullRequest: (v) => v.publishInfo, + _other: () => undefined + }), + _other: () => undefined + }); + if (publishInfo == null || publishInfo.type !== "pypi") { + return undefined; + } + const password = (publishInfo.credentials?.password ?? "").trim(); + if (password !== "OIDC" && password !== "") { + return undefined; + } + const registriesV2 = structuredClone(emptyRegistriesConfigV2); + registriesV2.pypi = { + registryUrl: publishInfo.registryUrl, + username: "__token__", + password: "OIDC", + packageName: publishInfo.packageName, + pypiMetadata: publishInfo.pypiMetadata + }; + return { + registries: structuredClone(emptyRegistriesConfig), + registriesV2, + publishTarget: undefined, + version + }; +} + +const emptyRegistriesConfig: FernGeneratorExec.GeneratorRegistriesConfig = { + maven: { registryUrl: "", username: "", password: "", group: "", signature: undefined }, + npm: { registryUrl: "", token: "", scope: "" } +}; + +const emptyRegistriesConfigV2: FernGeneratorExec.GeneratorRegistriesConfigV2 = { + maven: { registryUrl: "", username: "", password: "", coordinate: "", signature: undefined }, + npm: { registryUrl: "", token: "", packageName: "" }, + pypi: { registryUrl: "", username: "", password: "", packageName: "", pypiMetadata: undefined }, + rubygems: { registryUrl: "", apiKey: "", packageName: "" }, + nuget: { registryUrl: "", apiKey: "", packageName: "" }, + crates: { registryUrl: "", token: "", packageName: "" } +}; diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/index.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/index.ts index f75914b7c7ab..1ba78f935f8a 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/src/index.ts +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/index.ts @@ -1,6 +1,7 @@ export { findGeneratorLineNumber, GeneratorOccurrenceTracker, getOutputRepoUrl } from "./automationMetadata.js"; export { getFernSdkGenApiLanguage, isFernSdkGenApiEnabled } from "./fernSdkGenApi.js"; export { getDynamicGeneratorConfig } from "./getDynamicGeneratorConfig.js"; +export { getGeneratorConfig, getGithubPublishConfig, getLicensePathFromConfig } from "./getGeneratorConfig.js"; export type { PublishTarget } from "./publishTarget.js"; export { extractPublishTarget } from "./publishTarget.js"; export type { diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/migrateIntermediateRepresentationForInvocation.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/migrateIntermediateRepresentationForInvocation.ts new file mode 100644 index 000000000000..929972272640 --- /dev/null +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/migrateIntermediateRepresentationForInvocation.ts @@ -0,0 +1,40 @@ +import { generatorsYml } from "@fern-api/configuration"; +import { getIrVersionForGenerator } from "@fern-api/core"; +import { + migrateIntermediateRepresentationForGenerator, + migrateIntermediateRepresentationToVersionForGenerator +} from "@fern-api/ir-migrations"; +import { IntermediateRepresentation } from "@fern-api/ir-sdk"; +import { TaskContext } from "@fern-api/task-context"; + +export async function migrateIntermediateRepresentationForInvocation({ + intermediateRepresentation, + generatorInvocation, + context, + irVersionOverride +}: { + intermediateRepresentation: IntermediateRepresentation; + generatorInvocation: generatorsYml.GeneratorInvocation; + context: TaskContext; + irVersionOverride: string | undefined; +}): Promise { + const irVersionFromFdr = await getIrVersionForGenerator(generatorInvocation); + const resolvedIrVersion = irVersionOverride ?? (irVersionFromFdr == null ? undefined : `v${irVersionFromFdr}`); + const targetGenerator = { + name: generatorInvocation.name, + version: generatorInvocation.version + }; + + return resolvedIrVersion == null + ? migrateIntermediateRepresentationForGenerator({ + intermediateRepresentation, + context, + targetGenerator + }) + : migrateIntermediateRepresentationToVersionForGenerator({ + intermediateRepresentation, + context, + irVersion: resolvedIrVersion, + targetGenerator + }); +} diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/prepareFernSdkGenApiRuntimeBundle.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/prepareFernSdkGenApiRuntimeBundle.ts new file mode 100644 index 000000000000..8985323d17e0 --- /dev/null +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/prepareFernSdkGenApiRuntimeBundle.ts @@ -0,0 +1,75 @@ +import { stripCliConfigKeys } from "@fern-api/api-workspace-commons"; +import { generatorsYml } from "@fern-api/configuration"; +import { AbsoluteFilePath } from "@fern-api/fs-utils"; +import { IntermediateRepresentation } from "@fern-api/ir-sdk"; +import { InteractiveTaskContext } from "@fern-api/task-context"; +import { GeneratorConfig } from "@fern-fern/generator-exec-sdk/serialization"; +import { promisify } from "util"; +import { gzip } from "zlib"; +import { getGeneratorConfig } from "./getGeneratorConfig.js"; +import { migrateIntermediateRepresentationForInvocation } from "./migrateIntermediateRepresentationForInvocation.js"; + +const gzipAsync = promisify(gzip); +const RUNTIME_IR_PATH = AbsoluteFilePath.of("/tmp/fern-runtime/ir.json"); +const RUNTIME_OUTPUT_PATH = AbsoluteFilePath.of("/fern/output"); + +export async function prepareFernSdkGenApiRuntimeBundle({ + apiName, + organization, + generatorInvocation, + sdkVersion, + intermediateRepresentation, + irVersionOverride, + writeUnitTests = false, + generateOauthClients = false, + generatePaginatedClients = false, + context +}: { + apiName: string; + organization: string; + generatorInvocation: generatorsYml.GeneratorInvocation; + sdkVersion: string; + intermediateRepresentation: IntermediateRepresentation; + irVersionOverride: string | undefined; + writeUnitTests?: boolean; + generateOauthClients?: boolean; + generatePaginatedClients?: boolean; + context: InteractiveTaskContext; +}): Promise { + const migratedIntermediateRepresentation = await migrateIntermediateRepresentationForInvocation({ + intermediateRepresentation, + generatorInvocation, + context, + irVersionOverride + }); + const config = getGeneratorConfig({ + workspaceName: apiName, + organization, + outputVersion: sdkVersion, + customConfig: stripCliConfigKeys(generatorInvocation.config), + generatorInvocation, + absolutePathToSnippet: undefined, + absolutePathToSnippetTemplates: undefined, + absolutePathToFernConfig: undefined, + writeUnitTests, + generateOauthClients, + generatePaginatedClients, + publishToRegistry: false, + omitPublishCredentials: true, + paths: { + snippetPath: undefined, + snippetTemplatePath: undefined, + irPath: RUNTIME_IR_PATH, + outputDirectory: RUNTIME_OUTPUT_PATH + } + }); + const serializedConfig = await GeneratorConfig.jsonOrThrow(config); + return gzipAsync( + Buffer.from( + JSON.stringify({ + config: serializedConfig, + ir: migratedIntermediateRepresentation + }) + ) + ); +} diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/runRemoteGenerationForGenerator.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/runRemoteGenerationForGenerator.ts index 2e3ca4497e8c..374e4fc595a3 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/src/runRemoteGenerationForGenerator.ts +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/runRemoteGenerationForGenerator.ts @@ -41,6 +41,7 @@ import { } from "./fernSdkGenApi.js"; import { getDynamicGeneratorConfig } from "./getDynamicGeneratorConfig.js"; import { pollJobAndReportStatus } from "./pollJobAndReportStatus.js"; +import { prepareFernSdkGenApiRuntimeBundle } from "./prepareFernSdkGenApiRuntimeBundle.js"; import { RemoteTaskHandler } from "./RemoteTaskHandler.js"; import { SourceUploader } from "./SourceUploader.js"; @@ -242,6 +243,8 @@ export async function runRemoteGenerationForGenerator({ }); const venus = createVenusService({ token: token.value }); + let generateOauthClients = false; + let generatePaginatedClients = false; if (!isAirGapped) { const orgResponse = await venus.organization.get({ orgId: projectConfig.organization }); @@ -253,6 +256,8 @@ export async function runRemoteGenerationForGenerator({ ir.readmeConfig.whiteLabel = true; } ir.selfHosted = orgResponse.body.selfHostedSdKs; + generateOauthClients = orgResponse.body.oauthClientEnabled ?? false; + generatePaginatedClients = orgResponse.body.paginationEnabled ?? false; } } @@ -365,6 +370,20 @@ export async function runRemoteGenerationForGenerator({ }; } + const enrichedIntermediateRepresentation: IntermediateRepresentation = { + ...ir, + fdrApiDefinitionId, + publishConfig: getPublishConfig({ + generatorInvocation: generatorInvocationWithEnvVarSubstitutions, + version: resolvedVersion, + userProvidedVersion: version, + packageName, + selfHosted: ir.selfHosted ?? false, + generateFullProject, + context: interactiveTaskContext + }) + }; + let result: RemoteTaskHandler.Response | undefined; let usedSdkGenApi = false; const sdkGenApiEnabled = isFernSdkGenApiEnabled(); @@ -410,6 +429,17 @@ export async function runRemoteGenerationForGenerator({ if (verify === true) { interactiveTaskContext.logger.warn("sdk-gen-api does not yet run Fern's post-generation verification step"); } + const runtimeBundle = await prepareFernSdkGenApiRuntimeBundle({ + apiName: getOriginalName(ir.apiName), + organization, + generatorInvocation: candidate.generatorInvocation, + sdkVersion: candidate.sdkVersion, + intermediateRepresentation: enrichedIntermediateRepresentation, + irVersionOverride, + generateOauthClients, + generatePaginatedClients, + context: interactiveTaskContext + }); const parameters = { apiName: getOriginalName(ir.apiName), organization, @@ -418,6 +448,7 @@ export async function runRemoteGenerationForGenerator({ sdkVersion: candidate.sdkVersion, token, specsTarGzBuffer: candidate.specsTarGzBuffer, + runtimeBundle, absolutePathToPreview, context: interactiveTaskContext, targetIdSeed: sdkGenApiTargetIdSeed, @@ -438,19 +469,7 @@ export async function runRemoteGenerationForGenerator({ generatorInvocation: generatorInvocationWithEnvVarSubstitutions, context: interactiveTaskContext, version: resolvedVersion, - intermediateRepresentation: { - ...ir, - fdrApiDefinitionId, - publishConfig: getPublishConfig({ - generatorInvocation: generatorInvocationWithEnvVarSubstitutions, - version: resolvedVersion, - userProvidedVersion: version, - packageName, - selfHosted: ir.selfHosted ?? false, - generateFullProject, - context: interactiveTaskContext - }) - }, + intermediateRepresentation: enrichedIntermediateRepresentation, shouldLogS3Url, token, whitelabel: whitelabel != null ? substituteEnvVars(whitelabel) : undefined, diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/sdk-gen-client/README.md b/packages/cli/generation/remote-generation/remote-workspace-runner/src/sdk-gen-client/README.md new file mode 100644 index 000000000000..b0d6a26c2038 --- /dev/null +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/sdk-gen-client/README.md @@ -0,0 +1,35 @@ +# Generator configuration compatibility + +`validateGeneratorConfigCompatibility()` is the authority for known first-party +generator aliases, language agreement, exact-version cutovers, accepted +configuration kinds, and payload routes. + +```ts +import { validateGeneratorConfigCompatibility } from "./sdk-gen-client/index.js"; + +const route = validateGeneratorConfigCompatibility({ + generatorId: "fernapi/fern-typescript-sdk", + language: "typescript", + requestedVersion: "4.0.0", + configKind: "sdk-config-v1" +}); + +// route.payloadKind === "sdk-config-ir-v1" +``` + +Versions below a generator's cutover require `legacy-fern` and route to a Fern +runtime bundle. Versions at or above cutover require `sdk-config-v1` and route to +SDK Config IR v1. + +Failures throw `GeneratorConfigCompatibilityError`, which carries stable input, +expected-value, retryability, and recommended-action fields. Product-specific +CLI guidance belongs at the call site. + +The alias and cutover matrix is private. Call `getGeneratorLanguage()` or the +validator instead of importing or copying policy data. + +## Tests + +Compatibility tests live in the runner's `src/__test__` directory and cover all +aliases, cutover boundaries, malformed inputs, stable diagnostics, prereleases, +and large SemVer identifiers. diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/sdk-gen-client/exactSemver.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/sdk-gen-client/exactSemver.ts new file mode 100644 index 000000000000..f38c9024652b --- /dev/null +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/sdk-gen-client/exactSemver.ts @@ -0,0 +1,74 @@ +/** Parsed SemVer precedence components, excluding build metadata. */ +export interface ParsedSemver { + core: readonly [string, string, string]; + prerelease: readonly string[] | null; +} + +const EXACT_SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + +/** Parses only complete SemVer 2.0 versions, rejecting ranges, aliases, and partial versions. */ +export function parseExactSemver(value: string): ParsedSemver | null { + const match = EXACT_SEMVER_PATTERN.exec(value); + if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined) { + return null; + } + + return { + core: [match[1], match[2], match[3]], + prerelease: match[4]?.split(".") ?? null + }; +} + +/** Compares two parsed exact versions according to SemVer 2.0 precedence. */ +export function compareSemver(left: ParsedSemver, right: ParsedSemver): number { + for (let index = 0; index < left.core.length; index += 1) { + const result = compareNumericIdentifier(left.core[index] ?? "", right.core[index] ?? ""); + if (result !== 0) { + return result; + } + } + + return comparePrerelease(left.prerelease, right.prerelease); +} + +function comparePrerelease(left: readonly string[] | null, right: readonly string[] | null): number { + if (left === null || right === null) { + return left === right ? 0 : left === null ? 1 : -1; + } + + const length = Math.max(left.length, right.length); + for (let index = 0; index < length; index += 1) { + const leftIdentifier = left[index]; + const rightIdentifier = right[index]; + if (leftIdentifier === undefined || rightIdentifier === undefined) { + return leftIdentifier === rightIdentifier ? 0 : leftIdentifier === undefined ? -1 : 1; + } + + const result = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier); + if (result !== 0) { + return result; + } + } + + return 0; +} + +function comparePrereleaseIdentifier(left: string, right: string): number { + const leftIsNumeric = /^\d+$/.test(left); + const rightIsNumeric = /^\d+$/.test(right); + if (leftIsNumeric && rightIsNumeric) { + return compareNumericIdentifier(left, right); + } + if (leftIsNumeric !== rightIsNumeric) { + return leftIsNumeric ? -1 : 1; + } + return left === right ? 0 : left < right ? -1 : 1; +} + +function compareNumericIdentifier(left: string, right: string): number { + if (left.length !== right.length) { + return left.length < right.length ? -1 : 1; + } + return left === right ? 0 : left < right ? -1 : 1; +} diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/sdk-gen-client/generatorConfigCompatibility.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/sdk-gen-client/generatorConfigCompatibility.ts new file mode 100644 index 000000000000..c85c42fed9ac --- /dev/null +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/sdk-gen-client/generatorConfigCompatibility.ts @@ -0,0 +1,215 @@ +// cspell:ignore kotlin +import { compareSemver, parseExactSemver } from "./exactSemver.js"; +import { type GeneratorPolicy, getGeneratorPolicy } from "./generatorConfigPolicy.js"; + +export type GeneratorLanguage = + | "typescript" + | "python" + | "java" + | "kotlin" + | "go" + | "csharp" + | "php" + | "ruby" + | "rust" + | "swift" + | "cli" + | "mcp"; + +export type GenerationConfigKind = "legacy-fern" | "sdk-config-v1"; + +export type GenerationPayloadKind = "fern-runtime-bundle" | "sdk-config-ir-v1"; + +/** Inputs used to select the configuration payload route for a generator invocation. */ +export interface ValidateGeneratorConfigCompatibilityInput { + generatorId: string; + language: GeneratorLanguage; + requestedVersion: string; + configKind: GenerationConfigKind; +} + +/** Validated route and payload kind that the caller should submit for generation. */ +export interface GenerationConfigRoute { + generatorId: string; + language: GeneratorLanguage; + requestedVersion: string; + cutoverVersion: string; + configKind: GenerationConfigKind; + payloadKind: GenerationPayloadKind; +} + +export type GeneratorConfigCompatibilityErrorCode = + | "UNKNOWN_GENERATOR" + | "GENERATOR_LANGUAGE_MISMATCH" + | "INVALID_GENERATOR_VERSION" + | "INVALID_CONFIG_KIND" + | "LEGACY_FERN_CONFIG_REQUIRED" + | "SDK_CONFIG_V1_REQUIRED"; + +export type GeneratorConfigCompatibilityRecommendedAction = + | "USE_KNOWN_GENERATOR_ID" + | "USE_GENERATOR_LANGUAGE" + | "USE_EXACT_GENERATOR_VERSION" + | "USE_SUPPORTED_CONFIG_KIND" + | "USE_LEGACY_FERN_CONFIG" + | "USE_SDK_CONFIG_V1"; + +interface GeneratorConfigCompatibilityErrorInput { + code: GeneratorConfigCompatibilityErrorCode; + message: string; + generatorId: string; + language: GeneratorLanguage; + requestedVersion: string; + cutoverVersion: string | null; + receivedConfigKind: unknown; + expectedLanguage: GeneratorLanguage | null; + expectedConfigKind: GenerationConfigKind | null; + recommendedAction: GeneratorConfigCompatibilityRecommendedAction; +} + +/** Stable diagnostic that the CLI can translate at its product boundary. */ +export class GeneratorConfigCompatibilityError extends Error { + public override readonly name = "GeneratorConfigCompatibilityError"; + public readonly code: GeneratorConfigCompatibilityErrorCode; + public readonly generatorId: string; + public readonly language: GeneratorLanguage; + public readonly requestedVersion: string; + public readonly cutoverVersion: string | null; + public readonly receivedConfigKind: unknown; + public readonly expectedConfigKind: GenerationConfigKind | null; + public readonly expectedLanguage: GeneratorLanguage | null; + public readonly retryable = false; + public readonly recommendedAction: GeneratorConfigCompatibilityRecommendedAction; + + public constructor(input: GeneratorConfigCompatibilityErrorInput) { + super(input.message); + this.code = input.code; + this.generatorId = input.generatorId; + this.language = input.language; + this.requestedVersion = input.requestedVersion; + this.cutoverVersion = input.cutoverVersion; + this.receivedConfigKind = input.receivedConfigKind; + this.expectedConfigKind = input.expectedConfigKind; + this.expectedLanguage = input.expectedLanguage; + this.recommendedAction = input.recommendedAction; + } +} + +/** Returns a known generator's language without exposing its cutover policy. */ +export function getGeneratorLanguage(generatorId: string): GeneratorLanguage | undefined { + return getGeneratorPolicy(generatorId)?.language; +} + +/** Validates identity, language, exact version, and config kind, then selects the payload route. */ +export function validateGeneratorConfigCompatibility( + input: ValidateGeneratorConfigCompatibilityInput +): GenerationConfigRoute { + const policy = getGeneratorPolicy(input.generatorId); + if (policy === undefined) { + throw compatibilityError(input, { + code: "UNKNOWN_GENERATOR", + message: `Unknown first-party generator: ${input.generatorId}`, + cutoverVersion: null, + expectedLanguage: null, + expectedConfigKind: null, + recommendedAction: "USE_KNOWN_GENERATOR_ID" + }); + } + + if (policy.language !== input.language) { + throw compatibilityError(input, { + code: "GENERATOR_LANGUAGE_MISMATCH", + message: `Generator ${input.generatorId} targets ${policy.language}, not ${input.language}`, + cutoverVersion: policy.cutoverVersion, + expectedLanguage: policy.language, + expectedConfigKind: null, + recommendedAction: "USE_GENERATOR_LANGUAGE" + }); + } + + const requestedVersion = parseExactSemver(input.requestedVersion); + if (requestedVersion === null) { + throw compatibilityError(input, { + code: "INVALID_GENERATOR_VERSION", + message: `Generator version must be an exact semantic version: ${input.requestedVersion}`, + cutoverVersion: policy.cutoverVersion, + expectedLanguage: policy.language, + expectedConfigKind: null, + recommendedAction: "USE_EXACT_GENERATOR_VERSION" + }); + } + + const isBeforeCutover = compareSemver(requestedVersion, policy.parsedCutoverVersion) < 0; + const expectedConfigKind = isBeforeCutover ? "legacy-fern" : "sdk-config-v1"; + const receivedConfigKind: unknown = input.configKind; + if (!isGenerationConfigKind(receivedConfigKind)) { + throw compatibilityError( + input, + { + code: "INVALID_CONFIG_KIND", + message: "Configuration kind must be legacy-fern or sdk-config-v1", + cutoverVersion: policy.cutoverVersion, + expectedLanguage: policy.language, + expectedConfigKind, + recommendedAction: "USE_SUPPORTED_CONFIG_KIND" + }, + receivedConfigKind + ); + } + if (receivedConfigKind !== expectedConfigKind) { + throw configKindError(input, policy, expectedConfigKind, receivedConfigKind); + } + + return { + generatorId: input.generatorId, + language: policy.language, + requestedVersion: input.requestedVersion, + cutoverVersion: policy.cutoverVersion, + configKind: receivedConfigKind, + payloadKind: isBeforeCutover ? "fern-runtime-bundle" : "sdk-config-ir-v1" + }; +} + +function compatibilityError( + input: ValidateGeneratorConfigCompatibilityInput, + details: Omit< + GeneratorConfigCompatibilityErrorInput, + "generatorId" | "language" | "requestedVersion" | "receivedConfigKind" + >, + receivedConfigKind: unknown = input.configKind +): GeneratorConfigCompatibilityError { + return new GeneratorConfigCompatibilityError({ + generatorId: input.generatorId, + language: input.language, + requestedVersion: input.requestedVersion, + receivedConfigKind, + ...details + }); +} + +function configKindError( + input: ValidateGeneratorConfigCompatibilityInput, + policy: GeneratorPolicy, + expectedConfigKind: GenerationConfigKind, + receivedConfigKind: GenerationConfigKind +): GeneratorConfigCompatibilityError { + const requiresLegacy = expectedConfigKind === "legacy-fern"; + return compatibilityError( + input, + { + code: requiresLegacy ? "LEGACY_FERN_CONFIG_REQUIRED" : "SDK_CONFIG_V1_REQUIRED", + message: requiresLegacy + ? `Generator ${input.generatorId} ${input.requestedVersion} requires legacy Fern configuration` + : `Generator ${input.generatorId} ${input.requestedVersion} requires SDK Config v1`, + cutoverVersion: policy.cutoverVersion, + expectedLanguage: policy.language, + expectedConfigKind, + recommendedAction: requiresLegacy ? "USE_LEGACY_FERN_CONFIG" : "USE_SDK_CONFIG_V1" + }, + receivedConfigKind + ); +} + +function isGenerationConfigKind(value: unknown): value is GenerationConfigKind { + return value === "legacy-fern" || value === "sdk-config-v1"; +} diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/sdk-gen-client/generatorConfigPolicy.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/sdk-gen-client/generatorConfigPolicy.ts new file mode 100644 index 000000000000..d150dda9044b --- /dev/null +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/sdk-gen-client/generatorConfigPolicy.ts @@ -0,0 +1,74 @@ +// cspell:ignore kotlin +import { type ParsedSemver, parseExactSemver } from "./exactSemver.js"; +import type { GeneratorLanguage } from "./generatorConfigCompatibility.js"; + +interface GeneratorPolicyDefinition { + language: GeneratorLanguage; + cutoverVersion: string; +} + +/** Validated internal policy associated with one first-party generator alias. */ +export interface GeneratorPolicy extends GeneratorPolicyDefinition { + parsedCutoverVersion: ParsedSemver; +} + +type GeneratorPolicyEntry = readonly [string, GeneratorPolicyDefinition]; + +/** Typed startup failure for an invalid private generator cutover definition. */ +export class GeneratorConfigPolicyInvariantError extends Error { + public override readonly name = "GeneratorConfigPolicyInvariantError"; + public readonly code = "INVALID_GENERATOR_CUTOVER_POLICY"; + public readonly generatorId: string; + public readonly language: GeneratorLanguage; + public readonly cutoverVersion: string; + public readonly retryable = false; + public readonly recommendedAction = "FIX_GENERATOR_CUTOVER_POLICY"; + + public constructor(generatorId: string, policy: GeneratorPolicyDefinition) { + super(`Generator ${generatorId} has an invalid cutover version: ${policy.cutoverVersion}`); + this.generatorId = generatorId; + this.language = policy.language; + this.cutoverVersion = policy.cutoverVersion; + } +} + +/** Internal constructor exported only for direct invariant testing. */ +export function createGeneratorPolicies( + entries: readonly GeneratorPolicyEntry[] +): ReadonlyMap { + return new Map( + entries.map(([generatorId, policy]) => { + const parsedCutoverVersion = parseExactSemver(policy.cutoverVersion); + if (parsedCutoverVersion === null) { + throw new GeneratorConfigPolicyInvariantError(generatorId, policy); + } + return [generatorId, { ...policy, parsedCutoverVersion }]; + }) + ); +} + +// This is the sole authority for first-party aliases and cutovers. +const GENERATOR_POLICIES = createGeneratorPolicies([ + ["fernapi/fern-typescript", { language: "typescript", cutoverVersion: "4.0.0" }], + ["fernapi/fern-typescript-sdk", { language: "typescript", cutoverVersion: "4.0.0" }], + ["fernapi/fern-typescript-node-sdk", { language: "typescript", cutoverVersion: "4.0.0" }], + ["fernapi/fern-typescript-browser-sdk", { language: "typescript", cutoverVersion: "4.0.0" }], + ["fernapi/fern-python-sdk", { language: "python", cutoverVersion: "6.0.0" }], + ["fernapi/fern-java-sdk", { language: "java", cutoverVersion: "5.0.0" }], + ["fernapi/fern-kotlin-sdk", { language: "kotlin", cutoverVersion: "5.0.0" }], + ["fernapi/fern-go-sdk", { language: "go", cutoverVersion: "2.0.0" }], + ["fernapi/fern-csharp-sdk", { language: "csharp", cutoverVersion: "3.0.0" }], + ["fernapi/fern-php-sdk", { language: "php", cutoverVersion: "3.0.0" }], + ["fernapi/fern-ruby-sdk", { language: "ruby", cutoverVersion: "2.0.0" }], + ["fernapi/fern-ruby-sdk-v2", { language: "ruby", cutoverVersion: "2.0.0" }], + ["fernapi/fern-rust-sdk", { language: "rust", cutoverVersion: "1.0.0" }], + ["fernapi/fern-swift-sdk", { language: "swift", cutoverVersion: "1.0.0" }], + ["fernapi/fern-cli", { language: "cli", cutoverVersion: "1.0.0" }], + ["fernapi/fern-cli-generator", { language: "cli", cutoverVersion: "1.0.0" }], + ["fernapi/fern-mcp-server", { language: "mcp", cutoverVersion: "0.1.0" }] +]); + +/** Resolves one alias without exposing the private policy collection. */ +export function getGeneratorPolicy(generatorId: string): GeneratorPolicy | undefined { + return GENERATOR_POLICIES.get(generatorId); +} diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/sdk-gen-client/index.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/sdk-gen-client/index.ts new file mode 100644 index 000000000000..25771b4606c1 --- /dev/null +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/sdk-gen-client/index.ts @@ -0,0 +1,14 @@ +export type { + GenerationConfigKind, + GenerationConfigRoute, + GenerationPayloadKind, + GeneratorConfigCompatibilityErrorCode, + GeneratorConfigCompatibilityRecommendedAction, + GeneratorLanguage, + ValidateGeneratorConfigCompatibilityInput +} from "./generatorConfigCompatibility.js"; +export { + GeneratorConfigCompatibilityError, + getGeneratorLanguage, + validateGeneratorConfigCompatibility +} from "./generatorConfigCompatibility.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c713ef2521a7..799951499f36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6348,6 +6348,9 @@ importers: '@fern-api/configuration': specifier: workspace:* version: link:../../../configuration + '@fern-api/configuration-loader': + specifier: workspace:* + version: link:../../../configuration-loader '@fern-api/core': specifier: workspace:* version: link:../../../../core @@ -6369,6 +6372,9 @@ importers: '@fern-api/generator-cli': specifier: workspace:* version: link:../../../../generator-cli + '@fern-api/github': + specifier: workspace:* + version: link:../../../../commons/github '@fern-api/ir-generator': specifier: workspace:* version: link:../../ir-generator @@ -6405,6 +6411,9 @@ importers: '@fern-fern/fiddle-sdk': specifier: 'catalog:' version: 1.1.0 + '@fern-fern/generator-exec-sdk': + specifier: 'catalog:' + version: 0.0.1167 axios: specifier: 'catalog:' version: 1.16.0