Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions .changeset/smarter-project-name-handling.md
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 48 additions & 1 deletion packages/cli-core/src/commands/init/bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
34 changes: 30 additions & 4 deletions packages/cli-core/src/commands/init/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,32 @@ function validateProjectName(value: string): string | true {
return true;
}

async function askProjectName(entry: BootstrapEntry): Promise<string> {
/**
* 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<string> {
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<string> {
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();
}
Expand Down Expand Up @@ -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.`,
Expand Down
Loading