Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- summary: |
Rename the `--pack` and `--pack-mode` flags on `fern generate` to `--package` and
`--package-mode`, and add `--package-only`, which builds the distributable artifact into
`fern-dist/` and removes the generated SDK source, leaving only the package in the output
directory.
type: feat

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning

Removing --pack/--pack-mode with no alias is a breaking CLI change; type: feat will bury it in a minor release note. Use the breaking type (break) so users who scripted against --pack actually see it — or keep hidden aliases for one release.

41 changes: 26 additions & 15 deletions packages/cli/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -875,17 +875,23 @@ function addGenerateCommand(cli: Argv<GlobalCliOptions>, cliContext: CliContext)
description:
"Generate test files even when generating to a local file system (tests are normally only generated for GitHub output modes)"
})
.option("pack", {
.option("package", {
boolean: true,
default: false,
description:
"After generating to the local file system, build distributable package artifacts (npm tarball, wheel, JAR, NuGet package, gem, etc.) into a fern-dist/ folder inside the output directory."
})
.option("pack-mode", {
.option("package-mode", {
choices: ["host", "docker"] as const,
default: "host" as const,
description:
"Where --pack runs the packaging toolchain: 'host' uses toolchains installed on this machine; 'docker' runs each toolchain inside an official Docker image (node, python, gradle, dotnet/sdk, ruby, composer, rust) with the output directory mounted, so no local toolchains are needed."
"Where --package runs the packaging toolchain: 'host' uses toolchains installed on this machine; 'docker' runs each toolchain inside an official Docker image (node, python, gradle, dotnet/sdk, ruby, composer, rust) with the output directory mounted, so no local toolchains are needed."
})
.option("package-only", {
boolean: true,
default: false,
description:
"Like --package, but only the fern-dist/ artifact is kept in the output directory — the generated SDK source is removed after the package is built."
}),
async (argv) => {
if (argv.api != null && argv.api.length > 0 && argv.docs != null) {
Expand Down Expand Up @@ -961,22 +967,25 @@ function addGenerateCommand(cli: Argv<GlobalCliOptions>, cliContext: CliContext)
{ code: CliError.Code.ConfigError }
);
}
if (argv.pack && argv.preview) {
return cliContext.failWithoutThrowing("The --pack flag cannot be used with --preview.", undefined, {
const shouldPackage = argv.package || argv.packageOnly;
if (shouldPackage && argv.preview) {
return cliContext.failWithoutThrowing("The --package flag cannot be used with --preview.", undefined, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion

When the user passed --package-only, these messages say --package, which they never typed. Consider deriving the flag name from which option was set, e.g. const packageFlag = argv.packageOnly ? "--package-only" : "--package"; and interpolating it into the three validation messages.

code: CliError.Code.ConfigError
});
}
if (argv.pack && argv.docs != null) {
if (shouldPackage && argv.docs != null) {
return cliContext.failWithoutThrowing(
"The --pack flag can only be used for API generation, not docs generation.",
"The --package flag can only be used for API generation, not docs generation.",
undefined,
{ code: CliError.Code.ConfigError }
);
}
if (argv.packMode !== "host" && !argv.pack) {
return cliContext.failWithoutThrowing("The --pack-mode flag can only be used with --pack.", undefined, {
code: CliError.Code.ConfigError
});
if (argv.packageMode !== "host" && !shouldPackage) {
return cliContext.failWithoutThrowing(
"The --package-mode flag can only be used with --package.",
undefined,
{ code: CliError.Code.ConfigError }
);
}
if (argv.output != null && argv.docs != null) {
return cliContext.failWithoutThrowing(
Expand Down Expand Up @@ -1018,8 +1027,9 @@ function addGenerateCommand(cli: Argv<GlobalCliOptions>, cliContext: CliContext)
requireEnvVars: argv["require-env-vars"],
skipIfNoDiff: argv["skip-if-no-diff"],
generateTests: argv["generate-tests"],
pack: argv.pack,
packMode: argv.packMode
pack: shouldPackage,
packMode: argv.packageMode,
packOnly: argv.packageOnly
});
}
if (argv.docs != null) {
Expand Down Expand Up @@ -1083,8 +1093,9 @@ function addGenerateCommand(cli: Argv<GlobalCliOptions>, cliContext: CliContext)
requireEnvVars: argv["require-env-vars"],
skipIfNoDiff: argv["skip-if-no-diff"],
generateTests: argv["generate-tests"],
pack: argv.pack,
packMode: argv.packMode
pack: shouldPackage,
packMode: argv.packageMode,
packOnly: argv.packageOnly
});
}
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,36 @@ describe("packLocalOutputForGroup", () => {
expect(command).toBe("podman");
});

it("removes everything except fern-dist when packOnly is set", async () => {
await writeFile(path.join(outputDir, "go.mod"), "module example.com/test\n");
await mkdir(path.join(outputDir, "client"), { recursive: true });
await writeFile(path.join(outputDir, "client", "client.go"), "package client\n");
const group = {
groupName: "test",
audiences: { type: "all" },
generators: [createGenerator({ name: "fernapi/fern-go-sdk", language: "go", outputPath: outputDir })]
} as unknown as generatorsYml.GeneratorGroup;

await packLocalOutputForGroup({ group, context: createMockTaskContext(), packOnly: true });

expect(await readdir(outputDir)).toEqual(["fern-dist"]);
const distFiles = await readdir(path.join(outputDir, "fern-dist"));
expect(distFiles).toEqual([`${path.basename(outputDir)}-source.zip`]);
});

it("keeps generated source alongside fern-dist when packOnly is not set", async () => {
await writeFile(path.join(outputDir, "go.mod"), "module example.com/test\n");
const group = {
groupName: "test",
audiences: { type: "all" },
generators: [createGenerator({ name: "fernapi/fern-go-sdk", language: "go", outputPath: outputDir })]
} as unknown as generatorsYml.GeneratorGroup;

await packLocalOutputForGroup({ group, context: createMockTaskContext() });

expect((await readdir(outputDir)).sort()).toEqual(["fern-dist", "go.mod"]);
});

it("fails when packaging a generator errors", async () => {
loggingExecaMock.mockRejectedValueOnce(new Error("python3 not found"));
const group = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ export async function generateWorkspace({
generateTests,
automation,
pack,
packMode
packMode,
packOnly
}: {
organization: string;
workspace: AbstractAPIWorkspace<unknown>;
Expand Down Expand Up @@ -99,8 +100,10 @@ export async function generateWorkspace({
automation?: AutomationRunOptions;
/** Build distributable package artifacts for local-file-system outputs after generation. */
pack?: boolean;
/** Where --pack runs the packaging toolchain: on the host or inside Docker toolchain images. */
/** Where packaging runs the toolchain: on the host or inside Docker toolchain images. */
packMode?: PackMode;
/** Keep only the fern-dist/ artifact in the output directory, removing the generated SDK source. */
packOnly?: boolean;
}): Promise<void> {
if (workspace.generatorsConfiguration == null) {
context.logger.warn("This workspaces has no generators.yml");
Expand Down Expand Up @@ -239,7 +242,7 @@ export async function generateWorkspace({
});
}
if (pack) {
await packLocalOutputForGroup({ group, context: groupContext, mode: packMode, runner });
await packLocalOutputForGroup({ group, context: groupContext, mode: packMode, runner, packOnly });
}
})
)
Expand Down
10 changes: 7 additions & 3 deletions packages/cli/cli/src/commands/generate/generateAPIWorkspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ export async function generateAPIWorkspaces({
generateTests,
automation,
pack,
packMode
packMode,
packOnly
}: {
project: Project;
cliContext: CliContext;
Expand Down Expand Up @@ -90,8 +91,10 @@ export async function generateAPIWorkspaces({
automation?: AutomationRunOptions;
/** Build distributable package artifacts for local-file-system outputs after generation. */
pack?: boolean;
/** Where --pack runs the packaging toolchain: on the host or inside Docker toolchain images. */
/** Where packaging runs the toolchain: on the host or inside Docker toolchain images. */
packMode?: PackMode;
/** Keep only the fern-dist/ artifact in the output directory, removing the generated SDK source. */
packOnly?: boolean;
}): Promise<void> {
let token: FernToken | undefined = undefined;

Expand Down Expand Up @@ -195,7 +198,8 @@ export async function generateAPIWorkspaces({
generateTests,
automation,
pack,
packMode
packMode,
packOnly
});
});
})
Expand Down
32 changes: 29 additions & 3 deletions packages/cli/cli/src/commands/generate/packLocalOutput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { AbsoluteFilePath, doesPathExist, join, RelativeFilePath } from "@fern-a
import { loggingExeca } from "@fern-api/logging-execa";
import { TaskContext } from "@fern-api/task-context";
import { createWriteStream } from "fs";
import { copyFile, mkdir, readdir, readFile, rename, rmdir } from "fs/promises";
import { copyFile, mkdir, readdir, readFile, rename, rm, rmdir } from "fs/promises";
import { basename } from "path";
import { ZipFile } from "yazl";

Expand All @@ -14,7 +14,7 @@ export const PACK_OUTPUT_DIRECTORY = "fern-dist";
/** Where the packaging toolchain runs: on the host machine, or inside a Docker toolchain image. */
export type PackMode = "host" | "docker";

/** Official toolchain images used when packing with `--pack-mode docker`. */
/** Official toolchain images used when packing with `--package-mode docker`. */
const PACK_DOCKER_IMAGES: Record<string, string> = {
typescript: "node:22",
python: "python:3.12",
Expand All @@ -39,12 +39,15 @@ export async function packLocalOutputForGroup({
group,
context,
mode = "host",
runner
runner,
packOnly = false
}: {
group: generatorsYml.GeneratorGroup;
context: TaskContext;
mode?: PackMode;
runner?: ContainerRunner;
/** Keep only the fern-dist/ artifact in each output directory, removing the generated SDK source. */
packOnly?: boolean;
}): Promise<void> {
const failures: string[] = [];
for (const generator of group.generators) {
Expand All @@ -62,6 +65,9 @@ export async function packLocalOutputForGroup({
}
try {
await packOutputForLanguage({ language, outputPath, context, mode, runner: runner ?? "docker" });
if (packOnly) {
await removeEverythingExceptDist({ outputPath, context });
}
Comment on lines +74 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning

The cleanup is inside the packaging try, so a failed rm (very plausible in --package-mode docker, where artifacts can be root-owned — force: true doesn't help with EACCES) is reported as "Failed to package …" even though the artifact built fine, and the whole group fails. Separate the concerns:

Suggested change
if (packOnly) {
await removeEverythingExceptDist({ outputPath, context });
}
}
if (packOnly) {
try {
await removeEverythingExceptDist({ outputPath, context });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
context.logger.warn(
`Packaged ${generator.name} but failed to remove generated source at ${outputPath}: ${message}`
);
}
}

(Note: this suggestion assumes the try/catch block is restructured so the catch precedes it — adjust placement accordingly.)

Comment on lines +74 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Swift SDK output is completely erased when only the package is requested

The whole generated Swift output folder is wiped (removeEverythingExceptDist at packages/cli/cli/src/commands/generate/packLocalOutput.ts:68-70) even though no shareable package was ever produced for Swift, so the user is left with an empty directory and no artifact.
Impact: Anyone running Swift generation with the package-only option loses their entire generated SDK and gets nothing back.

Swift packaging is a no-op warning, so nothing is created in fern-dist before deletion

In packOutputForLanguage, the swift branch (packages/cli/cli/src/commands/generate/packLocalOutput.ts:174-179) only logs a warning and returns — it never creates fern-dist/ nor any artifact. Because it doesn't throw, the packOnly path immediately runs removeEverythingExceptDist, which deletes every entry in the output dir (no fern-dist entry exists to skip), leaving the directory empty while logging "only fern-dist/ remains".

A fix would be to make packaging report whether an artifact was actually produced (or to skip the cleanup for languages with no package format), and only remove the source when fern-dist/ contains at least one artifact.

Prompt for agents
In packages/cli/cli/src/commands/generate/packLocalOutput.ts, when packOnly is set, removeEverythingExceptDist is called unconditionally after packOutputForLanguage resolves. For the `swift` language, packOutputForLanguage just logs a warning and returns without creating fern-dist/ or any artifact, so the cleanup deletes the entire generated output directory and leaves nothing behind (while logging that only fern-dist/ remains). Consider having packOutputForLanguage signal whether an artifact was produced (e.g. return a boolean or throw/skip for languages with no package format), or verify that fern-dist/ exists and is non-empty before removing anything, so a no-op packaging step can never wipe the generated source.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a1bfc44: packOutputForLanguage now returns whether an artifact was produced (false for Swift), and --package-only only removes source when one was — otherwise it warns and keeps the generated output. Covered by a new test.

} catch (error) {
const message = error instanceof Error ? error.message : String(error);
context.logger.error(`Failed to package ${generator.name} output at ${outputPath}: ${message}`);
Expand Down Expand Up @@ -262,6 +268,26 @@ async function zipDirectory({
});
}

/**
* Removes every entry in the output directory except the fern-dist/ artifact folder, so only the
* distributable package remains (used by --package-only).
*/
async function removeEverythingExceptDist({
outputPath,
context
}: {
outputPath: AbsoluteFilePath;
context: TaskContext;
}): Promise<void> {
for (const entry of await readdir(outputPath)) {
if (entry === PACK_OUTPUT_DIRECTORY) {
continue;
}
await rm(join(outputPath, RelativeFilePath.of(entry)), { recursive: true, force: true });
}
Comment on lines +310 to +315

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 critical

This recursively force-deletes every entry in a user-configured output-path. If someone points a local generator at a directory that isn't exclusively generator output (a checked-out SDK repo with .git, a folder with hand-written files, ./), --package-only silently destroys it — no dry run, no confirmation, no undo.

At minimum, refuse to run when the directory looks like it contains non-generated content (e.g. a .git directory) or preserve dotfiles, and log which entries are being removed at debug level. A safety check against outputPath resolving to a repo root / home dir would also be cheap insurance.

context.logger.info(`Removed generated SDK source from ${outputPath}; only ${PACK_OUTPUT_DIRECTORY}/ remains.`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Hand-written and version-control files in the output directory are deleted along with generated code

Every entry in the output directory is deleted (rm(..., { recursive: true, force: true }) at packages/cli/cli/src/commands/generate/packLocalOutput.ts:286), including files the user authored and asked to preserve as well as version-control data, so work unrelated to the generated SDK is lost.
Impact: Users can permanently lose custom code and repository history that lived next to the generated SDK.

Mechanism: cleanup does not exclude preserved or VCS entries

removeEverythingExceptDist (packages/cli/cli/src/commands/generate/packLocalOutput.ts:282-288) iterates all entries of the output dir and skips only PACK_OUTPUT_DIRECTORY. Local-file-system generation intentionally preserves files listed in the output directory's .fernignore (custom, hand-written code), and output directories are commonly checked-in repos containing .git. Note that zipDirectory deliberately excludes .git from artifacts (packages/cli/cli/src/commands/generate/packLocalOutput.ts:245-247), showing that .git is not considered generated content, yet the cleanup removes it. Excluding .git (and, ideally, .fernignore-protected paths) would keep the flag's intent while avoiding destroying non-generated data.

Prompt for agents
removeEverythingExceptDist in packages/cli/cli/src/commands/generate/packLocalOutput.ts deletes every entry of the generator's output directory except fern-dist/. This also removes .git (output directories are often git checkouts) and files preserved via .fernignore (hand-written custom code that local generation intentionally keeps). Consider excluding .git — consistent with zipDirectory, which already skips .git when building the source zip — and consider honoring the output directory's .fernignore entries so only generated SDK source is removed.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a1bfc44: the cleanup now always preserves .git, .fernignore, and every top-level path covered by a .fernignore entry (nested/glob entries are handled conservatively by keeping their whole top-level directory). Covered by a new test.

}

async function removeDistDirIfEmpty(outputPath: AbsoluteFilePath): Promise<void> {
const distDir = join(outputPath, RelativeFilePath.of(PACK_OUTPUT_DIRECTORY));
try {
Expand Down
Loading