From 234458cfce6f2ea6bca4f937441223365a2e1c99 Mon Sep 17 00:00:00 2001 From: lifanzou Date: Fri, 1 May 2026 05:25:16 -0700 Subject: [PATCH 01/11] fix(php): emit required global headers as named constructor arguments (#15634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the OpenAPI importer turns an apiKey-in-header security scheme into a required global header, the PHP SDK constructor takes it as a required positional parameter. The dynamic snippet generator was incorrectly placing these headers inside the options array, producing constructor calls that omitted the required parameter. Split getConstructorHeaderArgs into getRequiredGlobalHeaderArgs (emits NamedArgument[]) and getOptionalGlobalHeaderArgs (emits ConstructorField[]). Uses a dedicated isHeaderTypeOptional predicate that only checks for the optional wrapper — unlike the shared isOptional, it does not treat nullable aliases as optional, which would misclassify required headers with nullable alias types. Co-authored-by: Barry Co-authored-by: Claude Opus 4.6 --- .../src/EndpointSnippetGenerator.ts | 79 ++++++++++++++++++- .../DynamicSnippetsGenerator.test.ts.snap | 8 +- .../src/context/DynamicTypeLiteralMapper.ts | 8 ++ .../fix-snippet-required-global-headers.yml | 6 ++ 4 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 generators/php/sdk/changes/unreleased/fix-snippet-required-global-headers.yml diff --git a/generators/php/dynamic-snippets/src/EndpointSnippetGenerator.ts b/generators/php/dynamic-snippets/src/EndpointSnippetGenerator.ts index 73d1816c0843..79250269c1e4 100644 --- a/generators/php/dynamic-snippets/src/EndpointSnippetGenerator.ts +++ b/generators/php/dynamic-snippets/src/EndpointSnippetGenerator.ts @@ -234,14 +234,20 @@ export class EndpointSnippetGenerator { } this.context.errors.scope(Scope.Headers); + const requiredGlobalHeaderArgs: NamedArgument[] = []; + if (this.context.ir.headers != null) { + requiredGlobalHeaderArgs.push( + ...this.getRequiredGlobalHeaderArgs({ headers: this.context.ir.headers, values: snippet.headers }) + ); + } if (this.context.ir.headers != null && snippet.headers != null) { optionArgs.push( - ...this.getConstructorHeaderArgs({ headers: this.context.ir.headers, values: snippet.headers }) + ...this.getOptionalGlobalHeaderArgs({ headers: this.context.ir.headers, values: snippet.headers }) ); } this.context.errors.unscope(); - const args: NamedArgument[] = [...authArgs]; + const args: NamedArgument[] = [...requiredGlobalHeaderArgs, ...authArgs]; if (environmentArg != null) { args.push(environmentArg); @@ -678,7 +684,71 @@ export class EndpointSnippetGenerator { return []; } - private getConstructorHeaderArgs({ + // NOTE: We intentionally avoid this.context.isOptional here because it treats + // named aliases to nullable types as optional, which would misclassify required + // headers with nullable alias types (they're still required constructor params). + private isHeaderTypeOptional(typeReference: FernIr.dynamic.TypeReference): boolean { + switch (typeReference.type) { + case "optional": + return true; + case "nullable": + return this.isHeaderTypeOptional(typeReference.value); + default: + return false; + } + } + + private getRequiredGlobalHeaderArgs({ + headers, + values + }: { + headers: FernIr.dynamic.NamedParameter[]; + values: FernIr.dynamic.Values | undefined; + }): NamedArgument[] { + const args: NamedArgument[] = []; + for (const header of headers) { + if (this.isHeaderTypeOptional(header.typeReference)) { + continue; + } + const value = values?.[header.name.wireValue]; + const arg = this.getRequiredGlobalHeaderValue({ header, value }); + if (arg != null) { + args.push({ + name: this.context.getPropertyName(header.name.name), + assignment: arg + }); + } + } + return args; + } + + private getRequiredGlobalHeaderValue({ + header, + value + }: { + header: FernIr.dynamic.NamedParameter; + value: unknown; + }): php.TypeLiteral | undefined { + if (value !== undefined) { + const typeLiteral = this.context.dynamicTypeLiteralMapper.convert({ + typeReference: header.typeReference, + value + }); + if (php.TypeLiteral.isNop(typeLiteral)) { + return undefined; + } + return typeLiteral; + } + const placeholder = this.context.dynamicTypeLiteralMapper.generatePlaceholderValueForRequiredHeader({ + typeReference: header.typeReference + }); + if (php.TypeLiteral.isNop(placeholder)) { + return undefined; + } + return placeholder; + } + + private getOptionalGlobalHeaderArgs({ headers, values }: { @@ -687,6 +757,9 @@ export class EndpointSnippetGenerator { }): php.ConstructorField[] { const args: php.ConstructorField[] = []; for (const header of headers) { + if (!this.isHeaderTypeOptional(header.typeReference)) { + continue; + } const value = values[header.name.wireValue]; const arg = this.getConstructorHeaderArg({ header, value }); if (arg != null) { diff --git a/generators/php/dynamic-snippets/src/__test__/__snapshots__/DynamicSnippetsGenerator.test.ts.snap b/generators/php/dynamic-snippets/src/__test__/__snapshots__/DynamicSnippetsGenerator.test.ts.snap index 99847e7d165a..c1d474eec88f 100644 --- a/generators/php/dynamic-snippets/src/__test__/__snapshots__/DynamicSnippetsGenerator.test.ts.snap +++ b/generators/php/dynamic-snippets/src/__test__/__snapshots__/DynamicSnippetsGenerator.test.ts.snap @@ -635,10 +635,8 @@ namespace Example; use Acme\\AcmeClient; $client = new AcmeClient( + tenantId: 'my-tenant-id', token: '', - options: [ - 'tenantId' => 'my-tenant-id', - ], ); $client->plantstore->list(); " @@ -653,10 +651,8 @@ use Acme\\AcmeClient; use Acme\\Plantstore\\Types\\CreatePlantRequest; $client = new AcmeClient( + tenantId: 'my-tenant-id', token: '', - options: [ - 'tenantId' => 'my-tenant-id', - ], ); $client->plantstore->create( new CreatePlantRequest([ diff --git a/generators/php/dynamic-snippets/src/context/DynamicTypeLiteralMapper.ts b/generators/php/dynamic-snippets/src/context/DynamicTypeLiteralMapper.ts index 5d9a5809205e..95b8757a5fcd 100644 --- a/generators/php/dynamic-snippets/src/context/DynamicTypeLiteralMapper.ts +++ b/generators/php/dynamic-snippets/src/context/DynamicTypeLiteralMapper.ts @@ -386,6 +386,14 @@ export class DynamicTypeLiteralMapper { return this.context.isOptional(typeReference) || this.context.isNullable(typeReference); } + public generatePlaceholderValueForRequiredHeader({ + typeReference + }: { + typeReference: FernIr.dynamic.TypeReference; + }): php.TypeLiteral { + return this.generatePlaceholderValue(typeReference); + } + private generatePlaceholderValue(typeReference: FernIr.dynamic.TypeReference): php.TypeLiteral { switch (typeReference.type) { case "primitive": diff --git a/generators/php/sdk/changes/unreleased/fix-snippet-required-global-headers.yml b/generators/php/sdk/changes/unreleased/fix-snippet-required-global-headers.yml new file mode 100644 index 000000000000..8a7ef292ea01 --- /dev/null +++ b/generators/php/sdk/changes/unreleased/fix-snippet-required-global-headers.yml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + Fix dynamic snippet generator to emit required global headers as named + constructor arguments instead of inside the options array. + type: fix From 0fbe40132372dc1cd988e56a810fe85dc8e3a3d1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 12:29:18 +0000 Subject: [PATCH 02/11] chore(php): release 2.8.1 --- .../fix-snippet-required-global-headers.yml | 0 generators/php/sdk/versions.yml | 8 ++++++++ 2 files changed, 8 insertions(+) rename generators/php/sdk/changes/{unreleased => 2.8.1}/fix-snippet-required-global-headers.yml (100%) diff --git a/generators/php/sdk/changes/unreleased/fix-snippet-required-global-headers.yml b/generators/php/sdk/changes/2.8.1/fix-snippet-required-global-headers.yml similarity index 100% rename from generators/php/sdk/changes/unreleased/fix-snippet-required-global-headers.yml rename to generators/php/sdk/changes/2.8.1/fix-snippet-required-global-headers.yml diff --git a/generators/php/sdk/versions.yml b/generators/php/sdk/versions.yml index ae9d65795c15..a0a8482d431d 100644 --- a/generators/php/sdk/versions.yml +++ b/generators/php/sdk/versions.yml @@ -1,4 +1,12 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 2.8.1 + changelogEntry: + - summary: | + Fix dynamic snippet generator to emit required global headers as named + constructor arguments instead of inside the options array. + type: fix + createdAt: "2026-05-01" + irVersion: 66 - version: 2.8.0 changelogEntry: - summary: | From 008d2d1d8d597cfa7cb5be93526de0b6900b6489 Mon Sep 17 00:00:00 2001 From: Fern Support <126544928+fern-support@users.noreply.github.com> Date: Fri, 1 May 2026 08:39:36 -0400 Subject: [PATCH 03/11] chore(seed): update all seed snapshots (#15636) Co-authored-by: lifanzou <265923155+lifanzou@users.noreply.github.com> --- seed/php-sdk/bearer-token-environment-variable/README.md | 1 + .../src/dynamic-snippets/example0/snippet.php | 1 + seed/php-sdk/literal/README.md | 5 ++++- .../literal/src/dynamic-snippets/example0/snippet.php | 2 ++ .../literal/src/dynamic-snippets/example1/snippet.php | 2 ++ .../literal/src/dynamic-snippets/example2/snippet.php | 2 ++ .../literal/src/dynamic-snippets/example3/snippet.php | 2 ++ .../literal/src/dynamic-snippets/example4/snippet.php | 2 ++ .../literal/src/dynamic-snippets/example5/snippet.php | 2 ++ .../literal/src/dynamic-snippets/example6/snippet.php | 2 ++ .../literal/src/dynamic-snippets/example7/snippet.php | 2 ++ .../literal/src/dynamic-snippets/example8/snippet.php | 2 ++ .../literal/src/dynamic-snippets/example9/snippet.php | 2 ++ 13 files changed, 26 insertions(+), 1 deletion(-) diff --git a/seed/php-sdk/bearer-token-environment-variable/README.md b/seed/php-sdk/bearer-token-environment-variable/README.md index 5f595e58902b..060940225650 100644 --- a/seed/php-sdk/bearer-token-environment-variable/README.md +++ b/seed/php-sdk/bearer-token-environment-variable/README.md @@ -39,6 +39,7 @@ namespace Example; use Seed\SeedClient; $client = new SeedClient( + version: '1.0.0', apiKey: 'YOUR_API_KEY', ); $client->service->getWithBearerToken(); diff --git a/seed/php-sdk/bearer-token-environment-variable/src/dynamic-snippets/example0/snippet.php b/seed/php-sdk/bearer-token-environment-variable/src/dynamic-snippets/example0/snippet.php index 5ec85b5baae6..c5e84d056ab2 100644 --- a/seed/php-sdk/bearer-token-environment-variable/src/dynamic-snippets/example0/snippet.php +++ b/seed/php-sdk/bearer-token-environment-variable/src/dynamic-snippets/example0/snippet.php @@ -5,6 +5,7 @@ use Seed\SeedClient; $client = new SeedClient( + version: '1.0.0', apiKey: 'YOUR_API_KEY', options: [ 'baseUrl' => 'https://api.fern.com', diff --git a/seed/php-sdk/literal/README.md b/seed/php-sdk/literal/README.md index 73e004d6d1fd..e46818d0843f 100644 --- a/seed/php-sdk/literal/README.md +++ b/seed/php-sdk/literal/README.md @@ -39,7 +39,10 @@ namespace Example; use Seed\SeedClient; use Seed\Headers\Requests\SendLiteralsInHeadersRequest; -$client = new SeedClient(); +$client = new SeedClient( + version: '02-02-2024', + auditLogging: true, +); $client->headers->send( new SendLiteralsInHeadersRequest([ 'endpointVersion' => '02-12-2024', diff --git a/seed/php-sdk/literal/src/dynamic-snippets/example0/snippet.php b/seed/php-sdk/literal/src/dynamic-snippets/example0/snippet.php index a22b611fce01..9e70ead61c95 100644 --- a/seed/php-sdk/literal/src/dynamic-snippets/example0/snippet.php +++ b/seed/php-sdk/literal/src/dynamic-snippets/example0/snippet.php @@ -6,6 +6,8 @@ use Seed\Headers\Requests\SendLiteralsInHeadersRequest; $client = new SeedClient( + version: '02-02-2024', + auditLogging: true, options: [ 'baseUrl' => 'https://api.fern.com', ], diff --git a/seed/php-sdk/literal/src/dynamic-snippets/example1/snippet.php b/seed/php-sdk/literal/src/dynamic-snippets/example1/snippet.php index b39f0eb5d6b8..6346f6c072fd 100644 --- a/seed/php-sdk/literal/src/dynamic-snippets/example1/snippet.php +++ b/seed/php-sdk/literal/src/dynamic-snippets/example1/snippet.php @@ -6,6 +6,8 @@ use Seed\Headers\Requests\SendLiteralsInHeadersRequest; $client = new SeedClient( + version: '02-02-2024', + auditLogging: true, options: [ 'baseUrl' => 'https://api.fern.com', ], diff --git a/seed/php-sdk/literal/src/dynamic-snippets/example2/snippet.php b/seed/php-sdk/literal/src/dynamic-snippets/example2/snippet.php index c2fd2150b5bc..fc6d4959aa9b 100644 --- a/seed/php-sdk/literal/src/dynamic-snippets/example2/snippet.php +++ b/seed/php-sdk/literal/src/dynamic-snippets/example2/snippet.php @@ -8,6 +8,8 @@ use Seed\Inlined\Types\ANestedLiteral; $client = new SeedClient( + version: '02-02-2024', + auditLogging: true, options: [ 'baseUrl' => 'https://api.fern.com', ], diff --git a/seed/php-sdk/literal/src/dynamic-snippets/example3/snippet.php b/seed/php-sdk/literal/src/dynamic-snippets/example3/snippet.php index f65bba7758a4..e16b772eca87 100644 --- a/seed/php-sdk/literal/src/dynamic-snippets/example3/snippet.php +++ b/seed/php-sdk/literal/src/dynamic-snippets/example3/snippet.php @@ -8,6 +8,8 @@ use Seed\Inlined\Types\ANestedLiteral; $client = new SeedClient( + version: '02-02-2024', + auditLogging: true, options: [ 'baseUrl' => 'https://api.fern.com', ], diff --git a/seed/php-sdk/literal/src/dynamic-snippets/example4/snippet.php b/seed/php-sdk/literal/src/dynamic-snippets/example4/snippet.php index 5a7c45909206..e85d1f00bf9a 100644 --- a/seed/php-sdk/literal/src/dynamic-snippets/example4/snippet.php +++ b/seed/php-sdk/literal/src/dynamic-snippets/example4/snippet.php @@ -5,6 +5,8 @@ use Seed\SeedClient; $client = new SeedClient( + version: '02-02-2024', + auditLogging: true, options: [ 'baseUrl' => 'https://api.fern.com', ], diff --git a/seed/php-sdk/literal/src/dynamic-snippets/example5/snippet.php b/seed/php-sdk/literal/src/dynamic-snippets/example5/snippet.php index 5a7c45909206..e85d1f00bf9a 100644 --- a/seed/php-sdk/literal/src/dynamic-snippets/example5/snippet.php +++ b/seed/php-sdk/literal/src/dynamic-snippets/example5/snippet.php @@ -5,6 +5,8 @@ use Seed\SeedClient; $client = new SeedClient( + version: '02-02-2024', + auditLogging: true, options: [ 'baseUrl' => 'https://api.fern.com', ], diff --git a/seed/php-sdk/literal/src/dynamic-snippets/example6/snippet.php b/seed/php-sdk/literal/src/dynamic-snippets/example6/snippet.php index 68c61890732e..20dc5b642264 100644 --- a/seed/php-sdk/literal/src/dynamic-snippets/example6/snippet.php +++ b/seed/php-sdk/literal/src/dynamic-snippets/example6/snippet.php @@ -6,6 +6,8 @@ use Seed\Query\Requests\SendLiteralsInQueryRequest; $client = new SeedClient( + version: '02-02-2024', + auditLogging: true, options: [ 'baseUrl' => 'https://api.fern.com', ], diff --git a/seed/php-sdk/literal/src/dynamic-snippets/example7/snippet.php b/seed/php-sdk/literal/src/dynamic-snippets/example7/snippet.php index 456cec3bed7c..c39e0388f388 100644 --- a/seed/php-sdk/literal/src/dynamic-snippets/example7/snippet.php +++ b/seed/php-sdk/literal/src/dynamic-snippets/example7/snippet.php @@ -6,6 +6,8 @@ use Seed\Query\Requests\SendLiteralsInQueryRequest; $client = new SeedClient( + version: '02-02-2024', + auditLogging: true, options: [ 'baseUrl' => 'https://api.fern.com', ], diff --git a/seed/php-sdk/literal/src/dynamic-snippets/example8/snippet.php b/seed/php-sdk/literal/src/dynamic-snippets/example8/snippet.php index 2c8c1677dc66..61416284e8ca 100644 --- a/seed/php-sdk/literal/src/dynamic-snippets/example8/snippet.php +++ b/seed/php-sdk/literal/src/dynamic-snippets/example8/snippet.php @@ -8,6 +8,8 @@ use Seed\Reference\Types\NestedObjectWithLiterals; $client = new SeedClient( + version: '02-02-2024', + auditLogging: true, options: [ 'baseUrl' => 'https://api.fern.com', ], diff --git a/seed/php-sdk/literal/src/dynamic-snippets/example9/snippet.php b/seed/php-sdk/literal/src/dynamic-snippets/example9/snippet.php index ae6d8b018e6a..d046d4c5af09 100644 --- a/seed/php-sdk/literal/src/dynamic-snippets/example9/snippet.php +++ b/seed/php-sdk/literal/src/dynamic-snippets/example9/snippet.php @@ -8,6 +8,8 @@ use Seed\Reference\Types\NestedObjectWithLiterals; $client = new SeedClient( + version: '02-02-2024', + auditLogging: true, options: [ 'baseUrl' => 'https://api.fern.com', ], From 373adc69211d5cb0e7f6be0f5b8509bfb3212fec Mon Sep 17 00:00:00 2001 From: Fern Support <126544928+fern-support@users.noreply.github.com> Date: Fri, 1 May 2026 08:49:00 -0400 Subject: [PATCH 04/11] chore(seed): update all seed snapshots (#15637) Co-authored-by: dsinghvi <10870189+dsinghvi@users.noreply.github.com> From 3fb202641bd9626636747f7459cb07c225831665 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 06:25:49 -0700 Subject: [PATCH 05/11] feat(cli): add `fern automations upgrade` command (#15537) * feat(cli): add fern automations upgrade command with structured JSON output Add `fern automations upgrade` command that wraps `fern upgrade` and `fern generator upgrade` into a single invocation with `--json` output. Designed for consumption by the fern-upgrade GitHub Action, replacing the snapshot.js / diff.js approach with a single CLI call. Also replace the brittle hardcoded changelog URL map in upgradeGenerator.ts with a regex-based derivation that supports all current and future generators. Co-Authored-By: barry.zou * fix: use writeJsonToStdout for automations upgrade JSON output The CliContext.enableJsonMode() middleware redirects process.stdout to stderr as a safety net. Use writeJsonToStdout() which temporarily restores stdout before writing, ensuring JSON output goes to fd 1. Co-Authored-By: barry.zou * fix: sort automations upgrade JSON output deterministically Sort generators, skippedMajor, and alreadyUpToDate arrays by name+group+api before returning. Promise.all over workspaces may resolve in any order, causing non-deterministic output that could produce unnecessary PR body churn. Co-Authored-By: barry.zou * feat: default --include-major to true for automations upgrade + add tests Co-Authored-By: barry.zou --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: barry.zou --- .../add-automations-upgrade-command.yml | 10 + packages/cli/cli/src/cli.ts | 100 +++++++ .../executeAutomationsUpgrade.test.ts | 160 +++++++++++ .../upgrade/executeAutomationsUpgrade.ts | 258 ++++++++++++++++++ .../src/commands/upgrade/upgradeGenerator.ts | 25 +- 5 files changed, 540 insertions(+), 13 deletions(-) create mode 100644 packages/cli/cli/changes/unreleased/add-automations-upgrade-command.yml create mode 100644 packages/cli/cli/src/commands/automations/upgrade/__test__/executeAutomationsUpgrade.test.ts create mode 100644 packages/cli/cli/src/commands/automations/upgrade/executeAutomationsUpgrade.ts diff --git a/packages/cli/cli/changes/unreleased/add-automations-upgrade-command.yml b/packages/cli/cli/changes/unreleased/add-automations-upgrade-command.yml new file mode 100644 index 000000000000..6f5bf0649c33 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/add-automations-upgrade-command.yml @@ -0,0 +1,10 @@ +- summary: | + Add `fern automations upgrade` command that wraps `fern upgrade` and + `fern generator upgrade` into a single invocation with structured JSON + output (`--json`). Designed for consumption by the fern-upgrade GitHub + Action. + type: feat +- summary: | + Replace brittle hardcoded changelog URL map in `upgradeGenerator.ts` with + a regex-based derivation that supports all current and future generators. + type: fix diff --git a/packages/cli/cli/src/cli.ts b/packages/cli/cli/src/cli.ts index 6fa81b73d214..52d20c988839 100644 --- a/packages/cli/cli/src/cli.ts +++ b/packages/cli/cli/src/cli.ts @@ -46,6 +46,7 @@ import { addGeneratorCommands, addGetOrganizationCommand } from "./cliV2.js"; import { addGeneratorToWorkspaces } from "./commands/add-generator/addGeneratorToWorkspaces.js"; import { executeAutomationsGenerate } from "./commands/automations/generate/executeAutomationsGenerate.js"; import { listPreviewGroups } from "./commands/automations/listPreviewGroups.js"; +import { executeAutomationsUpgrade } from "./commands/automations/upgrade/executeAutomationsUpgrade.js"; import { diff } from "./commands/diff/diff.js"; import { previewDocsWorkspace } from "./commands/docs-dev/devDocsWorkspace.js"; import { docsDiff } from "./commands/docs-diff/docsDiff.js"; @@ -2334,6 +2335,7 @@ function addAutomationsCommand(cli: Argv, cliContext: CliConte cli.command("automations", false, (yargs) => { addAutomationsGenerateCommand(yargs, cliContext); addAutomationsPreviewCommand(yargs, cliContext); + addAutomationsUpgradeCommand(yargs, cliContext); return yargs.demandCommand(); }); } @@ -2607,6 +2609,104 @@ function addAutomationsGenerateCommand(cli: Argv, cliContext: ); } +/** + * `fern automations upgrade` + * + * Wraps `fern upgrade` (CLI version) and `fern generator upgrade` (generator + * versions) into a single command that outputs structured JSON to stdout. + * + * Designed for consumption by the fern-upgrade GitHub Action, replacing the + * previous snapshot.js / diff.js approach with a single CLI invocation. + * + * Flags: + * --include-major Include major version bumps for generators (default: false). + * --json Output structured JSON to stdout (for machine consumption). + * + * JSON output format (--json): + * { + * "cli": { "from": "4.66.0", "to": "4.96.0", "upgraded": true }, + * "generators": [ + * { + * "name": "fernapi/fern-typescript-sdk", + * "group": "ts-sdk", + * "api": "api", + * "from": "3.63.4", + * "to": "3.65.5", + * "changelog": "https://buildwithfern.com/learn/sdks/generators/typescript/changelog", + * "migrationsApplied": 1 + * } + * ], + * "skippedMajor": [{ "name": "...", "current": "0.28.0", "latest": "1.37.0" }], + * "alreadyUpToDate": [{ "name": "...", "version": "3.65.5" }] + * } + * + * Example GitHub Actions usage: + * - run: | + * UPGRADE_JSON=$(fern automations upgrade --json --include-major) + * echo "upgrade-json=$UPGRADE_JSON" >> "$GITHUB_OUTPUT" + * env: + * FERN_TOKEN: ${{ secrets.FERN_TOKEN }} + */ +function addAutomationsUpgradeCommand(cli: Argv, cliContext: CliContext) { + cli.command( + "upgrade", + false, // hidden + (yargs) => + yargs + .option("include-major", { + boolean: true, + default: true, + description: + "Whether to include major version upgrades for generators. " + + "When true (default), all upgrades including major versions are applied." + }) + .option("json", { + boolean: true, + default: false, + description: "Output results as JSON to stdout (for machine consumption)." + }), + async (argv) => { + cliContext.instrumentPostHogEvent({ + command: "fern automations upgrade" + }); + + const result = await executeAutomationsUpgrade({ + cliContext, + options: { + includeMajor: argv["include-major"] + } + }); + + if (argv.json) { + cliContext.writeJsonToStdout(result); + } else { + // Human-readable summary + const { cli: cliResult, generators, skippedMajor, alreadyUpToDate } = result; + if (cliResult.upgraded) { + cliContext.logger.info(`CLI: ${cliResult.from} -> ${cliResult.to}`); + } else { + cliContext.logger.info(`CLI: ${cliResult.from} (already up to date)`); + } + if (generators.length > 0) { + cliContext.logger.info(`Generators upgraded: ${generators.length}`); + for (const gen of generators) { + cliContext.logger.info(` ${gen.name}: ${gen.from} -> ${gen.to}`); + } + } + if (alreadyUpToDate.length > 0) { + cliContext.logger.info(`Generators already up to date: ${alreadyUpToDate.length}`); + } + if (skippedMajor.length > 0) { + cliContext.logger.info(`Major upgrades available (skipped): ${skippedMajor.length}`); + for (const skip of skippedMajor) { + cliContext.logger.info(` ${skip.name}: ${skip.current} -> ${skip.latest}`); + } + } + } + } + ); +} + function addSdkPreviewCommand(cli: Argv, cliContext: CliContext) { cli.command( "preview", diff --git a/packages/cli/cli/src/commands/automations/upgrade/__test__/executeAutomationsUpgrade.test.ts b/packages/cli/cli/src/commands/automations/upgrade/__test__/executeAutomationsUpgrade.test.ts new file mode 100644 index 000000000000..ca1232b4770b --- /dev/null +++ b/packages/cli/cli/src/commands/automations/upgrade/__test__/executeAutomationsUpgrade.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; + +import { getChangelogUrl } from "../executeAutomationsUpgrade.js"; + +describe("getChangelogUrl", () => { + it("derives typescript changelog URL from SDK generator name", () => { + expect(getChangelogUrl("fernapi/fern-typescript-sdk")).toBe( + "https://buildwithfern.com/learn/sdks/generators/typescript/changelog" + ); + }); + + it("derives typescript changelog URL from node SDK variant", () => { + expect(getChangelogUrl("fernapi/fern-typescript-node-sdk")).toBe( + "https://buildwithfern.com/learn/sdks/generators/typescript/changelog" + ); + }); + + it("derives python changelog URL", () => { + expect(getChangelogUrl("fernapi/fern-python-sdk")).toBe( + "https://buildwithfern.com/learn/sdks/generators/python/changelog" + ); + }); + + it("derives go changelog URL", () => { + expect(getChangelogUrl("fernapi/fern-go-sdk")).toBe( + "https://buildwithfern.com/learn/sdks/generators/go/changelog" + ); + }); + + it("derives java changelog URL", () => { + expect(getChangelogUrl("fernapi/fern-java-sdk")).toBe( + "https://buildwithfern.com/learn/sdks/generators/java/changelog" + ); + }); + + it("derives csharp changelog URL", () => { + expect(getChangelogUrl("fernapi/fern-csharp-sdk")).toBe( + "https://buildwithfern.com/learn/sdks/generators/csharp/changelog" + ); + }); + + it("derives ruby changelog URL from v2 variant", () => { + expect(getChangelogUrl("fernapi/fern-ruby-sdk-v2")).toBe( + "https://buildwithfern.com/learn/sdks/generators/ruby/changelog" + ); + }); + + it("derives php changelog URL", () => { + expect(getChangelogUrl("fernapi/fern-php-sdk")).toBe( + "https://buildwithfern.com/learn/sdks/generators/php/changelog" + ); + }); + + it("derives swift changelog URL", () => { + expect(getChangelogUrl("fernapi/fern-swift-sdk")).toBe( + "https://buildwithfern.com/learn/sdks/generators/swift/changelog" + ); + }); + + it("derives rust changelog URL", () => { + expect(getChangelogUrl("fernapi/fern-rust-sdk")).toBe( + "https://buildwithfern.com/learn/sdks/generators/rust/changelog" + ); + }); + + it("returns undefined for unrecognized generator names", () => { + expect(getChangelogUrl("some-other-generator")).toBeUndefined(); + }); + + it("returns undefined for empty string", () => { + expect(getChangelogUrl("")).toBeUndefined(); + }); + + it("returns undefined for generator without fernapi prefix", () => { + expect(getChangelogUrl("custom-org/fern-typescript-sdk")).toBeUndefined(); + }); + + it("handles model generators (non-SDK)", () => { + expect(getChangelogUrl("fernapi/fern-java-model")).toBe( + "https://buildwithfern.com/learn/sdks/generators/java/changelog" + ); + }); + + it("handles server generators", () => { + expect(getChangelogUrl("fernapi/fern-python-server")).toBe( + "https://buildwithfern.com/learn/sdks/generators/python/changelog" + ); + }); +}); + +describe("AutomationsUpgradeResult schema", () => { + it("documents the expected JSON output shape", () => { + // This test documents the contract between CLI and GHA consumers. + // If the schema changes, this test should be updated alongside the GHA. + const exampleResult = { + cli: { from: "4.66.0", to: "4.96.0", upgraded: true }, + generators: [ + { + name: "fernapi/fern-typescript-sdk", + group: "ts-sdk", + api: "api", + from: "3.63.4", + to: "3.65.5", + changelog: "https://buildwithfern.com/learn/sdks/generators/typescript/changelog", + migrationsApplied: 1 + } + ], + skippedMajor: [{ name: "fernapi/fern-ruby-sdk-v2", current: "0.3.0", latest: "1.0.0" }], + alreadyUpToDate: [{ name: "fernapi/fern-go-sdk", version: "1.37.0" }] + }; + + // Verify all top-level keys exist + expect(exampleResult).toHaveProperty("cli"); + expect(exampleResult).toHaveProperty("generators"); + expect(exampleResult).toHaveProperty("skippedMajor"); + expect(exampleResult).toHaveProperty("alreadyUpToDate"); + + // Verify cli shape + expect(exampleResult.cli).toHaveProperty("from"); + expect(exampleResult.cli).toHaveProperty("to"); + expect(exampleResult.cli).toHaveProperty("upgraded"); + + // Verify generator entry shape + const gen = exampleResult.generators[0]; + expect(gen).toBeDefined(); + expect(gen).toHaveProperty("name"); + expect(gen).toHaveProperty("group"); + expect(gen).toHaveProperty("api"); + expect(gen).toHaveProperty("from"); + expect(gen).toHaveProperty("to"); + expect(gen).toHaveProperty("changelog"); + expect(gen).toHaveProperty("migrationsApplied"); + + // Verify skippedMajor entry shape + const skip = exampleResult.skippedMajor[0]; + expect(skip).toBeDefined(); + expect(skip).toHaveProperty("name"); + expect(skip).toHaveProperty("current"); + expect(skip).toHaveProperty("latest"); + + // Verify alreadyUpToDate entry shape + const upToDate = exampleResult.alreadyUpToDate[0]; + expect(upToDate).toBeDefined(); + expect(upToDate).toHaveProperty("name"); + expect(upToDate).toHaveProperty("version"); + }); + + it("supports null api field for single-API projects", () => { + const entry = { + name: "fernapi/fern-typescript-sdk", + group: "ts-sdk", + api: null, + from: "3.63.4", + to: "3.65.5", + changelog: "https://buildwithfern.com/learn/sdks/generators/typescript/changelog", + migrationsApplied: 0 + }; + expect(entry.api).toBeNull(); + }); +}); diff --git a/packages/cli/cli/src/commands/automations/upgrade/executeAutomationsUpgrade.ts b/packages/cli/cli/src/commands/automations/upgrade/executeAutomationsUpgrade.ts new file mode 100644 index 000000000000..84848c35f6e7 --- /dev/null +++ b/packages/cli/cli/src/commands/automations/upgrade/executeAutomationsUpgrade.ts @@ -0,0 +1,258 @@ +import { + addDefaultDockerOrgIfNotPresent, + FERN_DIRECTORY, + getFernDirectory, + getPathToGeneratorsConfiguration, + loadProjectConfig +} from "@fern-api/configuration-loader"; +import { Project } from "@fern-api/project-loader"; +import { CliError } from "@fern-api/task-context"; +import chalk from "chalk"; +import { writeFile } from "fs/promises"; + +import { CliContext } from "../../../cli-context/CliContext.js"; +import { loadProjectAndRegisterWorkspacesWithContext } from "../../../cliCommons.js"; +import { upgrade } from "../../upgrade/upgrade.js"; +import { loadAndUpdateGenerators } from "../../upgrade/upgradeGenerator.js"; + +const CHANGELOG_BASE = "https://buildwithfern.com/learn/sdks/generators"; + +/** + * Derives the changelog URL from a generator name. + * Generator names follow the pattern "fernapi/fern--sdk[-variant]", + * and changelog pages live at buildwithfern.com/learn/sdks/generators//changelog. + */ +export function getChangelogUrl(generatorName: string): string | undefined { + const match = generatorName.match(/^fernapi\/fern-([a-z]+)/); + if (!match?.[1]) { + return undefined; + } + return `${CHANGELOG_BASE}/${match[1]}/changelog`; +} + +export interface AutomationsUpgradeOptions { + includeMajor: boolean; +} + +interface CliUpgradeResult { + from: string; + to: string; + upgraded: boolean; +} + +interface GeneratorUpgradeEntry { + name: string; + group: string; + api: string | null; + from: string; + to: string; + changelog: string | undefined; + migrationsApplied: number; +} + +interface SkippedMajorEntry { + name: string; + current: string; + latest: string; +} + +interface AlreadyUpToDateEntry { + name: string; + version: string; +} + +export interface AutomationsUpgradeResult { + cli: CliUpgradeResult; + generators: GeneratorUpgradeEntry[]; + skippedMajor: SkippedMajorEntry[]; + alreadyUpToDate: AlreadyUpToDateEntry[]; +} + +/** + * Reads the current CLI version from fern.config.json before any upgrade runs. + */ +async function getCurrentCliVersion(cliContext: CliContext): Promise { + const fernDirectory = await getFernDirectory(); + if (fernDirectory == null) { + cliContext.failAndThrow(`Directory "${FERN_DIRECTORY}" not found.`, undefined, { + code: CliError.Code.ConfigError + }); + } + const projectConfig = await cliContext.runTask((context) => + loadProjectConfig({ directory: fernDirectory, context }) + ); + return projectConfig.version; +} + +/** + * Top-level runner for `fern automations upgrade`. + * + * Orchestrates both `fern upgrade` (CLI version) and `fern generator upgrade` + * (generator versions), then outputs a structured JSON summary to stdout. + * + * The JSON output is designed for consumption by the fern-upgrade GitHub Action, + * replacing the previous snapshot.js / diff.js approach with a single CLI call. + * + * JSON output format (on --json): + * { + * "cli": { "from": "4.66.0", "to": "4.96.0", "upgraded": true }, + * "generators": [ + * { + * "name": "fernapi/fern-typescript-sdk", + * "group": "ts-sdk", + * "api": "api", + * "from": "3.63.4", + * "to": "3.65.5", + * "changelog": "https://...", + * "migrationsApplied": 1 + * } + * ], + * "skippedMajor": [{ "name": "...", "current": "...", "latest": "..." }], + * "alreadyUpToDate": [{ "name": "...", "version": "..." }] + * } + */ +export async function executeAutomationsUpgrade({ + cliContext, + options +}: { + cliContext: CliContext; + options: AutomationsUpgradeOptions; +}): Promise { + // 1. Capture the CLI version before upgrade + const cliVersionBefore = await getCurrentCliVersion(cliContext); + + // 2. Run `fern upgrade --yes` to upgrade CLI version + run migrations + cliContext.logger.info("Running CLI upgrade..."); + await upgrade({ + cliContext, + includePreReleases: false, + targetVersion: undefined, + fromVersion: undefined, + yes: true + }); + + // Read the CLI version after upgrade + const cliVersionAfter = await getCurrentCliVersion(cliContext); + const cliUpgraded = cliVersionBefore !== cliVersionAfter; + + if (cliUpgraded) { + cliContext.logger.info(`CLI upgraded: ${chalk.dim(cliVersionBefore)} -> ${chalk.green(cliVersionAfter)}`); + } else { + cliContext.logger.info("CLI already up to date."); + } + + // 3. Load project and run generator upgrades across all workspaces + cliContext.logger.info("Running generator upgrades..."); + const project = await loadProjectAndRegisterWorkspacesWithContext(cliContext, { + commandLineApiWorkspace: undefined, + defaultToAllApiWorkspaces: true + }); + + const generatorResults = await upgradeGeneratorsForAllWorkspaces({ + cliContext, + project, + includeMajor: options.includeMajor + }); + + // 4. Build the structured result + const result: AutomationsUpgradeResult = { + cli: { + from: cliVersionBefore, + to: cliVersionAfter, + upgraded: cliUpgraded + }, + generators: generatorResults.generators, + skippedMajor: generatorResults.skippedMajor, + alreadyUpToDate: generatorResults.alreadyUpToDate + }; + + return result; +} + +/** + * Runs generator upgrades across all API workspaces in the project, + * collecting structured results instead of just logging to console. + */ +async function upgradeGeneratorsForAllWorkspaces({ + cliContext, + project, + includeMajor +}: { + cliContext: CliContext; + project: Project; + includeMajor: boolean; +}): Promise<{ + generators: GeneratorUpgradeEntry[]; + skippedMajor: SkippedMajorEntry[]; + alreadyUpToDate: AlreadyUpToDateEntry[]; +}> { + const generators: GeneratorUpgradeEntry[] = []; + const skippedMajor: SkippedMajorEntry[] = []; + const alreadyUpToDate: AlreadyUpToDateEntry[] = []; + + await Promise.all( + project.apiWorkspaces.map(async (workspace) => { + await cliContext.runTaskForWorkspace(workspace, async (context) => { + const result = await loadAndUpdateGenerators({ + absolutePathToWorkspace: workspace.absoluteFilePath, + context, + generatorFilter: undefined, + groupFilter: undefined, + includeMajor, + skipAutoreleaseDisabled: false, + channel: undefined, + cliVersion: cliContext.environment.packageVersion + }); + + const absolutePathToGeneratorsConfiguration = await getPathToGeneratorsConfiguration({ + absolutePathToWorkspace: workspace.absoluteFilePath + }); + + if (absolutePathToGeneratorsConfiguration != null && result.updatedConfiguration != null) { + await writeFile(absolutePathToGeneratorsConfiguration, result.updatedConfiguration); + } + + for (const upgrade of result.appliedUpgrades) { + const normalizedName = addDefaultDockerOrgIfNotPresent(upgrade.generatorName); + generators.push({ + name: normalizedName, + group: upgrade.groupName, + api: workspace.workspaceName ?? null, + from: upgrade.previousVersion, + to: upgrade.newVersion, + changelog: getChangelogUrl(normalizedName), + migrationsApplied: upgrade.migrationsApplied ?? 0 + }); + } + + for (const skip of result.skippedMajorUpgrades) { + const normalizedName = addDefaultDockerOrgIfNotPresent(skip.generatorName); + skippedMajor.push({ + name: normalizedName, + current: skip.currentVersion, + latest: skip.latestMajorVersion + }); + } + + for (const upToDate of result.alreadyUpToDate) { + const normalizedName = addDefaultDockerOrgIfNotPresent(upToDate.generatorName); + alreadyUpToDate.push({ + name: normalizedName, + version: upToDate.version + }); + } + }); + }) + ); + + // Sort arrays deterministically so output is stable across runs + // (Promise.all over workspaces may resolve in any order) + generators.sort((a, b) => { + const key = (e: GeneratorUpgradeEntry): string => `${e.api ?? ""}::${e.group}::${e.name}`; + return key(a).localeCompare(key(b)); + }); + skippedMajor.sort((a, b) => a.name.localeCompare(b.name)); + alreadyUpToDate.sort((a, b) => a.name.localeCompare(b.name)); + + return { generators, skippedMajor, alreadyUpToDate }; +} diff --git a/packages/cli/cli/src/commands/upgrade/upgradeGenerator.ts b/packages/cli/cli/src/commands/upgrade/upgradeGenerator.ts index 5f873cf6b8e9..747ccb010504 100644 --- a/packages/cli/cli/src/commands/upgrade/upgradeGenerator.ts +++ b/packages/cli/cli/src/commands/upgrade/upgradeGenerator.ts @@ -45,20 +45,19 @@ interface SkippedAutoreleaseDisabled { version: string; } -function getChangelogUrl(generatorName: string): string | undefined { - const changelogMap: Record = { - "fernapi/fern-typescript-sdk": "https://buildwithfern.com/learn/sdks/generators/typescript/changelog", - "fernapi/fern-typescript-node-sdk": "https://buildwithfern.com/learn/sdks/generators/typescript/changelog", - "fernapi/fern-python-sdk": "https://buildwithfern.com/learn/sdks/generators/python/changelog", - "fernapi/fern-go-sdk": "https://buildwithfern.com/learn/sdks/generators/go/changelog", - "fernapi/fern-java-sdk": "https://buildwithfern.com/learn/sdks/generators/java/changelog", - "fernapi/fern-csharp-sdk": "https://buildwithfern.com/learn/sdks/generators/csharp/changelog", - "fernapi/fern-php-sdk": "https://buildwithfern.com/learn/sdks/generators/php/changelog", - "fernapi/fern-ruby-sdk": "https://buildwithfern.com/learn/sdks/generators/ruby/changelog", - "fernapi/fern-swift-sdk": "https://buildwithfern.com/learn/sdks/generators/swift/changelog" - }; +const CHANGELOG_BASE = "https://buildwithfern.com/learn/sdks/generators"; - return changelogMap[generatorName]; +/** + * Derives the changelog URL from a generator name. + * Generator names follow the pattern "fernapi/fern--sdk[-variant]", + * and changelog pages live at buildwithfern.com/learn/sdks/generators//changelog. + */ +function getChangelogUrl(generatorName: string): string | undefined { + const match = generatorName.match(/^fernapi\/fern-([a-z]+)/); + if (!match?.[1]) { + return undefined; + } + return `${CHANGELOG_BASE}/${match[1]}/changelog`; } export async function loadAndUpdateGenerators({ From 66a65e7dfdf6847dc6b7e401cc27f70e31eafd46 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 13:29:48 +0000 Subject: [PATCH 06/11] chore(cli): release 5.6.0 --- .../add-automations-upgrade-command.yml | 0 packages/cli/cli/versions.yml | 14 ++++++++++++++ 2 files changed, 14 insertions(+) rename packages/cli/cli/changes/{unreleased => 5.6.0}/add-automations-upgrade-command.yml (100%) diff --git a/packages/cli/cli/changes/unreleased/add-automations-upgrade-command.yml b/packages/cli/cli/changes/5.6.0/add-automations-upgrade-command.yml similarity index 100% rename from packages/cli/cli/changes/unreleased/add-automations-upgrade-command.yml rename to packages/cli/cli/changes/5.6.0/add-automations-upgrade-command.yml diff --git a/packages/cli/cli/versions.yml b/packages/cli/cli/versions.yml index 747a5f99a7f1..d59982493387 100644 --- a/packages/cli/cli/versions.yml +++ b/packages/cli/cli/versions.yml @@ -1,4 +1,18 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 5.6.0 + changelogEntry: + - summary: | + Add `fern automations upgrade` command that wraps `fern upgrade` and + `fern generator upgrade` into a single invocation with structured JSON + output (`--json`). Designed for consumption by the fern-upgrade GitHub + Action. + type: feat + - summary: | + Replace brittle hardcoded changelog URL map in `upgradeGenerator.ts` with + a regex-based derivation that supports all current and future generators. + type: fix + createdAt: "2026-05-01" + irVersion: 66 - version: 5.5.1 changelogEntry: - summary: | From 978a4523f7d2eb41bb303d8fefdf4d02cfb82a26 Mon Sep 17 00:00:00 2001 From: Deep Singhvi Date: Fri, 1 May 2026 11:50:01 -0400 Subject: [PATCH 07/11] fix(cli): resolve shared snippets in translated docs pages (#15639) Translated pages containing and references were not having those snippets resolved before being registered with FDR, causing 'Something went wrong' errors on rendered translated docs. This commit: - Adds replaceReferencedMarkdown/Code and transformAtPrefixImports processing to translated pages in publishDocs.ts and both preview servers - Implements locale-aware snippet loading that prefers translated snippets (e.g., translations/zh/snippets/foo.mdx) over base snippets - Re-exports the necessary utilities from @fern-api/docs-resolver Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../fix-translation-snippet-resolution.yml | 5 + .../docs-preview/src/runAppPreviewServer.ts | 76 +++++++- .../cli/docs-preview/src/runPreviewServer.ts | 69 ++++++- packages/cli/docs-resolver/src/index.ts | 8 +- .../src/publishDocs.ts | 181 ++++++++++++------ 5 files changed, 265 insertions(+), 74 deletions(-) create mode 100644 packages/cli/cli/changes/unreleased/fix-translation-snippet-resolution.yml diff --git a/packages/cli/cli/changes/unreleased/fix-translation-snippet-resolution.yml b/packages/cli/cli/changes/unreleased/fix-translation-snippet-resolution.yml new file mode 100644 index 000000000000..720c486e3647 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/fix-translation-snippet-resolution.yml @@ -0,0 +1,5 @@ +- summary: | + Fix translated docs pages failing to resolve shared snippets (`` and ``). + Snippet references are now resolved before registering translations, with locale-aware loading that prefers + translated snippets (e.g., `translations/zh/snippets/foo.mdx`) when available. + type: fix diff --git a/packages/cli/docs-preview/src/runAppPreviewServer.ts b/packages/cli/docs-preview/src/runAppPreviewServer.ts index 9696a38e5a5b..8d9c4a4488ff 100644 --- a/packages/cli/docs-preview/src/runAppPreviewServer.ts +++ b/packages/cli/docs-preview/src/runAppPreviewServer.ts @@ -4,11 +4,22 @@ import { applyTranslatedNavigationOverlays, getTranslatedAnnouncement, replaceImagePathsAndUrls, + replaceReferencedCode, + replaceReferencedMarkdown, stripMdxComments, + transformAtPrefixImports, wrapWithHttps } from "@fern-api/docs-resolver"; import { DocsV1Read, DocsV2Read, FernNavigation } from "@fern-api/fdr-sdk"; -import { AbsoluteFilePath, dirname, doesPathExist, listFiles, RelativeFilePath, resolve } from "@fern-api/fs-utils"; +import { + AbsoluteFilePath, + dirname, + doesPathExist, + listFiles, + RelativeFilePath, + relative, + resolve +} from "@fern-api/fs-utils"; import { runExeca } from "@fern-api/logging-execa"; import { Project } from "@fern-api/project-loader"; import { CliError, TaskContext } from "@fern-api/task-context"; @@ -711,7 +722,9 @@ export async function runAppPreviewServer({ * Computes translated definitions for each locale. * Similar to what publishDocs.ts does for production, but for local preview. */ - function computeTranslatedDefinitions(result: PreviewDocsResult): Map { + async function computeTranslatedDefinitions( + result: PreviewDocsResult + ): Promise> { const translations = new Map(); const { docsDefinition, translationPages, translationNavigationOverlays, collectedFileIds, docsWorkspacePath } = result; @@ -729,6 +742,28 @@ export async function runAppPreviewServer({ } try { + // Locale-aware file loaders that prefer translated snippets when available + const resolveLocalePath = async (filepath: AbsoluteFilePath): Promise => { + const relPath = relative(docsWorkspacePath, filepath); + const translatedPath = resolve( + docsWorkspacePath, + RelativeFilePath.of(`translations/${locale}/${relPath}`) + ); + return (await doesPathExist(translatedPath)) ? translatedPath : filepath; + }; + + const localeAwareMarkdownLoader = async (filepath: AbsoluteFilePath): Promise => { + const pathToRead = await resolveLocalePath(filepath); + const raw = await readFile(pathToRead, "utf-8"); + const fmMatch = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/); + return fmMatch != null ? raw.slice(fmMatch[0].length) : raw; + }; + + const localeAwareFileLoader = async (filepath: AbsoluteFilePath): Promise => { + const pathToRead = await resolveLocalePath(filepath); + return readFile(pathToRead, "utf-8"); + }; + // Build translated pages by merging base pages with locale-specific pages // Start by copying all defined pages from the base definition const translatedPages: Record = {}; @@ -741,10 +776,37 @@ export async function runAppPreviewServer({ for (const [pagePath, rawMarkdown] of Object.entries(localePages)) { try { const basePage = translatedPages[pagePath]; - // Strip MDX comments first - let processedMarkdown = stripMdxComments(rawMarkdown); - // Replace image paths using collected file IDs const absolutePathToMarkdownFile = resolve(docsWorkspacePath, RelativeFilePath.of(pagePath)); + + // Resolve snippets (locale-aware) + const { markdown: markdownResolved } = await replaceReferencedMarkdown({ + markdown: rawMarkdown, + absolutePathToFernFolder: docsWorkspacePath, + absolutePathToMarkdownFile, + context, + markdownLoader: localeAwareMarkdownLoader + }); + + // Resolve references (locale-aware) + const codeResolved = await replaceReferencedCode({ + markdown: markdownResolved, + absolutePathToFernFolder: docsWorkspacePath, + absolutePathToMarkdownFile, + context, + fileLoader: localeAwareFileLoader + }); + + // Transform @/ prefix imports to relative paths + const importsResolved = transformAtPrefixImports({ + markdown: codeResolved, + absolutePathToFernFolder: docsWorkspacePath, + absolutePathToMarkdownFile + }); + + // Strip MDX comments + let processedMarkdown = stripMdxComments(importsResolved); + + // Replace image paths using collected file IDs processedMarkdown = replaceImagePathsAndUrls( processedMarkdown, collectedFileIds, @@ -890,7 +952,7 @@ export async function runAppPreviewServer({ // Compute translated definitions after loading if (previewResult != null) { - translatedDefinitions = computeTranslatedDefinitions(previewResult); + translatedDefinitions = await computeTranslatedDefinitions(previewResult); if (translatedDefinitions.size > 0) { context.logger.info(`Computed translations for ${translatedDefinitions.size} locale(s)`); } @@ -1156,7 +1218,7 @@ export async function runAppPreviewServer({ previewResult = reloadedPreviewResult; // Recompute translated definitions - translatedDefinitions = computeTranslatedDefinitions(reloadedPreviewResult); + translatedDefinitions = await computeTranslatedDefinitions(reloadedPreviewResult); if (translatedDefinitions.size > 0) { context.logger.debug(`Recomputed translations for ${translatedDefinitions.size} locale(s)`); } diff --git a/packages/cli/docs-preview/src/runPreviewServer.ts b/packages/cli/docs-preview/src/runPreviewServer.ts index b32a400f60f3..aa290c05e4db 100644 --- a/packages/cli/docs-preview/src/runPreviewServer.ts +++ b/packages/cli/docs-preview/src/runPreviewServer.ts @@ -3,17 +3,21 @@ import { applyTranslatedNavigationOverlays, getTranslatedAnnouncement, replaceImagePathsAndUrls, + replaceReferencedCode, + replaceReferencedMarkdown, stripMdxComments, + transformAtPrefixImports, wrapWithHttps } from "@fern-api/docs-resolver"; import { DocsV1Read, DocsV2Read, FernNavigation } from "@fern-api/fdr-sdk"; -import { AbsoluteFilePath, dirname, doesPathExist, RelativeFilePath, resolve } from "@fern-api/fs-utils"; +import { AbsoluteFilePath, dirname, doesPathExist, RelativeFilePath, relative, resolve } from "@fern-api/fs-utils"; import { Project } from "@fern-api/project-loader"; import { CliError, TaskContext } from "@fern-api/task-context"; import chalk from "chalk"; import cors from "cors"; import express from "express"; +import { readFile } from "fs/promises"; import http from "http"; import path from "path"; import Watcher from "watcher"; @@ -144,7 +148,9 @@ export async function runPreviewServer({ /** * Computes translated definitions for each locale. */ - function computeTranslatedDefinitions(result: PreviewDocsResult): Map { + async function computeTranslatedDefinitions( + result: PreviewDocsResult + ): Promise> { const translations = new Map(); const { docsDefinition, translationPages, translationNavigationOverlays, collectedFileIds, docsWorkspacePath } = result; @@ -162,6 +168,28 @@ export async function runPreviewServer({ } try { + // Locale-aware file loaders that prefer translated snippets when available + const resolveLocalePath = async (filepath: AbsoluteFilePath): Promise => { + const relPath = relative(docsWorkspacePath, filepath); + const translatedPath = resolve( + docsWorkspacePath, + RelativeFilePath.of(`translations/${locale}/${relPath}`) + ); + return (await doesPathExist(translatedPath)) ? translatedPath : filepath; + }; + + const localeAwareMarkdownLoader = async (filepath: AbsoluteFilePath): Promise => { + const pathToRead = await resolveLocalePath(filepath); + const raw = await readFile(pathToRead, "utf-8"); + const fmMatch = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/); + return fmMatch != null ? raw.slice(fmMatch[0].length) : raw; + }; + + const localeAwareFileLoader = async (filepath: AbsoluteFilePath): Promise => { + const pathToRead = await resolveLocalePath(filepath); + return readFile(pathToRead, "utf-8"); + }; + // Build translated pages by merging base pages with locale-specific pages // Start by copying all defined pages from the base definition const translatedPages: Record = {}; @@ -174,10 +202,37 @@ export async function runPreviewServer({ for (const [pagePath, rawMarkdown] of Object.entries(localePages)) { try { const basePage = translatedPages[pagePath]; - // Strip MDX comments first - let processedMarkdown = stripMdxComments(rawMarkdown); - // Replace image paths using collected file IDs const absolutePathToMarkdownFile = resolve(docsWorkspacePath, RelativeFilePath.of(pagePath)); + + // Resolve snippets (locale-aware) + const { markdown: markdownResolved } = await replaceReferencedMarkdown({ + markdown: rawMarkdown, + absolutePathToFernFolder: docsWorkspacePath, + absolutePathToMarkdownFile, + context, + markdownLoader: localeAwareMarkdownLoader + }); + + // Resolve references (locale-aware) + const codeResolved = await replaceReferencedCode({ + markdown: markdownResolved, + absolutePathToFernFolder: docsWorkspacePath, + absolutePathToMarkdownFile, + context, + fileLoader: localeAwareFileLoader + }); + + // Transform @/ prefix imports to relative paths + const importsResolved = transformAtPrefixImports({ + markdown: codeResolved, + absolutePathToFernFolder: docsWorkspacePath, + absolutePathToMarkdownFile + }); + + // Strip MDX comments + let processedMarkdown = stripMdxComments(importsResolved); + + // Replace image paths using collected file IDs processedMarkdown = replaceImagePathsAndUrls( processedMarkdown, collectedFileIds, @@ -280,7 +335,7 @@ export async function runPreviewServer({ // Compute translated definitions after loading if (previewResult != null) { - translatedDefinitions = computeTranslatedDefinitions(previewResult); + translatedDefinitions = await computeTranslatedDefinitions(previewResult); if (translatedDefinitions.size > 0) { context.logger.info(`Computed translations for ${translatedDefinitions.size} locale(s)`); } @@ -327,7 +382,7 @@ export async function runPreviewServer({ if (reloadedPreviewResult != null) { previewResult = reloadedPreviewResult; // Recompute translated definitions - translatedDefinitions = computeTranslatedDefinitions(reloadedPreviewResult); + translatedDefinitions = await computeTranslatedDefinitions(reloadedPreviewResult); if (translatedDefinitions.size > 0) { context.logger.debug(`Recomputed translations for ${translatedDefinitions.size} locale(s)`); } diff --git a/packages/cli/docs-resolver/src/index.ts b/packages/cli/docs-resolver/src/index.ts index 0923970e5a12..86fb39895525 100644 --- a/packages/cli/docs-resolver/src/index.ts +++ b/packages/cli/docs-resolver/src/index.ts @@ -1,7 +1,13 @@ import { type docsYml } from "@fern-api/configuration"; // Re-export markdown utilities needed for translation processing -export { replaceImagePathsAndUrls, stripMdxComments } from "@fern-api/docs-markdown-utils"; +export { + replaceImagePathsAndUrls, + replaceReferencedCode, + replaceReferencedMarkdown, + stripMdxComments, + transformAtPrefixImports +} from "@fern-api/docs-markdown-utils"; export { applyTranslatedFrontmatterToNavTree } from "./applyTranslatedFrontmatterToNavTree.js"; export { applyTranslatedNavigationOverlays, diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocs.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocs.ts index 4bb15af19ec1..a85f45902094 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocs.ts +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocs.ts @@ -9,7 +9,10 @@ import { DocsDefinitionResolver, getTranslatedAnnouncement, replaceImagePathsAndUrls, + replaceReferencedCode, + replaceReferencedMarkdown, stripMdxComments, + transformAtPrefixImports, UploadedFile, wrapWithHttps } from "@fern-api/docs-resolver"; @@ -21,7 +24,14 @@ type SnippetsConfig = APIV1Write.SnippetsConfig; type DocsDefinition = DocsV1Write.DocsDefinition; import { stitchGlobalTheme } from "@fern-api/docs-resolver"; -import { AbsoluteFilePath, convertToFernHostRelativeFilePath, RelativeFilePath, resolve } from "@fern-api/fs-utils"; +import { + AbsoluteFilePath, + convertToFernHostRelativeFilePath, + doesPathExist, + RelativeFilePath, + relative, + resolve +} from "@fern-api/fs-utils"; import { convertIrToDynamicSnippetsIr, generateIntermediateRepresentation } from "@fern-api/ir-generator"; import { getOriginalName } from "@fern-api/ir-utils"; import { detectAirGappedMode, OSSWorkspace } from "@fern-api/lazy-fern-workspace"; @@ -652,70 +662,123 @@ export async function publishDocs({ // any sidebar-title / slug frontmatter in the translated pages. // // For each translated page, we apply the same transformations as default locale pages: - // 1. Strip MDX comments to prevent leakage - // 2. Replace relative image paths with file IDs (using base page path for resolution) - // 3. Preserve editThisPageUrl/editThisPageLaunch from the base page - // - // TODO(translations-alpha): Translated pages still need: - // - replaceReferencedMarkdown/Code for and - // - transformAtPrefixImports for @/... and @components/... imports + // 1. Resolve and snippet references + // 2. Transform @/ prefix imports to relative paths + // 3. Strip MDX comments to prevent leakage + // 4. Replace relative image paths with file IDs (using base page path for resolution) + // 5. Preserve editThisPageUrl/editThisPageLaunch from the base page const collectedFileIds = resolver.getCollectedFileIds(); const docsWorkspacePath = resolver.getDocsWorkspacePath(); + // Create a locale-aware file loader that prefers translated snippets + // (e.g., translations/zh/snippets/foo.mdx) over base snippets. + const resolveLocalePath = async (filepath: AbsoluteFilePath): Promise => { + const relPath = relative(docsWorkspacePath, filepath); + const translatedPath = resolve( + docsWorkspacePath, + RelativeFilePath.of(`translations/${locale}/${relPath}`) + ); + return (await doesPathExist(translatedPath)) ? translatedPath : filepath; + }; + + const localeAwareMarkdownLoader = async (filepath: AbsoluteFilePath): Promise => { + const pathToRead = await resolveLocalePath(filepath); + const raw = await readFile(pathToRead, "utf-8"); + // Strip frontmatter (---\n...\n---) from snippet files + const fmMatch = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/); + return fmMatch != null ? raw.slice(fmMatch[0].length) : raw; + }; + + const localeAwareFileLoader = async (filepath: AbsoluteFilePath): Promise => { + const pathToRead = await resolveLocalePath(filepath); + return readFile(pathToRead, "utf-8"); + }; + + const translatedPageEntries = await Promise.all( + Object.entries(localePages).map(async ([path, rawMarkdown]) => { + try { + const basePage = docsDefinition.pages[path as DocsV1Write.PageId]; + const absolutePathToMarkdownFile = resolve( + docsWorkspacePath, + RelativeFilePath.of(path) + ); + + // Resolve snippets (must happen before image processing). + // Uses locale-aware loader to prefer translated snippets when available. + const { markdown: markdownResolved } = await replaceReferencedMarkdown({ + markdown: rawMarkdown, + absolutePathToFernFolder: docsWorkspacePath, + absolutePathToMarkdownFile, + context, + markdownLoader: localeAwareMarkdownLoader + }); + + // Resolve references (also locale-aware) + const codeResolved = await replaceReferencedCode({ + markdown: markdownResolved, + absolutePathToFernFolder: docsWorkspacePath, + absolutePathToMarkdownFile, + context, + fileLoader: localeAwareFileLoader + }); + + // Transform @/ prefix imports to relative paths + const importsResolved = transformAtPrefixImports({ + markdown: codeResolved, + absolutePathToFernFolder: docsWorkspacePath, + absolutePathToMarkdownFile + }); + + // Strip MDX comments + let processedMarkdown = stripMdxComments(importsResolved); + + // Replace image paths using the base page's location for resolution + // (translated pages reference the same images as the default locale) + processedMarkdown = replaceImagePathsAndUrls( + processedMarkdown, + collectedFileIds, + {}, // markdownFilesToPathName not needed for translations + { + absolutePathToMarkdownFile, + absolutePathToFernFolder: docsWorkspacePath + }, + context + ); + + // Rewrite editThisPageUrl to point to the translated file + let editThisPageUrl = basePage?.editThisPageUrl; + if (editThisPageUrl != null) { + const fernPathPattern = `/fern/${path}`; + const translatedPath = `/fern/translations/${locale}/${path}`; + editThisPageUrl = editThisPageUrl.replace( + fernPathPattern, + translatedPath + ) as typeof editThisPageUrl; + } + return [ + path, + { + markdown: processedMarkdown, + rawMarkdown: processedMarkdown, + editThisPageUrl, + editThisPageLaunch: basePage?.editThisPageLaunch + } + ]; + } catch (pageError) { + context.logger.warn( + `Failed to process translated page "${path}" for locale "${locale}": ${String(pageError)}. Falling back to base page.` + ); + return undefined; + } + }) + ); + const translatedPages = { ...docsDefinition.pages, ...Object.fromEntries( - Object.entries(localePages) - .map(([path, rawMarkdown]) => { - try { - const basePage = docsDefinition.pages[path as DocsV1Write.PageId]; - // Strip MDX comments first - let processedMarkdown = stripMdxComments(rawMarkdown); - // Replace image paths using the base page's location for resolution - // (translated pages reference the same images as the default locale) - const absolutePathToMarkdownFile = resolve( - docsWorkspacePath, - RelativeFilePath.of(path) - ); - processedMarkdown = replaceImagePathsAndUrls( - processedMarkdown, - collectedFileIds, - {}, // markdownFilesToPathName not needed for translations - { - absolutePathToMarkdownFile, - absolutePathToFernFolder: docsWorkspacePath - }, - context - ); - // Rewrite editThisPageUrl to point to the translated file - // URL format: .../fern/${path}?plain=1 -> .../fern/translations/${locale}/${path}?plain=1 - let editThisPageUrl = basePage?.editThisPageUrl; - if (editThisPageUrl != null) { - // Replace /fern/${path} with /fern/translations/${locale}/${path} - const fernPathPattern = `/fern/${path}`; - const translatedPath = `/fern/translations/${locale}/${path}`; - editThisPageUrl = editThisPageUrl.replace( - fernPathPattern, - translatedPath - ) as typeof editThisPageUrl; - } - return [ - path, - { - markdown: processedMarkdown, - rawMarkdown: processedMarkdown, - editThisPageUrl, - editThisPageLaunch: basePage?.editThisPageLaunch - } - ]; - } catch (pageError) { - context.logger.warn( - `Failed to process translated page "${path}" for locale "${locale}": ${String(pageError)}. Falling back to base page.` - ); - return undefined; - } - }) - .filter((entry): entry is NonNullable => entry != null) + translatedPageEntries.filter( + (entry): entry is NonNullable => entry != null + ) ) }; let updatedRoot = applyTranslatedFrontmatterToNavTree( From c5f27a7e78f00f0c04a77c00d3807d05ad6ae48a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 15:54:13 +0000 Subject: [PATCH 08/11] chore(cli): release 5.6.1 --- .../fix-translation-snippet-resolution.yml | 0 packages/cli/cli/versions.yml | 9 +++++++++ 2 files changed, 9 insertions(+) rename packages/cli/cli/changes/{unreleased => 5.6.1}/fix-translation-snippet-resolution.yml (100%) diff --git a/packages/cli/cli/changes/unreleased/fix-translation-snippet-resolution.yml b/packages/cli/cli/changes/5.6.1/fix-translation-snippet-resolution.yml similarity index 100% rename from packages/cli/cli/changes/unreleased/fix-translation-snippet-resolution.yml rename to packages/cli/cli/changes/5.6.1/fix-translation-snippet-resolution.yml diff --git a/packages/cli/cli/versions.yml b/packages/cli/cli/versions.yml index d59982493387..09e6ff7b69c4 100644 --- a/packages/cli/cli/versions.yml +++ b/packages/cli/cli/versions.yml @@ -1,4 +1,13 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 5.6.1 + changelogEntry: + - summary: | + Fix translated docs pages failing to resolve shared snippets (`` and ``). + Snippet references are now resolved before registering translations, with locale-aware loading that prefers + translated snippets (e.g., `translations/zh/snippets/foo.mdx`) when available. + type: fix + createdAt: "2026-05-01" + irVersion: 66 - version: 5.6.0 changelogEntry: - summary: | From 70152529c8654033dbb24b94f783005bacbc0103 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Fri, 1 May 2026 12:40:07 -0400 Subject: [PATCH 09/11] fix(typescript): preserve nullable wrappers on map value types in serde schema (#15640) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/TypeReferenceToSchemaConverter.ts | 23 ++++++++----------- .../__test__/TypeReferenceConverters.test.ts | 22 ++++++++++++++++++ .../fix-nullable-map-values-serde.yml | 8 +++++++ 3 files changed, 40 insertions(+), 13 deletions(-) create mode 100644 generators/typescript/sdk/changes/unreleased/fix-nullable-map-values-serde.yml diff --git a/generators/typescript/model/type-reference-converters/src/TypeReferenceToSchemaConverter.ts b/generators/typescript/model/type-reference-converters/src/TypeReferenceToSchemaConverter.ts index 59bab607929f..6bae2b082b44 100644 --- a/generators/typescript/model/type-reference-converters/src/TypeReferenceToSchemaConverter.ts +++ b/generators/typescript/model/type-reference-converters/src/TypeReferenceToSchemaConverter.ts @@ -98,9 +98,10 @@ export class TypeReferenceToSchemaConverter extends AbstractTypeReferenceConvert { keyType, valueType }: FernIr.MapType, params: ConvertTypeReferenceParams ): Zurg.Schema { - // Strip optional/nullable wrappers from the value type to match the type converter, + // Strip optional wrappers from the value type to match the type converter, // which uses `typeNodeWithoutUndefined` for record value types. - const unwrappedValueType = unwrapOptionalAndNullable(valueType); + // Nullable wrappers are preserved so the schema correctly reflects nullable map values. + const unwrappedValueType = unwrapOptional(valueType); return this.zurg.record({ keySchema: this.convert({ ...params, typeReference: keyType }), valueSchema: this.convert({ ...params, typeReference: unwrappedValueType }) @@ -128,18 +129,14 @@ export class TypeReferenceToSchemaConverter extends AbstractTypeReferenceConvert } /** - * Unwraps optional and nullable container wrappers from a type reference. - * This is used for map value types where the type converter strips optional/nullable - * (via `typeNodeWithoutUndefined`) but the schema converter needs to match. + * Unwraps optional container wrappers from a type reference. + * This is used for map value types where the type converter strips undefined + * (via `typeNodeWithoutUndefined`) but preserves null. The schema converter + * must match: strip optional but keep nullable so `.nullable()` is emitted. */ -function unwrapOptionalAndNullable(typeReference: FernIr.TypeReference): FernIr.TypeReference { - if (typeReference.type === "container") { - if (typeReference.container.type === "optional") { - return unwrapOptionalAndNullable(typeReference.container.optional); - } - if (typeReference.container.type === "nullable") { - return unwrapOptionalAndNullable(typeReference.container.nullable); - } +function unwrapOptional(typeReference: FernIr.TypeReference): FernIr.TypeReference { + if (typeReference.type === "container" && typeReference.container.type === "optional") { + return unwrapOptional(typeReference.container.optional); } return typeReference; } diff --git a/generators/typescript/model/type-reference-converters/src/__test__/TypeReferenceConverters.test.ts b/generators/typescript/model/type-reference-converters/src/__test__/TypeReferenceConverters.test.ts index f6028236e8fe..c5ab67d3811e 100644 --- a/generators/typescript/model/type-reference-converters/src/__test__/TypeReferenceConverters.test.ts +++ b/generators/typescript/model/type-reference-converters/src/__test__/TypeReferenceConverters.test.ts @@ -407,6 +407,28 @@ describe("TypeReferenceToSchemaConverter", () => { expect(getTextOfTsNode(result.toExpression())).toBe("zurg.record(zurg.string(), zurg.number())"); }); + it("converts map with nullable values to zurg.record() with .nullable() on value schema", () => { + const converter = createConverter({ + resolveTypeReference: () => + FernIr.ResolvedTypeReference.primitive({ v1: FernIr.PrimitiveTypeV1.String, v2: undefined }) + }); + const result = converter.convert({ + typeReference: mapRef(primitiveRef("STRING"), nullableRef(primitiveRef("INTEGER"))) + }); + expect(getTextOfTsNode(result.toExpression())).toBe("zurg.record(zurg.string(), zurg.number().nullable())"); + }); + + it("converts map with optional(nullable) values strips optional, preserves nullable", () => { + const converter = createConverter({ + resolveTypeReference: () => + FernIr.ResolvedTypeReference.primitive({ v1: FernIr.PrimitiveTypeV1.String, v2: undefined }) + }); + const result = converter.convert({ + typeReference: mapRef(primitiveRef("STRING"), optionalRef(nullableRef(primitiveRef("INTEGER")))) + }); + expect(getTextOfTsNode(result.toExpression())).toBe("zurg.record(zurg.string(), zurg.number().nullable())"); + }); + it("converts map with enum keys to zurg.partialRecord()", () => { const converter = createConverter({ resolveTypeReference: (ref) => { diff --git a/generators/typescript/sdk/changes/unreleased/fix-nullable-map-values-serde.yml b/generators/typescript/sdk/changes/unreleased/fix-nullable-map-values-serde.yml new file mode 100644 index 000000000000..2669875ef3ae --- /dev/null +++ b/generators/typescript/sdk/changes/unreleased/fix-nullable-map-values-serde.yml @@ -0,0 +1,8 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + Fix serialization schema for map types with nullable values. Previously, the + schema generator stripped nullable wrappers from map value types, causing a + type mismatch between the API types (which correctly included `| null`) and + the serialization layer (which omitted `.nullable()` on the value schema). + type: fix From 2cac80deded42f85e994bb9ca2174540b5e8657e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 16:44:31 +0000 Subject: [PATCH 10/11] chore(typescript): release 3.68.1 --- .../fix-nullable-map-values-serde.yml | 0 generators/typescript/sdk/versions.yml | 10 ++++++++++ 2 files changed, 10 insertions(+) rename generators/typescript/sdk/changes/{unreleased => 3.68.1}/fix-nullable-map-values-serde.yml (100%) diff --git a/generators/typescript/sdk/changes/unreleased/fix-nullable-map-values-serde.yml b/generators/typescript/sdk/changes/3.68.1/fix-nullable-map-values-serde.yml similarity index 100% rename from generators/typescript/sdk/changes/unreleased/fix-nullable-map-values-serde.yml rename to generators/typescript/sdk/changes/3.68.1/fix-nullable-map-values-serde.yml diff --git a/generators/typescript/sdk/versions.yml b/generators/typescript/sdk/versions.yml index 9e07aa50737f..6ad02075720b 100644 --- a/generators/typescript/sdk/versions.yml +++ b/generators/typescript/sdk/versions.yml @@ -1,4 +1,14 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 3.68.1 + changelogEntry: + - summary: | + Fix serialization schema for map types with nullable values. Previously, the + schema generator stripped nullable wrappers from map value types, causing a + type mismatch between the API types (which correctly included `| null`) and + the serialization layer (which omitted `.nullable()` on the value schema). + type: fix + createdAt: "2026-05-01" + irVersion: 66 - version: 3.68.0 changelogEntry: - summary: | From 712df458414d41ee4fa72bcde53d15db914c0ab8 Mon Sep 17 00:00:00 2001 From: Naman Anand Date: Fri, 1 May 2026 23:22:45 +0530 Subject: [PATCH 11/11] feat(cli-v2): add `-` stdin/stdout marker support to api compile, sdk generate, sdk preview, and api split (#15324) * feat(cli-v2): add shared stdin/stdout marker helper and migrate api compile Introduces packages/cli/cli-v2/src/io/stdio.ts with: - isStdioMarker / STDIO_MARKER for the conventional `-` argument - readInput / readJsonOrPath (JSON-first with file fallback, stdin when `-`) - writeOutputJson / writeOutputString (stdout when `-`) - StdioMarkerGuard to enforce one stdin and one stdout per command Migrates `fern api compile --output` to use the helper as the first consumer; behavior is unchanged. Follow-up work will migrate the remaining commands. * Stream JSON to stdout via pipeline; type fixes Use stream/promises.pipeline to pipe JsonStreamStringify output to stdout (with end: false) instead of manual event/listener wiring. Change writeOutputJson signature to accept AbsoluteFilePath | "-" and pass absolute paths through to streamObjectToFile without re-wrapping. Tighten typings (Buffer.concat chunks typed as Uint8Array[], readStreamToString treats chunks as strings). Update tests to import and use AbsoluteFilePath, adjust the capture helper typing, and add a test that readInput throws for missing files. Also increase streamed JSON pretty indent to 4 for readability. * feat(cli-v2): support `-` as stdin/stdout marker on more commands - sdk generate / sdk preview: `--api -` reads spec from stdin - api split: `--output -` prints overlay/overrides to stdout (preview) - ApiSpecResolver: new resolveStdin path with json/yaml extension inference * Use stream Readable/Writable for stdio Replace NodeJS.ReadableStream/WritableStream types with stream.Readable/Writable across stdio-related APIs and tests. Update ApiSpecResolver to accept a Readable stdin and normalize stdin references to "stdin" when resolving from STDIN. Adjust writeOutputString to accept AbsoluteFilePath | "-" and pipe output to stdout via stream.pipeline (avoiding direct stdout.write). Add StdioMarkerGuard usage in GenerateCommand to claim stdin when the api flag is the stdio marker. Update tests to use Readable and AbsoluteFilePath and fix related expectations. --------- Co-authored-by: naman.anand --- .../src/api/resolver/ApiSpecResolver.ts | 37 +++- .../resolver/__test__/ApiSpecResolver.test.ts | 58 +++++ .../src/commands/api/compile/command.ts | 32 +-- .../cli-v2/src/commands/api/split/command.ts | 56 ++++- .../src/commands/sdk/generate/command.ts | 8 +- .../src/commands/sdk/preview/command.ts | 2 +- .../cli/cli-v2/src/io/__test__/stdio.test.ts | 200 ++++++++++++++++++ packages/cli/cli-v2/src/io/stdio.ts | 191 +++++++++++++++++ .../unreleased/cli-v2-stdio-marker.yml | 26 +++ 9 files changed, 590 insertions(+), 20 deletions(-) create mode 100644 packages/cli/cli-v2/src/api/resolver/__test__/ApiSpecResolver.test.ts create mode 100644 packages/cli/cli-v2/src/io/__test__/stdio.test.ts create mode 100644 packages/cli/cli-v2/src/io/stdio.ts create mode 100644 packages/cli/cli/changes/unreleased/cli-v2-stdio-marker.yml diff --git a/packages/cli/cli-v2/src/api/resolver/ApiSpecResolver.ts b/packages/cli/cli-v2/src/api/resolver/ApiSpecResolver.ts index ecd5f7a0d297..e99a6078f082 100644 --- a/packages/cli/cli-v2/src/api/resolver/ApiSpecResolver.ts +++ b/packages/cli/cli-v2/src/api/resolver/ApiSpecResolver.ts @@ -3,14 +3,18 @@ import { CliError } from "@fern-api/task-context"; import { mkdtemp, readFile, writeFile } from "fs/promises"; import { tmpdir } from "os"; import path from "path"; +import { Readable } from "stream"; import { FETCH_API_SPEC_REQUEST_TIMEOUT_MS } from "../../constants.js"; import type { Context } from "../../context/Context.js"; +import { isStdioMarker, readInput, STDIO_MARKER } from "../../io/stdio.js"; import type { ApiSpec, ApiSpecType } from "../config/ApiSpec.js"; import { ApiSpecDetector } from "./ApiSpecDetector.js"; export namespace ApiSpecResolver { export interface Args { reference: string; + /** Optional stdin stream (defaults to process.stdin). Used for testing. */ + stdin?: Readable; } export interface Result { @@ -33,15 +37,46 @@ export class ApiSpecResolver { } /** - * Resolves a string reference (local path or URL) to a fully-constructed ApiSpec. + * Resolves a string reference (local path, URL, or `-` for stdin) to a + * fully-constructed ApiSpec. */ public async resolve(args: ApiSpecResolver.Args): Promise { + if (isStdioMarker(args.reference)) { + return this.resolveStdin({ stdin: args.stdin }); + } if (this.isUrl(args.reference)) { return this.resolveUrl(args); } return this.resolveLocal(args); } + private async resolveStdin({ stdin }: { stdin?: Readable }): Promise { + const content = await readInput(STDIO_MARKER, { stdin }); + if (content.trim().length === 0) { + throw new CliError({ + message: 'No input received on stdin (--api "-").', + code: CliError.Code.ConfigError + }); + } + const extension = this.inferExtensionFromContent(content); + const tempDir = await mkdtemp(path.join(tmpdir(), "fern-")); + const absoluteFilePath = AbsoluteFilePath.of(path.join(tempDir, `spec${extension}`)); + await writeFile(absoluteFilePath, content, "utf-8"); + + const specType = await this.detector.detect({ absoluteFilePath, content, reference: "stdin" }); + return { + absoluteFilePath, + reference: "stdin", + spec: this.buildApiSpec({ absoluteFilePath, specType, origin: "stdin" }) + }; + } + + private inferExtensionFromContent(content: string): string { + const trimmed = content.trimStart(); + const first = trimmed[0]; + return first === "{" || first === "[" ? ".json" : ".yaml"; + } + private async resolveUrl({ reference }: { reference: string }): Promise { const { content, contentType } = await this.fetchContent({ url: reference }); const extension = this.inferExtension({ url: reference, contentType }); diff --git a/packages/cli/cli-v2/src/api/resolver/__test__/ApiSpecResolver.test.ts b/packages/cli/cli-v2/src/api/resolver/__test__/ApiSpecResolver.test.ts new file mode 100644 index 000000000000..e0646a6525d5 --- /dev/null +++ b/packages/cli/cli-v2/src/api/resolver/__test__/ApiSpecResolver.test.ts @@ -0,0 +1,58 @@ +import { AbsoluteFilePath } from "@fern-api/fs-utils"; +import { CliError } from "@fern-api/task-context"; +import { readFile } from "fs/promises"; +import { Readable } from "stream"; +import { describe, expect, it } from "vitest"; +import { createTestContext } from "../../../__test__/utils/createTestContext.js"; +import { ApiSpecResolver } from "../ApiSpecResolver.js"; + +function stringReadable(value: string): Readable { + return Readable.from([Buffer.from(value, "utf-8")]); +} + +describe("ApiSpecResolver", () => { + describe('resolve("-")', () => { + it("reads JSON OpenAPI spec from stdin and writes it to a temp .json file", async () => { + const context = await createTestContext({ cwd: AbsoluteFilePath.of("/") }); + const resolver = new ApiSpecResolver({ context }); + + const json = JSON.stringify({ openapi: "3.0.0", info: { title: "t", version: "1.0.0" }, paths: {} }); + const result = await resolver.resolve({ reference: "-", stdin: stringReadable(json) }); + + expect(result.reference).toBe("stdin"); + expect(result.absoluteFilePath.endsWith("spec.json")).toBe(true); + expect("openapi" in result.spec).toBe(true); + const written = await readFile(result.absoluteFilePath, "utf-8"); + expect(JSON.parse(written)).toEqual(JSON.parse(json)); + }); + + it("reads YAML AsyncAPI spec from stdin and writes it to a temp .yaml file", async () => { + const context = await createTestContext({ cwd: AbsoluteFilePath.of("/") }); + const resolver = new ApiSpecResolver({ context }); + + const yamlSpec = "asyncapi: 2.6.0\ninfo:\n title: t\n version: 1.0.0\n"; + const result = await resolver.resolve({ reference: "-", stdin: stringReadable(yamlSpec) }); + + expect(result.absoluteFilePath.endsWith("spec.yaml")).toBe(true); + expect("asyncapi" in result.spec).toBe(true); + }); + + it("throws when stdin is empty", async () => { + const context = await createTestContext({ cwd: AbsoluteFilePath.of("/") }); + const resolver = new ApiSpecResolver({ context }); + + await expect(resolver.resolve({ reference: "-", stdin: stringReadable("") })).rejects.toBeInstanceOf( + CliError + ); + }); + + it("throws when stdin contains content with no openapi/asyncapi key", async () => { + const context = await createTestContext({ cwd: AbsoluteFilePath.of("/") }); + const resolver = new ApiSpecResolver({ context }); + + await expect(resolver.resolve({ reference: "-", stdin: stringReadable('{"foo": "bar"}') })).rejects.toThrow( + /openapi|asyncapi/i + ); + }); + }); +}); diff --git a/packages/cli/cli-v2/src/commands/api/compile/command.ts b/packages/cli/cli-v2/src/commands/api/compile/command.ts index f5de6c8416e8..a5fab5ed6448 100644 --- a/packages/cli/cli-v2/src/commands/api/compile/command.ts +++ b/packages/cli/cli-v2/src/commands/api/compile/command.ts @@ -1,15 +1,14 @@ import { Audiences } from "@fern-api/configuration"; -import { streamObjectToFile } from "@fern-api/fs-utils"; import { CliError } from "@fern-api/task-context"; import chalk from "chalk"; -import { JsonStreamStringify } from "json-stream-stringify"; import type { Argv } from "yargs"; import { ApiChecker } from "../../../api/checker/ApiChecker.js"; import { IrCompiler } from "../../../api/compiler/IrCompiler.js"; import type { ApiDefinition } from "../../../api/config/ApiDefinition.js"; import type { Context } from "../../../context/Context.js"; import type { GlobalArgs } from "../../../context/GlobalArgs.js"; +import { isStdioMarker, StdioMarkerGuard, writeOutputJson } from "../../../io/stdio.js"; import { LANGUAGES } from "../../../sdk/config/Language.js"; import type { Workspace } from "../../../workspace/Workspace.js"; import { command } from "../../_internal/command.js"; @@ -39,6 +38,11 @@ export class CompileCommand { }); } + const stdio = new StdioMarkerGuard(); + if (isStdioMarker(args.output)) { + stdio.claimStdout("output"); + } + const compiler = new IrCompiler({ context, cliVersion: workspace.cliVersion @@ -120,23 +124,19 @@ export class CompileCommand { } private async writeOutput(context: Context, args: CompileCommand.Args, object: unknown): Promise { - if (args.output === "-") { - const stream = new JsonStreamStringify(object, undefined, 2); - stream.pipe(process.stdout); - return new Promise((resolve, reject) => { - stream.on("end", resolve); - stream.on("error", reject); - }); + if (args.output == null) { + // No output specified — just compile and validate. + return; } - if (args.output != null) { - const outputPath = context.resolveOutputFilePath(args.output); - if (outputPath != null) { - await streamObjectToFile(outputPath, object, { pretty: true }); - context.stderr.debug(chalk.dim(` Wrote IR to ${outputPath}`)); - } + if (isStdioMarker(args.output)) { + await writeOutputJson(args.output, object, { pretty: true }); return; } - // No output specified — just compile and validate. + const outputPath = context.resolveOutputFilePath(args.output); + if (outputPath != null) { + await writeOutputJson(outputPath, object, { pretty: true }); + context.stderr.debug(chalk.dim(` Wrote IR to ${outputPath}`)); + } } } diff --git a/packages/cli/cli-v2/src/commands/api/split/command.ts b/packages/cli/cli-v2/src/commands/api/split/command.ts index 403c6cb10ee0..4d47dd492904 100644 --- a/packages/cli/cli-v2/src/commands/api/split/command.ts +++ b/packages/cli/cli-v2/src/commands/api/split/command.ts @@ -12,6 +12,7 @@ import { FERN_YML_FILENAME } from "../../../config/fern-yml/constants.js"; import { FernYmlEditor } from "../../../config/fern-yml/FernYmlEditor.js"; import type { Context } from "../../../context/Context.js"; import type { GlobalArgs } from "../../../context/GlobalArgs.js"; +import { isStdioMarker, StdioMarkerGuard, writeOutputString } from "../../../io/stdio.js"; import { Icons } from "../../../ui/format.js"; import { command } from "../../_internal/command.js"; import type { SpecEntry } from "../utils/filterSpecs.js"; @@ -58,6 +59,13 @@ export class SplitCommand { } const format: SplitFormat = normalizeSplitFormat(args.format ?? OVERLAY_NAME); + + const stdio = new StdioMarkerGuard(); + if (isStdioMarker(args.output)) { + stdio.claimStdout("output"); + return this.handleStdoutPreview(context, entries, format); + } + const fernYmlPath = workspace.absoluteFilePath; if (fernYmlPath == null) { throw new CliError({ @@ -101,6 +109,51 @@ export class SplitCommand { } } + /** + * Preview-only mode: when `--output -` is set, write the overlay/overrides + * for the matching spec to stdout without modifying any workspace files + * (no git restore, no fern.yml updates, no merge with existing files). + * + * Requires that exactly one spec match `--api`, since multiple stdout + * writes would interleave incoherently. + */ + private async handleStdoutPreview(context: Context, entries: SpecEntry[], format: SplitFormat): Promise { + if (entries.length > 1) { + const names = entries.map((e) => path.basename(e.specFilePath)).join(", "); + throw new CliError({ + message: `--output "-" requires --api to select a single spec, but ${entries.length} matched: ${names}.`, + code: CliError.Code.ConfigError + }); + } + const entry = entries[0]; + if (entry == null) { + // Defensive: the caller checks for empty entries, but TS needs this. + return; + } + + const fernYmlPath = entry.specFilePath; // Used only for git repo root resolution. + const repoRoot = await this.getRepoRoot(fernYmlPath); + const [currentContent, originalRaw] = await Promise.all([ + loadSpec(entry.specFilePath), + this.getFileFromGitHead(repoRoot, entry.specFilePath) + ]); + const originalContent = parseSpec(originalRaw, entry.specFilePath); + + if (!hasChanges(originalContent, currentContent)) { + context.stderr.info(chalk.dim(`${entry.specFilePath}: no changes from git HEAD.`)); + return; + } + + // Always serialize as YAML for stdout — both formats are conventionally YAML. + const stdoutFilename = format === OVERLAY_NAME ? "stdout-overlay.yml" : "stdout-overrides.yml"; + const payload = + format === OVERLAY_NAME + ? generateOverlay(originalContent, currentContent) + : generateOverrides(originalContent, currentContent); + const serialized = serializeSpec(payload, stdoutFilename); + await writeOutputString("-", serialized); + } + private async splitAsOverlay( context: Context, editor: FernYmlEditor, @@ -283,7 +336,8 @@ export function addSplitCommand(cli: Argv): void { }) .option("output", { type: "string", - description: "Custom output path for the new overlay/override file" + description: + 'Custom output path for the new overlay/override file. Use "-" to print the diff to stdout (preview only — does not modify the workspace).' }) .option("format", { type: "string", diff --git a/packages/cli/cli-v2/src/commands/sdk/generate/command.ts b/packages/cli/cli-v2/src/commands/sdk/generate/command.ts index fa4f0fbd5b9d..b922fe23feab 100644 --- a/packages/cli/cli-v2/src/commands/sdk/generate/command.ts +++ b/packages/cli/cli-v2/src/commands/sdk/generate/command.ts @@ -17,6 +17,7 @@ import { GENERATE_COMMAND_TIMEOUT_MS } from "../../../constants.js"; import type { Context } from "../../../context/Context.js"; import type { GlobalArgs } from "../../../context/GlobalArgs.js"; import { SourcedValidationError } from "../../../errors/SourcedValidationError.js"; +import { isStdioMarker, StdioMarkerGuard } from "../../../io/stdio.js"; import { SdkChecker } from "../../../sdk/checker/SdkChecker.js"; import { LANGUAGES, type Language } from "../../../sdk/config/Language.js"; import type { Target } from "../../../sdk/config/Target.js"; @@ -83,6 +84,11 @@ export declare namespace GenerateCommand { export class GenerateCommand { public async handle(context: Context, args: GenerateCommand.Args): Promise { + const stdio = new StdioMarkerGuard(); + if (isStdioMarker(args.api)) { + stdio.claimStdin("api"); + } + const result = await context.loadWorkspace(); if (result == null) { return this.handleWithFlags(context, args); @@ -729,7 +735,7 @@ export function addGenerateCommand(cli: Argv): void { yargs .option("api", { type: "string", - description: "Path or URL to an API spec file (enables no-config mode)" + description: 'Path or URL to an API spec file, or "-" to read from stdin (enables no-config mode)' }) .option("audience", { type: "array", diff --git a/packages/cli/cli-v2/src/commands/sdk/preview/command.ts b/packages/cli/cli-v2/src/commands/sdk/preview/command.ts index 17ba77c867d5..a6bc282bfe1d 100644 --- a/packages/cli/cli-v2/src/commands/sdk/preview/command.ts +++ b/packages/cli/cli-v2/src/commands/sdk/preview/command.ts @@ -47,7 +47,7 @@ export function addPreviewCommand(cli: Argv): void { yargs .option("api", { type: "string", - description: "Path or URL to an API spec file (enables no-config mode)" + description: 'Path or URL to an API spec file, or "-" to read from stdin (enables no-config mode)' }) .option("audience", { type: "array", diff --git a/packages/cli/cli-v2/src/io/__test__/stdio.test.ts b/packages/cli/cli-v2/src/io/__test__/stdio.test.ts new file mode 100644 index 000000000000..3f3ac541be64 --- /dev/null +++ b/packages/cli/cli-v2/src/io/__test__/stdio.test.ts @@ -0,0 +1,200 @@ +import { AbsoluteFilePath } from "@fern-api/fs-utils"; +import { CliError } from "@fern-api/task-context"; + +import { mkdtemp, readFile, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { Readable, Writable } from "stream"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + isStdioMarker, + readInput, + readJsonOrPath, + StdioMarkerGuard, + writeOutputJson, + writeOutputString +} from "../stdio.js"; + +function stringReadable(value: string): Readable { + return Readable.from([Buffer.from(value, "utf-8")]); +} + +function capturingWritable(): { stream: Writable; getOutput: () => string } { + const chunks: Buffer[] = []; + const stream = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(Buffer.from(chunk)); + callback(); + } + }); + return { + stream, + getOutput: () => Buffer.concat(chunks).toString("utf-8") + }; +} + +describe("isStdioMarker", () => { + it("returns true for `-`", () => { + expect(isStdioMarker("-")).toBe(true); + }); + + it("returns false for other values", () => { + expect(isStdioMarker("foo")).toBe(false); + expect(isStdioMarker("")).toBe(false); + expect(isStdioMarker(undefined)).toBe(false); + expect(isStdioMarker(null)).toBe(false); + expect(isStdioMarker("--")).toBe(false); + }); +}); + +describe("readInput", () => { + let tmpDir: string; + let filePath: string; + + beforeAll(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "stdio-readInput-")); + filePath = join(tmpDir, "input.txt"); + await writeFile(filePath, "hello from file"); + }); + + afterAll(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("reads from stdin when value is `-`", async () => { + const stdin = stringReadable("hello from stdin"); + const result = await readInput("-", { stdin }); + expect(result).toBe("hello from stdin"); + }); + + it("reads from a file path otherwise", async () => { + const result = await readInput(filePath); + expect(result).toBe("hello from file"); + }); + + it("throws when the file does not exist", async () => { + const badPath = join(tmpDir, "does-not-exist.txt"); + await expect(readInput(badPath)).rejects.toThrow(); + }); +}); + +describe("readJsonOrPath", () => { + let tmpDir: string; + let filePath: string; + + beforeAll(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "stdio-readJsonOrPath-")); + filePath = join(tmpDir, "input.json"); + await writeFile(filePath, JSON.stringify({ fromFile: true })); + }); + + afterAll(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("parses inline JSON objects", async () => { + const result = await readJsonOrPath('{"hello": "world"}'); + expect(result).toEqual({ hello: "world" }); + }); + + it("parses inline JSON arrays", async () => { + const result = await readJsonOrPath("[1, 2, 3]"); + expect(result).toEqual([1, 2, 3]); + }); + + it("reads a file path when the value is not obviously JSON", async () => { + const result = await readJsonOrPath(filePath); + expect(result).toEqual({ fromFile: true }); + }); + + it("reads JSON from stdin when value is `-`", async () => { + const stdin = stringReadable('{"fromStdin": true}'); + const result = await readJsonOrPath("-", { stdin }); + expect(result).toEqual({ fromStdin: true }); + }); + + it("throws a ConfigError when the value is neither valid JSON nor a readable file", async () => { + const badPath = join(tmpDir, "does-not-exist.json"); + await expect(readJsonOrPath(badPath, { flagName: "config" })).rejects.toThrowError(CliError); + }); + + it("throws a ParseError when stdin contains invalid JSON", async () => { + const stdin = stringReadable("not json"); + await expect(readJsonOrPath("-", { stdin, flagName: "config" })).rejects.toThrowError(/invalid JSON/); + }); +}); + +describe("writeOutputJson", () => { + let tmpDir: string; + + beforeAll(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "stdio-writeOutputJson-")); + }); + + afterAll(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("streams JSON to stdout when path is `-`", async () => { + const { stream, getOutput } = capturingWritable(); + await writeOutputJson("-", { hello: "world" }, { stdout: stream, pretty: false }); + expect(JSON.parse(getOutput())).toEqual({ hello: "world" }); + }); + + it("pretty-prints by default", async () => { + const { stream, getOutput } = capturingWritable(); + await writeOutputJson("-", { a: 1 }, { stdout: stream }); + expect(getOutput()).toContain("\n"); + }); + + it("writes to a file when path is an absolute path", async () => { + const filePath = AbsoluteFilePath.of(join(tmpDir, "output.json")); + await writeOutputJson(filePath, { hello: "file" }, { pretty: false }); + const content = await readFile(filePath, "utf-8"); + expect(JSON.parse(content)).toEqual({ hello: "file" }); + }); +}); + +describe("writeOutputString", () => { + let tmpDir: string; + + beforeAll(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "stdio-writeOutputString-")); + }); + + afterAll(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("writes a string to stdout when path is `-`", async () => { + const { stream, getOutput } = capturingWritable(); + await writeOutputString("-", "hello stdout", { stdout: stream }); + expect(getOutput()).toBe("hello stdout"); + }); + + it("writes to a file when path is a real path", async () => { + const filePath = AbsoluteFilePath.of(join(tmpDir, "out.txt")); + await writeOutputString(filePath, "hello file"); + expect(await readFile(filePath, "utf-8")).toBe("hello file"); + }); +}); + +describe("StdioMarkerGuard", () => { + it("allows one stdin and one stdout claim", () => { + const guard = new StdioMarkerGuard(); + expect(() => guard.claimStdin("api")).not.toThrow(); + expect(() => guard.claimStdout("output")).not.toThrow(); + }); + + it("throws when stdin is claimed twice", () => { + const guard = new StdioMarkerGuard(); + guard.claimStdin("api"); + expect(() => guard.claimStdin("overlay")).toThrowError(/stdin once per command/); + }); + + it("throws when stdout is claimed twice", () => { + const guard = new StdioMarkerGuard(); + guard.claimStdout("output"); + expect(() => guard.claimStdout("report")).toThrowError(/stdout once per command/); + }); +}); diff --git a/packages/cli/cli-v2/src/io/stdio.ts b/packages/cli/cli-v2/src/io/stdio.ts new file mode 100644 index 000000000000..25eb0a539a2b --- /dev/null +++ b/packages/cli/cli-v2/src/io/stdio.ts @@ -0,0 +1,191 @@ +import { AbsoluteFilePath, streamObjectToFile } from "@fern-api/fs-utils"; +import { CliError } from "@fern-api/task-context"; + +import { readFile, writeFile } from "fs/promises"; +import { JsonStreamStringify } from "json-stream-stringify"; +import { Readable, Writable } from "stream"; +import { pipeline } from "stream/promises"; + +/** + * Conventional Unix marker used on I/O flags: `-` means stdin for inputs and + * stdout for outputs, depending on the context of the flag. + */ +export const STDIO_MARKER = "-"; + +/** + * Returns true when `value` is the conventional `-` stdin/stdout marker. + */ +export function isStdioMarker(value: string | undefined | null): value is "-" { + return value === STDIO_MARKER; +} + +/** + * Read the contents of a file path, or stdin when the value is `-`. + * + * Pass a custom stream via `stdin` in tests. + */ +export async function readInput( + pathOrDash: string, + { stdin = process.stdin }: { stdin?: Readable } = {} +): Promise { + if (isStdioMarker(pathOrDash)) { + return readStreamToString(stdin); + } + return readFile(pathOrDash, "utf-8"); +} + +/** + * Interpret a value as either inline JSON or a file path (falling back to stdin + * when the value is `-`). Cheapest-operation-first: a value that obviously + * looks like JSON is parsed directly, a file path is only read from disk when + * JSON parsing fails. + * + * Throws `CliError` when the input is neither a valid JSON document nor a + * readable file. + */ +export async function readJsonOrPath( + input: string, + { + stdin = process.stdin, + flagName + }: { + stdin?: Readable; + /** Optional flag name used to produce clearer error messages. */ + flagName?: string; + } = {} +): Promise { + if (isStdioMarker(input)) { + const raw = await readStreamToString(stdin); + return parseJsonOrThrow(raw, { flagName, source: "stdin" }); + } + + const trimmed = input.trim(); + if (looksLikeJson(trimmed)) { + try { + return JSON.parse(trimmed); + } catch { + // Fall through — try reading as a file path. + } + } + + let raw: string; + try { + raw = await readFile(input, "utf-8"); + } catch { + throw new CliError({ + message: + flagName != null + ? `--${flagName}: could not parse value as JSON and could not read it as a file: ${input}` + : `Could not parse value as JSON and could not read it as a file: ${input}`, + code: CliError.Code.ConfigError + }); + } + + return parseJsonOrThrow(raw, { flagName, source: input }); +} + +/** + * Write a JSON-serializable object to a file path, or stream it to stdout when + * the value is `-`. + * + * When writing to a file, `pathOrDash` must be an absolute path. + */ +export async function writeOutputJson( + pathOrDash: AbsoluteFilePath | "-", + object: unknown, + { pretty = true, stdout = process.stdout }: { pretty?: boolean; stdout?: Writable } = {} +): Promise { + if (isStdioMarker(pathOrDash)) { + // JsonStreamStringify implements the Readable interface but the library + // does not export typings that satisfy NodeJS.ReadableStream directly. + const stream = new JsonStreamStringify(object, undefined, pretty ? 4 : undefined) as NodeJS.ReadableStream; + await pipeline(stream, stdout, { end: false }); + return; + } + + await streamObjectToFile(pathOrDash, object, { pretty }); +} + +/** + * Write a string or Buffer to a file path, or to stdout when the value is `-`. + * + * When writing to a file, `pathOrDash` must be an absolute path. + */ +export async function writeOutputString( + pathOrDash: AbsoluteFilePath | "-", + data: string | Buffer, + { stdout = process.stdout }: { stdout?: Writable } = {} +): Promise { + if (isStdioMarker(pathOrDash)) { + await pipeline(Readable.from([data]), stdout, { end: false }); + return; + } + await writeFile(pathOrDash, data); +} + +/** + * Enforces the Unix convention that `-` can refer to at most one stdin source + * and one stdout destination per command invocation. Use one guard instance + * per command execution and call `claimStdin` / `claimStdout` before consuming + * stdin/stdout for a given flag. + */ +export class StdioMarkerGuard { + private stdinFlag: string | undefined; + private stdoutFlag: string | undefined; + + public claimStdin(flagName: string): void { + if (this.stdinFlag != null) { + throw new CliError({ + message: `\"-\" can only refer to stdin once per command, but both --${this.stdinFlag} and --${flagName} were set to \"-\".`, + code: CliError.Code.ConfigError + }); + } + this.stdinFlag = flagName; + } + + public claimStdout(flagName: string): void { + if (this.stdoutFlag != null) { + throw new CliError({ + message: `\"-\" can only refer to stdout once per command, but both --${this.stdoutFlag} and --${flagName} were set to \"-\".`, + code: CliError.Code.ConfigError + }); + } + this.stdoutFlag = flagName; + } +} + +async function readStreamToString(stream: Readable): Promise { + stream.setEncoding("utf-8"); + let result = ""; + for await (const chunk of stream) { + // setEncoding("utf-8") ensures chunks are always strings. + result += chunk as string; + } + return result; +} + +function looksLikeJson(value: string): boolean { + if (value.length === 0) { + return false; + } + const first = value[0]; + return first === "{" || first === "["; +} + +function parseJsonOrThrow( + raw: string, + { flagName, source }: { flagName: string | undefined; source: string } +): unknown { + try { + return JSON.parse(raw); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new CliError({ + message: + flagName != null + ? `--${flagName}: invalid JSON from ${source}: ${detail}` + : `Invalid JSON from ${source}: ${detail}`, + code: CliError.Code.ParseError + }); + } +} diff --git a/packages/cli/cli/changes/unreleased/cli-v2-stdio-marker.yml b/packages/cli/cli/changes/unreleased/cli-v2-stdio-marker.yml new file mode 100644 index 000000000000..b11cb0978943 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/cli-v2-stdio-marker.yml @@ -0,0 +1,26 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + cli-v2: introduce a shared stdin/stdout abstraction so commands uniformly honor + the Unix `-` marker on I/O flags (stdin for inputs, stdout for outputs), with a + "`-` used at most once per command" guard and a JSON-or-path input helper that + parses inline JSON first and falls back to a file read. + type: internal + +- summary: | + cli-v2: `fern api compile --output -` now uses the shared stdio helper (behavior + unchanged — still streams IR JSON to stdout). + type: internal + +- summary: | + cli-v2: `fern sdk generate --api -` and `fern sdk preview --api -` now read the + OpenAPI/AsyncAPI spec from stdin (e.g. `cat openapi.json | fern sdk generate + --api - --org acme --target typescript --output ./sdk`). + type: feat + +- summary: | + cli-v2: `fern api split --output -` prints the generated overlay/overrides for + the matching spec to stdout as a preview, without modifying the workspace + (no git-HEAD restore, no fern.yml updates). Requires `--api` to select a single + spec. + type: feat