Skip to content

Commit b9a86a1

Browse files
devin-ai-integration[bot]lifanzouBarryclaude
authored
feat(cli): route fern sdk preview through remote generation, add --local and --push-diff (#14856)
* feat: route fern sdk preview through Fiddle for remote generation by default Co-Authored-By: barry.zou <barry.zou@buildwithfern.com> * feat(cli): route fern sdk preview through Fiddle for remote generation by default Co-Authored-By: barry.zou <barry.zou@buildwithfern.com> * feat(cli): add --push-diff flag and use publishV2 by default for remote preview - Default fern sdk preview (no flags): publishV2(npmOverride) → registry-only publish, safe to merge independently - With --push-diff: githubV2(push) + preview=true → Fiddle publishes AND pushes diff branch to SDK repo - Added explicit isPreview param to remote generation chain (createAndStartJob, runRemoteGenerationForGenerator, runRemoteGenerationForAPIWorkspace) so preview flag can be set independently of absolutePathToPreview Co-Authored-By: barry.zou <barry.zou@buildwithfern.com> * chore: trigger CI re-run with correct PR title Co-Authored-By: barry.zou <barry.zou@buildwithfern.com> * fix(cli): set isPreview=false for remote preview to avoid dry-run publish In Fiddle, preview=true causes dryRun=true in the generator config, which makes the generator run 'npm publish --dry-run' instead of actually publishing. For sdk preview via publishV2(npmOverride), we want the generator to actually publish to the registry. Co-Authored-By: barry.zou <barry.zou@buildwithfern.com> * chore(cli): remove internal Fiddle implementation details from comments Co-Authored-By: barry.zou <barry.zou@buildwithfern.com> * fix(cli): address PR review — _other returns undefined, isPreview conditional on pushDiff, clarify JSDoc Co-Authored-By: barry.zou <barry.zou@buildwithfern.com> * fix(cli): decouple isPreview from Fiddle preview field, remove --push-diff Co-Authored-By: barry.zou <barry.zou@buildwithfern.com> * fix(cli): replace fiddlePreview with publishV2 output mode check Co-Authored-By: barry.zou <barry.zou@buildwithfern.com> * fix(cli): simplify preview field — just send isPreview, let Fiddle handle dryRun Co-Authored-By: barry.zou <barry.zou@buildwithfern.com> * feat(cli): decouple fiddlePreview from isPreview, add --push-diff support - Add `fiddlePreview` param to decouple what's sent to Fiddle as `preview` from CLI-internal `isPreview` behavior. For sdk preview, isPreview=true (lenient env vars, skip version check) while fiddlePreview=false (so Fiddle doesn't set dryRun=true). This preserves fern generate --preview behavior exactly as-is. - Add `pushPreviewBranch` param threaded through the remote generation chain. Will be sent to Fiddle once fiddle-sdk is bumped. - Add `--push-diff` CLI flag for pushing preview branches to SDK repos. - Add `getGithubOwnerRepo` helper and pushDiff support to `overrideGroupOutputForRemotePreview` — uses githubV2(push) with npm publishInfo when --push-diff is set and generator has github config. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore(cli): consolidate overrideGroupOutputForPreview into single function Merge the local-Docker-only overrideGroupOutputForPreview and remote-only overrideGroupOutputForRemotePreview into a single exported function with an optional pushDiff param. Extract createNpmOverrideOutputMode helper to deduplicate the publishV2(npmOverride) construction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(cli): gate --push-diff, add validation, clarify dryRun comment - Error out on --push-diff until fiddle-sdk is bumped with pushPreviewBranch support — without it, Fiddle would treat the githubV2(push) job as a normal push to the default branch. - Error on --push-diff combined with --output (incompatible flags). - Warn when --push-diff falls back to registry-only publish because the generator has no github output configuration. - Clarify the dryRun comment in createAndStartJob.ts explaining that fiddlePreview is the sole mechanism preventing dryRun for sdk preview jobs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs(cli): improve --output and --push-diff option descriptions Document the behavior differences between modes: omitting --output uses remote generation, providing it uses local Docker. Add examples for --output combinations. Clarify --push-diff branch naming, GitHub App requirement, and incompatibility with --output. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(cli): add --local flag to fern sdk preview, make --output use remote generation Co-Authored-By: barry.zou <barry.zou@buildwithfern.com> * docs(cli): update --push-diff help text to reference --local instead of --output Co-Authored-By: barry.zou <barry.zou@buildwithfern.com> * fix(cli): override output mode to downloadFiles for remote disk-only preview When --output is provided without a registry URL and without --local, remote generation was using the generator's original output mode (e.g. npm publish to registry.npmjs.org) which fails. Override to downloadFiles so the generator produces files without publishing, and the CLI downloads them from S3 to the specified path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(cli): add unit tests for sdk preview output mode overrides Cover getGithubOwnerRepo, overrideGroupOutputForDownload, and overrideGroupOutputForPreview including pushDiff routing logic. Add clarifying comment on publishV2 being registry-only. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(cli): remove unnecessary isPreview param from createAndStartJob isPreview was threaded to createAndStartJob only to participate in the fallback chain for the Fiddle preview flag, but no caller needed it — fern sdk preview already passes fiddlePreview explicitly, and fern generate --preview relies on absolutePathToPreview != null. Simplify the fallback to: fiddlePreview ?? absolutePathToPreview != null. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore(cli): remove test config files accidentally committed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(cli): remove extra destructured params from createJob inner function automationMode and autoMerge are in the type signature but not destructured on main (they're commented out in the createJobV3 call). The merge conflict resolution incorrectly added them to the destructuring. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: barry.zou <barry.zou@buildwithfern.com> Co-authored-by: Barry <barry@Barrys-MacBook-Pro.local> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ba5b837 commit b9a86a1

