From b6449618cc73725603fa1717dda9679e177c3556 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Thu, 13 Aug 2026 18:09:25 +0530 Subject: [PATCH] fix(cli): re-prompt when the project directory already exists `openui create` aborted when the target directory was taken, so an interactive run had to be started over. It also only checked after the template prompt, so you answered everything before being rejected. - Check the name as soon as it is resolved, before any other prompt. - Interactive runs report the collision and ask again, defaulting to the next free `-`. After 5 attempts they abort as before. - Non-interactive runs still fail immediately with TARGET_EXISTS, since there is nobody to ask. - Move the logic to `lib/target-dir.ts` to keep `create-app.ts` readable. - Add `cli_target_exists`, fired on every collision in both modes. Interactive runs now recover instead of throwing, so the signal that used to reach analytics via `cli_create_failed` would otherwise be lost. --- .../openui-cli/src/commands/create-app.ts | 26 ++++--- packages/openui-cli/src/lib/target-dir.ts | 76 +++++++++++++++++++ 2 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 packages/openui-cli/src/lib/target-dir.ts 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(); + } +}