Skip to content

Commit 048b301

Browse files
feat(cli): add fern automations preview command (#15141)
* feat(cli): add fern automations list preview subcommand Adds a new hidden subcommand that discovers previewable generator groups and outputs a JSON array of objects with groupName, apiName, and generator. A generator is previewable when: - It is a supported TypeScript/npm generator - automation.preview is not false in generators.yml Designed for consumption by the fern-preview GitHub Action to replace client-side YAML parsing of generators.yml with a single CLI call. Co-Authored-By: barry.zou <barry.zou@buildwithfern.com> * test(cli): add unit tests for listPreviewGroups Extracts core filtering logic from the inline command handler into a standalone listPreviewGroups() function for testability. Adds 20 tests covering: - Basic detection of all 3 TypeScript generator variants - Exclusion of non-TypeScript generators (Python, Java, Go) - automation.preview flag filtering - Mixed-language groups - Multi-API workspace support with apiName - Group name filtering - Edge cases (empty workspaces, null config, empty groups) Co-Authored-By: barry.zou <barry.zou@buildwithfern.com> * refactor(cli): address PR review feedback - Extract list generate logic into listGenerateCommands.ts (matches list preview pattern, fixes inconsistency #2) - Use Pick<AbstractAPIWorkspace> instead of manual WorkspaceGeneratorsInfo interface (#3) - Add --json flag to list preview command (#5) - Deduplicate to one entry per (groupName, apiName) pair (Concern #2) - Add 16 unit tests for listGenerateCommands - Update listPreviewGroups tests for deduplication behavior (21 tests) Co-Authored-By: barry.zou <barry.zou@buildwithfern.com> * docs(cli): update JSDoc to reference --json flag in GHA example Co-Authored-By: barry.zou <barry.zou@buildwithfern.com> * feat(cli): replace fern automations list preview with fern automations preview Consolidate discovery and execution into a single command: - sdkPreview() now returns SdkPreviewResult instead of writing stdout - New addAutomationsPreviewCommand discovers groups via listPreviewGroups() and calls sdkPreview() for each, aggregating results as JSON - Remove addAutomationsListPreviewCommand (listing-only, no longer needed) - Output formatting moved to CLI command handler (writeSdkPreviewOutput) Co-Authored-By: barry.zou <barry.zou@buildwithfern.com> * fix(cli): fix biome import ordering in cli.ts Co-Authored-By: barry.zou <barry.zou@buildwithfern.com> * fix(cli): address review feedback for automations preview - Add optional code field to SdkPreviewError and propagate CliError.Code values (AuthError, ConfigError) through all error return paths - Move AutomationsPreviewGroupResult interface to module level - Use bracket notation argv["push-diff"] for consistency with other commands Co-Authored-By: barry.zou <barry.zou@buildwithfern.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: barry.zou <barry.zou@buildwithfern.com>
1 parent a43b66c commit 048b301

7 files changed

Lines changed: 1000 additions & 111 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json
2+
3+
- summary: |
4+
Add `fern automations preview` command that discovers previewable generator
5+
groups and runs SDK preview for each one, aggregating results. Replaces the
6+
previous `fern automations list preview` listing-only command with a single
7+
command that handles both discovery and execution. Designed for consumption
8+
by the `fern-preview` GitHub Action.
9+
type: feat

packages/cli/cli/src/cli.ts

Lines changed: 228 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ import { getLatestVersionOfCli } from "./cli-context/upgrade-utils/getLatestVers
4444
import { GlobalCliOptions, loadProjectAndRegisterWorkspacesWithContext } from "./cliCommons.js";
4545
import { addGeneratorCommands, addGetOrganizationCommand } from "./cliV2.js";
4646
import { addGeneratorToWorkspaces } from "./commands/add-generator/addGeneratorToWorkspaces.js";
47+
import { listGenerateCommands } from "./commands/automations/listGenerateCommands.js";
48+
import { listPreviewGroups } from "./commands/automations/listPreviewGroups.js";
4749
import { diff } from "./commands/diff/diff.js";
4850
import { previewDocsWorkspace } from "./commands/docs-dev/devDocsWorkspace.js";
4951
import { docsDiff } from "./commands/docs-diff/docsDiff.js";
@@ -56,7 +58,6 @@ import { formatWorkspaces } from "./commands/format/formatWorkspaces.js";
5658
import { parseGeneratorArg } from "./commands/generate/filterGenerators.js";
5759
import { GenerationMode, generateAPIWorkspaces } from "./commands/generate/generateAPIWorkspaces.js";
5860
import { generateDocsWorkspace } from "./commands/generate/generateDocsWorkspace.js";
59-
import { shouldSkipGenerator } from "./commands/generate/shouldSkipGenerator.js";
6061
import { generateDynamicIrForWorkspaces } from "./commands/generate-dynamic-ir/generateDynamicIrForWorkspaces.js";
6162
import { generateFdrApiDefinitionForWorkspaces } from "./commands/generate-fdr/generateFdrApiDefinitionForWorkspaces.js";
6263
import { generateIrForWorkspaces } from "./commands/generate-ir/generateIrForWorkspaces.js";
@@ -71,6 +72,7 @@ import { mockServer } from "./commands/mock/mockServer.js";
7172
import { registerWorkspacesV1 } from "./commands/register/registerWorkspacesV1.js";
7273
import { registerWorkspacesV2 } from "./commands/register/registerWorkspacesV2.js";
7374
import { sdkDiffCommand } from "./commands/sdk-diff/sdkDiffCommand.js";
75+
import type { SdkPreviewResult, SdkPreviewSuccess } from "./commands/sdk-preview/sdkPreview.js";
7476
import { sdkPreview } from "./commands/sdk-preview/sdkPreview.js";
7577
import { selfUpdate } from "./commands/self-update/selfUpdate.js";
7678
import { testOutput } from "./commands/test/testOutput.js";
@@ -2230,6 +2232,7 @@ function addAutomationsCommand(cli: Argv<GlobalCliOptions>, cliContext: CliConte
22302232
cli.command("automations", false, (yargs) => {
22312233
addAutomationsListCommand(yargs, cliContext);
22322234
addAutomationsGenerateCommand(yargs, cliContext);
2235+
addAutomationsPreviewCommand(yargs, cliContext);
22332236
return yargs.demandCommand();
22342237
});
22352238
}
@@ -2324,41 +2327,177 @@ function addAutomationsListGenerateCommand(cli: Argv<GlobalCliOptions>, cliConte
23242327
defaultToAllApiWorkspaces: true
23252328
});
23262329

2327-
const commands: string[] = [];
2330+
const commands = listGenerateCommands({
2331+
workspaces: project.apiWorkspaces,
2332+
groupFilter: argv.group,
2333+
version: argv.version,
2334+
autoMerge: argv["auto-merge"]
2335+
});
2336+
2337+
// Output JSON array of commands to stdout for GitHub Actions consumption
2338+
process.stdout.write(JSON.stringify(commands));
2339+
}
2340+
);
2341+
}
2342+
2343+
/**
2344+
* `fern automations preview`
2345+
*
2346+
* Runs SDK preview for all previewable generator groups in the project.
2347+
* Discovers eligible groups using the same criteria as `listPreviewGroups`,
2348+
* then calls `sdkPreview` for each one, aggregating results.
2349+
*
2350+
* A generator is considered previewable when:
2351+
* - It is a supported TypeScript/npm generator (fern-typescript-sdk, node-sdk, browser-sdk)
2352+
* - `automation.preview` is not false in generators.yml
2353+
*
2354+
* One preview is run per unique (groupName, apiName) pair. Errors are
2355+
* isolated per group — a failure in one group does not block the others.
2356+
*
2357+
* JSON output format (--json):
2358+
* {
2359+
* "results": [
2360+
* {
2361+
* "groupName": "ts-sdk",
2362+
* "apiName": null,
2363+
* "status": "success",
2364+
* "org": "acme",
2365+
* "previews": [{ "preview_id": "...", "install": "...", ... }]
2366+
* },
2367+
* { "groupName": "node", "apiName": "bar", "status": "error", "error": "..." }
2368+
* ]
2369+
* }
2370+
*
2371+
* Example GitHub Actions usage:
2372+
* - run: fern automations preview --json --push-diff
2373+
* env:
2374+
* FERN_TOKEN: ${{ secrets.FERN_TOKEN }}
2375+
*/
2376+
interface AutomationsPreviewGroupResult {
2377+
groupName: string;
2378+
apiName: string | null;
2379+
status: "success" | "error";
2380+
org?: string;
2381+
previews?: SdkPreviewSuccess["previews"];
2382+
error?: string;
2383+
}
2384+
2385+
function addAutomationsPreviewCommand(cli: Argv<GlobalCliOptions>, cliContext: CliContext) {
2386+
cli.command(
2387+
"preview",
2388+
false, // hidden
2389+
(yargs) =>
2390+
yargs
2391+
.option("group", {
2392+
type: "string",
2393+
description:
2394+
"Filter to a specific generator group (e.g. 'sdk'). Omit to preview all eligible groups."
2395+
})
2396+
.option("json", {
2397+
boolean: true,
2398+
default: false,
2399+
description: "Output results as JSON (for machine consumption)."
2400+
})
2401+
.option("push-diff", {
2402+
boolean: true,
2403+
default: false,
2404+
description:
2405+
"Push a preview diff branch (fern-preview-{version}) to each SDK repo " +
2406+
"in addition to publishing to the preview registry."
2407+
}),
2408+
async (argv) => {
2409+
cliContext.instrumentPostHogEvent({
2410+
command: "fern automations preview"
2411+
});
2412+
2413+
const project = await loadProjectAndRegisterWorkspacesWithContext(cliContext, {
2414+
commandLineApiWorkspace: undefined,
2415+
defaultToAllApiWorkspaces: true
2416+
});
2417+
2418+
const groups = listPreviewGroups({
2419+
workspaces: project.apiWorkspaces,
2420+
groupFilter: argv.group
2421+
});
2422+
2423+
if (groups.length === 0) {
2424+
if (argv.json) {
2425+
process.stdout.write(JSON.stringify({ results: [] }, null, 2) + "\n");
2426+
} else {
2427+
cliContext.logger.info("No eligible generator groups found for preview.");
2428+
}
2429+
return;
2430+
}
2431+
2432+
cliContext.logger.info(
2433+
`Found ${groups.length} previewable group(s): ${groups.map((g) => g.groupName).join(", ")}`
2434+
);
23282435

2329-
for (const workspace of project.apiWorkspaces) {
2330-
const generatorsConfiguration = workspace.generatorsConfiguration;
2331-
const groups = generatorsConfiguration?.groups ?? [];
2332-
const rootAutorelease = generatorsConfiguration?.rawConfiguration.autorelease;
2333-
for (const group of groups) {
2334-
if (argv.group != null && group.groupName !== argv.group) {
2335-
continue;
2436+
const results: AutomationsPreviewGroupResult[] = [];
2437+
2438+
for (const group of groups) {
2439+
const apiLabel = group.apiName != null ? ` (api: ${group.apiName})` : "";
2440+
cliContext.logger.info(`Running preview for ${group.groupName}${apiLabel}...`);
2441+
2442+
try {
2443+
const result = await sdkPreview({
2444+
cliContext,
2445+
groupName: group.groupName,
2446+
generatorFilter: undefined,
2447+
apiName: group.apiName ?? undefined,
2448+
output: undefined,
2449+
local: false,
2450+
pushDiff: argv["push-diff"]
2451+
});
2452+
2453+
if (result.status === "success") {
2454+
results.push({
2455+
groupName: group.groupName,
2456+
apiName: group.apiName,
2457+
status: "success",
2458+
org: result.org,
2459+
previews: result.previews
2460+
});
2461+
} else {
2462+
results.push({
2463+
groupName: group.groupName,
2464+
apiName: group.apiName,
2465+
status: "error",
2466+
error: result.message
2467+
});
23362468
}
2337-
for (let i = 0; i < group.generators.length; i++) {
2338-
const generator = group.generators[i];
2339-
if (generator == null || shouldSkipGenerator({ generator, rootAutorelease })) {
2340-
continue;
2341-
}
2469+
} catch (error) {
2470+
const message = error instanceof Error ? error.message : String(error);
2471+
cliContext.logger.warn(`Preview failed for group '${group.groupName}': ${message}`);
2472+
results.push({
2473+
groupName: group.groupName,
2474+
apiName: group.apiName,
2475+
status: "error",
2476+
error: message
2477+
});
2478+
}
2479+
}
23422480

2343-
const parts = ["fern", "automations", "generate"];
2344-
if (workspace.workspaceName != null) {
2345-
parts.push("--api", workspace.workspaceName);
2346-
}
2347-
parts.push("--group", group.groupName);
2348-
parts.push("--generator", String(i));
2349-
if (argv.version != null) {
2350-
parts.push("--version", argv.version);
2351-
}
2352-
if (argv["auto-merge"]) {
2353-
parts.push("--auto-merge");
2481+
if (argv.json) {
2482+
process.stdout.write(JSON.stringify({ results }, null, 2) + "\n");
2483+
} else {
2484+
for (const groupResult of results) {
2485+
if (groupResult.status === "success" && groupResult.previews != null) {
2486+
for (const preview of groupResult.previews) {
2487+
if (preview.install) {
2488+
cliContext.logger.info(`${groupResult.groupName}: ${preview.install}`);
2489+
}
23542490
}
2355-
commands.push(parts.join(" "));
2491+
} else if (groupResult.status === "error") {
2492+
cliContext.logger.warn(`${groupResult.groupName}: ${groupResult.error}`);
23562493
}
23572494
}
23582495
}
23592496

2360-
// Output JSON array of commands to stdout for GitHub Actions consumption
2361-
process.stdout.write(JSON.stringify(commands));
2497+
const hasErrors = results.some((r) => r.status === "error");
2498+
if (hasErrors) {
2499+
process.exitCode = 1;
2500+
}
23622501
}
23632502
);
23642503
}
@@ -2535,20 +2674,79 @@ function addSdkPreviewCommand(cli: Argv<GlobalCliOptions>, cliContext: CliContex
25352674
});
25362675
const generatorFilter =
25372676
argv.generator != null ? warnAndCorrectIncorrectDockerOrg(argv.generator, cliContext) : undefined;
2538-
await sdkPreview({
2677+
const result = await sdkPreview({
25392678
cliContext,
25402679
groupName: argv.group,
25412680
generatorFilter,
25422681
apiName: argv.api,
2543-
json: argv.json,
25442682
output: argv.output,
25452683
local: argv.local,
25462684
pushDiff: argv.pushDiff
25472685
});
2686+
writeSdkPreviewOutput({ result, json: argv.json, cliContext });
25482687
}
25492688
);
25502689
}
25512690

2691+
function writeSdkPreviewOutput({
2692+
result,
2693+
json,
2694+
cliContext
2695+
}: {
2696+
result: SdkPreviewResult;
2697+
json: boolean;
2698+
cliContext: CliContext;
2699+
}): void {
2700+
if (json) {
2701+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
2702+
if (result.status === "error") {
2703+
process.exitCode = 1;
2704+
}
2705+
return;
2706+
}
2707+
2708+
if (result.status === "error") {
2709+
return cliContext.failAndThrow(
2710+
result.message,
2711+
undefined,
2712+
result.code != null ? { code: result.code } : undefined
2713+
);
2714+
}
2715+
2716+
if (result.previews.length > 0) {
2717+
cliContext.logger.info("");
2718+
const hasRegistry = result.previews.some((p) => p.registry_url !== "");
2719+
if (hasRegistry) {
2720+
cliContext.logger.info(
2721+
`Published ${result.previews.length} preview package${result.previews.length > 1 ? "s" : ""}:`
2722+
);
2723+
for (const preview of result.previews) {
2724+
cliContext.logger.info("");
2725+
cliContext.logger.info(` ${preview.package_name}@${preview.version}`);
2726+
cliContext.logger.info(` Install: ${preview.install}`);
2727+
if (preview.diff_url) {
2728+
cliContext.logger.info(` Diff: ${preview.diff_url}`);
2729+
}
2730+
if (preview.output_path) {
2731+
cliContext.logger.info(` Output: ${preview.output_path}`);
2732+
}
2733+
}
2734+
} else {
2735+
cliContext.logger.info(
2736+
`Generated ${result.previews.length} preview SDK${result.previews.length > 1 ? "s" : ""}:`
2737+
);
2738+
for (const preview of result.previews) {
2739+
cliContext.logger.info("");
2740+
cliContext.logger.info(` ${preview.package_name}@${preview.version}`);
2741+
if (preview.diff_url) {
2742+
cliContext.logger.info(` Diff: ${preview.diff_url}`);
2743+
}
2744+
cliContext.logger.info(` Output: ${preview.output_path}`);
2745+
}
2746+
}
2747+
}
2748+
}
2749+
25522750
function addBetaCommand(cli: Argv<GlobalCliOptions>, cliContext: CliContext) {
25532751
cli.command(
25542752
"beta",

0 commit comments

Comments
 (0)