7 files changed

Lines changed: 604 additions & 73 deletions

File tree

packages/cli/cli/src/cli.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2447,7 +2447,27 @@ function addSdkPreviewCommand(cli: Argv<GlobalCliOptions>, cliContext: CliContex
24472447
string: true,
24482448
description:
24492449
"Output targets: filesystem paths and/or registry URLs. " +
2450-
"Omit to publish to the default preview registry and write to a temp directory."
2450+
"When omitted, publishes to the default preview registry via remote generation. " +
2451+
"Examples: --output ./out (disk only), --output https://registry.example.com (registry only), " +
2452+
"--output ./out --output https://registry.example.com (both)."
2453+
})
2454+
.option("local", {
2455+
boolean: true,
2456+
default: false,
2457+
description:
2458+
"Run generation locally via Docker instead of remotely through Fiddle. " +
2459+
"Requires Docker to be installed. " +
2460+
"Can be combined with --output for local disk output."
2461+
})
2462+
.option("push-diff", {
2463+
boolean: true,
2464+
default: false,
2465+
description:
2466+
"Push a preview diff branch (fern-preview-{version}) to the SDK repo " +
2467+
"in addition to publishing to the preview registry. " +
2468+
"Requires the generator to have github output configuration " +
2469+
"and the Fern GitHub App installed on the target repo. " +
2470+
"Cannot be combined with --local."
24512471
}),
24522472
async (argv) => {
24532473
await cliContext.instrumentPostHogEvent({
@@ -2461,7 +2481,9 @@ function addSdkPreviewCommand(cli: Argv<GlobalCliOptions>, cliContext: CliContex
24612481
generatorFilter,
24622482
apiName: argv.api,
24632483
json: argv.json,
2464-
output: argv.output
2484+
output: argv.output,
2485+
local: argv.local,
2486+
pushDiff: argv.pushDiff
24652487
});
24662488
}
24672489
);

packages/cli/cli/src/commands/sdk-preview/__test__/overrideOutputForPreview.test.ts

Lines changed: 294 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,52 @@
1+
import type { generatorsYml } from "@fern-api/configuration-loader";
2+
import { AbsoluteFilePath } from "@fern-api/fs-utils";
3+
import { FernFiddle } from "@fern-fern/fiddle-sdk";
14
import { describe, expect, it } from "vitest";
5+
import {
6+
getGithubOwnerRepo,
7+
isNpmGenerator,
8+
overrideGroupOutputForDownload,
9+
overrideGroupOutputForPreview
10+
} from "../overrideOutputForPreview.js";
211

