@bhooai/nexus-cli 0.1.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.
Files changed (75) hide show
  1. package/PLAN.md +141 -0
  2. package/README.md +34 -0
  3. package/package.json +25 -0
  4. package/src/commands/cluster.ts +133 -0
  5. package/src/commands/dev.ts +133 -0
  6. package/src/commands/doctor.ts +199 -0
  7. package/src/commands/init.ts +960 -0
  8. package/src/commands/node.ts +101 -0
  9. package/src/commands/pysetup.ts +136 -0
  10. package/src/commands/sync.ts +116 -0
  11. package/src/commands/uninstall.ts +287 -0
  12. package/src/config-sync.ts +384 -0
  13. package/src/dotenv.ts +39 -0
  14. package/src/index.ts +94 -0
  15. package/src/supervisor.ts +384 -0
  16. package/src/util.ts +123 -0
  17. package/src/wizard.ts +149 -0
  18. package/templates/Dockerfile +60 -0
  19. package/templates/README.md +69 -0
  20. package/templates/apps/admin/index.html +12 -0
  21. package/templates/apps/admin/package.json +24 -0
  22. package/templates/apps/admin/postcss.config.js +6 -0
  23. package/templates/apps/admin/src/main.tsx +10 -0
  24. package/templates/apps/admin/src/vite-env.d.ts +18 -0
  25. package/templates/apps/admin/tailwind.config.js +9 -0
  26. package/templates/apps/admin/tsconfig.json +17 -0
  27. package/templates/apps/admin/vite.config.ts +64 -0
  28. package/templates/apps/ai-server/main.py +43 -0
  29. package/templates/apps/ai-server/providers/__init__.py +3 -0
  30. package/templates/apps/ai-server/providers/base.py +111 -0
  31. package/templates/apps/ai-server/requirements.txt +3 -0
  32. package/templates/apps/ai-server/routers/__init__.py +3 -0
  33. package/templates/apps/ai-server/routers/chat.py +47 -0
  34. package/templates/apps/ai-server/routers/embeddings.py +30 -0
  35. package/templates/apps/ai-server/routers/lint.py +167 -0
  36. package/templates/apps/ai-server/routers/models.py +23 -0
  37. package/templates/apps/ai-server/routers/preflight.py +169 -0
  38. package/templates/apps/ai-server/settings.py +48 -0
  39. package/templates/apps/backend/package.json +33 -0
  40. package/templates/apps/backend/src/main.ts +375 -0
  41. package/templates/apps/backend/src/modules/admin/adminRoutes.ts +732 -0
  42. package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +391 -0
  43. package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +161 -0
  44. package/templates/apps/backend/src/modules/admin/lintProxy.ts +89 -0
  45. package/templates/apps/backend/src/modules/admin/preflightProxy.ts +242 -0
  46. package/templates/apps/backend/src/modules/admin/roleCatalog.ts +78 -0
  47. package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +449 -0
  48. package/templates/apps/backend/src/modules/ai/aiProxy.ts +265 -0
  49. package/templates/apps/backend/src/modules/auth/authRoutes.ts +220 -0
  50. package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +100 -0
  51. package/templates/apps/backend/src/modules/payments/paymentStore.ts +172 -0
  52. package/templates/apps/backend/src/modules/requests/requestLog.ts +175 -0
  53. package/templates/apps/backend/src/modules/users/userGraph.ts +83 -0
  54. package/templates/apps/backend/src/modules/users/userModel.ts +88 -0
  55. package/templates/apps/backend/src/plugins/CronScheduler.ts +69 -0
  56. package/templates/apps/backend/src/plugins/loadPlugins.ts +107 -0
  57. package/templates/apps/backend/tsconfig.json +14 -0
  58. package/templates/apps/frontend/index.html +12 -0
  59. package/templates/apps/frontend/package.json +19 -0
  60. package/templates/apps/frontend/src/main.tsx +64 -0
  61. package/templates/apps/frontend/vite.config.ts +63 -0
  62. package/templates/bin/nexus.js +35 -0
  63. package/templates/bin/serve-all.mjs +45 -0
  64. package/templates/dockerignore +15 -0
  65. package/templates/gitignore +12 -0
  66. package/templates/nexus.config.ts +69 -0
  67. package/templates/package.json +47 -0
  68. package/templates/tsconfig.json +17 -0
  69. package/templates/uploads/.gitkeep +0 -0
  70. package/tests/cli.test.ts +45 -0
  71. package/tests/config-sync.test.ts +201 -0
  72. package/tests/dotenv.test.ts +51 -0
  73. package/tsconfig.json +9 -0
  74. package/vitest.config.ts +9 -0
  75. package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +13 -0
