@enterpriseai/cli 3.8.2 → 3.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -15
- package/dist/commands/init.d.ts +69 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +512 -32
- package/dist/commands/init.js.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/error-guidance/catalog.d.ts +9 -41
- package/dist/lib/error-guidance/catalog.d.ts.map +1 -1
- package/dist/lib/error-guidance/catalog.js +12 -0
- package/dist/lib/error-guidance/catalog.js.map +1 -1
- package/dist/lib/error-guidance/match.d.ts.map +1 -1
- package/dist/lib/error-guidance/match.js +59 -1
- package/dist/lib/error-guidance/match.js.map +1 -1
- package/dist/lib/output.d.ts +3 -0
- package/dist/lib/output.d.ts.map +1 -1
- package/dist/lib/output.js +21 -0
- package/dist/lib/output.js.map +1 -1
- package/dist/lib/splash.d.ts +13 -0
- package/dist/lib/splash.d.ts.map +1 -0
- package/dist/lib/splash.js +151 -0
- package/dist/lib/splash.js.map +1 -0
- package/package.json +2 -2
- package/resources/linked-sources.json +1 -1
package/dist/commands/init.js
CHANGED
|
@@ -15,21 +15,101 @@ import inquirer from "inquirer";
|
|
|
15
15
|
import * as out from "../lib/output.js";
|
|
16
16
|
import { installGoferResources } from "../lib/gofer-installer.js";
|
|
17
17
|
import { applyGoferRefresh, planGoferRefresh } from "../lib/gofer-refresh.js";
|
|
18
|
-
import { isAuthenticated, loadTokens } from "../lib/auth.js";
|
|
18
|
+
import { browserLogin, isAuthenticated, loadTokens, resolveAuthConfig, storeTokens, validateResolvedAuthConfig, } from "../lib/auth.js";
|
|
19
19
|
import { publicApiUrlForHomeRegion, resolveActiveTenantContext, resolveMainCompanyTenantId, resolvePublicApiUrl, } from "../lib/tenant-context.js";
|
|
20
20
|
import { buildTenantHierarchy, promptForTenantFromHierarchy, } from "../lib/tenant-hierarchy.js";
|
|
21
21
|
import { parseApiError, PlatformAPIClient, } from "../lib/api.js";
|
|
22
|
-
import { patchEnvFile } from "../lib/config.js";
|
|
22
|
+
import { findProjectRoot, patchEnvFile } from "../lib/config.js";
|
|
23
23
|
import { pullCloudEnvValues } from "../lib/cloud-env.js";
|
|
24
|
+
import { findGuidance } from "../lib/error-guidance/match.js";
|
|
25
|
+
import { formatGuidanceText } from "../lib/error-guidance/render.js";
|
|
24
26
|
import { getActiveProfile, loadProfileConfig } from "../lib/profile.js";
|
|
25
27
|
import { errMsg, normalizeChildTenantDisplayNameOption } from "../lib/utils.js";
|
|
26
28
|
import { saveProjectManifest } from "../lib/project-manifest.js";
|
|
29
|
+
import { printEaiSplash } from "../lib/splash.js";
|
|
27
30
|
const exec = promisify(execFile);
|
|
28
31
|
const require = createRequire(import.meta.url);
|
|
29
32
|
const pkg = require("../../package.json");
|
|
30
33
|
const TEMPLATE_REPO = "https://github.com/eai-tools/eai-app-template.git";
|
|
31
34
|
const GITHUB_ORG = "eai-tools";
|
|
32
35
|
const TEMPLATE_REPO_LABEL = `${GITHUB_ORG}/eai-app-template`;
|
|
36
|
+
const ONBOARDING_DOCS_URL = "https://www.enterpriseaigroup.com/docs/getting-started";
|
|
37
|
+
export function describeAppCreationFailure(error) {
|
|
38
|
+
const guidance = findGuidance({
|
|
39
|
+
operation: "tenant app create",
|
|
40
|
+
status: error.status,
|
|
41
|
+
serverCode: error.code,
|
|
42
|
+
message: error.message,
|
|
43
|
+
});
|
|
44
|
+
const lines = [`App creation failed: ${error.message}`];
|
|
45
|
+
if (guidance) {
|
|
46
|
+
lines.push("", formatGuidanceText(guidance));
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
lines.push("", "Try next:", "1. eai whoami [read-only]", " Confirm the signed-in account and selected workspace.", "2. eai tenant list --all --format json [read-only]", " Check that the account can access the workspace.", "3. eai errors list [read-only]", " Inspect known recovery guidance before retrying.");
|
|
50
|
+
}
|
|
51
|
+
lines.push("", `Getting started: ${ONBOARDING_DOCS_URL}`);
|
|
52
|
+
return lines.join("\n");
|
|
53
|
+
}
|
|
54
|
+
export function describeCreateFlowFailure(error) {
|
|
55
|
+
return [
|
|
56
|
+
errMsg(error),
|
|
57
|
+
"",
|
|
58
|
+
"Try next:",
|
|
59
|
+
"1. eai whoami [read-only]",
|
|
60
|
+
" Confirm the signed-in account and selected workspace.",
|
|
61
|
+
"2. eai errors list [read-only]",
|
|
62
|
+
" Inspect known recovery guidance before retrying.",
|
|
63
|
+
`3. Read the setup guide: ${ONBOARDING_DOCS_URL}`,
|
|
64
|
+
].join("\n");
|
|
65
|
+
}
|
|
66
|
+
function startEaiStep(message) {
|
|
67
|
+
return ora({
|
|
68
|
+
text: message,
|
|
69
|
+
spinner: { interval: 120, frames: ["◇"] },
|
|
70
|
+
color: "cyan",
|
|
71
|
+
indent: 2,
|
|
72
|
+
}).start();
|
|
73
|
+
}
|
|
74
|
+
function showCreateSection(title) {
|
|
75
|
+
out.blank();
|
|
76
|
+
out.heading(`${chalk.cyan("◇")} ${title}`);
|
|
77
|
+
}
|
|
78
|
+
const CREATE_AI_TOOL_CHOICES = [
|
|
79
|
+
{ name: "Codex", value: "codex" },
|
|
80
|
+
{ name: "Claude", value: "claude" },
|
|
81
|
+
{ name: "VS Code", value: "vscode" },
|
|
82
|
+
{ name: "Gemini", value: "gemini" },
|
|
83
|
+
];
|
|
84
|
+
const CREATE_AI_TOOL_LABELS = {
|
|
85
|
+
codex: "Codex",
|
|
86
|
+
claude: "Claude",
|
|
87
|
+
vscode: "VS Code",
|
|
88
|
+
gemini: "Gemini",
|
|
89
|
+
};
|
|
90
|
+
const CREATE_PROMPT_THEME = {
|
|
91
|
+
prefix: {
|
|
92
|
+
idle: chalk.cyan("◇"),
|
|
93
|
+
done: chalk.green("✔"),
|
|
94
|
+
},
|
|
95
|
+
style: {
|
|
96
|
+
message: (text) => chalk.bold(text),
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
const CREATE_SELECT_THEME = {
|
|
100
|
+
...CREATE_PROMPT_THEME,
|
|
101
|
+
icon: { cursor: chalk.cyan("●") },
|
|
102
|
+
style: {
|
|
103
|
+
message: (text, status) => status === "done" ? "" : chalk.bold(text),
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
const CREATE_NESTED_PROMPT_THEME = {
|
|
107
|
+
...CREATE_PROMPT_THEME,
|
|
108
|
+
prefix: {
|
|
109
|
+
idle: ` ${chalk.cyan("◇")}`,
|
|
110
|
+
done: ` ${chalk.green("✔")}`,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
33
113
|
function buildInitialProjectManifest(templatePlan, packageProfile) {
|
|
34
114
|
return {
|
|
35
115
|
schemaVersion: 1,
|
|
@@ -159,6 +239,27 @@ async function copyTemplateIntoTargetDir(templateSource, targetDir) {
|
|
|
159
239
|
await rm(templateDir, { recursive: true, force: true });
|
|
160
240
|
}
|
|
161
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* `--from` accepts any GitHub repo or local path, and install runs after
|
|
244
|
+
* .env.local has been generated and hydrated. Only the canonical template is
|
|
245
|
+
* trusted to execute npm lifecycle scripts on the developer machine.
|
|
246
|
+
*/
|
|
247
|
+
export function buildTemplateInstallArgs(from) {
|
|
248
|
+
const args = ["install", "--no-audit", "--no-fund"];
|
|
249
|
+
if (from !== TEMPLATE_REPO)
|
|
250
|
+
args.push("--ignore-scripts");
|
|
251
|
+
return args;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Tenant binding produced by the most recent `init` run, so the guided `create`
|
|
255
|
+
* flow can report against the runtime tenant instead of the parent workspace.
|
|
256
|
+
*/
|
|
257
|
+
let lastInitBinding;
|
|
258
|
+
export function consumeLastInitBinding() {
|
|
259
|
+
const binding = lastInitBinding;
|
|
260
|
+
lastInitBinding = undefined;
|
|
261
|
+
return binding;
|
|
262
|
+
}
|
|
162
263
|
export const initCommand = new Command("init")
|
|
163
264
|
.description("Scaffold a new application")
|
|
164
265
|
.argument("[name]", "Name for the app (kebab-case)")
|
|
@@ -171,7 +272,11 @@ export const initCommand = new Command("init")
|
|
|
171
272
|
.option("--child-tenant <name>", "Create or reuse a child company tenant display name for the app runtime boundary")
|
|
172
273
|
.option("--create-child-tenant", "Prompt for a child company tenant instead of using the selected company tenant")
|
|
173
274
|
.option("--no-gofer", "Skip installing Gofer AI CLI assets")
|
|
275
|
+
.option("--no-install", "Skip installing the generated app dependencies")
|
|
174
276
|
.option("--package-profile <profile>", "Package profile to record for block catalog discovery: external, internal, or hybrid", "external")
|
|
277
|
+
.option("--display-name <name>", "Display name for the app")
|
|
278
|
+
.option("--description <description>", "One-sentence description for the app")
|
|
279
|
+
.option("--no-splash", "Skip the interactive EAI wordmark")
|
|
175
280
|
.addHelpText("after", `
|
|
176
281
|
Gofer AI CLI assets are installed by default:
|
|
177
282
|
.specify/ commands, scripts, templates, hooks, and memory folders
|
|
@@ -187,6 +292,7 @@ another repo or local path.
|
|
|
187
292
|
Use --no-gofer only when you need a bare app scaffold.
|
|
188
293
|
`)
|
|
189
294
|
.action(async (nameArg, options) => {
|
|
295
|
+
await printEaiSplash(options.splash);
|
|
190
296
|
const publicApiUrl = await resolvePublicApiUrl();
|
|
191
297
|
const tenantContext = await loadActiveTenantForInit(publicApiUrl);
|
|
192
298
|
const activeTenant = tenantContext.activeTenant;
|
|
@@ -201,17 +307,19 @@ Use --no-gofer only when you need a bare app scaffold.
|
|
|
201
307
|
targetDir = await resolveInitTargetDir(nameArg, targetUsesCurrentDir);
|
|
202
308
|
const binding = await createTenantAppForInit(publicApiUrl, tenantContext, options.companyTenant || options.tenant, options.parentTenant, {
|
|
203
309
|
slug: nameArg,
|
|
204
|
-
displayName: toDisplayName(nameArg),
|
|
310
|
+
displayName: options.displayName || toDisplayName(nameArg),
|
|
205
311
|
}, options.childTenant, Boolean(options.createChildTenant), false);
|
|
206
312
|
parentTenantId = binding.parentTenantId;
|
|
207
313
|
tenantId = binding.runtimeTenantId;
|
|
314
|
+
lastInitBinding = binding;
|
|
208
315
|
const capabilities = tenantId
|
|
209
316
|
? await evaluateInitCapabilities(publicApiUrl, tenantId)
|
|
210
317
|
: defaultInitCapabilities();
|
|
211
318
|
initOptions = {
|
|
212
319
|
name: nameArg,
|
|
213
|
-
displayName: toDisplayName(nameArg),
|
|
214
|
-
description:
|
|
320
|
+
displayName: options.displayName || toDisplayName(nameArg),
|
|
321
|
+
description: options.description ||
|
|
322
|
+
`${options.displayName || toDisplayName(nameArg)} application`,
|
|
215
323
|
parentTenantId,
|
|
216
324
|
tenantId,
|
|
217
325
|
tenantHomeRegion: binding.runtimeTenantHomeRegion ?? activeTenant?.homeRegion,
|
|
@@ -227,7 +335,7 @@ Use --no-gofer only when you need a bare app scaffold.
|
|
|
227
335
|
type: "input",
|
|
228
336
|
name: "name",
|
|
229
337
|
message: "App name (kebab-case):",
|
|
230
|
-
default: nameArg,
|
|
338
|
+
default: nameArg || undefined,
|
|
231
339
|
validate: (input) => {
|
|
232
340
|
if (!/^[a-z][a-z0-9-]*$/.test(input)) {
|
|
233
341
|
return "Must be lowercase, start with a letter, and contain only letters, numbers, and hyphens";
|
|
@@ -239,13 +347,13 @@ Use --no-gofer only when you need a bare app scaffold.
|
|
|
239
347
|
type: "input",
|
|
240
348
|
name: "displayName",
|
|
241
349
|
message: "Display name:",
|
|
242
|
-
default: (answers) => toDisplayName(answers.name),
|
|
350
|
+
default: (answers) => options.displayName || toDisplayName(answers.name),
|
|
243
351
|
},
|
|
244
352
|
{
|
|
245
353
|
type: "input",
|
|
246
354
|
name: "description",
|
|
247
355
|
message: "Description:",
|
|
248
|
-
default: (answers) => `${answers.displayName} application`,
|
|
356
|
+
default: (answers) => options.description || `${answers.displayName} application`,
|
|
249
357
|
},
|
|
250
358
|
]);
|
|
251
359
|
const appName = String(baseAnswers.name);
|
|
@@ -270,6 +378,7 @@ Use --no-gofer only when you need a bare app scaffold.
|
|
|
270
378
|
}, options.childTenant, Boolean(options.createChildTenant), true);
|
|
271
379
|
parentTenantId = binding.parentTenantId;
|
|
272
380
|
tenantId = binding.runtimeTenantId;
|
|
381
|
+
lastInitBinding = binding;
|
|
273
382
|
const featureAnswers = await promptFeatureOptions(publicApiUrl, tenantId);
|
|
274
383
|
initOptions = {
|
|
275
384
|
...baseAnswers,
|
|
@@ -280,10 +389,10 @@ Use --no-gofer only when you need a bare app scaffold.
|
|
|
280
389
|
packageProfile,
|
|
281
390
|
};
|
|
282
391
|
}
|
|
283
|
-
out.heading(`Creating ${chalk.cyan(initOptions.displayName)}`);
|
|
392
|
+
out.heading(` ${chalk.cyan("◇")} Creating ${chalk.cyan(initOptions.displayName)}`);
|
|
284
393
|
out.blank();
|
|
285
394
|
// Step 1: Clone template
|
|
286
|
-
const cloneSpinner =
|
|
395
|
+
const cloneSpinner = startEaiStep("Cloning template...");
|
|
287
396
|
const templatePlan = resolveTemplateClonePlan(options.from);
|
|
288
397
|
try {
|
|
289
398
|
if (targetUsesCurrentDir) {
|
|
@@ -306,7 +415,7 @@ Use --no-gofer only when you need a bare app scaffold.
|
|
|
306
415
|
process.exit(1);
|
|
307
416
|
}
|
|
308
417
|
// Step 2: Update package.json
|
|
309
|
-
const pkgSpinner =
|
|
418
|
+
const pkgSpinner = startEaiStep("Customizing package.json...");
|
|
310
419
|
try {
|
|
311
420
|
const pkgPath = join(targetDir, "package.json");
|
|
312
421
|
const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
|
|
@@ -320,7 +429,7 @@ Use --no-gofer only when you need a bare app scaffold.
|
|
|
320
429
|
pkgSpinner.fail("Failed to update package.json");
|
|
321
430
|
}
|
|
322
431
|
// Step 3: Generate .env.local with placeholders
|
|
323
|
-
const envSpinner =
|
|
432
|
+
const envSpinner = startEaiStep("Generating .env.local...");
|
|
324
433
|
try {
|
|
325
434
|
const envContent = generateEnvFile(initOptions);
|
|
326
435
|
await writeFile(join(targetDir, ".env.local"), envContent, "utf-8");
|
|
@@ -331,7 +440,7 @@ Use --no-gofer only when you need a bare app scaffold.
|
|
|
331
440
|
envSpinner.fail("Failed to generate .env.local");
|
|
332
441
|
}
|
|
333
442
|
// Step 4: Generate Object Types scaffold
|
|
334
|
-
const typesSpinner =
|
|
443
|
+
const typesSpinner = startEaiStep("Creating Object Types scaffold...");
|
|
335
444
|
try {
|
|
336
445
|
const typesContent = generateObjectTypesScaffold(initOptions);
|
|
337
446
|
await writeFile(join(targetDir, "src", "eai.config", "object-types.ts"), typesContent, "utf-8");
|
|
@@ -341,7 +450,7 @@ Use --no-gofer only when you need a bare app scaffold.
|
|
|
341
450
|
typesSpinner.fail("Failed to create Object Types scaffold");
|
|
342
451
|
}
|
|
343
452
|
// Step 5: Generate deploy workflow
|
|
344
|
-
const deploySpinner =
|
|
453
|
+
const deploySpinner = startEaiStep("Creating deployment workflow...");
|
|
345
454
|
try {
|
|
346
455
|
const workflowDir = join(targetDir, ".github", "workflows");
|
|
347
456
|
await mkdir(workflowDir, { recursive: true });
|
|
@@ -353,7 +462,7 @@ Use --no-gofer only when you need a bare app scaffold.
|
|
|
353
462
|
deploySpinner.fail("Failed to create deployment workflow");
|
|
354
463
|
}
|
|
355
464
|
// Step 6: Generate project CLAUDE.md
|
|
356
|
-
const claudeSpinner =
|
|
465
|
+
const claudeSpinner = startEaiStep("Generating CLAUDE.md...");
|
|
357
466
|
try {
|
|
358
467
|
const claudeContent = generateClaudeMd(initOptions);
|
|
359
468
|
await writeFile(join(targetDir, "CLAUDE.md"), claudeContent, "utf-8");
|
|
@@ -364,7 +473,7 @@ Use --no-gofer only when you need a bare app scaffold.
|
|
|
364
473
|
}
|
|
365
474
|
// Step 7: Install Gofer AI CLI assets
|
|
366
475
|
if (options.gofer) {
|
|
367
|
-
const goferSpinner =
|
|
476
|
+
const goferSpinner = startEaiStep("Installing Gofer AI CLI assets...");
|
|
368
477
|
try {
|
|
369
478
|
const summary = await installGoferResources(targetDir, {
|
|
370
479
|
workflowProfile: "enterpriseai",
|
|
@@ -378,7 +487,7 @@ Use --no-gofer only when you need a bare app scaffold.
|
|
|
378
487
|
}
|
|
379
488
|
}
|
|
380
489
|
// Step 8: Record project manifest for future safe refreshes
|
|
381
|
-
const manifestSpinner =
|
|
490
|
+
const manifestSpinner = startEaiStep("Recording project manifest...");
|
|
382
491
|
try {
|
|
383
492
|
const initialManifest = buildInitialProjectManifest(templatePlan, initOptions.packageProfile);
|
|
384
493
|
await saveProjectManifest(targetDir, initialManifest);
|
|
@@ -395,8 +504,30 @@ Use --no-gofer only when you need a bare app scaffold.
|
|
|
395
504
|
out.error(err instanceof Error ? err.message : String(err));
|
|
396
505
|
process.exit(1);
|
|
397
506
|
}
|
|
398
|
-
// Step 9:
|
|
399
|
-
|
|
507
|
+
// Step 9: Install project dependencies
|
|
508
|
+
if (options.install !== false) {
|
|
509
|
+
const installArgs = buildTemplateInstallArgs(options.from);
|
|
510
|
+
const trusted = !installArgs.includes("--ignore-scripts");
|
|
511
|
+
if (!trusted) {
|
|
512
|
+
out.warn(`Installing a custom template with ${chalk.cyan("--ignore-scripts")} because ${chalk.cyan(options.from)} is not the canonical EAI app template.`);
|
|
513
|
+
out.nestedInfo(`If you trust that source, run ${chalk.cyan("npm rebuild")} inside ${chalk.cyan(targetDir)} to execute its lifecycle scripts.`);
|
|
514
|
+
}
|
|
515
|
+
const installSpinner = startEaiStep("Installing app dependencies...");
|
|
516
|
+
try {
|
|
517
|
+
await exec("npm", installArgs, {
|
|
518
|
+
cwd: targetDir,
|
|
519
|
+
});
|
|
520
|
+
installSpinner.succeed("Installed app dependencies");
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
installSpinner.fail("Failed to install app dependencies");
|
|
524
|
+
out.error(errMsg(err));
|
|
525
|
+
out.nestedInfo(`Run \`npm install\` inside ${chalk.cyan(targetDir)} and retry.`);
|
|
526
|
+
process.exit(1);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// Step 10: Initialize git
|
|
530
|
+
const gitSpinner = startEaiStep("Initializing git...");
|
|
400
531
|
try {
|
|
401
532
|
await exec("git", ["init"], { cwd: targetDir });
|
|
402
533
|
await exec("git", ["add", "."], { cwd: targetDir });
|
|
@@ -434,25 +565,374 @@ Use --no-gofer only when you need a bare app scaffold.
|
|
|
434
565
|
}
|
|
435
566
|
}
|
|
436
567
|
out.blank();
|
|
437
|
-
out.
|
|
568
|
+
out.nestedSuccess(`Created ${chalk.bold(initOptions.displayName)} at ${chalk.cyan(targetDir)}`);
|
|
438
569
|
out.blank();
|
|
439
|
-
out.heading("Next steps:");
|
|
570
|
+
out.heading(" Next steps:");
|
|
440
571
|
out.blank();
|
|
441
572
|
if (initOptions.tenantId) {
|
|
442
|
-
out.
|
|
443
|
-
out.
|
|
573
|
+
out.nestedDim(`Main company tenant: ${chalk.cyan(initOptions.parentTenantId)}`);
|
|
574
|
+
out.nestedDim(`Bound to tenant: ${chalk.cyan(initOptions.tenantId)}`);
|
|
444
575
|
}
|
|
445
576
|
if (!entraProvisioned) {
|
|
446
|
-
out.
|
|
577
|
+
out.nestedDim(`Run ${chalk.cyan("eai provision entra")} inside the project to set up Entra authentication.`);
|
|
447
578
|
}
|
|
448
|
-
out.
|
|
579
|
+
out.nestedDim(`Template: ${templatePlan.displaySource}`);
|
|
449
580
|
if (options.gofer) {
|
|
450
|
-
out.
|
|
581
|
+
out.nestedDim("Gofer: Claude /0_gofer_start; Codex uses the repo-local Gofer skills; Gemini /gofer:1_gofer_research; Copilot .github prompts/skills.");
|
|
451
582
|
}
|
|
452
|
-
out.
|
|
453
|
-
out.
|
|
583
|
+
out.nestedDim(`Package profile: ${initOptions.packageProfile}`);
|
|
584
|
+
out.nestedDim(`CLI docs: https://github.com/${GITHUB_ORG}/eai`);
|
|
454
585
|
out.blank();
|
|
455
586
|
});
|
|
587
|
+
/**
|
|
588
|
+
* Guided first-run setup matching the public Getting Started flow.
|
|
589
|
+
*
|
|
590
|
+
* The legacy `init` command remains the low-level scaffold entry point. This
|
|
591
|
+
* command owns the first-run experience: local checks, browser auth, tenant
|
|
592
|
+
* confirmation, non-interactive scaffolding, and the hand-off to Gofer.
|
|
593
|
+
*/
|
|
594
|
+
export const createCommand = new Command("create")
|
|
595
|
+
.description("Guide a new builder through EAI setup and create an application")
|
|
596
|
+
.argument("[name]", "Name for the app (kebab-case)")
|
|
597
|
+
.option("--from <repo>", "GitHub repo URL or local path for template", TEMPLATE_REPO)
|
|
598
|
+
.option("--skip-prompts", "Use defaults without interactive prompts", false)
|
|
599
|
+
.option("--skip-onboarding", "Skip first-run checks and use the legacy init scaffold flow", false)
|
|
600
|
+
.option("--current-dir", "Scaffold into the current directory instead of creating ./<name>", false)
|
|
601
|
+
.option("--tenant <id>", "Main company tenant ID (deprecated alias for --company-tenant)")
|
|
602
|
+
.option("--company-tenant <id>", "Main company tenant ID that owns this app")
|
|
603
|
+
.option("--parent-tenant <id>", "Immediate parent company tenant ID for the new child company")
|
|
604
|
+
.option("--child-tenant <name>", "Create or reuse a child company tenant display name for the app runtime boundary")
|
|
605
|
+
.option("--create-child-tenant", "Prompt for a child company tenant instead of using the selected company tenant")
|
|
606
|
+
.option("--no-gofer", "Skip installing Gofer AI CLI assets")
|
|
607
|
+
.option("--no-install", "Skip installing the generated app dependencies")
|
|
608
|
+
.option("--package-profile <profile>", "Package profile to record for block catalog discovery: external, internal, or hybrid", "external")
|
|
609
|
+
.option("--tool <tool>", "AI tool to prepare for: codex, claude, vscode, or gemini")
|
|
610
|
+
.option("--no-splash", "Skip the interactive EAI wordmark")
|
|
611
|
+
.addHelpText("after", `
|
|
612
|
+
Guided setup:
|
|
613
|
+
1. Check Git, Node.js, and npm
|
|
614
|
+
2. Sign in with the browser and choose the signup workspace
|
|
615
|
+
3. Confirm the project name and folder
|
|
616
|
+
4. Create the app with Gofer AI CLI assets
|
|
617
|
+
5. Check builder readiness and hand off to /0_business_scenario
|
|
618
|
+
|
|
619
|
+
The command does not create a root tenant. Complete Website signup first so
|
|
620
|
+
the CLI can use the onboarding-created company workspace.
|
|
621
|
+
|
|
622
|
+
Use --skip-onboarding for the legacy scaffold prompts, or use eai init for
|
|
623
|
+
the low-level scaffold command directly.
|
|
624
|
+
`)
|
|
625
|
+
.action(async (nameArg, options) => {
|
|
626
|
+
await runCreateFlow(nameArg, options);
|
|
627
|
+
});
|
|
628
|
+
async function runCreateFlow(nameArg, options) {
|
|
629
|
+
await printEaiSplash(options.splash);
|
|
630
|
+
if (options.skipOnboarding || options.skipPrompts) {
|
|
631
|
+
const initArgs = buildForwardedInitArgs(nameArg, options);
|
|
632
|
+
await initCommand.parseAsync(initArgs, { from: "user" });
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
636
|
+
out.error("Guided `eai create` requires an interactive terminal. Use `eai create <name> --skip-prompts` for automation, or run it from a real terminal.");
|
|
637
|
+
process.exit(1);
|
|
638
|
+
}
|
|
639
|
+
try {
|
|
640
|
+
await runCreatePreflight();
|
|
641
|
+
const answers = await promptCreateOnboarding(nameArg, options);
|
|
642
|
+
showCreateSection("Sign in to EAI");
|
|
643
|
+
await ensureCreateAuthentication();
|
|
644
|
+
showCreateSection("Choose your EAI workspace");
|
|
645
|
+
const bootstrapPublicApiUrl = await resolvePublicApiUrl();
|
|
646
|
+
let tenantContext;
|
|
647
|
+
try {
|
|
648
|
+
tenantContext = await resolveCreateTenantContext(bootstrapPublicApiUrl, options);
|
|
649
|
+
}
|
|
650
|
+
catch (error) {
|
|
651
|
+
out.error(error instanceof Error ? error.message : String(error));
|
|
652
|
+
out.nestedInfo(`Complete Website signup, then retry: ${ONBOARDING_DOCS_URL}`);
|
|
653
|
+
process.exit(1);
|
|
654
|
+
}
|
|
655
|
+
out.nestedSuccess(`Using company workspace ${chalk.cyan(tenantContext.activeTenant.displayName)} ${chalk.dim(`(${tenantContext.activeTenant.id})`)}`);
|
|
656
|
+
showCreateSection("Build your project");
|
|
657
|
+
const initArgs = buildForwardedInitArgs(answers.name, options, answers, tenantContext.activeTenant.id);
|
|
658
|
+
await initCommand.parseAsync(initArgs, { from: "user" });
|
|
659
|
+
// `init` may have bound the app to a freshly created child company. Readiness
|
|
660
|
+
// must be checked against that runtime tenant, not the parent workspace.
|
|
661
|
+
const binding = consumeLastInitBinding();
|
|
662
|
+
const targetDir = answers.useCurrentDirectory
|
|
663
|
+
? resolve(process.cwd())
|
|
664
|
+
: resolve(process.cwd(), answers.name);
|
|
665
|
+
await reportCreateCompletion(targetDir, publicApiUrlForHomeRegion(binding?.runtimeTenantHomeRegion) ||
|
|
666
|
+
tenantContext.publicApiUrl, binding?.runtimeTenantId || tenantContext.activeTenant.id, answers.aiTool, options.gofer !== false);
|
|
667
|
+
}
|
|
668
|
+
catch (error) {
|
|
669
|
+
out.error(describeCreateFlowFailure(error));
|
|
670
|
+
process.exit(1);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Resolve the workspace for guided create.
|
|
675
|
+
*
|
|
676
|
+
* An explicit --company-tenant/--tenant is passed through as `tenantId` so the
|
|
677
|
+
* membership is validated and the cached active tenant cannot silently replace
|
|
678
|
+
* the operator's choice on a state-changing app create.
|
|
679
|
+
*/
|
|
680
|
+
export function resolveCreateTenantContext(publicApiUrl, options) {
|
|
681
|
+
return resolveActiveTenantContext({
|
|
682
|
+
publicApiUrl,
|
|
683
|
+
interactive: true,
|
|
684
|
+
tenantId: options.companyTenant || options.tenant,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
export function buildForwardedInitArgs(nameArg, options, answers, tenantId) {
|
|
688
|
+
const appName = answers?.name || nameArg;
|
|
689
|
+
const args = [];
|
|
690
|
+
if (appName)
|
|
691
|
+
args.push(appName);
|
|
692
|
+
if (options.skipPrompts || answers)
|
|
693
|
+
args.push("--skip-prompts");
|
|
694
|
+
args.push("--no-splash");
|
|
695
|
+
// `tenantId` is the validated resolution of the explicit options, so the two
|
|
696
|
+
// can no longer disagree; prefer it because it is always a canonical ID.
|
|
697
|
+
const companyTenant = tenantId || options.companyTenant || options.tenant;
|
|
698
|
+
if (companyTenant)
|
|
699
|
+
args.push("--company-tenant", companyTenant);
|
|
700
|
+
if (options.parentTenant)
|
|
701
|
+
args.push("--parent-tenant", options.parentTenant);
|
|
702
|
+
if (options.childTenant)
|
|
703
|
+
args.push("--child-tenant", options.childTenant);
|
|
704
|
+
if (options.createChildTenant)
|
|
705
|
+
args.push("--create-child-tenant");
|
|
706
|
+
if (answers?.useCurrentDirectory || options.currentDir) {
|
|
707
|
+
args.push("--current-dir");
|
|
708
|
+
}
|
|
709
|
+
if (answers?.displayName)
|
|
710
|
+
args.push("--display-name", answers.displayName);
|
|
711
|
+
if (answers?.description)
|
|
712
|
+
args.push("--description", answers.description);
|
|
713
|
+
if (options.from && options.from !== TEMPLATE_REPO) {
|
|
714
|
+
args.push("--from", options.from);
|
|
715
|
+
}
|
|
716
|
+
if (options.gofer === false)
|
|
717
|
+
args.push("--no-gofer");
|
|
718
|
+
if (options.install === false)
|
|
719
|
+
args.push("--no-install");
|
|
720
|
+
if (options.packageProfile) {
|
|
721
|
+
args.push("--package-profile", options.packageProfile);
|
|
722
|
+
}
|
|
723
|
+
return args;
|
|
724
|
+
}
|
|
725
|
+
async function runCreatePreflight() {
|
|
726
|
+
showCreateSection("Making sure your computer has the correct prerequisites.");
|
|
727
|
+
const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] || "0", 10);
|
|
728
|
+
if (nodeMajor < 20) {
|
|
729
|
+
throw new Error(`Node.js ${process.versions.node} is too old. EAI CLI requires Node.js 20 or newer.`);
|
|
730
|
+
}
|
|
731
|
+
const checks = [
|
|
732
|
+
{ label: "Git", command: "git", args: ["--version"] },
|
|
733
|
+
{ label: "npm", command: "npm", args: ["--version"] },
|
|
734
|
+
];
|
|
735
|
+
out.nestedSuccess(`Node.js ${process.versions.node}`);
|
|
736
|
+
for (const check of checks) {
|
|
737
|
+
try {
|
|
738
|
+
const result = await exec(check.command, check.args);
|
|
739
|
+
const version = result.stdout.trim() || result.stderr.trim();
|
|
740
|
+
out.nestedSuccess(`${check.label} ${version}`);
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
throw new Error(`${check.label} is required before creating an EAI app. Install it, reopen your terminal, and run \`npx eai-cli create\` again.`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
out.nestedSuccess("Local tooling is ready");
|
|
747
|
+
}
|
|
748
|
+
async function promptCreateOnboarding(nameArg, options) {
|
|
749
|
+
const requestedTool = options.tool?.trim().toLowerCase();
|
|
750
|
+
if (requestedTool &&
|
|
751
|
+
!CREATE_AI_TOOL_CHOICES.some((choice) => choice.value === requestedTool)) {
|
|
752
|
+
throw new Error(`Unknown --tool "${options.tool}". Use codex, claude, vscode, or gemini.`);
|
|
753
|
+
}
|
|
754
|
+
out.blank();
|
|
755
|
+
const toolAnswer = requestedTool
|
|
756
|
+
? { aiTool: requestedTool }
|
|
757
|
+
: await inquirer.prompt([
|
|
758
|
+
{
|
|
759
|
+
type: "select",
|
|
760
|
+
name: "aiTool",
|
|
761
|
+
message: "Which AI tool will you use for this project?",
|
|
762
|
+
choices: CREATE_AI_TOOL_CHOICES,
|
|
763
|
+
default: "codex",
|
|
764
|
+
theme: CREATE_SELECT_THEME,
|
|
765
|
+
},
|
|
766
|
+
]);
|
|
767
|
+
out.blank();
|
|
768
|
+
const nameAnswer = await inquirer.prompt([
|
|
769
|
+
{
|
|
770
|
+
type: "input",
|
|
771
|
+
name: "name",
|
|
772
|
+
message: "What is the name of your project?\n ",
|
|
773
|
+
default: nameArg ? toKebabCase(nameArg) : undefined,
|
|
774
|
+
theme: CREATE_PROMPT_THEME,
|
|
775
|
+
validate: (input) => {
|
|
776
|
+
if (!/^[a-z][a-z0-9-]*$/.test(input.trim())) {
|
|
777
|
+
return "Use lowercase letters, numbers, and hyphens; start with a letter";
|
|
778
|
+
}
|
|
779
|
+
return true;
|
|
780
|
+
},
|
|
781
|
+
},
|
|
782
|
+
]);
|
|
783
|
+
out.blank();
|
|
784
|
+
const displayNameAnswer = await inquirer.prompt([
|
|
785
|
+
{
|
|
786
|
+
type: "input",
|
|
787
|
+
name: "displayName",
|
|
788
|
+
message: "What should we call your project?\n ",
|
|
789
|
+
default: toDisplayName(String(nameAnswer.name)),
|
|
790
|
+
theme: CREATE_PROMPT_THEME,
|
|
791
|
+
},
|
|
792
|
+
]);
|
|
793
|
+
out.blank();
|
|
794
|
+
const descriptionAnswer = await inquirer.prompt([
|
|
795
|
+
{
|
|
796
|
+
type: "input",
|
|
797
|
+
name: "description",
|
|
798
|
+
message: "What does your project do?\n ",
|
|
799
|
+
default: `${displayNameAnswer.displayName} application`,
|
|
800
|
+
theme: CREATE_PROMPT_THEME,
|
|
801
|
+
},
|
|
802
|
+
]);
|
|
803
|
+
const appName = String(nameAnswer.name).trim();
|
|
804
|
+
let useCurrentDirectory = true;
|
|
805
|
+
if (!options.currentDir) {
|
|
806
|
+
out.blank();
|
|
807
|
+
const locationAnswer = await inquirer.prompt([
|
|
808
|
+
{
|
|
809
|
+
type: "select",
|
|
810
|
+
name: "location",
|
|
811
|
+
message: "Where should we create your project?",
|
|
812
|
+
choices: [
|
|
813
|
+
{ name: `New folder ./${appName}`, value: "new" },
|
|
814
|
+
{
|
|
815
|
+
name: `Current folder ./${basename(process.cwd())}`,
|
|
816
|
+
value: "current",
|
|
817
|
+
},
|
|
818
|
+
],
|
|
819
|
+
default: "new",
|
|
820
|
+
theme: CREATE_SELECT_THEME,
|
|
821
|
+
},
|
|
822
|
+
]);
|
|
823
|
+
useCurrentDirectory = String(locationAnswer.location) === "current";
|
|
824
|
+
}
|
|
825
|
+
return {
|
|
826
|
+
name: appName,
|
|
827
|
+
displayName: String(displayNameAnswer.displayName).trim(),
|
|
828
|
+
description: String(descriptionAnswer.description).trim(),
|
|
829
|
+
useCurrentDirectory,
|
|
830
|
+
aiTool: String(toolAnswer.aiTool),
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
async function ensureCreateAuthentication() {
|
|
834
|
+
if (await isAuthenticated()) {
|
|
835
|
+
out.nestedSuccess("EAI login is already active");
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
const { proceed } = await inquirer.prompt([
|
|
839
|
+
{
|
|
840
|
+
type: "confirm",
|
|
841
|
+
name: "proceed",
|
|
842
|
+
message: "No EAI login was found. Open the browser to sign in now?\n ",
|
|
843
|
+
default: true,
|
|
844
|
+
theme: CREATE_NESTED_PROMPT_THEME,
|
|
845
|
+
},
|
|
846
|
+
]);
|
|
847
|
+
if (!proceed) {
|
|
848
|
+
throw new Error("Sign-in is required before creating an EAI app.");
|
|
849
|
+
}
|
|
850
|
+
const profile = getActiveProfile();
|
|
851
|
+
const projectRoot = await findProjectRoot();
|
|
852
|
+
const resolvedConfig = await resolveAuthConfig(projectRoot || undefined, profile);
|
|
853
|
+
const configIssue = validateResolvedAuthConfig(resolvedConfig);
|
|
854
|
+
if (configIssue)
|
|
855
|
+
throw new Error(configIssue);
|
|
856
|
+
out.nestedInfo("Opening your browser to complete EAI sign-in...");
|
|
857
|
+
const tokens = await browserLogin(resolvedConfig.tenantName, resolvedConfig.tenantId, resolvedConfig.clientId, resolvedConfig.authScope);
|
|
858
|
+
await storeTokens(tokens);
|
|
859
|
+
out.nestedSuccess(`Authenticated as ${chalk.bold(tokens.upn || "user")}`);
|
|
860
|
+
}
|
|
861
|
+
async function reportCreateCompletion(targetDir, publicApiUrl, tenantId, aiTool, goferExpected) {
|
|
862
|
+
const hasGofer = await Promise.all([
|
|
863
|
+
access(join(targetDir, ".specify")),
|
|
864
|
+
access(join(targetDir, ".agents")),
|
|
865
|
+
])
|
|
866
|
+
.then(() => true)
|
|
867
|
+
.catch(() => false);
|
|
868
|
+
out.blank();
|
|
869
|
+
if (goferExpected && hasGofer) {
|
|
870
|
+
out.nestedSuccess("Gofer AI CLI assets confirmed");
|
|
871
|
+
}
|
|
872
|
+
else if (goferExpected) {
|
|
873
|
+
out.warn("Gofer assets were not found; run `eai gofer refresh` inside the project.");
|
|
874
|
+
}
|
|
875
|
+
let builderReady = false;
|
|
876
|
+
try {
|
|
877
|
+
const client = new PlatformAPIClient(publicApiUrl, tenantId);
|
|
878
|
+
const readiness = await client.getBuilderReadiness({ tenantId, workflowKeys: [] });
|
|
879
|
+
builderReady = readiness.status === "available";
|
|
880
|
+
if (builderReady) {
|
|
881
|
+
out.nestedSuccess("Builder readiness is available");
|
|
882
|
+
}
|
|
883
|
+
else {
|
|
884
|
+
out.warn(`Builder readiness: ${readiness.status}`);
|
|
885
|
+
for (const check of readiness.checks) {
|
|
886
|
+
out.nestedInfo(`${check.key}: ${check.status} — ${check.reasonMessage}`);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
catch (error) {
|
|
891
|
+
out.warn(`Builder readiness could not be checked yet: ${error instanceof Error ? error.message : String(error)}`);
|
|
892
|
+
}
|
|
893
|
+
out.blank();
|
|
894
|
+
const summary = buildCreateCompletionSummary(builderReady, aiTool);
|
|
895
|
+
out.heading(summary.heading);
|
|
896
|
+
out.nestedInfo(`Project folder: ${chalk.cyan(targetDir)}`);
|
|
897
|
+
out.nestedInfo(`AI tool: ${chalk.cyan(CREATE_AI_TOOL_LABELS[aiTool])}`);
|
|
898
|
+
for (const step of summary.steps)
|
|
899
|
+
out.nestedInfo(step);
|
|
900
|
+
out.nestedDim(`Setup guide: ${ONBOARDING_DOCS_URL}`);
|
|
901
|
+
out.blank();
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Next-step copy for guided create. A failed or unchecked builder readiness must
|
|
905
|
+
* not be presented as a ready workspace, and must not send the builder straight
|
|
906
|
+
* into a hand-off that is known to be unproven.
|
|
907
|
+
*/
|
|
908
|
+
export function buildCreateCompletionSummary(builderReady, aiTool) {
|
|
909
|
+
const toolLabel = CREATE_AI_TOOL_LABELS[aiTool];
|
|
910
|
+
if (builderReady) {
|
|
911
|
+
return {
|
|
912
|
+
heading: `${chalk.green("✔")} Your EAI workspace is ready`,
|
|
913
|
+
steps: [
|
|
914
|
+
`Open that folder in ${toolLabel}, then start:`,
|
|
915
|
+
chalk.cyan("/0_business_scenario <describe what you want to build>"),
|
|
916
|
+
],
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
return {
|
|
920
|
+
heading: `${chalk.yellow("!")} Project created; builder setup is not confirmed yet`,
|
|
921
|
+
steps: [
|
|
922
|
+
`Re-check with ${chalk.cyan("eai doctor")} inside the project folder.`,
|
|
923
|
+
`If it stays unavailable, ask your workspace tenant-admin to finish setup: ${ONBOARDING_DOCS_URL}`,
|
|
924
|
+
`Once readiness reports available, open the folder in ${toolLabel} and start ${chalk.cyan("/0_business_scenario")}.`,
|
|
925
|
+
],
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
export function toKebabCase(value) {
|
|
929
|
+
return value
|
|
930
|
+
.trim()
|
|
931
|
+
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
|
932
|
+
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
933
|
+
.replace(/^-+|-+$/g, "")
|
|
934
|
+
.toLowerCase();
|
|
935
|
+
}
|
|
456
936
|
function resolvePackageProfile(value) {
|
|
457
937
|
if (value === "external" || value === "internal" || value === "hybrid") {
|
|
458
938
|
return value;
|
|
@@ -466,7 +946,7 @@ function resolvePackageProfile(value) {
|
|
|
466
946
|
* on success. Non-fatal: logs a warning and returns false on any failure.
|
|
467
947
|
*/
|
|
468
948
|
async function provisionEntraInline(targetDir, appName, tenantId, publicApiUrl) {
|
|
469
|
-
const spinner =
|
|
949
|
+
const spinner = startEaiStep("Provisioning Entra app registration...");
|
|
470
950
|
try {
|
|
471
951
|
const client = new PlatformAPIClient(publicApiUrl, tenantId);
|
|
472
952
|
const authSiteUrl = `http://localhost:3000/${appName}`;
|
|
@@ -717,7 +1197,7 @@ async function createTenantAppForInit(publicApiUrl, tenantContext, companyFlag,
|
|
|
717
1197
|
});
|
|
718
1198
|
if (!res.ok) {
|
|
719
1199
|
const error = await parseApiError(res);
|
|
720
|
-
out.error(
|
|
1200
|
+
out.error(describeAppCreationFailure(error));
|
|
721
1201
|
process.exit(1);
|
|
722
1202
|
}
|
|
723
1203
|
const payload = (await res.json());
|
|
@@ -726,14 +1206,14 @@ async function createTenantAppForInit(publicApiUrl, tenantContext, companyFlag,
|
|
|
726
1206
|
? String(childTenant.id || "")
|
|
727
1207
|
: "";
|
|
728
1208
|
if (!childTenantId) {
|
|
729
|
-
out.
|
|
1209
|
+
out.nestedInfo(`Created app ${chalk.cyan(appSeed.slug)} under company tenant ${chalk.cyan(immediateParentTenantId)}.`);
|
|
730
1210
|
return {
|
|
731
1211
|
parentTenantId: companyTenantId,
|
|
732
1212
|
runtimeTenantId: immediateParentTenantId,
|
|
733
1213
|
runtimeTenantHomeRegion: activeTenant?.homeRegion,
|
|
734
1214
|
};
|
|
735
1215
|
}
|
|
736
|
-
out.
|
|
1216
|
+
out.nestedInfo(`Created app ${chalk.cyan(appSeed.slug)} under main company ${chalk.cyan(companyTenantId)} with child company ${chalk.cyan(childTenantId)}.`);
|
|
737
1217
|
const childTenantHomeRegion = childTenant && typeof childTenant === "object"
|
|
738
1218
|
? childTenant.homeRegion
|
|
739
1219
|
: undefined;
|