diff --git a/packages/openui-cli/src/commands/create-app.ts b/packages/openui-cli/src/commands/create-app.ts index 9fa63d7ce..3e0f7db09 100644 --- a/packages/openui-cli/src/commands/create-app.ts +++ b/packages/openui-cli/src/commands/create-app.ts @@ -12,6 +12,7 @@ import { import { runSkillInstall, shouldInstallSkill } from "../lib/install-skill"; import { runCommand } from "../lib/process-runner"; import { resolveArgs } from "../lib/resolve-args"; +import { resolveAvailableTarget } from "../lib/target-dir"; import { CliCancelledError, CreateError, telemetry } from "../lib/telemetry"; import { cliErrorProperties, processErrorProperties } from "../lib/utils"; @@ -121,7 +122,8 @@ export async function runCreateApp(options: CreateAppOptions): Promise { immediate_arg: options.immediate, }); - const args = await resolveArgs( + // Resolved on its own, and validated before anything else is asked + const nameArgs = await resolveArgs( { name: options.name ? { value: options.name } @@ -129,6 +131,16 @@ export async function runCreateApp(options: CreateAppOptions): Promise { prompt: { type: "input", message: "Project name?", default: "openui-agent" }, required: true, }, + }, + interactive, + ); + const { name, targetDir } = await resolveAvailableTarget( + (nameArgs as { name: string }).name, + interactive, + ); + + const args = await resolveArgs( + { template: options.template ? { value: options.template } : { @@ -152,7 +164,7 @@ export async function runCreateApp(options: CreateAppOptions): Promise { interactive, ); - const { name, template } = args as { name: string; template: TemplateName }; + const { template } = args as { template: TemplateName }; const aiSetup = aiSetupFromTemplate(template); telemetry.register({ template, ai_setup: aiSetup }); telemetry.capture("cli_ai_setup_selected", { @@ -161,16 +173,6 @@ export async function runCreateApp(options: CreateAppOptions): Promise { ai_setup: aiSetup, }); - const targetDir = path.resolve(process.cwd(), name); - if (fs.existsSync(targetDir)) { - throw new CreateError( - "preflight", - `Directory "${name}" already exists.`, - "filesystem", - "TARGET_EXISTS", - ); - } - const templateDir = path.join(__dirname, "..", "templates", template); if (!fs.existsSync(templateDir)) { throw new CreateError( diff --git a/packages/openui-cli/src/lib/target-dir.ts b/packages/openui-cli/src/lib/target-dir.ts new file mode 100644 index 000000000..146facb20 --- /dev/null +++ b/packages/openui-cli/src/lib/target-dir.ts @@ -0,0 +1,76 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { resolveArgs } from "./resolve-args"; +import { CreateError, telemetry } from "./telemetry"; + +/** How many times an interactive run may re-prompt before aborting. */ +const MAX_NAME_RETRIES = 5; + +/** Suggest the next free `-` so the retry prompt has a usable default. */ +function suggestAvailableName(name: string): string { + const numbered = /^(.*?)-(\d+)$/.exec(name); + const base = numbered?.[1] || name; + let suffix = numbered ? Number(numbered[2]) + 1 : 2; + while (suffix < 1000 && fs.existsSync(path.resolve(process.cwd(), `${base}-${suffix}`))) { + suffix += 1; + } + return `${base}-${suffix}`; +} + +/** Resolve a project directory that does not exist yet */ +export async function resolveAvailableTarget( + requestedName: string, + interactive: boolean, +): Promise<{ name: string; targetDir: string }> { + let name = requestedName; + let retries = 0; + + for (;;) { + const targetDir = path.resolve(process.cwd(), name); + if (!fs.existsSync(targetDir)) { + if (retries > 0) telemetry.capture("cli_target_name_retried", { retries }); + return { name, targetDir }; + } + + const exhausted = retries >= MAX_NAME_RETRIES; + + // Fired on every collision, in both modes. Interactive runs now recover + // instead of throwing, so without this the TARGET_EXISTS signal that used + // to reach analytics via cli_create_failed would disappear for them. + telemetry.capture("cli_target_exists", { + interactive, + attempt: retries + 1, + exhausted, + error_code: "TARGET_EXISTS", + }); + + if (!interactive || exhausted) { + throw new CreateError( + "preflight", + exhausted + ? `Directory "${name}" already exists. Aborting after ${MAX_NAME_RETRIES} attempts.` + : `Directory "${name}" already exists.`, + "filesystem", + "TARGET_EXISTS", + ); + } + + console.error(`Directory "${name}" already exists. Choose a different project name.`); + retries += 1; + const retry = await resolveArgs( + { + name: { + prompt: { + type: "input", + message: "Project name?", + default: suggestAvailableName(name), + }, + required: true, + }, + }, + interactive, + ); + name = (retry as { name: string }).name.trim(); + } +}