3-
import { isNpmGenerator } from "../overrideOutputForPreview.js";
12+
/**
13+
* Creates a minimal GeneratorInvocation for testing.
14+
* Only the fields relevant to output mode override logic are set;
15+
* everything else uses safe defaults.
16+
*/
17+
function makeGenerator(
18+
outputMode: FernFiddle.remoteGen.OutputMode,
19+
overrides?: Partial<generatorsYml.GeneratorInvocation>
20+
): generatorsYml.GeneratorInvocation {
21+
return {
22+
name: "fernapi/fern-typescript-node-sdk",
23+
version: "0.57.10",
24+
config: {},
25+
outputMode,
26+
automation: { generate: false, upgrade: false, preview: false, verify: false },
27+
containerImage: undefined,
28+
irVersionOverride: undefined,
29+
absolutePathToLocalOutput: AbsoluteFilePath.of("/tmp/test-output"),
30+
absolutePathToLocalSnippets: undefined,
31+
keywords: undefined,
32+
smartCasing: false,
33+
disableExamples: false,
34+
language: undefined,
35+
publishMetadata: undefined,
36+
readme: undefined,
37+
settings: undefined,
38+
...overrides
39+
};
40+
}
41+
42+
function makeGroup(generators: generatorsYml.GeneratorInvocation[]): generatorsYml.GeneratorGroup {
43+
return {
44+
groupName: "test-group",
45+
audiences: { type: "all" },
46+
generators,
47+
reviewers: undefined
48+
};
49+
}
450

