Skip to content

Commit a559f0d

Browse files
feat(cli-v2): add fern sdk list command (#15800)
* feat(cli-v2): add `fern sdk list` command Adds a new `fern sdk list` subcommand that: 1. Lists all configured SDKs from the user's local fern.yml 2. Lists all available SDK generators from the Fern registry Supports --language, --type, and --json flags for filtering and machine-readable output. Refs: FER-8897 * Add tests and tighten sdk list command Adds comprehensive unit tests for sdk list (parse filters, filtering, output formatting, human/json output) and updates the ListCommand implementation: import schemas helper, warn when workspace load fails, improve output formatting (pad version, use language display name), add informative message when a non-sdk type filter is used, simplify type-filter logic, and use schema guards to detect git output types. Also make parseLanguageFilter/parseTypeFilter more robust and explicit in error messages. --------- Co-authored-by: Naman Anand <info@buildwithfern.com>
1 parent 093012c commit a559f0d

5 files changed

Lines changed: 567 additions & 0 deletions

File tree

packages/cli/cli-v2/src/commands/sdk/command.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { commandGroup } from "../_internal/commandGroup.js";
44
import { addAddCommand } from "./add/index.js";
55
import { addCheckCommand } from "./check/index.js";
66
import { addGenerateCommand } from "./generate/index.js";
7+
import { addListCommand } from "./list/index.js";
78
import { addPreviewCommand } from "./preview/index.js";
89
import { addUpdateCommand } from "./update/index.js";
910

@@ -12,6 +13,7 @@ export function addSdkCommand(cli: Argv<GlobalArgs>): void {
1213
addAddCommand,
1314
addCheckCommand,
1415
addGenerateCommand,
16+
addListCommand,
1517
addPreviewCommand,
1618
addUpdateCommand
1719
]);
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import { AbsoluteFilePath } from "@fern-api/fs-utils";
2+
import { CliError } from "@fern-api/task-context";
3+
import { describe, expect, it } from "vitest";
4+
import { createTestContextWithCapture } from "../../../../__test__/utils/createTestContext.js";
5+
import type { Target } from "../../../../sdk/config/Target.js";
6+
import { ListCommand } from "../command.js";
7+
8+
// ---------------------------------------------------------------------------
9+
// Helpers
10+
// ---------------------------------------------------------------------------
11+
12+
function makeTarget(lang: Target["lang"], overrides: Partial<Omit<Target, "lang">> = {}): Target {
13+
return {
14+
name: lang,
15+
api: "api",
16+
image: `fernapi/fern-${lang}-sdk`,
17+
registry: undefined,
18+
lang,
19+
version: "1.0.0",
20+
sourceLocation: { file: "fern.yml", range: { start: { line: 0, column: 0 }, end: { line: 0, column: 0 } } },
21+
output: { path: `./generated/${lang}` },
22+
...overrides
23+
} as unknown as Target;
24+
}
25+
26+
type InternalListCommand = {
27+
parseLanguageFilter(value: string): string;
28+
parseTypeFilter(value: string): string;
29+
filterConfiguredTargets(opts: {
30+
targets: Target[];
31+
languageFilter: string | undefined;
32+
typeFilter: string | undefined;
33+
}): Target[];
34+
formatOutput(target: Target): string;
35+
printHuman(opts: {
36+
context: Awaited<ReturnType<typeof createTestContextWithCapture>>["context"];
37+
configuredTargets: Target[];
38+
availableGenerators: never[];
39+
languageFilter: string | undefined;
40+
typeFilter: string | undefined;
41+
}): void;
42+
printJson(opts: {
43+
context: Awaited<ReturnType<typeof createTestContextWithCapture>>["context"];
44+
configuredTargets: Target[];
45+
availableGenerators: never[];
46+
languageFilter: string | undefined;
47+
typeFilter: string | undefined;
48+
}): void;
49+
};
50+
51+
function internals(cmd: ListCommand): InternalListCommand {
52+
return cmd as unknown as InternalListCommand;
53+
}
54+
55+
// ---------------------------------------------------------------------------
56+
// parseLanguageFilter
57+
// ---------------------------------------------------------------------------
58+
59+
describe("ListCommand.parseLanguageFilter", () => {
60+
const cmd = new ListCommand();
61+
62+
it("accepts valid lowercase language", () => {
63+
expect(internals(cmd).parseLanguageFilter("typescript")).toBe("typescript");
64+
});
65+
66+
it("accepts valid uppercase language (case-insensitive)", () => {
67+
expect(internals(cmd).parseLanguageFilter("TypeScript")).toBe("typescript");
68+
});
69+
70+
it("throws CliError for unsupported language", () => {
71+
expect(() => internals(cmd).parseLanguageFilter("cobol")).toThrow(CliError);
72+
});
73+
74+
it("error message includes the bad value", () => {
75+
expect(() => internals(cmd).parseLanguageFilter("cobol")).toThrow('"cobol" is not a supported language');
76+
});
77+
});
78+
79+
// ---------------------------------------------------------------------------
80+
// parseTypeFilter
81+
// ---------------------------------------------------------------------------
82+
83+
describe("ListCommand.parseTypeFilter", () => {
84+
const cmd = new ListCommand();
85+
86+
it.each(["sdk", "model", "server"])("accepts valid type '%s'", (type) => {
87+
expect(internals(cmd).parseTypeFilter(type)).toBe(type);
88+
});
89+
90+
it("accepts uppercase type (case-insensitive)", () => {
91+
expect(internals(cmd).parseTypeFilter("SDK")).toBe("sdk");
92+
});
93+
94+
it("throws CliError for unsupported type", () => {
95+
expect(() => internals(cmd).parseTypeFilter("webhook")).toThrow(CliError);
96+
});
97+
98+
it("error message includes the bad value", () => {
99+
expect(() => internals(cmd).parseTypeFilter("webhook")).toThrow('"webhook" is not a supported generator type');
100+
});
101+
});
102+
103+
// ---------------------------------------------------------------------------
104+
// filterConfiguredTargets
105+
// ---------------------------------------------------------------------------
106+
107+
describe("ListCommand.filterConfiguredTargets", () => {
108+
const cmd = new ListCommand();
109+
const targets = [makeTarget("typescript"), makeTarget("python"), makeTarget("go")];
110+
111+
it("returns all targets when no filters applied", () => {
112+
expect(
113+
internals(cmd).filterConfiguredTargets({ targets, languageFilter: undefined, typeFilter: undefined })
114+
).toHaveLength(3);
115+
});
116+
117+
it("filters by language", () => {
118+
const result = internals(cmd).filterConfiguredTargets({
119+
targets,
120+
languageFilter: "typescript",
121+
typeFilter: undefined
122+
});
123+
expect(result).toHaveLength(1);
124+
expect(result[0]?.lang).toBe("typescript");
125+
});
126+
127+
it("returns empty array when --type is not 'sdk'", () => {
128+
expect(
129+
internals(cmd).filterConfiguredTargets({ targets, languageFilter: undefined, typeFilter: "model" })
130+
).toHaveLength(0);
131+
expect(
132+
internals(cmd).filterConfiguredTargets({ targets, languageFilter: undefined, typeFilter: "server" })
133+
).toHaveLength(0);
134+
});
135+
136+
it("returns all targets when --type is 'sdk'", () => {
137+
expect(
138+
internals(cmd).filterConfiguredTargets({ targets, languageFilter: undefined, typeFilter: "sdk" })
139+
).toHaveLength(3);
140+
});
141+
142+
it("applies language and type filters together", () => {
143+
const result = internals(cmd).filterConfiguredTargets({
144+
targets,
145+
languageFilter: "python",
146+
typeFilter: "sdk"
147+
});
148+
expect(result).toHaveLength(1);
149+
expect(result[0]?.lang).toBe("python");
150+
});
151+
});
152+
153+
// ---------------------------------------------------------------------------
154+
// formatOutput
155+
// ---------------------------------------------------------------------------
156+
157+
describe("ListCommand.formatOutput", () => {
158+
const cmd = new ListCommand();
159+
160+
it("returns path when no git config", () => {
161+
expect(internals(cmd).formatOutput(makeTarget("typescript", { output: { path: "./out" } }))).toBe("./out");
162+
});
163+
164+
it("returns './' when neither path nor git is configured", () => {
165+
expect(internals(cmd).formatOutput(makeTarget("typescript", { output: {} }))).toBe("./");
166+
});
167+
168+
it("returns git.repository for GitHub repository output", () => {
169+
const target = makeTarget("typescript", {
170+
output: { git: { repository: "acme/my-sdk", mode: "push" } as never }
171+
});
172+
expect(internals(cmd).formatOutput(target)).toBe("acme/my-sdk");
173+
});
174+
175+
it("returns git.uri for self-hosted git output", () => {
176+
const target = makeTarget("typescript", {
177+
output: { git: { uri: "git@github.internal/acme/my-sdk", mode: "push" } as never }
178+
});
179+
expect(internals(cmd).formatOutput(target)).toBe("git@github.internal/acme/my-sdk");
180+
});
181+
});
182+
183+
// ---------------------------------------------------------------------------
184+
// printHuman — output messages
185+
// ---------------------------------------------------------------------------
186+
187+
describe("ListCommand printHuman messages", () => {
188+
const cmd = new ListCommand();
189+
190+
it("shows informative message when --type model is used (not misleading 'no match')", async () => {
191+
const { context, getStderr } = await createTestContextWithCapture({
192+
cwd: AbsoluteFilePath.of("/tmp")
193+
});
194+
195+
internals(cmd).printHuman({
196+
context,
197+
configuredTargets: [makeTarget("typescript"), makeTarget("python")],
198+
availableGenerators: [],
199+
languageFilter: undefined,
200+
typeFilter: "model"
201+
});
202+
203+
const output = getStderr();
204+
expect(output).toContain("always of type 'sdk'");
205+
expect(output).not.toContain("No configured SDK targets match the given filters");
206+
});
207+
208+
it("shows 'No SDK targets configured' when no targets and no filters", async () => {
209+
const { context, getStderr } = await createTestContextWithCapture({
210+
cwd: AbsoluteFilePath.of("/tmp")
211+
});
212+
213+
internals(cmd).printHuman({
214+
context,
215+
configuredTargets: [],
216+
availableGenerators: [],
217+
languageFilter: undefined,
218+
typeFilter: undefined
219+
});
220+
221+
expect(getStderr()).toContain("No SDK targets configured");
222+
});
223+
});
224+
225+
// ---------------------------------------------------------------------------
226+
// printJson — output structure
227+
// ---------------------------------------------------------------------------
228+
229+
describe("ListCommand.printJson", () => {
230+
const cmd = new ListCommand();
231+
232+
it("outputs valid JSON with 'configured' and 'available' keys", async () => {
233+
const { context, getStdout } = await createTestContextWithCapture({
234+
cwd: AbsoluteFilePath.of("/tmp")
235+
});
236+
237+
internals(cmd).printJson({
238+
context,
239+
configuredTargets: [makeTarget("typescript")],
240+
availableGenerators: [],
241+
languageFilter: undefined,
242+
typeFilter: undefined
243+
});
244+
245+
const parsed = JSON.parse(getStdout()) as { configured: unknown[]; available: unknown[] };
246+
expect(parsed).toHaveProperty("configured");
247+
expect(parsed).toHaveProperty("available");
248+
expect(parsed.configured).toHaveLength(1);
249+
expect(parsed.available).toHaveLength(0);
250+
});
251+
252+
it("configured entries include expected fields", async () => {
253+
const { context, getStdout } = await createTestContextWithCapture({
254+
cwd: AbsoluteFilePath.of("/tmp")
255+
});
256+
257+
internals(cmd).printJson({
258+
context,
259+
configuredTargets: [makeTarget("python", { groups: ["staging"] })],
260+
availableGenerators: [],
261+
languageFilter: undefined,
262+
typeFilter: undefined
263+
});
264+
265+
const parsed = JSON.parse(getStdout()) as {
266+
configured: Array<{ name: string; language: string; version: string; groups: string[] }>;
267+
};
268+
const entry = parsed.configured[0];
269+
expect(entry?.name).toBe("python");
270+
expect(entry?.language).toBe("python");
271+
expect(entry?.version).toBe("1.0.0");
272+
expect(entry?.groups).toEqual(["staging"]);
273+
});
274+
});

0 commit comments

Comments
 (0)