Skip to content

Commit

Permalink
fix(cli): Adds check for overwriting files in generate output (#5278)
Browse files Browse the repository at this point in the history
* Adds check for overrwriting files in generate output

* Adds check for CI env for directory overwrite prompt

* Updates from PR comments

* Add unit tests and force flag for directory overwrite fix

* Adds e2e tests for CI env and --force flag

* Fixes lint errors and tests in CI env

* Add changelog and create directory for output location persistence files

* chore: update changelog

* chore: update changelog

* Update versions.yml

* chore: update changelog

---------

Co-authored-by: ajkpersonal <[email protected]>
Co-authored-by: Darwin Ding <[email protected]>
Co-authored-by: dubwub <[email protected]>
Co-authored-by: Deep Singhvi <[email protected]>
Co-authored-by: dsinghvi <[email protected]>
  • Loading branch information
6 people authored Dec 9, 2024
1 parent 0002a9a commit f448b33
Show file tree
Hide file tree
Showing 19 changed files with 597 additions and 10 deletions.
4 changes: 4 additions & 0 deletions fern/pages/changelogs/cli/2024-12-09.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## 0.45.4-rc0
**`(fix):`** The CLI prompts the user to confirm output directory overwrites on fern generate.


15 changes: 15 additions & 0 deletions fern/pages/changelogs/fastapi/2024-12-09.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
## 1.6.0
**`(fix):`** The FastAPI generator now supports a new mode for pydantic model generation,
to use pydantic v1 on v2 versions.

```yml generators.yml
generators:
server:
- name: fernapi/fern-fastapi-server
version: 1.6.0
config:
pydantic:
version: "v1_on_v2"
````


4 changes: 4 additions & 0 deletions fern/pages/changelogs/python-sdk/2024-12-08.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## 4.3.9
**`(fix):`** Fix indentation in generated README.md sections to ensure proper formatting and readability.


6 changes: 4 additions & 2 deletions packages/cli/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"@fern-fern/fiddle-sdk": "0.0.584",
"@fern-fern/generators-sdk": "0.114.0-5745f9e74",
"@fern-typescript/fetcher": "workspace:*",
"@inquirer/prompts": "^7.1.0",
"@types/axios": "^0.14.0",
"@types/boxen": "^3.0.1",
"@types/get-port": "^4.2.0",
Expand Down Expand Up @@ -116,5 +117,6 @@
"vitest": "^2.1.4",
"yaml": "^2.4.5",
"yargs": "^17.4.1"
}
}
},
"dependencies": {}
}
134 changes: 134 additions & 0 deletions packages/cli/cli/src/__test__/checkOutputDirectory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { AbsoluteFilePath, join, RelativeFilePath } from "@fern-api/fs-utils";
import { mkdir, writeFile } from "fs/promises";
import tmp from "tmp-promise";
import { checkOutputDirectory } from "../commands/generate/checkOutputDirectory";
import { getOutputDirectories } from "../persistence/output-directories/getOutputDirectories";
import { storeOutputDirectories } from "../persistence/output-directories/storeOutputDirectories";
import { describe, it, expect, beforeEach, vi, Mock, afterEach } from "vitest";
import { CliContext } from "../cli-context/CliContext";
import { isCI } from "../utils/isCI";

vi.mock("../utils/isCI", () => ({
isCI: vi.fn().mockReturnValue(false)
}));

describe("checkOutputDirectory", () => {
let mockCliContext: {
confirmPrompt: Mock & ((message: string, defaultValue?: boolean) => Promise<boolean>);
};

beforeEach(() => {
mockCliContext = {
confirmPrompt: vi.fn().mockImplementation(async () => true)
};
});

afterEach(() => {
vi.clearAllMocks();
});

it("doesn't prompt if directory doesn't exist", async () => {
const tmpDir = await tmp.dir();
const nonExistentPath = join(AbsoluteFilePath.of(tmpDir.path), RelativeFilePath.of("non-existent"));

const result = await checkOutputDirectory(nonExistentPath, mockCliContext as unknown as CliContext, false);

expect(result).toEqual({
shouldProceed: true
});
expect(mockCliContext.confirmPrompt).not.toHaveBeenCalled();
});

it("doesn't prompt if directory is empty", async () => {
const tmpDir = await tmp.dir();
const emptyDir = join(AbsoluteFilePath.of(tmpDir.path), RelativeFilePath.of("empty"));
await mkdir(emptyDir);

const result = await checkOutputDirectory(emptyDir, mockCliContext as unknown as CliContext, false);

expect(result).toEqual({
shouldProceed: true
});
expect(mockCliContext.confirmPrompt).not.toHaveBeenCalled();
});

it("prompts for confirmation if directory has files and not in safelist", async () => {
const tmpDir = await tmp.dir();
const dirWithFiles = join(AbsoluteFilePath.of(tmpDir.path), RelativeFilePath.of("with-files"));
await mkdir(dirWithFiles);
await writeFile(join(dirWithFiles, RelativeFilePath.of("test.txt")), "test");

mockCliContext.confirmPrompt.mockResolvedValueOnce(true);

const result = await checkOutputDirectory(dirWithFiles, mockCliContext as unknown as CliContext, false);

expect(result).toEqual({
shouldProceed: true
});
expect(mockCliContext.confirmPrompt).toHaveBeenCalledTimes(1);
});

it("doesn't prompt if directory is in safelist", async () => {
const tmpDir = await tmp.dir();
const safelistedDir = join(AbsoluteFilePath.of(tmpDir.path), RelativeFilePath.of("safelisted"));
await mkdir(safelistedDir);
await writeFile(join(safelistedDir, RelativeFilePath.of("test.txt")), "test");

// Add to safelist
await storeOutputDirectories([safelistedDir]);

const result = await checkOutputDirectory(safelistedDir, mockCliContext as unknown as CliContext, false);

expect(result).toEqual({
shouldProceed: true
});
expect(mockCliContext.confirmPrompt).not.toHaveBeenCalled();
});

it("saves directory to safelist when requested", async () => {
const tmpDir = await tmp.dir();
const dirToSafelist = join(AbsoluteFilePath.of(tmpDir.path), RelativeFilePath.of("to-safelist"));
await mkdir(dirToSafelist);
await writeFile(join(dirToSafelist, RelativeFilePath.of("test.txt")), "test");

mockCliContext.confirmPrompt.mockResolvedValueOnce(true);

const result = await checkOutputDirectory(dirToSafelist, mockCliContext as unknown as CliContext, false);

expect(result).toEqual({
shouldProceed: true
});

// Verify directory was added to safelist
const savedDirectories = await getOutputDirectories();
expect(savedDirectories).toContain(dirToSafelist);
});

it("doesn't proceed if user declines overwrite", async () => {
const tmpDir = await tmp.dir();
const dirWithFiles = join(AbsoluteFilePath.of(tmpDir.path), RelativeFilePath.of("with-files"));
await mkdir(dirWithFiles);
await writeFile(join(dirWithFiles, RelativeFilePath.of("test.txt")), "test");

mockCliContext.confirmPrompt.mockResolvedValueOnce(false); // overwrite prompt

const result = await checkOutputDirectory(dirWithFiles, mockCliContext as unknown as CliContext, false);

expect(result).toEqual({
shouldProceed: false
});
expect(mockCliContext.confirmPrompt).toHaveBeenCalledTimes(1);
});

it("doesn't prompt if force is true", async () => {
const tmpDir = await tmp.dir();
const dirWithFiles = join(AbsoluteFilePath.of(tmpDir.path), RelativeFilePath.of("with-files"));
await mkdir(dirWithFiles);
await writeFile(join(dirWithFiles, RelativeFilePath.of("test.txt")), "test");

const result = await checkOutputDirectory(dirWithFiles, mockCliContext as unknown as CliContext, true);

expect(result).toEqual({ shouldProceed: true });
expect(mockCliContext.confirmPrompt).not.toHaveBeenCalled();
});
});
39 changes: 39 additions & 0 deletions packages/cli/cli/src/__test__/checkOutputDirectoryCI.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { AbsoluteFilePath, join, RelativeFilePath } from "@fern-api/fs-utils";
import { mkdir, writeFile } from "fs/promises";
import tmp from "tmp-promise";
import { checkOutputDirectory } from "../commands/generate/checkOutputDirectory";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { CliContext } from "../cli-context/CliContext";
import { isCI } from "../utils/isCI";

vi.mock("../utils/isCI", () => ({
isCI: vi.fn().mockReturnValue(true)
}));

describe("checkOutputDirectory in CI", () => {
let mockCliContext: Partial<CliContext>;

beforeEach(() => {
mockCliContext = {
confirmPrompt: vi.fn()
};
});

afterEach(() => {
vi.clearAllMocks();
});

it("doesn't prompt in CI environment even with files present", async () => {
const tmpDir = await tmp.dir();
const dirWithFiles = join(AbsoluteFilePath.of(tmpDir.path), RelativeFilePath.of("with-files"));
await mkdir(dirWithFiles);
await writeFile(join(dirWithFiles, RelativeFilePath.of("test.txt")), "test");

const result = await checkOutputDirectory(dirWithFiles, mockCliContext as CliContext, false);

expect(result).toEqual({
shouldProceed: true
});
expect(mockCliContext.confirmPrompt).not.toHaveBeenCalled();
});
});
14 changes: 14 additions & 0 deletions packages/cli/cli/src/cli-context/CliContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { TtyAwareLogger } from "./TtyAwareLogger";
import { getFernUpgradeMessage } from "./upgrade-utils/getFernUpgradeMessage";
import { FernGeneratorUpgradeInfo, getProjectGeneratorUpgrades } from "./upgrade-utils/getGeneratorVersions";
import { getLatestVersionOfCli } from "./upgrade-utils/getLatestVersionOfCli";
import { confirm } from "@inquirer/prompts";

const WORKSPACE_NAME_COLORS = ["#2E86AB", "#A23B72", "#F18F01", "#C73E1D", "#CCE2A3"];

Expand Down Expand Up @@ -306,6 +307,19 @@ export class CliContext {
}
return this._isUpgradeAvailable;
}

/**
* Prompts the user for confirmation with a yes/no question
* @param message The message to display to the user
* @param defaultValue Optional default value (defaults to false)
* @returns Promise<boolean> representing the user's choice
*/
public async confirmPrompt(message: string, defaultValue = false): Promise<boolean> {
return await confirm({
message,
default: defaultValue
});
}
}

function wrapWorkspaceNameForPrefix(workspaceName: string): string {
Expand Down
11 changes: 9 additions & 2 deletions packages/cli/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,11 @@ function addGenerateCommand(cli: Argv<GlobalCliOptions>, cliContext: CliContext)
boolean: true,
default: false,
description: "Prevent auto-deletion of the Docker containers."
})
.option("force", {
boolean: true,
default: false,
description: "Ignore prompts to confirm generation, defaults to false"
}),
async (argv) => {
if (argv.api != null && argv.docs != null) {
Expand All @@ -402,7 +407,8 @@ function addGenerateCommand(cli: Argv<GlobalCliOptions>, cliContext: CliContext)
keepDocker: argv.keepDocker,
useLocalDocker: argv.local,
preview: argv.preview,
mode: argv.mode
mode: argv.mode,
force: argv.force
});
}
if (argv.docs != null) {
Expand Down Expand Up @@ -439,7 +445,8 @@ function addGenerateCommand(cli: Argv<GlobalCliOptions>, cliContext: CliContext)
keepDocker: argv.keepDocker,
useLocalDocker: argv.local,
preview: argv.preview,
mode: argv.mode
mode: argv.mode,
force: argv.force
});
}
);
Expand Down
69 changes: 69 additions & 0 deletions packages/cli/cli/src/commands/generate/checkOutputDirectory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { AbsoluteFilePath, doesPathExist } from "@fern-api/fs-utils";
import { readdir } from "fs/promises";
import { CliContext } from "../../cli-context/CliContext";
import { getOutputDirectories } from "../../persistence/output-directories/getOutputDirectories";
import { storeOutputDirectories } from "../../persistence/output-directories/storeOutputDirectories";
import { isCI } from "../../utils/isCI";

export interface CheckOutputDirectoryResult {
shouldProceed: boolean;
}

/**
* Checks if an output directory is safe to write to and handles user confirmations
* @param outputPath The path to check
* @param cliContext The CLI context for prompting
* @returns Object containing whether to proceed and if directory was saved
*/
export async function checkOutputDirectory(
outputPath: AbsoluteFilePath | undefined,
cliContext: CliContext,
force: boolean
): Promise<CheckOutputDirectoryResult> {
if (!outputPath || isCI() || force) {
return {
shouldProceed: true
};
}

// First check if this is already a saved output directory
const savedDirectories = await getOutputDirectories();
if (savedDirectories?.includes(outputPath)) {
return {
shouldProceed: true
};
}

// Check if directory exists and has files
const doesExist = await doesPathExist(outputPath);
if (!doesExist) {
return {
shouldProceed: true
};
}

const files = await readdir(outputPath);
if (files.length === 0) {
return {
shouldProceed: true
};
}

// Prompt user for confirmation since directory has files
const shouldOverwrite = await cliContext.confirmPrompt(
`Directory ${outputPath} contains existing files that may be overwritten. Continue?`,
false
);

if (!shouldOverwrite) {
return {
shouldProceed: false
};
}

await storeOutputDirectories([...(savedDirectories ?? []), outputPath]);

return {
shouldProceed: true
};
}
19 changes: 18 additions & 1 deletion packages/cli/cli/src/commands/generate/generateAPIWorkspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { Project } from "@fern-api/project-loader";
import { CliContext } from "../../cli-context/CliContext";
import { PREVIEW_DIRECTORY } from "../../constants";
import { generateWorkspace } from "./generateAPIWorkspace";
import { checkOutputDirectory } from "./checkOutputDirectory";
import { isCI } from "../../utils/isCI";

export const GenerationMode = {
PullRequest: "pull-request"
Expand All @@ -22,7 +24,8 @@ export async function generateAPIWorkspaces({
keepDocker,
useLocalDocker,
preview,
mode
mode,
force
}: {
project: Project;
cliContext: CliContext;
Expand All @@ -33,6 +36,7 @@ export async function generateAPIWorkspaces({
keepDocker: boolean;
preview: boolean;
mode: GenerationMode | undefined;
force: boolean;
}): Promise<void> {
let token: FernToken | undefined = undefined;

Expand All @@ -52,6 +56,19 @@ export async function generateAPIWorkspaces({
token = currentToken;
}

for (const workspace of project.apiWorkspaces) {
for (const generator of workspace.generatorsConfiguration?.groups.flatMap((group) => group.generators) ?? []) {
const { shouldProceed } = await checkOutputDirectory(
generator.absolutePathToLocalOutput,
cliContext,
force
);
if (!shouldProceed) {
cliContext.failAndThrow("Generation cancelled");
}
}
}

await cliContext.instrumentPostHogEvent({
orgId: project.config.organization,
command: "fern generate",
Expand Down
Loading

0 comments on commit f448b33

Please sign in to comment.