import { execFile, execFileSync } from "child_process"; import fs from "fs"; import os from "os"; import path from "path"; import { fileURLToPath, pathToFileURL } from "url"; import { DISPATCH_WORKSPACE_ROOT_REDIRECTS, getWorkspaceAppIdValidationError, } from "../shared/workspace-app-id.js"; import { setupAgentSymlinks } from "./setup-agents.js"; import { coreTemplates, getTemplate, allTemplateNames, type TemplateMeta, } from "./templates-meta.js"; import { workspacifyApp, parseWorkspaceScope } from "./workspacify.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const REPO = "BuilderIO/agent-native"; const TEMPLATES_DIR = "templates"; const POSTGRES_DEPENDENCY_VERSION = "^3.4.9"; const STANDALONE_EXACT_DEPENDENCY_OVERRIDES: Record = { "@react-router/dev": "8.1.0", "@react-router/fs-routes": "8.1.0", "react-router": "8.1.0", }; const REACT_ROUTER_BUILD_DEPENDENCIES = [ "@react-router/dev", "@react-router/fs-routes", "react-router", "vite", ] as const; const MINIMUM_RELEASE_AGE_EXCLUDES = [ '"@typescript/*"', '"@sentry/*"', "fast-xml-parser", "typescript", "typescript-7", ]; const FIRST_PARTY_TARBALL_SYMLINK_EXCLUDES = [ "*/CLAUDE.md", "*/.claude/skills", ]; const localPackageTarballs = new Map(); /** * Tagged error for input that fails CLI-level validation (repo names, app * names, etc.). The Sentry `beforeSend` hook in cli/index.ts drops events * whose top-level exception type is `ValidationError` so we don't pollute * Sentry with expected user-input rejections. */ export class ValidationError extends Error { constructor(message: string) { super(message); this.name = "ValidationError"; } } /** * Move the primitive-first and chat on-ramps to the top of the list so they * line up with clack's default highlight. */ function onRampFirst(templates: TemplateMeta[]): TemplateMeta[] { return moveTemplatesToFront(templates, ["headless", "chat"]); } function moveTemplatesToFront( templates: TemplateMeta[], preferredNames: string[], ): TemplateMeta[] { const preferred = preferredNames .map((name) => templates.find((t) => t.name === name)) .filter((template): template is TemplateMeta => Boolean(template)); if (preferred.length === 0) return templates; const preferredSet = new Set(preferred.map((t) => t.name)); return [...preferred, ...templates.filter((t) => !preferredSet.has(t.name))]; } /** Primitive-first scaffold option appended to standalone pickers. */ const HEADLESS_OPTION = { name: "headless", label: "Headless", hint: "Action-first app with one hello primitive and no UI shell", }; export interface CreateAppOptions { /** Pre-select these templates in the picker. Comma-separated string or array. */ template?: string; /** Scaffold a single standalone app (old behavior). Skips workspace creation. */ standalone?: boolean; /** Internal: skip pnpm install at the end (for tests). */ noInstall?: boolean; /** * Internal: always scaffold a workspace and skip the start-shape prompt. * Used by the deprecated `create-workspace` alias, whose contract is an * unconditional workspace scaffold. */ forceWorkspace?: boolean; } /** * Main entry for `agent-native create [name]`. * * Default behavior: scaffold a workspace at / with a multi-select * template picker. Use --standalone for the single-app standalone flow. * * If run *inside* an existing workspace, falls through to the add-app * flow that scaffolds one new app under apps//. */ export async function createApp( name?: string, opts?: CreateAppOptions, ): Promise { const clack = await import("@clack/prompts"); // Reject an invalid provided name before any interactive prompt so bad input // fails fast instead of blocking on the start-shape picker below. if (name !== undefined) { assertValidProjectName(name, clack); } // If we're already inside a workspace, the meaning of `create ` is // "add a new app to this workspace". Delegate to add-app. const workspace = detectWorkspace(process.cwd()); if (workspace) { await addAppToWorkspace(name, opts); return; } // Standalone escape hatch — behaves like the old single-app flow. if (opts?.standalone) { await createStandaloneApp(name, opts, clack); return; } // When exactly one template is specified explicitly, treat it as a // standalone scaffold (script-friendly, matches historic behavior). // Use `--template a,b` or pass no --template to opt into the workspace // flow with the multi-select picker. const parsed = parseTemplateList(opts?.template); // Headless can't live in a workspace, so reject it when more than one // template is requested or when workspace semantics are forced. if ( parsed.includes("headless") && (parsed.length > 1 || opts?.forceWorkspace) ) { clack.cancel( "The headless scaffold is standalone-only. Use `agent-native create my-app --headless`, or use the Chat template when adding a UI app to a workspace.", ); process.exit(1); } // A single explicit template scaffolds a standalone app, unless the caller // forces workspace semantics (the deprecated `create-workspace` alias), in // which case the template is preselected in the workspace picker below. if (parsed.length === 1 && !opts?.forceWorkspace) { await createStandaloneApp(name, opts, clack); return; } // No template specified: ask what shape to start from before diving into // "which templates?". The on-ramp choice implies the project structure, so // we don't ask a separate "workspace or standalone?" question — Chat and // Headless scaffold a single standalone app (the lightest starts; headless // cannot live in a workspace), while Template continues into the workspace // multi-select. if (parsed.length === 0) { // The deprecated `create-workspace` alias forces workspace semantics, so // it must skip the start-shape prompt and scaffold a workspace directly. if (opts?.forceWorkspace) { await createWorkspaceInteractive(name, opts, clack); return; } const shape = await promptStartShape(clack); if (shape === "headless" || shape === "chat") { await createStandaloneApp(name, { ...opts, template: shape }, clack); return; } // shape === "template" → full app(s) in a workspace. await createWorkspaceInteractive(name, opts, clack); return; } // Multiple explicit templates: create a workspace with them. await createWorkspaceInteractive(name, opts, clack); } /** * Top-level on-ramp shown by the bare `create ` command (no flags). The * choice made here implies the project structure, so we deliberately avoid a * separate "workspace or standalone?" question: * - "template" → full app(s) in a workspace (the multi-select picker) * - "chat" → a single standalone chat UI app * - "headless" → a single standalone action-first app with no UI shell * Chat and headless are standalone on purpose: a monorepo is unnecessary * ceremony for the lightest on-ramps, and headless cannot be a workspace * member. Either can grow into a workspace later via `add-app`. */ async function promptStartShape( clack: typeof import("@clack/prompts"), ): Promise<"template" | "chat" | "headless"> { const choice = await clack.select(startShapePromptOptions()); if (clack.isCancel(choice)) { clack.cancel("Cancelled."); process.exit(0); } return choice as "template" | "chat" | "headless"; } function startShapePromptOptions() { return { message: "How do you want to start?", initialValue: "chat", options: [ { value: "chat", label: "Chat", hint: "A single app with a minimal chat UI and the browser shell wired up", }, { value: "template", label: "Full template(s)", hint: "Clone complete apps (Mail, Calendar, Slides, ...) into a workspace", }, { value: "headless", label: "Headless", hint: "A single action-first app with one primitive and no UI shell", }, ], }; } /** * Validate a project name supplied on the command line. Mirrors the rule in * `promptNameIfMissing` so an invalid name is rejected before any interactive * prompt runs (the sub-flows re-validate, which is a harmless no-op). */ function assertValidProjectName( name: string, clack: typeof import("@clack/prompts"), ): void { if (!/^[a-z][a-z0-9-]*$/.test(name)) { clack.cancel( `Invalid name "${name}". Use lowercase letters, numbers, and hyphens (must start with a letter).`, ); process.exit(1); } } /* ───────────────────────────────────────────────────────────────────────── * Workspace creation (new default) * ───────────────────────────────────────────────────────────────────────── */ async function createWorkspaceInteractive( name: string | undefined, opts: CreateAppOptions | undefined, clack: typeof import("@clack/prompts"), ): Promise { clack.intro("Create a new agent-native workspace"); name = await promptNameIfMissing(name, clack, "workspace", "my-platform"); const preselected = parseTemplateList(opts?.template); clack.note( [ `You're creating a workspace named "${name}". A workspace is a monorepo`, "container — it isn't an app itself. Inside it you pick one or more apps", "(below), and each app gets its own route, agent, and UI. Apps in the", "same workspace share auth, database, and the agent chat. Add more apps", "later with `npx @agent-native/core@latest add-app`. Chat is the UI on-ramp", "for a minimal chat-first app with the browser shell already wired.", "Dispatch is always included as the workspace control plane —", "it owns shared secrets, messaging, approvals, and cross-app routing.", ].join("\n"), "About workspaces", ); // Dispatch is the workspace control plane (shared secrets, messaging, // approvals, cross-app routing) and is always scaffolded — the picker // only shows the optional apps. If the user explicitly passed // `--template=...`, those entries get unioned with dispatch. const optionalPicks = preselected.length > 0 ? preselected.filter((t) => t !== "dispatch") : await promptTemplatePicker(preselected, clack, { defaultTemplates: ["chat"], preferredFirst: ["chat"], excludeNames: ["dispatch"], }); const templates = ["dispatch", ...optionalPicks]; const targetDir = path.resolve(process.cwd(), name); if (fs.existsSync(targetDir)) { clack.cancel(`Directory "${name}" already exists.`); process.exit(1); } const s = clack.spinner(); s.start(`Scaffolding workspace "${name}"...`); const appNames = new Set(); const scaffoldedApps: string[] = []; try { await scaffoldWorkspaceRoot(targetDir, name); const workspaceCoreName = `@${name}/shared`; for (let i = 0; i < templates.length; i++) { const templateName = templates[i]; const appName = workspaceAppNameForTemplateSelection(templateName); validateWorkspaceAppName(appName, clack, { allowDispatch: appName === "dispatch" && templateName === "dispatch", }); if (appNames.has(appName)) { clack.cancel( `Workspace app "${appName}" is selected more than once. Choose unique app templates or app names.`, ); process.exit(1); } appNames.add(appName); scaffoldedApps.push(appName); // Distinguish download vs local copy in the spinner so a multi-second // GitHub fetch doesn't look like a frozen "Scaffolding..." message. // Mirrors the local-vs-remote decision inside scaffoldAppTemplate. const willDownload = templateName !== "headless" && templateName.startsWith("github:") ? true : !findLocalTemplate(normalizeTemplateName(templateName)); s.message( willDownload ? `Downloading ${titleCase(appName)} template (${i + 1}/${templates.length})...` : `Scaffolding ${titleCase(appName)} (${i + 1}/${templates.length})...`, ); const appDir = path.join(targetDir, "apps", appName); await scaffoldAppTemplate(appDir, templateName); s.message( `Configuring ${titleCase(appName)} (${i + 1}/${templates.length})...`, ); replacePlaceholders(appDir, appName, appTitleForScaffold(appName), name); rewriteTrackingAppId(appDir, appName, templateName); workspacifyApp({ appDir, appName, templateName, workspaceRoot: targetDir, workspaceCoreName, coreDependencyVersion: getCoreDependencyVersion(), dispatchDependencyVersion: getDispatchDependencyVersion(), toolkitDependencyVersion: getToolkitDependencyVersion(), }); fixPackageJsonName(appDir, appName, templateName); fixWebManifestName(appDir, appName, templateName); rewriteNetlifyToml(appDir, appName, "workspace"); renameGitignore(appDir); // Each app owns its own .claude / .agents symlinks. setupAgentSymlinks(appDir); } s.message("Adding shared packages..."); await scaffoldRequiredPackages(templates, targetDir); s.stop( `Workspace scaffolded with ${templates.length} app${templates.length === 1 ? "" : "s"}.`, ); } catch (err: any) { s.stop("Failed to scaffold workspace."); // Remove the partially-scaffolded workspace so a retry of `agent-native // create ` doesn't trip the "Directory already exists" guard. cleanupOnFailure(targetDir); clack.cancel(err?.message ?? String(err)); process.exit(1); } tryGitInit(targetDir); // Show the user the tree we just built so the workspace/app distinction is // visible, not just described. First-time users routinely expect their // workspace name to be the app — seeing apps/