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
26 changes: 14 additions & 12 deletions packages/openui-cli/src/commands/create-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -121,14 +122,25 @@ export async function runCreateApp(options: CreateAppOptions): Promise<void> {
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 }
: {
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 }
: {
Expand All @@ -152,7 +164,7 @@ export async function runCreateApp(options: CreateAppOptions): Promise<void> {
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", {
Expand All @@ -161,16 +173,6 @@ export async function runCreateApp(options: CreateAppOptions): Promise<void> {
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(
Expand Down
76 changes: 76 additions & 0 deletions packages/openui-cli/src/lib/target-dir.ts
Original file line number Diff line number Diff line change
@@ -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 `<base>-<n>` 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();
}
}
Loading