diff --git a/.changeset/smarter-project-name-handling.md b/.changeset/smarter-project-name-handling.md new file mode 100644 index 00000000..ff29090a --- /dev/null +++ b/.changeset/smarter-project-name-handling.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Auto-increment the default project name in `clerk init` when a directory with that name already exists, and re-prompt instead of erroring out when the chosen name collides. diff --git a/packages/cli-core/src/commands/init/bootstrap.test.ts b/packages/cli-core/src/commands/init/bootstrap.test.ts index 10ce08e1..9690ba15 100644 --- a/packages/cli-core/src/commands/init/bootstrap.test.ts +++ b/packages/cli-core/src/commands/init/bootstrap.test.ts @@ -1,6 +1,13 @@ import { test, expect, describe, spyOn, afterAll } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { BOOTSTRAP_REGISTRY } from "./bootstrap-registry.ts"; -import { promptAndBootstrap, resolvePackageManager } from "./bootstrap.ts"; +import { + findAvailableProjectName, + promptAndBootstrap, + resolvePackageManager, +} from "./bootstrap.ts"; function entryFor(dep: string) { const entry = BOOTSTRAP_REGISTRY.find((e) => e.dep === dep); @@ -167,6 +174,28 @@ describe("resolvePackageManager", () => { }); }); +describe("findAvailableProjectName", () => { + test("returns the base name when no collision exists", async () => { + const tmp = mkdtempSync(join(tmpdir(), "clerk-bootstrap-")); + try { + expect(await findAvailableProjectName(tmp, "my-app")).toBe("my-app"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + test("increments suffix until an unused name is found", async () => { + const tmp = mkdtempSync(join(tmpdir(), "clerk-bootstrap-")); + try { + mkdirSync(join(tmp, "my-app")); + mkdirSync(join(tmp, "my-app-2")); + expect(await findAvailableProjectName(tmp, "my-app")).toBe("my-app-3"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + describe("promptAndBootstrap", () => { test("throws a usage-exit CliError when skipConfirm is set without a framework override", async () => { await expect( @@ -178,6 +207,24 @@ describe("promptAndBootstrap", () => { }); }); + test("nameOverride collision still throws (explicit name)", async () => { + const tmp = mkdtempSync(join(tmpdir(), "clerk-bootstrap-")); + try { + mkdirSync(join(tmp, "my-app")); + await expect( + promptAndBootstrap(tmp, { name: "Next.js", dep: "next" } as never, { + skipConfirm: true, + nameOverride: "my-app", + }), + ).rejects.toMatchObject({ + name: "CliError", + message: expect.stringContaining("already exists"), + }); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + test("guard still fires when implicitBootstrap is combined with skipConfirm", async () => { // --starter -y path: implicitBootstrap + skipConfirm. Guard still applies. await expect( diff --git a/packages/cli-core/src/commands/init/bootstrap.ts b/packages/cli-core/src/commands/init/bootstrap.ts index 1f6b0fe4..b90ec502 100644 --- a/packages/cli-core/src/commands/init/bootstrap.ts +++ b/packages/cli-core/src/commands/init/bootstrap.ts @@ -91,11 +91,32 @@ function validateProjectName(value: string): string | true { return true; } -async function askProjectName(entry: BootstrapEntry): Promise { +/** + * Find an unused project name by appending -2, -3, … until the directory does not exist. + * Returns the original name when nothing collides. + */ +export async function findAvailableProjectName(cwd: string, base: string): Promise { + if (!(await dirExists(join(cwd, base)))) return base; + for (let i = 2; i < 1000; i++) { + const candidate = `${base}-${i}`; + if (!(await dirExists(join(cwd, candidate)))) return candidate; + } + throw new CliError(`Could not find an available project name based on '${base}'.`); +} + +async function askProjectName(entry: BootstrapEntry, cwd: string): Promise { + const defaultName = await findAvailableProjectName(cwd, entry.defaultProjectName); const name = await input({ message: "Project name:", - default: entry.defaultProjectName, - validate: validateProjectName, + default: defaultName, + validate: async (value) => { + const valid = validateProjectName(value); + if (valid !== true) return valid; + if (await dirExists(join(cwd, value.trim()))) { + return `Directory '${value.trim()}' already exists. Pick a different name.`; + } + return true; + }, }); return name.trim(); } @@ -190,9 +211,14 @@ export async function promptAndBootstrap( const entry = await pickFramework(frameworkOverride); const pm = pmOverride ?? (skipConfirm ? resolvePackageManager() : await pickPackageManager()); const projectName = - nameOverride ?? (skipConfirm ? entry.defaultProjectName : await askProjectName(entry)); + nameOverride ?? + (skipConfirm + ? await findAvailableProjectName(cwd, entry.defaultProjectName) + : await askProjectName(entry, cwd)); const projectDir = join(cwd, projectName); + // `askProjectName` and `findAvailableProjectName` already guarantee an unused dir; + // this guard only fires for an explicit `--name` collision. if (await dirExists(projectDir)) { throw new CliError( `Directory '${projectName}' already exists. Pick a different name or remove it first.`,