551
describe("isNpmGenerator", () => {
652
it("recognizes known TypeScript SDK generators", () => {
@@ -33,3 +79,250 @@ describe("isNpmGenerator", () => {
3379
expect(isNpmGenerator("fernapi/fern-csharp-sdk")).toBe(false);
3480
});
3581
});
82+
83+
describe("getGithubOwnerRepo", () => {
84+
it("returns undefined for downloadFiles", () => {
85+
const mode = FernFiddle.remoteGen.OutputMode.downloadFiles({});
86+
expect(getGithubOwnerRepo(mode)).toBeUndefined();
87+
});
88+
89+
it("extracts owner/repo from github (v1)", () => {
90+
const mode = FernFiddle.OutputMode.github({
91+
owner: "fern-api",
92+
repo: "fern-typescript-sdk"
93+
});
94+
expect(getGithubOwnerRepo(mode)).toEqual({ owner: "fern-api", repo: "fern-typescript-sdk" });
95+
});
96+
97+
it("extracts owner/repo from githubV2 push", () => {
98+
const mode = FernFiddle.OutputMode.githubV2(
99+
FernFiddle.GithubOutputModeV2.push({
100+
owner: "fern-demo",
101+
repo: "sdk-preview-test-sdk",
102+
branch: undefined,
103+
license: undefined,
104+
downloadSnippets: false
105+
})
106+
);
107+
expect(getGithubOwnerRepo(mode)).toEqual({ owner: "fern-demo", repo: "sdk-preview-test-sdk" });
108+
});
109+
110+
it("extracts owner/repo from githubV2 commitAndRelease", () => {
111+
const mode = FernFiddle.OutputMode.githubV2(
112+
FernFiddle.GithubOutputModeV2.commitAndRelease({
113+
owner: "my-org",
114+
repo: "my-sdk"
115+
})
116+
);
117+
expect(getGithubOwnerRepo(mode)).toEqual({ owner: "my-org", repo: "my-sdk" });
118+
});
119+
120+
it("extracts owner/repo from githubV2 pullRequest", () => {
121+
const mode = FernFiddle.OutputMode.githubV2(
122+
FernFiddle.GithubOutputModeV2.pullRequest({
123+
owner: "acme",
124+
repo: "acme-sdk"
125+
})
126+
);
127+
expect(getGithubOwnerRepo(mode)).toEqual({ owner: "acme", repo: "acme-sdk" });
128+
});
129+
130+
it("returns undefined for publish (v1)", () => {
131+
const mode = FernFiddle.OutputMode.publish({
132+
registryOverrides: {
133+
npm: {
134+
registryUrl: "https://registry.npmjs.org",
135+
packageName: "@acme/sdk",
136+
token: "token"
137+
}
138+
}
139+
});
140+
expect(getGithubOwnerRepo(mode)).toBeUndefined();
141+
});
142+
143+
it("returns undefined for publishV2 — registry-only, no owner/repo", () => {
144+
const mode = FernFiddle.OutputMode.publishV2(
145+
FernFiddle.remoteGen.PublishOutputModeV2.npmOverride({
146+
registryUrl: "https://npm.buildwithfern.com",
147+
packageName: "@acme/sdk",
148+
token: "token",
149+
downloadSnippets: false
150+
})
151+
);
152+
expect(getGithubOwnerRepo(mode)).toBeUndefined();
153+
});
154+
});
155+
156+
describe("overrideGroupOutputForDownload", () => {
157+
it("overrides output mode to downloadFiles", () => {
158+
const generator = makeGenerator(
159+
FernFiddle.OutputMode.publishV2(
160+
FernFiddle.remoteGen.PublishOutputModeV2.npmOverride({
161+
registryUrl: "https://registry.npmjs.org",
162+
packageName: "@acme/sdk",
163+
token: "token",
164+
downloadSnippets: false
165+
})
166+
)
167+
);
168+
const group = makeGroup([generator]);
169+
170+
const result = overrideGroupOutputForDownload({ group });
171+
172+
expect(result.generators).toHaveLength(1);
173+
expect(result.generators[0]?.outputMode.type).toBe("downloadFiles");
174+
expect(result.generators[0]?.absolutePathToLocalOutput).toBeUndefined();
175+
});
176+
177+
it("preserves other generator fields", () => {
178+
const generator = makeGenerator(FernFiddle.OutputMode.github({ owner: "o", repo: "r" }), {
179+
name: "fernapi/fern-typescript-sdk",
180+
version: "1.0.0"
181+
});
182+
const group = makeGroup([generator]);
183+
184+
const result = overrideGroupOutputForDownload({ group });
185+
186+
expect(result.generators[0]?.name).toBe("fernapi/fern-typescript-sdk");
187+
expect(result.generators[0]?.version).toBe("1.0.0");
188+
});
189+
190+
it("overrides all generators in the group", () => {
191+
const gen1 = makeGenerator(FernFiddle.OutputMode.github({ owner: "o", repo: "r" }));
192+
const gen2 = makeGenerator(
193+
FernFiddle.OutputMode.publishV2(
194+
FernFiddle.remoteGen.PublishOutputModeV2.npmOverride({
195+
registryUrl: "https://npm.buildwithfern.com",
196+
packageName: "@acme/sdk",
197+
token: "t",
198+
downloadSnippets: false
199+
})
200+
)
201+
);
202+
const group = makeGroup([gen1, gen2]);
203+
204+
const result = overrideGroupOutputForDownload({ group });
205+
206+
expect(result.generators).toHaveLength(2);
207+
for (const gen of result.generators) {
208+
expect(gen.outputMode.type).toBe("downloadFiles");
209+
}
210+
});
211+
});
212+
213+
describe("overrideGroupOutputForPreview", () => {
214+
const previewParams = {
215+
packageName: "@fern-preview/acme-sdk",
216+
token: "fern-token-123",
217+
registryUrl: "https://npm.buildwithfern.com"
218+
};
219+
220+
it("uses publishV2(npmOverride) by default (no pushDiff)", () => {
221+
const generator = makeGenerator(FernFiddle.OutputMode.github({ owner: "o", repo: "r" }));
222+
const group = makeGroup([generator]);
223+
224+
const result = overrideGroupOutputForPreview({ group, ...previewParams });
225+
226+
expect(result.generators).toHaveLength(1);
227+
const outputMode = result.generators[0]?.outputMode;
228+
expect(outputMode?.type).toBe("publishV2");
229+
expect(result.generators[0]?.absolutePathToLocalOutput).toBeUndefined();
230+
});
231+
232+
it("uses publishV2(npmOverride) when pushDiff is false", () => {
233+
const generator = makeGenerator(
234+
FernFiddle.OutputMode.githubV2(
235+
FernFiddle.GithubOutputModeV2.push({
236+
owner: "fern-demo",
237+
repo: "sdk-repo",
238+
branch: undefined,
239+
license: undefined,
240+
downloadSnippets: false
241+
})
242+
)
243+
);
244+
const group = makeGroup([generator]);
245+
246+
const result = overrideGroupOutputForPreview({ group, ...previewParams, pushDiff: false });
247+
248+
expect(result.generators[0]?.outputMode.type).toBe("publishV2");
249+
});
250+
251+
it("uses githubV2(push) with publishInfo when pushDiff is true and generator has github config", () => {
252+
const generator = makeGenerator(
253+
FernFiddle.OutputMode.githubV2(
254+
FernFiddle.GithubOutputModeV2.push({
255+
owner: "fern-demo",
256+
repo: "sdk-preview-test-sdk",
257+
branch: undefined,
258+
license: undefined,
259+
downloadSnippets: false
260+
})
261+
)
262+
);
263+
const group = makeGroup([generator]);
264+
265+
const result = overrideGroupOutputForPreview({ group, ...previewParams, pushDiff: true });
266+
267+
expect(result.generators).toHaveLength(1);
268+
const outputMode = result.generators[0]?.outputMode;
269+
expect(outputMode?.type).toBe("githubV2");
270+
});
271+
272+
it("falls back to publishV2(npmOverride) when pushDiff is true but generator has no github config", () => {
273+
const generator = makeGenerator(
274+
FernFiddle.OutputMode.publishV2(
275+
FernFiddle.remoteGen.PublishOutputModeV2.npmOverride({
276+
registryUrl: "https://registry.npmjs.org",
277+
packageName: "@acme/sdk",
278+
token: "original-token",
279+
downloadSnippets: false
280+
})
281+
)
282+
);
283+
const group = makeGroup([generator]);
284+
285+
const result = overrideGroupOutputForPreview({ group, ...previewParams, pushDiff: true });
286+
287+
expect(result.generators[0]?.outputMode.type).toBe("publishV2");
288+
});
289+
290+
it("falls back to publishV2(npmOverride) when pushDiff is true but generator uses downloadFiles", () => {
291+
const generator = makeGenerator(FernFiddle.remoteGen.OutputMode.downloadFiles({}));
292+
const group = makeGroup([generator]);
293+
294+
const result = overrideGroupOutputForPreview({ group, ...previewParams, pushDiff: true });
295+
296+
expect(result.generators[0]?.outputMode.type).toBe("publishV2");
297+
});
298+
299+
it("extracts owner/repo from github v1 when pushDiff is true", () => {
300+
const generator = makeGenerator(FernFiddle.OutputMode.github({ owner: "legacy-org", repo: "legacy-sdk" }));
301+
const group = makeGroup([generator]);
302+
303+
const result = overrideGroupOutputForPreview({ group, ...previewParams, pushDiff: true });
304+
305+
expect(result.generators[0]?.outputMode.type).toBe("githubV2");
306+
});
307+
308+
it("clears absolutePathToLocalOutput", () => {
309+
const generator = makeGenerator(FernFiddle.OutputMode.github({ owner: "o", repo: "r" }), {
310+
absolutePathToLocalOutput: AbsoluteFilePath.of("/tmp/original-output")
311+
});
312+
const group = makeGroup([generator]);
313+
314+
const result = overrideGroupOutputForPreview({ group, ...previewParams });
315+
316+
expect(result.generators[0]?.absolutePathToLocalOutput).toBeUndefined();
317+
});
318+
319+
it("preserves group-level fields", () => {
320+
const generator = makeGenerator(FernFiddle.remoteGen.OutputMode.downloadFiles({}));
321+
const group = makeGroup([generator]);
322+
group.groupName = "my-preview-group";
323+
324+
const result = overrideGroupOutputForPreview({ group, ...previewParams });
325+
326+
expect(result.groupName).toBe("my-preview-group");
327+
});
328+
});

0 commit comments

Comments
 (0)