@@ -0,0 +1,960 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { dirname, join, relative, resolve, basename } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { networkInterfaces } from 'node:os';
7
+ import { nodeIdFor } from '@bhooai/nexus-cluster';
8
+ import { scanRuntimes, scanServices, isPortFree, type Prereq, tcpReachable, parseHostPort } from '../util.js';
9
+ import {
10
+ resolveProjectInfo as resolveProjectInfoDb,
11
+ connectProjectInfo,
12
+ upsertProjectInfo,
13
+ closeProjectInfo,
14
+ sanitizeDbName,
15
+ type ProjectInfo,
16
+ } from '@bhooai/nexus-data';
17
+ import { pysetup } from './pysetup.js';
18
+ import {
19
+ isInteractive as wizardInteractive,
20
+ banner,
21
+ confirm,
22
+ prompt,
23
+ select,
24
+ multiSelect,
25
+ promptHidden,
26
+ summaryTable,
27
+ statusIcon,
28
+ closeWizard,
29
+ COLORS,
30
+ } from '../wizard.js';
31
+
32
+ interface InitOptions {
33
+ force?: boolean;
34
+ target?: string;
35
+ }
36
+
37
+ const { GREEN, RED, YELLOW, CYAN, DIM, BOLD, RESET } = COLORS;
38
+
39
+ const __dirname = dirname(fileURLToPath(import.meta.url));
40
+ // templates/ lives at the package root (sibling of src/ and dist/), so it works
41
+ // whether this file runs from src/commands/ (dev, via tsx) or dist/commands/.
42
+ const TEMPLATES = resolve(__dirname, '..', '..', 'templates');
43
+
44
+ /** AI provider catalogue - labels shown in the multi-select, env var each key writes.
45
+ * The `envComment` is the line written above the key in .env. Order matches the
46
+ * .env block layout (local providers first, then cloud providers alphabetically). */
47
+ const AI_PROVIDERS: Array<{ id: string; label: string; envVar: string; needsKey: boolean; envComment: string }> = [
48
+ { id: 'ollama', label: 'Ollama (local, no key)', envVar: 'NEXUS_AI_OLLAMA_API_KEY', needsKey: false, envComment: 'Ollama (local) - no API key needed' },
49
+ { id: 'openai', label: 'OpenAI', envVar: 'NEXUS_AI_OPENAI_API_KEY', needsKey: true, envComment: 'OpenAI' },
50
+ { id: 'anthropic', label: 'Anthropic (Claude)', envVar: 'NEXUS_AI_ANTHROPIC_API_KEY', needsKey: true, envComment: 'Anthropic Claude' },
51
+ { id: 'google', label: 'Google (Gemini)', envVar: 'NEXUS_AI_GOOGLE_API_KEY', needsKey: true, envComment: 'Google Gemini' },
52
+ { id: 'groq', label: 'Groq', envVar: 'NEXUS_AI_GROQ_API_KEY', needsKey: true, envComment: 'Groq' },
53
+ { id: 'mistral', label: 'Mistral', envVar: 'NEXUS_AI_MISTRAL_API_KEY', needsKey: true, envComment: 'Mistral AI' },
54
+ { id: 'cohere', label: 'Cohere', envVar: 'NEXUS_AI_COHERE_API_KEY', needsKey: true, envComment: 'Cohere' },
55
+ { id: 'together', label: 'Together AI', envVar: 'NEXUS_AI_TOGETHER_API_KEY', needsKey: true, envComment: 'Together AI' },
56
+ { id: 'fireworks', label: 'Fireworks', envVar: 'NEXUS_AI_FIREWORKS_API_KEY', needsKey: true, envComment: 'Fireworks AI' },
57
+ { id: 'deepseek', label: 'DeepSeek', envVar: 'NEXUS_AI_DEEPSEEK_API_KEY', needsKey: true, envComment: 'DeepSeek' },
58
+ { id: 'perplexity', label: 'Perplexity', envVar: 'NEXUS_AI_PERPLEXITY_API_KEY', needsKey: true, envComment: 'Perplexity' },
59
+ { id: 'xai', label: 'xAI (Grok)', envVar: 'NEXUS_AI_XAI_API_KEY', needsKey: true, envComment: 'xAI (Grok)' },
60
+ { id: 'replicate', label: 'Replicate', envVar: 'NEXUS_AI_REPLICATE_API_KEY', needsKey: true, envComment: 'Replicate' },
61
+ { id: 'huggingface', label: 'Hugging Face', envVar: 'NEXUS_AI_HUGGINGFACE_API_KEY', needsKey: true, envComment: 'Hugging Face' },
62
+ { id: 'nvidia', label: 'NVIDIA NIM', envVar: 'NEXUS_AI_NVIDIA_API_KEY', needsKey: true, envComment: 'NVIDIA NIM' },
63
+ { id: 'openrouter', label: 'OpenRouter', envVar: 'NEXUS_AI_OPENROUTER_API_KEY', needsKey: true, envComment: 'OpenRouter' },
64
+ { id: 'lmstudio', label: 'LM Studio (local, no key)', envVar: 'NEXUS_AI_LMSTUDIO_API_KEY', needsKey: false, envComment: 'LM Studio (local) - no API key needed' },
65
+ { id: 'alephalpha', label: 'Aleph Alpha', envVar: 'NEXUS_AI_ALEPHALPHA_API_KEY', needsKey: true, envComment: 'Aleph Alpha' },
66
+ { id: 'stability', label: 'Stability AI', envVar: 'NEXUS_AI_STABILITY_API_KEY', needsKey: true, envComment: 'Stability AI' },
67
+ { id: 'azure', label: 'Azure OpenAI', envVar: 'NEXUS_AI_AZURE_API_KEY', needsKey: true, envComment: 'Azure OpenAI' },
68
+ ];
69
+
70
+ /** Payment provider key/secret env-var names (empty by default - fill in .env). */
71
+ const PAYMENT_ENV_KEYS = [
72
+ 'NEXUS_PAYMENTS_RAZORPAY_KEY_ID',
73
+ 'NEXUS_PAYMENTS_RAZORPAY_KEY_SECRET',
74
+ 'NEXUS_PAYMENTS_PAYPAL_CLIENT_ID',
75
+ 'NEXUS_PAYMENTS_PAYPAL_CLIENT_SECRET',
76
+ 'NEXUS_PAYMENTS_PAYU_MERCHANT_KEY',
77
+ 'NEXUS_PAYMENTS_PAYU_SALT',
78
+ 'NEXUS_PAYMENTS_SKRILL_MERCHANT_EMAIL',
79
+ 'NEXUS_PAYMENTS_PAYONEER_PROGRAM_ID',
80
+ 'NEXUS_PAYMENTS_PAYONEER_API_KEY',
81
+ ];
82
+
83
+ /** Recursively collect every file under `dir`, as paths relative to `dir`. */
84
+ function listFiles(dir: string, base = dir): string[] {
85
+ const out: string[] = [];
86
+ for (const entry of readdirSync(dir)) {
87
+ const abs = join(dir, entry);
88
+ if (statSync(abs).isDirectory()) {
89
+ out.push(...listFiles(abs, base));
90
+ } else {
91
+ out.push(relative(base, abs).replace(/\\/g, '/'));
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+
97
+ function argValue(args: string[], name: string): string | undefined {
98
+ const i = args.indexOf(name);
99
+ if (i >= 0) return args[i + 1];
100
+ return args.find((a) => a.startsWith(`${name}=`))?.slice(name.length + 1);
101
+ }
102
+
103
+ /** First non-internal IPv4 of this host (display only). */
104
+ function localAddress(): string {
105
+ for (const entry of Object.values(networkInterfaces())) {
106
+ for (const net of entry ?? []) {
107
+ if (net.family === 'IPv4' && !net.internal) return net.address;
108
+ }
109
+ }
110
+ return '127.0.0.1';
111
+ }
112
+
113
+ /** Print onboarding instructions for the chosen server kind. */
114
+ function printSetup(kind: 'root' | 'node', role: string, token: string, port: number): void {
115
+ const ip = localAddress();
116
+ if (kind === 'root') {
117
+ console.log('');
118
+ console.log(`${GREEN}BhooAI Nexus root server${RESET} - hub of the node mesh`);
119
+ console.log(` ${CYAN}npm run dev${RESET} start backend, frontend, AI server, admin`);
120
+ console.log(` ${CYAN}nexus cluster serve${RESET} enable the mesh (load balancer) + autoscaler`);
121
+ console.log(` ${CYAN}nexus cluster link <url>${RESET} register a node by its agent URL`);
122
+ console.log(` pairing token: ${DIM}${token}${RESET}`);
123
+ console.log(' add nodes from the admin UI \u2b21 Cluster tab, or with:');
124
+ console.log(` nexus cluster link http://<node-host>:<node-port>`);
125
+ return;
126
+ }
127
+ console.log('');
128
+ console.log(`${GREEN}BhooAI Nexus node server${RESET} - role ${role}`);
129
+ console.log(` ${CYAN}nexus node serve --role=${role} --port=${port}${RESET} start the ${role} node agent`);
130
+ console.log(` node id: ${DIM}${nodeIdFor(process.cwd(), role as never)}${RESET}`);
131
+ console.log(` ${GREEN}agent link: http://${ip}:${port}${RESET}`);
132
+ console.log(` pairing token: ${DIM}${token}${RESET}`);
133
+ console.log(' give these to the root operator to add this server:');
134
+ console.log(` nexus cluster link http://${ip}:${port}`);
135
+ }
136
+
137
+ // -- collected wizard choices ----------------------------------------
138
+ interface WizardChoices {
139
+ projectName: string;
140
+ kind: 'root' | 'node';
141
+ role: string;
142
+ agentPort: number;
143
+ mongoUri: string;
144
+ redisUrl: string;
145
+ aiProviders: string[];
146
+ aiKeys: Record<string, string>;
147
+ venv: boolean;
148
+ /** Shared cluster pairing token. For root: generated fresh. For node:
149
+ * reused from a sibling root project or prompted/flagged so the node
150
+ * can authenticate to the central. Empty = generate fresh (root only). */
151
+ clusterToken: string;
152
+ }
153
+
154
+ /** Parse the new wizard flags from the CLI args (non-interactive path). */
155
+ function parseWizardArgs(args: string[], target: string): WizardChoices {
156
+ const name = argValue(args, '--name') ?? basename(resolve(target));
157
+ const kind = (argValue(args, '--as') === 'node' ? 'node' : 'root') as 'root' | 'node';
158
+ const role = argValue(args, '--role') ?? 'backend';
159
+ const agentPort = Number(argValue(args, '--port') ?? '7575');
160
+ const mongoUri = argValue(args, '--mongo-uri') ?? `mongodb://localhost:27017/${name.replace(/[^a-z0-9_-]/gi, '-')}`;
161
+ const redisUrl = argValue(args, '--redis-url') ?? 'redis://localhost:6379';
162
+ const clusterToken = argValue(args, '--cluster-token') ?? '';
163
+ const aiProvidersArg = argValue(args, '--ai-providers') ?? 'ollama';
164
+ const aiProviders = aiProvidersArg.split(',').map((s) => s.trim()).filter(Boolean);
165
+ const aiKeys: Record<string, string> = {};
166
+ // --ai-key openai=sk-xxx anthropic=sk-ant-yyy (repeatable)
167
+ for (const a of args) {
168
+ const m = /^--ai-key=([a-z0-9_-]+)=(.*)$/i.exec(a);
169
+ if (m) aiKeys[m[1]!] = m[2]!;
170
+ const i = args.indexOf('--ai-key');
171
+ if (i >= 0) {
172
+ const v = args[i + 1];
173
+ if (v) {
174
+ const kv = v.split('=');
175
+ if (kv.length === 2) aiKeys[kv[0]!] = kv[1]!;
176
+ }
177
+ }
178
+ }
179
+ const venv = args.includes('--venv') ? true : args.includes('--no-venv') ? false : true;
180
+ return { projectName: name, kind, role, agentPort, mongoUri, redisUrl, aiProviders, aiKeys, venv, clusterToken };
181
+ }
182
+
183
+ /** Scaffold a new Nexus project from templates/.
184
+ * `nexus init [target] [--force] [--as=root|node] [--role=R] [--port=N]
185
+ * [--name=N] [--mongo-uri=URI] [--redis-url=URL] [--ai-providers=a,b]
186
+ * [--ai-key id=val] [--venv|--no-venv] [--no-interactive] [--no-install]
187
+ * [--skip-mongo-check]`.
188
+ */
189
+ export async function init(opts: InitOptions = {}, args: string[] = []): Promise<number> {
190
+ const targetArg = opts.target ?? args[0] ?? '.';
191
+ const target = resolve(targetArg);
192
+ const force = opts.force ?? args.includes('--force');
193
+ const skipInstall = args.includes('--no-install') || args.includes('--skip-install');
194
+ const skipPysetup = skipInstall; // pysetup is part of "install everything"
195
+ const noInteractive = args.includes('--no-interactive') || !wizardInteractive();
196
+ const skipMongoCheck = args.includes('--skip-mongo-check');
197
+
198
+ if (!existsSync(TEMPLATES)) {
199
+ console.error(`init: templates directory not found at ${TEMPLATES}`);
200
+ return 1;
201
+ }
202
+
203
+ // -- Step 0: banner ----------------------------------------------
204
+ banner('BhooAI Nexus - project setup wizard', [
205
+ 'Scaffolds backend + frontend + admin + Python AI server,',
206
+ 'wires Mongo/Redis, generates secrets, installs Node + Python deps.',
207
+ noInteractive ? 'Running non-interactively (flag-driven).' : 'Answer the prompts; defaults are shown in [brackets].',
208
+ ]);
209
+
210
+ // -- Step 1: prerequisite scan -----------------------------------
211
+ const runtimes = await scanRuntimes();
212
+ console.log(` ${BOLD}Prerequisites${RESET}`);
213
+ for (const r of runtimes) {
214
+ const icon = statusIcon(r.ok);
215
+ const color = !r.ok && r.critical ? RED : !r.ok ? YELLOW : CYAN;
216
+ console.log(` ${icon} ${color}${r.name.padEnd(10)}${RESET} ${DIM}${r.detail}${RESET}`);
217
+ }
218
+ console.log();
219
+
220
+ const criticalMissing = runtimes.filter((r) => r.critical && !r.ok);
221
+ if (criticalMissing.length > 0) {
222
+ console.log(`${RED} Missing critical prerequisites: ${criticalMissing.map((r) => r.name).join(', ')}${RESET}`);
223
+ console.log(`${RED} Install them before running \`nexus init\` again.${RESET}\n`);
224
+ return 1;
225
+ }
226
+
227
+ // Mongo/Redis reachability (warn, don't block - wizard can still scaffold).
228
+ let services: Prereq[] = [];
229
+ if (!skipMongoCheck) {
230
+ // We don't have a config yet; probe the defaults the template ships with.
231
+ services = await scanServices({
232
+ db: { uri: 'mongodb://localhost:27017' },
233
+ redis: { url: 'redis://localhost:6379' },
234
+ ai: { serverUrl: 'http://localhost:8000' },
235
+ server: { host: '0.0.0.0', port: 4000 },
236
+ frontend: { port: 3000 },
237
+ admin: { port: 3001 },
238
+ });
239
+ const mongoDown = services.find((s) => s.name === 'mongodb' && !s.ok);
240
+ const redisDown = services.find((s) => s.name === 'redis' && !s.ok);
241
+ if (mongoDown || redisDown) {
242
+ console.log(` ${YELLOW}[!] ${mongoDown?.detail ?? ''}${mongoDown && redisDown ? ' | ' : ''}${redisDown?.detail ?? ''}${RESET}`);
243
+ if (!noInteractive) {
244
+ // Retry loop: y / n / re-check. Lets the user start Mongo/Redis in
245
+ // another terminal and re-probe without aborting the wizard.
246
+ let decided = false;
247
+ while (!decided) {
248
+ const choice = await select(
249
+ 'Mongo/Redis not reachable. What do you want to do?',
250
+ [
251
+ { label: 'Retry check (I have started/restarted them)', value: 'retry' },
252
+ { label: 'Continue anyway (skip - fix later)', value: 'yes' },
253
+ { label: 'Abort setup', value: 'no' },
254
+ ],
255
+ 'retry',
256
+ );
257
+ if (choice === 'retry') {
258
+ services = await scanServices({
259
+ db: { uri: 'mongodb://localhost:27017' },
260
+ redis: { url: 'redis://localhost:6379' },
261
+ ai: { serverUrl: 'http://localhost:8000' },
262
+ server: { host: '0.0.0.0', port: 4000 },
263
+ frontend: { port: 3000 },
264
+ admin: { port: 3001 },
265
+ });
266
+ const md = services.find((s) => s.name === 'mongodb' && !s.ok);
267
+ const rd = services.find((s) => s.name === 'redis' && !s.ok);
268
+ if (!md && !rd) {
269
+ console.log(` ${GREEN}[OK] Mongo + Redis reachable now${RESET}\n`);
270
+ decided = true;
271
+ } else {
272
+ console.log(` ${YELLOW}[!] ${md?.detail ?? ''}${md && rd ? ' | ' : ''}${rd?.detail ?? ''}${RESET}`);
273
+ }
274
+ } else if (choice === 'yes') {
275
+ decided = true;
276
+ } else {
277
+ console.log(`${DIM}Aborted.${RESET}`);
278
+ closeWizard();
279
+ return 1;
280
+ }
281
+ }
282
+ } else {
283
+ console.log(` ${DIM}(continuing - pass --skip-mongo-check to silence)${RESET}`);
284
+ }
285
+ console.log();
286
+ }
287
+ }
288
+
289
+ // -- Steps 2-9: gather choices -----------------------------------
290
+ const choices = parseWizardArgs(args, target);
291
+
292
+ if (!noInteractive) {
293
+ choices.projectName = await prompt('Project name?', choices.projectName);
294
+
295
+ // Step 3: server kind
296
+ choices.kind = await select<'root' | 'node'>(
297
+ 'Run this server as:',
298
+ [
299
+ { label: 'root - hub of your node mesh (load balancer + autoscaler)', value: 'root' },
300
+ { label: 'node - a worker that joins a root and takes a role', value: 'node' },
301
+ ],
302
+ choices.kind,
303
+ );
304
+
305
+ // Step 4: cluster role + port + token (only for node)
306
+ if (choices.kind === 'node') {
307
+ choices.role = await select(
308
+ 'Node role:',
309
+ [
310
+ { label: 'backend (API core)', value: 'backend' },
311
+ { label: 'ai (AI inference engine)', value: 'ai' },
312
+ { label: 'files (static + uploads storage)', value: 'files' },
313
+ { label: 'database (Mongo + Redis)', value: 'database' },
314
+ ],
315
+ choices.role,
316
+ );
317
+ const portStr = await prompt('Node agent port?', String(choices.agentPort));
318
+ const p = Number(portStr);
319
+ if (Number.isFinite(p) && p > 0) choices.agentPort = p;
320
+
321
+ // The node must present the SAME pairing token as the root. Try to
322
+ // detect a sibling root project's token; else prompt for it.
323
+ const detected = detectSiblingRootToken(target);
324
+ if (detected) {
325
+ choices.clusterToken = detected;
326
+ console.log(` ${GREEN}[OK]${RESET} reused cluster token from sibling root project`);
327
+ } else if (choices.clusterToken) {
328
+ console.log(` ${DIM}using --cluster-token${RESET}`);
329
+ } else {
330
+ const t = await prompt('Cluster pairing token (from the root - run `nexus cluster serve` there to see it)?', '');
331
+ choices.clusterToken = t.trim();
332
+ }
333
+ }
334
+
335
+ // Step 5: ports - auto-allocate is the default; just confirm.
336
+ const autoPorts = await confirm('Auto-allocate free ports for backend/frontend/admin/ai?', true);
337
+ if (!autoPorts) {
338
+ console.log(` ${DIM}(custom ports - edit nexus.config.ts after scaffolding)${RESET}`);
339
+ }
340
+
341
+ // Step 6: Mongo URI
342
+ const defaultMongo = `mongodb://localhost:27017/${choices.projectName.replace(/[^a-z0-9_-]/gi, '-')}`;
343
+ choices.mongoUri = await prompt('MongoDB URI?', defaultMongo);
344
+
345
+ // Step 7: Redis URL
346
+ choices.redisUrl = await prompt('Redis URL?', 'redis://localhost:6379');
347
+
348
+ // Step 8: AI providers
349
+ const providerOptions = AI_PROVIDERS.map((p) => ({ label: p.label, value: p.id }));
350
+ const defaultProviders = choices.aiProviders.length ? choices.aiProviders : ['ollama'];
351
+ const selected = await multiSelect('AI providers to enable:', providerOptions, defaultProviders);
352
+ choices.aiProviders = selected;
353
+ choices.aiKeys = {};
354
+ for (const id of selected) {
355
+ const meta = AI_PROVIDERS.find((p) => p.id === id);
356
+ if (meta?.needsKey) {
357
+ const key = await promptHidden(`${meta.label} API key (Enter to skip, fill later in .env):`);
358
+ if (key) choices.aiKeys[id] = key;
359
+ }
360
+ }
361
+
362
+ // Step 9: Python venv
363
+ choices.venv = await confirm('Create a Python virtualenv for the AI server?', true);
364
+
365
+ // Step 10: review
366
+ console.log(`\n ${BOLD}Review${RESET}`);
367
+ const review: Array<{ label: string; value: string; ok?: boolean }> = [
368
+ { label: 'project', value: choices.projectName },
369
+ { label: 'kind', value: choices.kind },
370
+ { label: 'mongo', value: choices.mongoUri },
371
+ { label: 'redis', value: choices.redisUrl },
372
+ { label: 'ai providers', value: choices.aiProviders.join(', ') || '(none)' },
373
+ { label: 'python venv', value: choices.venv ? 'yes' : 'no' },
374
+ ];
375
+ summaryTable(review);
376
+ console.log();
377
+ const proceed = await confirm('Proceed with setup?', true);
378
+ if (!proceed) {
379
+ console.log(`${DIM}Aborted.${RESET}`);
380
+ closeWizard();
381
+ return 1;
382
+ }
383
+ console.log();
384
+ }
385
+
386
+ // -- Step 11: scaffold templates ---------------------------------
387
+ console.log(` ${BOLD}Scaffolding project files...${RESET}`);
388
+ const files = listFiles(TEMPLATES);
389
+ let created = 0;
390
+ let skipped = 0;
391
+ for (const rel of files) {
392
+ // npm strips .gitignore/.dockerignore files from published tarballs, so
393
+ // the template ships them as `gitignore`/`dockerignore` and restores the
394
+ // conventional names here.
395
+ const outputRel = rel === 'gitignore' ? '.gitignore' : rel === 'dockerignore' ? '.dockerignore' : rel;
396
+ const abs = join(target, outputRel);
397
+ const preservesExistingConfig = outputRel === 'nexus.config.ts' &&
398
+ ['js', 'mjs', 'cjs'].some((ext) => existsSync(join(target, `nexus.config.${ext}`)));
399
+ if (outputRel === 'package.json' && existsSync(abs) && !force) {
400
+ mergePackageManifest(abs, join(TEMPLATES, rel));
401
+ console.log(` ${GREEN}update${RESET} ${outputRel}`);
402
+ continue;
403
+ }
404
+ if ((existsSync(abs) || preservesExistingConfig) && !force) {
405
+ console.log(` ${DIM}skip${RESET} ${outputRel} (exists)`);
406
+ skipped++;
407
+ continue;
408
+ }
409
+ mkdirSync(dirname(abs), { recursive: true });
410
+ writeFileSync(abs, readFileSync(join(TEMPLATES, rel)));
411
+ console.log(` ${GREEN}create${RESET} ${outputRel}`);
412
+ created++;
413
+ }
414
+
415
+ // -- Step 11.5: stamp the chosen project name into package.json --
416
+ // The template ships with `"name": "my-nexus-app"`. resolveProjectInfo()
417
+ // (used by the backend + admin) reads this field as the canonical project
418
+ // identity, so it must match what the user typed - otherwise the admin
419
+ // sidebar shows "My Nexus App" and the nexus_projects record is orphaned.
420
+ patchPackageName(target, choices.projectName);
421
+
422
+ // -- Step 12: wire framework dependency --------------------------
423
+ wireFrameworkDependency(target);
424
+
425
+ // -- Step 13: allocate free ports --------------------------------
426
+ await allocateProjectPorts(target);
427
+
428
+ // -- Step 14: write config (cluster + chosen Mongo/Redis/AI) -----
429
+ // Root generates a fresh pairing token; node reuses the one supplied
430
+ // (detected from a sibling root, prompted, or passed via --cluster-token)
431
+ // so the central can authenticate this node's agent.
432
+ const token = choices.clusterToken || randomBytes(16).toString('hex');
433
+ applyClusterConfig(target, choices.kind, token);
434
+ patchConfig(target, {
435
+ mongoUri: choices.mongoUri,
436
+ redisUrl: choices.redisUrl,
437
+ });
438
+
439
+ // -- Step 15: generate .env (JWT + Mongo/Redis + AI keys + payments) --
440
+ ensureJwtSecret(target);
441
+ writeEnvBlock(target, [
442
+ { key: 'MONGODB_URI', value: choices.mongoUri },
443
+ { key: 'REDIS_URL', value: choices.redisUrl },
444
+ // Python AI server binds loopback so only Node (same host) can reach it.
445
+ { key: 'AI_HOST', value: '127.0.0.1' },
446
+ ]);
447
+ writeAiKeys(target, choices.aiKeys);
448
+ ensureEnvKeys(target, PAYMENT_ENV_KEYS, 'Payment provider keys', '# Fill in the keys for payment providers you want to use.');
449
+
450
+ // -- Step 15.5: register project in nexus_projects.projects ----
451
+ // Pre-registers the project (status 'stopped') so the frontend/backend/admin
452
+ // can recognise it by name + path + settings before the first `npm run dev`.
453
+ // Non-fatal: if Mongo is down (user chose "continue anyway"), skip silently.
454
+ await registerProject(target, choices, token);
455
+
456
+ console.log(`\nScaffolded ${created} file(s) into ${target}${skipped ? ` (${skipped} skipped)` : ''}.`);
457
+
458
+ // -- Step 16: npm install ----------------------------------------
459
+ if (!skipInstall) {
460
+ if (installDependencies(target) !== 0) {
461
+ closeWizard();
462
+ return 1;
463
+ }
464
+ } else {
465
+ console.log(` ${DIM}Skipping npm install (--no-install/--skip-install).${RESET}`);
466
+ }
467
+
468
+ // -- Step 17: Python setup (pysetup) -----------------------------
469
+ if (!skipPysetup) {
470
+ const pyArgs: string[] = [];
471
+ if (choices.venv) pyArgs.push('--venv');
472
+ console.log(`\n ${BOLD}Setting up Python AI server...${RESET}`);
473
+ const pyCode = await pysetup(pyArgs);
474
+ if (pyCode !== 0) {
475
+ console.log(` ${YELLOW}Python setup skipped/failed (exit ${pyCode}). Run \`npm run pysetup\` later.${RESET}`);
476
+ }
477
+ } else {
478
+ console.log(` ${DIM}Skipping Python setup (--skip-install).${RESET}`);
479
+ }
480
+
481
+ // -- Step 18: verify ---------------------------------------------
482
+ await verifyProject(target);
483
+
484
+ // -- Step 19: next steps -----------------------------------------
485
+ printSetup(choices.kind, choices.role, token, choices.agentPort);
486
+ console.log(`\n ${BOLD}Next:${RESET} ${CYAN}cd ${relative(process.cwd(), target) || '.'}${RESET} then ${CYAN}npm run dev${RESET}\n`);
487
+
488
+ closeWizard();
489
+ return 0;
490
+ }
491
+
492
+ /** Detect a sibling root project's cluster token by scanning the parent
493
+ * directory for `nexus.config.{ts,js,mjs,cjs}` files that have
494
+ * `cluster: { enabled: true, token: '<non-empty>' }`. Returns the first
495
+ * match (excluding the target itself), or '' if none found.
496
+ * This lets `nexus init --as=node` reuse the root's pairing token
497
+ * automatically when the node is scaffolded next to the root. */
498
+ function detectSiblingRootToken(target: string): string {
499
+ const parent = dirname(target);
500
+ let entries: string[];
501
+ try {
502
+ entries = readdirSync(parent);
503
+ } catch {
504
+ return '';
505
+ }
506
+ for (const name of entries) {
507
+ const sibling = join(parent, name);
508
+ if (resolve(sibling) === resolve(target)) continue;
509
+ if (!statSync(sibling).isDirectory()) continue;
510
+ for (const ext of ['ts', 'js', 'mjs', 'cjs']) {
511
+ const cfgPath = join(sibling, `nexus.config.${ext}`);
512
+ if (!existsSync(cfgPath)) continue;
513
+ try {
514
+ const src = readFileSync(cfgPath, 'utf8');
515
+ // Match: cluster: { ... enabled: true, ... token: '<value>', ... }
516
+ if (!/cluster\s*:/.test(src)) continue;
517
+ if (!/enabled\s*:\s*true/.test(src)) continue;
518
+ const m = /\btoken\s*:\s*'([0-9a-fA-F]{16,})'/.exec(src);
519
+ if (m && m[1]) return m[1];
520
+ } catch { /* ignore unreadable */ }
521
+ }
522
+ }
523
+ return '';
524
+ }
525
+
526
+ /** Patch the `name` field of the scaffolded package.json to the chosen project
527
+ * name. The template ships with "my-nexus-app"; resolveProjectInfo() reads
528
+ * this as the canonical identity, so it must match the user's choice. */
529
+ function patchPackageName(target: string, name: string): void {
530
+ const pkgPath = join(target, 'package.json');
531
+ if (!existsSync(pkgPath)) return;
532
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name?: string };
533
+ if (pkg.name === name) return;
534
+ pkg.name = name;
535
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
536
+ console.log(` ${GREEN}update${RESET} package.json (name: ${name})`);
537
+ }
538
+
539
+ /** Patch nexus.config.ts with the chosen Mongo URI / Redis URL (idempotent, preserves formatting). */
540
+ function patchConfig(target: string, values: { mongoUri: string; redisUrl: string }): void {
541
+ const configPath = join(target, 'nexus.config.ts');
542
+ if (!existsSync(configPath)) return;
543
+ let src = readFileSync(configPath, 'utf8');
544
+ let changed = false;
545
+ const mongoRe = /(db:\s*\{\s*uri:\s*')[^']*(')/;
546
+ if (mongoRe.test(src) && !src.includes(`uri: '${values.mongoUri}'`)) {
547
+ src = src.replace(mongoRe, `$1${values.mongoUri}$2`);
548
+ changed = true;
549
+ }
550
+ const redisRe = /(redis:\s*\{\s*url:\s*')[^']*(')/;
551
+ if (redisRe.test(src) && !src.includes(`url: '${values.redisUrl}'`)) {
552
+ src = src.replace(redisRe, `$1${values.redisUrl}$2`);
553
+ changed = true;
554
+ }
555
+ if (changed) {
556
+ writeFileSync(configPath, src);
557
+ console.log(` ${GREEN}update${RESET} nexus.config.ts (mongo/redis)`);
558
+ }
559
+ }
560
+
561
+ /** Write a list of KEY=VALUE entries to .env (appending, no clobber of existing keys). */
562
+ function writeEnvBlock(target: string, entries: Array<{ key: string; value: string }>): void {
563
+ const envPath = join(target, '.env');
564
+ let content = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
565
+ let added = false;
566
+ for (const { key, value } of entries) {
567
+ if (new RegExp(`^${key}\\s*=`, 'm').test(content)) {
568
+ content = content.replace(new RegExp(`^${key}\\s*=.*$`, 'm'), `${key}=${value}`);
569
+ } else {
570
+ content = `${content.trimEnd()}\n${key}=${value}\n`;
571
+ }
572
+ added = true;
573
+ }
574
+ if (added) {
575
+ writeFileSync(envPath, content);
576
+ console.log(` ${GREEN}update${RESET} .env (MONGODB_URI/REDIS_URL/AI_HOST)`);
577
+ }
578
+ }
579
+
580
+ /** Write the full AI provider API key block (all 20 providers, with comments)
581
+ * into .env, then overwrite the ones the user actually entered with their
582
+ * values. Idempotent - if the block already exists, only fills in missing
583
+ * keys + updates user-entered values. */
584
+ function writeAiKeys(target: string, keys: Record<string, string>): void {
585
+ const envPath = join(target, '.env');
586
+ let content = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
587
+
588
+ const sectionHeader = '# -- AI provider API keys ---------------------------------------------';
589
+ const sectionIntro = '# Fill in the keys for providers you want to use, then enable them from';
590
+ const sectionIntro2 = '# the admin panel -> AI Agents -> AI Providers tab.';
591
+ const sectionIntro3 = '# Local providers (ollama, lmstudio) don\'t need keys.';
592
+ const blockExists = content.includes('NEXUS_AI_OLLAMA_API_KEY');
593
+
594
+ if (!blockExists) {
595
+ // Append the full block (header + intro + per-provider comment + KEY=).
596
+ const lines: string[] = ['', sectionHeader, sectionIntro, sectionIntro2, sectionIntro3, ''];
597
+ for (const p of AI_PROVIDERS) {
598
+ lines.push(`# ${p.envComment}`);
599
+ lines.push(`${p.envVar}=`);
600
+ }
601
+ lines.push('');
602
+ content = `${content.trimEnd()}\n${lines.join('\n')}`;
603
+ } else {
604
+ // Block exists - add any missing provider keys (idempotent for upgrades).
605
+ for (const p of AI_PROVIDERS) {
606
+ if (!new RegExp(`^${p.envVar}\\s*=`, 'm').test(content)) {
607
+ content = `${content.trimEnd()}\n# ${p.envComment}\n${p.envVar}=\n`;
608
+ }
609
+ }
610
+ }
611
+
612
+ // Overwrite the keys the user actually entered with their values.
613
+ let changed = false;
614
+ for (const [id, value] of Object.entries(keys)) {
615
+ const meta = AI_PROVIDERS.find((p) => p.id === id);
616
+ if (!meta) continue;
617
+ if (new RegExp(`^${meta.envVar}\\s*=`, 'm').test(content)) {
618
+ content = content.replace(new RegExp(`^${meta.envVar}\\s*=.*$`, 'm'), `${meta.envVar}=${value}`);
619
+ changed = true;
620
+ }
621
+ }
622
+
623
+ writeFileSync(envPath, content);
624
+ console.log(` ${GREEN}update${RESET} .env (AI provider keys - ${AI_PROVIDERS.length} placeholders${Object.keys(keys).length ? ` + ${Object.keys(keys).length} entered` : ''})`);
625
+ }
626
+
627
+ /** Pre-register the scaffolded project in the shared `nexus_projects.projects`
628
+ * collection. Writes name, path, dbName, status='stopped', and a settings
629
+ * snapshot (kind, role, mongo, redis, ai providers, payments, ports, paths)
630
+ * so frontend/backend/admin can recognise the project before first startup.
631
+ * Non-fatal: skipped (with a warning) if Mongo is unreachable - the backend
632
+ * will upsert a fresher record on its first `npm run dev`. */
633
+ async function registerProject(target: string, choices: WizardChoices, clusterToken: string): Promise<void> {
634
+ console.log(`\n ${BOLD}Registering project in nexus_projects...${RESET}`);
635
+ try {
636
+ const base = await resolveProjectInfoDb(target);
637
+ const dbName = sanitizeDbName(choices.projectName);
638
+
639
+ // Probe Mongo before connecting (user may have chosen "continue anyway").
640
+ const mongo = parseHostPort(choices.mongoUri, 27017);
641
+ const mongoOk = await tcpReachable(mongo.host, mongo.port, 1500);
642
+ if (!mongoOk) {
643
+ console.log(` ${YELLOW}[!] Mongo unreachable - skipping project registration (backend will register on first start)${RESET}`);
644
+ return;
645
+ }
646
+
647
+ connectProjectInfo(choices.mongoUri, { autoIndex: true });
648
+ const settings: Record<string, unknown> = {
649
+ env: 'development',
650
+ kind: choices.kind,
651
+ ...(choices.kind === 'node' ? { role: choices.role } : {}),
652
+ mongoUri: choices.mongoUri,
653
+ redisUrl: choices.redisUrl,
654
+ database: dbName,
655
+ ai: {
656
+ providers: choices.aiProviders,
657
+ // Keys are NOT stored here - they live only in .env (secrets sink).
658
+ keysConfigured: Object.keys(choices.aiKeys),
659
+ },
660
+ payments: { currency: 'INR', providersConfigured: [] },
661
+ cluster: { enabled: choices.kind === 'root', token: clusterToken },
662
+ paths: { uploads: 'uploads', plugins: 'plugins', certs: 'certs', logs: 'logs' },
663
+ ports: { backend: 4000, frontend: 3000, admin: 3001, ai: 8000 },
664
+ };
665
+ const record: ProjectInfo = {
666
+ ...base,
667
+ name: choices.projectName,
668
+ path: target,
669
+ dbName,
670
+ status: 'stopped',
671
+ version: '0.1.0',
672
+ startedAt: undefined,
673
+ settings,
674
+ };
675
+ await upsertProjectInfo(record);
676
+ console.log(` ${GREEN}[OK]${RESET} registered ${CYAN}${choices.projectName}${RESET} ${DIM}-> nexus_projects.projects${RESET}`);
677
+ await closeProjectInfo();
678
+ } catch (err) {
679
+ console.log(` ${YELLOW}[!] project registration skipped: ${(err as Error).message}${RESET}`);
680
+ }
681
+ }
682
+
683
+ /** Run a mini-doctor on the freshly scaffolded project. Non-fatal - just prints. */
684
+ async function verifyProject(target: string): Promise<void> {
685
+ console.log(`\n ${BOLD}Verifying...${RESET}`);
686
+ try {
687
+ // Load the just-written config via the framework loader.
688
+ const { loadConfigAuto } = await import('../../../nexus-core/src/index.js');
689
+ const cfg = await loadConfigAuto({ root: target });
690
+ const services = await scanServices(cfg);
691
+ for (const s of services) {
692
+ const icon = statusIcon(s.ok);
693
+ const color = s.ok ? CYAN : YELLOW;
694
+ console.log(` ${icon} ${color}${s.name.padEnd(14)}${RESET} ${DIM}${s.detail}${RESET}`);
695
+ }
696
+ // Assert Python AI server stays loopback (security invariant).
697
+ const aiHost = new URL(cfg.ai.serverUrl).hostname;
698
+ const loopback = aiHost === '127.0.0.1' || aiHost === 'localhost';
699
+ console.log(` ${statusIcon(loopback)} ${loopback ? CYAN : RED}ai loopback${RESET} ${DIM}${aiHost}${loopback ? '' : ' - should be 127.0.0.1'}${RESET}`);
700
+ } catch (err) {
701
+ console.log(` ${YELLOW}[!] verify skipped: ${(err as Error).message}${RESET}`);
702
+ }
703
+ console.log();
704
+ }
705
+
706
+ /** Write the `cluster` section into the scaffolded nexus.config.ts.
707
+ * The template ships with `cluster: { enabled: false, token: '' }`, so this
708
+ * always patches the existing line - setting `enabled` (true for root, false
709
+ * for node) and stamping a fresh pairing `token`. Idempotent on re-runs. */
710
+ function applyClusterConfig(target: string, kind: 'root' | 'node', token: string): void {
711
+ const configPath = join(target, 'nexus.config.ts');
712
+ if (!existsSync(configPath)) return;
713
+ let src = readFileSync(configPath, 'utf8');
714
+ const enabled = kind === 'root';
715
+ const desiredToken = token;
716
+
717
+ if (!/cluster\s*:/.test(src)) {
718
+ // No cluster block at all - insert one after the opening brace.
719
+ const clusterLine = ` cluster: { enabled: ${enabled}, token: '${desiredToken}' },`;
720
+ writeFileSync(configPath, src.replace(
721
+ /(const config: Partial<NexusConfig> = \{\r?\n)/,
722
+ `$1${clusterLine}\n`,
723
+ ));
724
+ console.log(` ${GREEN}create${RESET} nexus.config.ts (cluster: ${kind})`);
725
+ return;
726
+ }
727
+
728
+ // Patch the existing cluster line's `enabled` + `token` in place.
729
+ let changed = false;
730
+
731
+ // enabled: <bool>
732
+ const enabledRe = /(cluster\s*:\s*\{[^}]*\benabled\s*:\s*)(false|true)/;
733
+ const enabledMatch = enabledRe.exec(src);
734
+ if (enabledMatch && enabledMatch[2] !== String(enabled)) {
735
+ src = src.replace(enabledRe, `$1${enabled}`);
736
+ changed = true;
737
+ }
738
+
739
+ // token: '<value>' - only stamp a fresh token when the existing one is empty,
740
+ // so re-running `nexus init .` doesn't invalidate already-paired cluster nodes.
741
+ const tokenRe = /(cluster\s*:\s*\{[^}]*\btoken\s*:\s*')([^']*)(')/;
742
+ const tokenMatch = tokenRe.exec(src);
743
+ if (tokenMatch && tokenMatch[2] === '' && desiredToken) {
744
+ src = src.replace(tokenRe, `$1${desiredToken}$3`);
745
+ changed = true;
746
+ }
747
+
748
+ if (changed) {
749
+ writeFileSync(configPath, src);
750
+ console.log(` ${GREEN}update${RESET} nexus.config.ts (cluster: ${kind}, enabled: ${enabled})`);
751
+ } else {
752
+ console.log(` ${DIM}keep${RESET} nexus.config.ts (cluster: ${kind} already set)`);
753
+ }
754
+ }
755
+
756
+ /** Set up the bhooai-nexus workspace + file: dependency for a scaffolded project.
757
+ * The framework path is resolved relative to the Nexus CLI's own location. */
758
+ function wireFrameworkDependency(target: string): void {
759
+ // FRAMEWORK_PACKAGES is three levels up from packages/nexus-cli/src/commands/init.ts:
760
+ // packages/nexus-cli/ -> ../../../
761
+ // So the framework root (bhooai-nexus) is at:
762
+ const frameworkRoot = resolve(__dirname, '..', '..', '..', '..');
763
+ const targetRel = relative(target, frameworkRoot).replace(/\\/g, '/');
764
+
765
+ // Update the generated package.json: stamp the correct framework path.
766
+ const pkgPath = join(target, 'package.json');
767
+ if (!existsSync(pkgPath)) return;
768
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, any>;
769
+
770
+ // Workspaces: point at the framework's packages/ instead of a local copy.
771
+ pkg.workspaces = [`${targetRel}/packages/*`, 'apps/backend', 'apps/admin', 'apps/frontend'];
772
+
773
+ // Dependencies: add the bhooai-nexus file: dep for the CLI bin.
774
+ const deps = (pkg.dependencies = pkg.dependencies ?? {}) as Record<string, any>;
775
+ deps['bhooai-nexus'] = `file:${targetRel}`;
776
+
777
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
778
+ console.log(` ${GREEN}update${RESET} package.json (framework workspace -> ${targetRel})`);
779
+
780
+ // Update tailwind.config.js - point the content scanner at the framework's
781
+ // nexus-admin source so Tailwind generates utility classes used by the admin SPA.
782
+ const tailwindPath = join(target, 'apps', 'admin', 'tailwind.config.js');
783
+ if (existsSync(tailwindPath)) {
784
+ // From apps/admin/ to the framework: first go up to the project root (../..),
785
+ // then follow targetRel to the framework root.
786
+ const adminRel = `../../${targetRel}`;
787
+ const tailwindSrc = readFileSync(tailwindPath, 'utf8');
788
+ const updated = tailwindSrc.replace(
789
+ /'\.\.\/\.\.\/[^']*nexus-admin\/src\/\*\*\/\*\.\{ts,tsx\}'/,
790
+ `'${adminRel}/packages/nexus-admin/src/**/*.{ts,tsx}'`,
791
+ );
792
+ if (updated !== tailwindSrc) {
793
+ writeFileSync(tailwindPath, updated);
794
+ console.log(` ${GREEN}update${RESET} apps/admin/tailwind.config.js (content path -> framework)`);
795
+ }
796
+ }
797
+
798
+ // Update vite.config.ts - add /ai proxy entry if missing.
799
+ const vitePath = join(target, 'apps', 'admin', 'vite.config.ts');
800
+ if (existsSync(vitePath)) {
801
+ let viteSrc = readFileSync(vitePath, 'utf8');
802
+ if (!viteSrc.includes("'/ai'")) {
803
+ viteSrc = viteSrc.replace(
804
+ /'\/csrf-token': \{ target, changeOrigin: true \},/,
805
+ `'/csrf-token': { target, changeOrigin: true },\n '/ai': { target, changeOrigin: true },`,
806
+ );
807
+ writeFileSync(vitePath, viteSrc);
808
+ console.log(` ${GREEN}update${RESET} apps/admin/vite.config.ts (added /ai proxy)`);
809
+ }
810
+ }
811
+ }
812
+
813
+ /** Auto-allocate free ports for a freshly scaffolded project and patch its
814
+ * nexus.config.ts. All projects ship with the same defaults (server 4000,
815
+ * frontend 3000, admin 3001, AI 8000, cluster LB 8080, agent 7575), so a
816
+ * second project running on the same machine collides - its admin Vite fails
817
+ * to bind and the browser hits another project's admin (wrong project name). */
818
+ async function allocateProjectPorts(target: string): Promise<void> {
819
+ const configPath = join(target, 'nexus.config.ts');
820
+ if (!existsSync(configPath)) return;
821
+ let src = readFileSync(configPath, 'utf8');
822
+
823
+ const defaults: Array<{ key: string; port: number }> = [
824
+ { key: 'server', port: 4000 },
825
+ { key: 'frontend', port: 3000 },
826
+ { key: 'admin', port: 3001 },
827
+ { key: 'ai', port: 8000 },
828
+ { key: 'lb', port: 8080 },
829
+ { key: 'agent', port: 7575 },
830
+ ];
831
+
832
+ const allocated: Record<string, number> = {};
833
+ let changed = false;
834
+ for (const { key, port } of defaults) {
835
+ if (await isPortFree('127.0.0.1', port)) {
836
+ allocated[key] = port;
837
+ continue;
838
+ }
839
+ let free = port;
840
+ for (let i = 1; i <= 100; i++) {
841
+ if (await isPortFree('127.0.0.1', port + i)) { free = port + i; break; }
842
+ }
843
+ if (free !== port) {
844
+ allocated[key] = free;
845
+ changed = true;
846
+ } else {
847
+ allocated[key] = port;
848
+ }
849
+ }
850
+
851
+ if (!changed) return;
852
+
853
+ const patch = (section: string, portKey: string, value: number) => {
854
+ const re = new RegExp(`(${section}\\s*:\\s*\\{[^}]*${portKey}\\s*:\\s*)\\d+`, '');
855
+ if (re.test(src)) src = src.replace(re, `$1${value}`);
856
+ };
857
+
858
+ patch('server', 'port', allocated.server!);
859
+ patch('frontend', 'port', allocated.frontend!);
860
+ patch('admin', 'port', allocated.admin!);
861
+ const aiRe = /(ai:\s*\{\s*serverUrl:\s*'http:\/\/[^:]+:)\d+/;
862
+ if (aiRe.test(src)) src = src.replace(aiRe, `$1${allocated.ai}`);
863
+ patch('cluster', 'lbPort', allocated.lb!);
864
+ patch('cluster', 'nodeAgentPort', allocated.agent!);
865
+
866
+ writeFileSync(configPath, src);
867
+ console.log(` ${GREEN}update${RESET} nexus.config.ts (free ports: server ${allocated.server}, frontend ${allocated.frontend}, admin ${allocated.admin}, ai ${allocated.ai})`);
868
+ }
869
+
870
+ function installDependencies(target: string): number {
871
+ console.log('\n Installing project dependencies (React, Vite, admin, and framework packages)...');
872
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
873
+ const result = spawnSync(npm, ['install', '--no-audit', '--no-fund'], {
874
+ cwd: target,
875
+ stdio: 'inherit',
876
+ // Windows npm is a .cmd shim and requires shell execution.
877
+ shell: process.platform === 'win32',
878
+ });
879
+ if (result.error) {
880
+ console.error(`init: npm install failed: ${result.error.message}`);
881
+ return 1;
882
+ }
883
+ if (result.status !== 0) {
884
+ console.error(`init: npm install exited with code ${result.status ?? 'unknown'}`);
885
+ return result.status ?? 1;
886
+ }
887
+ console.log(' Dependencies installed.');
888
+ return 0;
889
+ }
890
+
891
+ function ensureJwtSecret(target: string): void {
892
+ const envPath = join(target, '.env');
893
+ const existing = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
894
+ const current = /^NEXUS_AUTH_JWT_SECRET\s*=\s*(.*)$/m.exec(existing)?.[1]?.trim();
895
+ if (current && current !== 'change-me-please' && !current.startsWith('change-me-please-')) {
896
+ // Secret already set - just ensure the placeholder sections exist.
897
+ ensureEnvKeys(target, PAYMENT_ENV_KEYS, 'Payment provider keys', '# Fill in the keys for payment providers you want to use.');
898
+ return;
899
+ }
900
+
901
+ const secret = randomBytes(32).toString('base64url');
902
+ const line = `NEXUS_AUTH_JWT_SECRET=${secret}`;
903
+ let next = /^NEXUS_AUTH_JWT_SECRET\s*=.*$/m.test(existing)
904
+ ? existing.replace(/^NEXUS_AUTH_JWT_SECRET\s*=.*$/m, line)
905
+ : `${existing.trimEnd()}${existing.trimEnd() ? '\n' : ''}${line}\n`;
906
+ writeFileSync(envPath, next);
907
+ console.log(` ${GREEN}${existing ? 'update' : 'create'}${RESET} .env (JWT secret generated)`);
908
+ ensureEnvKeys(target, PAYMENT_ENV_KEYS, 'Payment provider keys', '# Fill in the keys for payment providers you want to use.');
909
+ }
910
+
911
+ /** Ensure all env-var keys (payments) exist in .env (empty). Idempotent. */
912
+ function ensureEnvKeys(target: string, keys: string[], sectionTitle: string, sectionComment: string): void {
913
+ const envPath = join(target, '.env');
914
+ let content = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
915
+ let added = false;
916
+
917
+ if (keys.some((k) => new RegExp(`^${k}\\s*=`, 'm').test(content))) {
918
+ // Section exists - add any missing keys individually.
919
+ for (const key of keys) {
920
+ if (!new RegExp(`^${key}\\s*=`, 'm').test(content)) {
921
+ content = `${content.trimEnd()}\n${key}=\n`;
922
+ added = true;
923
+ }
924
+ }
925
+ } else {
926
+ // Add the full block.
927
+ const block = [
928
+ '',
929
+ `# \u2500\u2500 ${sectionTitle} \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`,
930
+ sectionComment,
931
+ '',
932
+ ...keys.map((k) => `${k}=`),
933
+ '',
934
+ ].join('\n');
935
+ content = `${content.trimEnd()}${block}`;
936
+ added = true;
937
+ }
938
+
939
+ if (added) {
940
+ writeFileSync(envPath, content);
941
+ console.log(` ${GREEN}update${RESET} .env (${sectionTitle} placeholders added)`);
942
+ }
943
+ }
944
+
945
+ function mergePackageManifest(targetPath: string, templatePath: string): void {
946
+ const current = JSON.parse(readFileSync(targetPath, 'utf8')) as Record<string, any>;
947
+ const template = JSON.parse(readFileSync(templatePath, 'utf8')) as Record<string, any>;
948
+ const merged = {
949
+ ...template,
950
+ ...current,
951
+ type: template.type ?? current.type,
952
+ workspaces: template.workspaces ?? current.workspaces,
953
+ scripts: { ...(current.scripts ?? {}), ...(template.scripts ?? {}) },
954
+ // Keep project-specific versions and local file: dependencies when an
955
+ // existing manifest is upgraded; the template only supplies missing keys.
956
+ dependencies: { ...(template.dependencies ?? {}), ...(current.dependencies ?? {}) },
957
+ devDependencies: { ...(template.devDependencies ?? {}), ...(current.devDependencies ?? {}) },
958
+ };
959
+ writeFileSync(targetPath, `${JSON.stringify(merged, null, 2)}\n`);
960
+ }