@bhooai/nexus-cli 0.1.4 → 2.0.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 (112) hide show
  1. package/package.json +8 -12
  2. package/src/commands/add.ts +274 -0
  3. package/src/commands/build.ts +91 -0
  4. package/src/commands/db.ts +241 -0
  5. package/src/commands/dev.ts +149 -186
  6. package/src/commands/doctor.ts +102 -184
  7. package/src/commands/init.ts +204 -958
  8. package/src/commands/maintenance-commands.ts +40 -0
  9. package/src/commands/plugin.ts +233 -0
  10. package/src/commands/queue.ts +145 -0
  11. package/src/commands/test.ts +34 -0
  12. package/src/dispatcher.ts +98 -0
  13. package/src/generators.ts +102 -0
  14. package/src/index.ts +32 -90
  15. package/src/make/generators.ts +431 -0
  16. package/src/ports.ts +85 -0
  17. package/src/prompts.ts +68 -0
  18. package/src/templating/render.ts +109 -0
  19. package/src/util.ts +74 -112
  20. package/templates/base/Dockerfile.ejs +68 -0
  21. package/templates/base/README.md.ejs +62 -0
  22. package/templates/base/apps/admin/index.html.ejs +12 -0
  23. package/templates/base/apps/admin/nginx.conf.ejs +16 -0
  24. package/templates/base/apps/admin/package.json.ejs +15 -0
  25. package/templates/base/apps/admin/src/main.tsx.ejs +7 -0
  26. package/templates/base/apps/admin/vite.config.ts.ejs +22 -0
  27. package/templates/base/apps/ai-server/main.py.ejs +30 -0
  28. package/templates/base/apps/ai-server/requirements.txt +4 -0
  29. package/templates/base/apps/backend/src/main.ts +14 -0
  30. package/templates/base/apps/backend/src/routes/index.ts.ejs +11 -0
  31. package/templates/base/apps/frontend/index.html.ejs +12 -0
  32. package/templates/base/apps/frontend/nginx.conf.ejs +33 -0
  33. package/templates/base/apps/frontend/package.json.ejs +11 -0
  34. package/templates/base/apps/frontend/src/main.tsx.ejs +16 -0
  35. package/templates/base/apps/frontend/vite.config.ts.ejs +22 -0
  36. package/templates/base/docker-compose.yml.ejs +93 -0
  37. package/templates/base/dockerignore +21 -0
  38. package/templates/base/gitignore +43 -0
  39. package/templates/base/nexus.config.ts.ejs +97 -0
  40. package/templates/base/package.json.ejs +40 -0
  41. package/templates/{tsconfig.json → base/tsconfig.json} +2 -3
  42. package/tsconfig.json +5 -4
  43. package/PLAN.md +0 -141
  44. package/README.md +0 -34
  45. package/src/commands/cluster.ts +0 -133
  46. package/src/commands/node.ts +0 -106
  47. package/src/commands/pysetup.ts +0 -142
  48. package/src/commands/sync.ts +0 -116
  49. package/src/commands/uninstall.ts +0 -287
  50. package/src/config-sync.ts +0 -384
  51. package/src/dotenv.ts +0 -39
  52. package/src/supervisor.ts +0 -394
  53. package/src/wizard.ts +0 -149
  54. package/templates/Dockerfile +0 -60
  55. package/templates/README.md +0 -69
  56. package/templates/apps/admin/index.html +0 -12
  57. package/templates/apps/admin/package.json +0 -22
  58. package/templates/apps/admin/postcss.config.js +0 -3
  59. package/templates/apps/admin/src/main.tsx +0 -10
  60. package/templates/apps/admin/src/vite-env.d.ts +0 -18
  61. package/templates/apps/admin/tailwind.config.js +0 -9
  62. package/templates/apps/admin/tsconfig.json +0 -17
  63. package/templates/apps/admin/vite.config.ts +0 -64
  64. package/templates/apps/ai-server/main.py +0 -43
  65. package/templates/apps/ai-server/providers/__init__.py +0 -3
  66. package/templates/apps/ai-server/providers/base.py +0 -111
  67. package/templates/apps/ai-server/requirements.txt +0 -3
  68. package/templates/apps/ai-server/routers/__init__.py +0 -3
  69. package/templates/apps/ai-server/routers/chat.py +0 -47
  70. package/templates/apps/ai-server/routers/embeddings.py +0 -30
  71. package/templates/apps/ai-server/routers/lint.py +0 -167
  72. package/templates/apps/ai-server/routers/models.py +0 -23
  73. package/templates/apps/ai-server/routers/preflight.py +0 -169
  74. package/templates/apps/ai-server/settings.py +0 -48
  75. package/templates/apps/backend/package.json +0 -34
  76. package/templates/apps/backend/src/main.ts +0 -375
  77. package/templates/apps/backend/src/modules/admin/adminRoutes.ts +0 -732
  78. package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +0 -391
  79. package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +0 -161
  80. package/templates/apps/backend/src/modules/admin/lintProxy.ts +0 -89
  81. package/templates/apps/backend/src/modules/admin/preflightProxy.ts +0 -242
  82. package/templates/apps/backend/src/modules/admin/roleCatalog.ts +0 -78
  83. package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +0 -449
  84. package/templates/apps/backend/src/modules/ai/aiProxy.ts +0 -265
  85. package/templates/apps/backend/src/modules/auth/authRoutes.ts +0 -220
  86. package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +0 -100
  87. package/templates/apps/backend/src/modules/payments/paymentStore.ts +0 -172
  88. package/templates/apps/backend/src/modules/requests/requestLog.ts +0 -175
  89. package/templates/apps/backend/src/modules/users/userGraph.ts +0 -83
  90. package/templates/apps/backend/src/modules/users/userModel.ts +0 -88
  91. package/templates/apps/backend/src/plugins/CronScheduler.ts +0 -69
  92. package/templates/apps/backend/src/plugins/loadPlugins.ts +0 -107
  93. package/templates/apps/backend/tsconfig.json +0 -14
  94. package/templates/apps/frontend/index.html +0 -12
  95. package/templates/apps/frontend/package.json +0 -20
  96. package/templates/apps/frontend/postcss.config.js +0 -3
  97. package/templates/apps/frontend/src/index.css +0 -44
  98. package/templates/apps/frontend/src/main.tsx +0 -65
  99. package/templates/apps/frontend/tailwind.config.js +0 -9
  100. package/templates/apps/frontend/vite.config.ts +0 -72
  101. package/templates/bin/nexus.js +0 -35
  102. package/templates/bin/serve-all.mjs +0 -45
  103. package/templates/dockerignore +0 -15
  104. package/templates/gitignore +0 -12
  105. package/templates/nexus.config.ts +0 -69
  106. package/templates/package.json +0 -47
  107. package/tests/cli.test.ts +0 -52
  108. package/tests/config-sync.test.ts +0 -201
  109. package/tests/dotenv.test.ts +0 -51
  110. package/vitest.config.ts +0 -9
  111. package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +0 -13
  112. /package/templates/{uploads → base/apps/backend/storage}/.gitkeep +0 -0
@@ -1,1009 +1,255 @@
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';
1
+ /**
2
+ * nexus init scaffold a new Nexus project.
3
+ *
4
+ * Modes:
5
+ * nexus init my-app — quick, no prompts, sensible defaults
6
+ * nexus init --interactive my-app — full wizard with example picker
7
+ * nexus init my-app --example chat — quick + named example
8
+ */
9
+ import { mkdir, writeFile, cp, readFile, readdir } from 'node:fs/promises';
10
+ import { existsSync } from 'node:fs';
11
+ import { join, resolve, dirname, basename } from 'node:path';
5
12
  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;
13
+ import type { CommandContext } from '../dispatcher.js';
14
+ import { prompt, confirm, select, isInteractive } from '../prompts.js';
15
+ import { randomSecret, slugify } from '../util.js';
16
+ import { ensurePort } from '../ports.js';
17
+ import { renderString, isTemplateName, stripTemplateSuffix } from '../templating/render.js';
18
+
19
+ const HERE = dirname(fileURLToPath(import.meta.url));
20
+ const TEMPLATES_DIR = resolve(HERE, '..', '..', 'templates');
21
+ // Examples ship as @bhooai/nexus-examples workspace package.
22
+ const EXAMPLES_DIR = resolve(HERE, '..', '..', '..', 'nexus-examples', 'examples');
23
+
24
+ interface InitVars {
25
+ name: string;
26
+ nameSlug: string;
27
+ jwtSecret: string;
143
28
  mongoUri: string;
144
29
  redisUrl: string;
30
+ example: string;
31
+ backendPort: number;
32
+ frontendPort: number;
33
+ adminPort: number;
34
+ aiPort: number;
145
35
  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
36
  }
153
37
 
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
- }
38
+ export async function run(ctx: CommandContext): Promise<void> {
39
+ const args = ctx.args;
40
+ const targetDirArg = args._[0] ?? '.';
41
+ const interactive = !!args.flags.interactive;
42
+ let example = (args.flags.example as string) ?? 'empty';
43
+ let name = (args.flags.name as string) ?? '';
182
44
 
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;
45
+ // Resolve target directory
46
+ const targetDir = resolve(process.cwd(), targetDirArg);
47
+ if (!name) {
48
+ name = targetDirArg === '.' ? basename(process.cwd()) : basename(targetDir);
201
49
  }
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}`);
50
+ name = slugify(name);
51
+ if (!name) {
52
+ console.error('Project name cannot be empty after slugification.');
53
+ process.exitCode = 1;
54
+ return;
217
55
  }
218
- console.log();
219
56
 
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
- }
57
+ // Interactive mode: ask for everything
58
+ let mongoUri = (args.flags['mongo-uri'] as string) ?? `mongodb://localhost:27017/${name}`;
59
+ let redisUrl = (args.flags['redis-url'] as string) ?? 'redis://localhost:6379';
60
+ let aiProviders: string[] = [];
61
+ let useVenv = true;
226
62
 
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: 3300 },
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: 3300 },
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
- }
63
+ if (interactive && isInteractive()) {
64
+ console.log(`\nCreating Nexus project: ${name}\n`);
288
65
 
289
- // -- Steps 2-9: gather choices -----------------------------------
290
- const choices = parseWizardArgs(args, target);
66
+ // Example picker
67
+ const examples = await listExamples();
68
+ example = await select('Start with:', ['empty', ...examples] as const) as string;
291
69
 
292
- if (!noInteractive) {
293
- choices.projectName = await prompt('Project name?', choices.projectName);
70
+ mongoUri = await prompt('MongoDB URI', mongoUri);
71
+ redisUrl = await prompt('Redis URL', redisUrl);
294
72
 
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,
73
+ const providerNames = await prompt(
74
+ 'AI providers (comma-separated, empty for none) — e.g. ollama,openai',
75
+ '',
303
76
  );
77
+ aiProviders = providerNames
78
+ .split(',')
79
+ .map((s) => s.trim())
80
+ .filter(Boolean);
81
+
82
+ useVenv = await confirm('Create Python virtualenv for AI server?', true);
83
+ }
84
+
85
+ // Allocate ports via registry (writes .nexus-ports.json)
86
+ // We allocate AFTER targetDir is created (see below), so use a temp registry for now.
87
+ // Simpler: pre-pick ports via nextFreePort, then commit them once the dir exists.
88
+ const backendPort = 4000;
89
+ const frontendPort = 3000;
90
+ const adminPort = 3300;
91
+ const aiPort = 8000;
92
+
93
+ const vars: InitVars = {
94
+ name,
95
+ nameSlug: name,
96
+ jwtSecret: randomSecret(32),
97
+ mongoUri,
98
+ redisUrl,
99
+ example,
100
+ backendPort,
101
+ frontendPort,
102
+ adminPort,
103
+ aiPort,
104
+ aiProviders,
105
+ };
304
106
 
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, choices.role, token, choices.agentPort);
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}`);
107
+ if (existsSync(targetDir) && targetDirArg !== '.' && !args.flags.force) {
108
+ const ok = isInteractive() ? await confirm(`Directory ${targetDir} exists — proceed?`, false) : false;
109
+ if (!ok) {
110
+ console.error('Aborted.');
111
+ process.exitCode = 1;
112
+ return;
476
113
  }
477
- } else {
478
- console.log(` ${DIM}Skipping Python setup (--skip-install).${RESET}`);
479
114
  }
480
115
 
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`);
116
+ // 1. Create directory
117
+ await mkdir(targetDir, { recursive: true });
487
118
 
488
- closeWizard();
489
- return 0;
490
- }
119
+ // Commit allocated ports into the project's registry so `add` / `dev` see them.
120
+ await ensurePort(targetDir, 'backend');
121
+ await ensurePort(targetDir, 'frontend');
122
+ await ensurePort(targetDir, 'admin');
123
+ await ensurePort(targetDir, 'ai-server');
491
124
 
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
- }
125
+ // 2. Render base templates (recursive)
126
+ await renderTemplateTree(join(TEMPLATES_DIR, 'base'), targetDir, vars);
525
127
 
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
- }
128
+ // 2b. Create the full Laravel-style folder tree for the default backend.
129
+ // Mirrors what `add backend` does so subsequent `make:*` calls find their homes.
130
+ await ensureBackendFolderTree(join(targetDir, 'apps', 'backend'));
538
131
 
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}`);
132
+ // 3. Apply example overlay (if not 'empty')
133
+ if (example && example !== 'empty') {
134
+ const exampleDir = join(EXAMPLES_DIR, example);
135
+ if (existsSync(exampleDir)) {
136
+ await renderTemplateTree(exampleDir, targetDir, vars);
137
+ console.log(`✓ Applied example: ${example}`);
569
138
  } 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
- }
139
+ console.warn(`! Example "${example}" not found — continuing with empty scaffold.`);
609
140
  }
610
141
  }
611
142
 
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
- }
143
+ // 4. .env
144
+ const envPath = join(targetDir, '.env');
145
+ if (!existsSync(envPath)) {
146
+ const envContent = buildEnvFile(vars);
147
+ await writeFile(envPath, envContent, 'utf-8');
621
148
  }
622
149
 
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
- }
150
+ // 5. Summary
151
+ console.log(`
152
+ ✓ Project scaffolded at ${targetDir}
626
153
 
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
- }
154
+ Next steps:
155
+ cd ${targetDirArg === '.' ? '.' : targetDirArg}
156
+ npm install
157
+ npx nexus dev
646
158
 
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: 3300, 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
- }
159
+ Ports allocated:
160
+ Backend : ${backendPort}
161
+ Frontend : ${frontendPort}
162
+ Admin : ${adminPort}
163
+ AI server : ${aiPort}
164
+ `);
681
165
  }
682
166
 
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();
167
+ async function listExamples(): Promise<string[]> {
168
+ const dir = EXAMPLES_DIR;
169
+ if (!existsSync(dir)) return [];
170
+ const entries = await readdir(dir, { withFileTypes: true });
171
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
704
172
  }
705
173
 
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), stamping a fresh pairing `token`, and (for nodes) the `role` so
710
- * `nexus dev` knows to auto-start a node agent. Idempotent on re-runs. */
711
- function applyClusterConfig(target: string, kind: 'root' | 'node', role: string, token: string, agentPort: number): void {
712
- const configPath = join(target, 'nexus.config.ts');
713
- if (!existsSync(configPath)) return;
714
- let src = readFileSync(configPath, 'utf8');
715
- const enabled = kind === 'root';
716
- const desiredToken = token;
717
-
718
- if (!/cluster\s*:/.test(src)) {
719
- // No cluster block at all - insert one after the opening brace.
720
- const roleLine = kind === 'node' ? `, role: '${role}'` : '';
721
- const clusterLine = ` cluster: { enabled: ${enabled}, token: '${desiredToken}'${roleLine}, nodeAgentPort: ${agentPort} },`;
722
- writeFileSync(configPath, src.replace(
723
- /(const config: Partial<NexusConfig> = \{\r?\n)/,
724
- `$1${clusterLine}\n`,
725
- ));
726
- console.log(` ${GREEN}create${RESET} nexus.config.ts (cluster: ${kind})`);
727
- return;
728
- }
729
-
730
- // Patch the existing cluster line's `enabled` + `token` (+ `role`/`nodeAgentPort`) in place.
731
- let changed = false;
732
-
733
- // enabled: <bool>
734
- const enabledRe = /(cluster\s*:\s*\{[^}]*\benabled\s*:\s*)(false|true)/;
735
- const enabledMatch = enabledRe.exec(src);
736
- if (enabledMatch && enabledMatch[2] !== String(enabled)) {
737
- src = src.replace(enabledRe, `$1${enabled}`);
738
- changed = true;
739
- }
740
-
741
- // token: '<value>' - only stamp a fresh token when the existing one is empty,
742
- // so re-running `nexus init .` doesn't invalidate already-paired cluster nodes.
743
- const tokenRe = /(cluster\s*:\s*\{[^}]*\btoken\s*:\s*')([^']*)(')/;
744
- const tokenMatch = tokenRe.exec(src);
745
- if (tokenMatch && tokenMatch[2] === '' && desiredToken) {
746
- src = src.replace(tokenRe, `$1${desiredToken}$3`);
747
- changed = true;
748
- }
749
-
750
- // role: '<role>' — set for nodes, remove for root. Only patch if present and different.
751
- if (kind === 'node') {
752
- const roleRe = /(cluster\s*:\s*\{[^}]*\brole\s*:\s*')([^']*)(')/;
753
- const roleMatch = roleRe.exec(src);
754
- if (roleMatch) {
755
- if (roleMatch[2] !== role) {
756
- src = src.replace(roleRe, `$1${role}$3`);
757
- changed = true;
758
- }
759
- } else {
760
- // No role key yet — insert it right after `enabled: <bool>,`.
761
- src = src.replace(
762
- /(cluster\s*:\s*\{[^}]*\benabled\s*:\s*(?:false|true)\s*,)/,
763
- `$1 role: '${role}',`,
764
- );
765
- changed = true;
766
- }
767
- }
768
-
769
- // nodeAgentPort: <number> — persist the port chosen at init so the admin
770
- // node-agent panel + `nexus dev` reflect it instead of the 7575 default.
771
- const portRe = /(cluster\s*:\s*\{[^}]*\bnodeAgentPort\s*:\s*)(\d+)/;
772
- const portMatch = portRe.exec(src);
773
- if (portMatch && portMatch[2] !== String(agentPort)) {
774
- src = src.replace(portRe, `$1${agentPort}`);
775
- changed = true;
776
- } else if (!portMatch) {
777
- // No nodeAgentPort key yet — insert it after `token: '...',`.
778
- src = src.replace(
779
- /(cluster\s*:\s*\{[^}]*\btoken\s*:\s*'[^']*'\s*,)/,
780
- `$1 nodeAgentPort: ${agentPort},`,
781
- );
782
- changed = true;
783
- }
174
+ /** The canonical 26-folder Laravel-style tree under a backend's src/. Exported for `add.ts`. */
175
+ export const BACKEND_FOLDERS = [
176
+ 'src/routes', 'src/graphql', 'src/ws',
177
+ 'src/mail/templates', 'src/mail/mailables',
178
+ 'src/errors/pages',
179
+ 'src/models', 'src/services', 'src/repositories', 'src/controllers',
180
+ 'src/middleware', 'src/validators', 'src/jobs', 'src/listeners',
181
+ 'src/events', 'src/policies', 'src/resources', 'src/requests',
182
+ 'src/helpers', 'src/config',
183
+ 'src/database/seeds', 'src/database/migrations',
184
+ 'src/providers', 'src/plugins', 'src/types', 'src/constants',
185
+ 'storage', 'storage/uploads', 'storage/private', 'storage/temp', 'storage/cache', 'storage/logs',
186
+ ];
784
187
 
785
- if (changed) {
786
- writeFileSync(configPath, src);
787
- console.log(` ${GREEN}update${RESET} nexus.config.ts (cluster: ${kind}, enabled: ${enabled}${kind === 'node' ? `, role: ${role}` : ''}, nodeAgentPort: ${agentPort})`);
788
- } else {
789
- console.log(` ${DIM}keep${RESET} nexus.config.ts (cluster: ${kind} already set)`);
188
+ async function ensureBackendFolderTree(backendDir: string): Promise<void> {
189
+ for (const f of BACKEND_FOLDERS) {
190
+ await mkdir(join(backendDir, f), { recursive: true });
790
191
  }
791
192
  }
792
193
 
793
- /** Set up the bhooai-nexus workspace + file: dependency for a scaffolded project.
794
- * The framework path is resolved relative to the Nexus CLI's own location. */
795
- function wireFrameworkDependency(target: string): void {
796
- // FRAMEWORK_PACKAGES is three levels up from packages/nexus-cli/src/commands/init.ts:
797
- // packages/nexus-cli/ -> ../../../
798
- // So the framework root (bhooai-nexus) is at:
799
- const frameworkRoot = resolve(__dirname, '..', '..', '..', '..');
800
- const targetRel = relative(target, frameworkRoot).replace(/\\/g, '/');
801
-
802
- // Update the generated package.json: stamp the correct framework path.
803
- const pkgPath = join(target, 'package.json');
804
- if (!existsSync(pkgPath)) return;
805
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, any>;
806
-
807
- // Workspaces: point at the framework's packages/ instead of a local copy.
808
- pkg.workspaces = [`${targetRel}/packages/*`, 'apps/backend', 'apps/admin', 'apps/frontend'];
809
-
810
- // Dependencies: add the bhooai-nexus file: dep for the CLI bin.
811
- const deps = (pkg.dependencies = pkg.dependencies ?? {}) as Record<string, any>;
812
- deps['bhooai-nexus'] = `file:${targetRel}`;
813
-
814
- writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
815
- console.log(` ${GREEN}update${RESET} package.json (framework workspace -> ${targetRel})`);
816
-
817
- // Update tailwind.config.js - point the content scanner at the framework's
818
- // nexus-admin source so Tailwind generates utility classes used by the admin SPA.
819
- const tailwindPath = join(target, 'apps', 'admin', 'tailwind.config.js');
820
- if (existsSync(tailwindPath)) {
821
- // From apps/admin/ to the framework: first go up to the project root (../..),
822
- // then follow targetRel to the framework root.
823
- const adminRel = `../../${targetRel}`;
824
- const tailwindSrc = readFileSync(tailwindPath, 'utf8');
825
- const updated = tailwindSrc.replace(
826
- /'\.\.\/\.\.\/[^']*nexus-admin\/src\/\*\*\/\*\.\{ts,tsx\}'/,
827
- `'${adminRel}/packages/nexus-admin/src/**/*.{ts,tsx}'`,
828
- );
829
- if (updated !== tailwindSrc) {
830
- writeFileSync(tailwindPath, updated);
831
- console.log(` ${GREEN}update${RESET} apps/admin/tailwind.config.js (content path -> framework)`);
832
- }
194
+ async function renderTemplateTree(srcDir: string, targetDir: string, vars: InitVars): Promise<void> {
195
+ if (!existsSync(srcDir)) {
196
+ console.warn(`[init] missing template dir: ${srcDir}`);
197
+ return;
833
198
  }
834
-
835
- // Update vite.config.ts - add /ai proxy entry if missing.
836
- const vitePath = join(target, 'apps', 'admin', 'vite.config.ts');
837
- if (existsSync(vitePath)) {
838
- let viteSrc = readFileSync(vitePath, 'utf8');
839
- if (!viteSrc.includes("'/ai'")) {
840
- viteSrc = viteSrc.replace(
841
- /'\/csrf-token': \{ target, changeOrigin: true \},/,
842
- `'/csrf-token': { target, changeOrigin: true },\n '/ai': { target, changeOrigin: true },`,
843
- );
844
- writeFileSync(vitePath, viteSrc);
845
- console.log(` ${GREEN}update${RESET} apps/admin/vite.config.ts (added /ai proxy)`);
199
+ const entries = await readdir(srcDir, { withFileTypes: true });
200
+ for (const e of entries) {
201
+ const src = join(srcDir, e.name);
202
+ const renderedName = isTemplateName(e.name) ? stripTemplateSuffix(e.name) : e.name;
203
+ // Handle special filenames
204
+ const fileName = renderedName === 'gitignore' ? '.gitignore'
205
+ : renderedName === 'dockerignore' ? '.dockerignore'
206
+ : renderedName;
207
+ const dest = join(targetDir, fileName);
208
+
209
+ if (e.isDirectory()) {
210
+ await mkdir(dest, { recursive: true });
211
+ await renderTemplateTree(src, dest, vars);
212
+ } else if (e.isFile()) {
213
+ try {
214
+ const raw = await readFile(src, 'utf-8');
215
+ const isTemplate = isTemplateName(e.name) || raw.includes('<%');
216
+ const content = isTemplate ? renderString(raw, vars as unknown as Record<string, unknown>) : raw;
217
+ await mkdir(dirname(dest), { recursive: true });
218
+ await writeFile(dest, content, 'utf-8');
219
+ } catch (err) {
220
+ console.warn(`[init] failed to render ${src}:`, err);
221
+ }
846
222
  }
847
223
  }
848
224
  }
849
225
 
850
- /** Auto-allocate free ports for a freshly scaffolded project and patch its
851
- * nexus.config.ts. All projects ship with the same defaults (server 4000,
852
- * frontend 3000, admin 3300, AI 8000, cluster LB 8080, agent 7575), so a
853
- * second project running on the same machine collides - its admin Vite fails
854
- * to bind and the browser hits another project's admin (wrong project name). */
855
- async function allocateProjectPorts(target: string): Promise<void> {
856
- const configPath = join(target, 'nexus.config.ts');
857
- if (!existsSync(configPath)) return;
858
- let src = readFileSync(configPath, 'utf8');
859
-
860
- const defaults: Array<{ key: string; port: number }> = [
861
- { key: 'server', port: 4000 },
862
- { key: 'frontend', port: 3000 },
863
- { key: 'admin', port: 3300 },
864
- { key: 'ai', port: 8000 },
865
- { key: 'lb', port: 8080 },
866
- { key: 'agent', port: 7575 },
867
- ];
868
-
869
- const allocated: Record<string, number> = {};
870
- let changed = false;
871
- for (const { key, port } of defaults) {
872
- if (await isPortFree('127.0.0.1', port)) {
873
- allocated[key] = port;
874
- continue;
875
- }
876
- let free = port;
877
- for (let i = 1; i <= 100; i++) {
878
- if (await isPortFree('127.0.0.1', port + i)) { free = port + i; break; }
879
- }
880
- if (free !== port) {
881
- allocated[key] = free;
882
- changed = true;
883
- } else {
884
- allocated[key] = port;
885
- }
886
- }
887
-
888
- if (!changed) return;
889
-
890
- const patch = (section: string, portKey: string, value: number) => {
891
- const re = new RegExp(`(${section}\\s*:\\s*\\{[^}]*${portKey}\\s*:\\s*)\\d+`, '');
892
- if (re.test(src)) src = src.replace(re, `$1${value}`);
893
- };
894
-
895
- patch('server', 'port', allocated.server!);
896
- patch('frontend', 'port', allocated.frontend!);
897
- patch('admin', 'port', allocated.admin!);
898
- const aiRe = /(ai:\s*\{\s*serverUrl:\s*'http:\/\/[^:]+:)\d+/;
899
- if (aiRe.test(src)) src = src.replace(aiRe, `$1${allocated.ai}`);
900
- patch('cluster', 'lbPort', allocated.lb!);
901
- patch('cluster', 'nodeAgentPort', allocated.agent!);
902
-
903
- writeFileSync(configPath, src);
904
- console.log(` ${GREEN}update${RESET} nexus.config.ts (free ports: server ${allocated.server}, frontend ${allocated.frontend}, admin ${allocated.admin}, ai ${allocated.ai})`);
905
- }
226
+ function buildEnvFile(vars: InitVars): string {
227
+ return `# BhooAI Nexus generated .env
228
+ # Secrets live here. Do not commit.
906
229
 
907
- function installDependencies(target: string): number {
908
- console.log('\n Installing project dependencies (React, Vite, admin, and framework packages)...');
909
- // Windows npm is a .cmd shim and requires shell execution. Passing the full
910
- // command line (no separate args array) avoids Node's DEP0190 warning, which
911
- // fires when args are combined with shell: true.
912
- const win = process.platform === 'win32';
913
- const npmArgs = ['install', '--no-audit', '--no-fund'];
914
- const result = win
915
- ? spawnSync(`npm.cmd ${npmArgs.join(' ')}`, { cwd: target, stdio: 'inherit', shell: true })
916
- : spawnSync('npm', npmArgs, { cwd: target, stdio: 'inherit' });
917
- if (result.error) {
918
- console.error(`init: npm install failed: ${result.error.message}`);
919
- return 1;
920
- }
921
- if (result.status !== 0) {
922
- console.error(`init: npm install exited with code ${result.status ?? 'unknown'}`);
923
- return result.status ?? 1;
924
- }
925
- // npm 11.7+ gates dependency install scripts behind an `allowScripts` policy
926
- // and prints an advisory warning for unreviewed scripts (e.g. esbuild's
927
- // postinstall). Record approvals in the scaffolded project so its own future
928
- // installs stay quiet. Older npm has no such command - the failure is
929
- // harmless, and `--no-allow-scripts-pin` keeps entries version-agnostic.
930
- const approve = win
931
- ? spawnSync('npm.cmd approve-scripts --all --no-allow-scripts-pin', { cwd: target, stdio: 'ignore', shell: true })
932
- : spawnSync('npm', ['approve-scripts', '--all', '--no-allow-scripts-pin'], { cwd: target, stdio: 'ignore' });
933
- if (approve.error || (approve.status !== null && approve.status !== 0)) {
934
- // Old npm - the advisory warning is cosmetic.
935
- }
936
- console.log(' Dependencies installed.');
937
- return 0;
938
- }
230
+ # Server
231
+ NEXUS_SERVER_PORT=${vars.backendPort}
232
+ NEXUS_FRONTEND_PORT=${vars.frontendPort}
233
+ NEXUS_ADMIN_PORT=${vars.adminPort}
234
+ AI_PORT=${vars.aiPort}
939
235
 
940
- function ensureJwtSecret(target: string): void {
941
- const envPath = join(target, '.env');
942
- const existing = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
943
- const current = /^NEXUS_AUTH_JWT_SECRET\s*=\s*(.*)$/m.exec(existing)?.[1]?.trim();
944
- if (current && current !== 'change-me-please' && !current.startsWith('change-me-please-')) {
945
- // Secret already set - just ensure the placeholder sections exist.
946
- ensureEnvKeys(target, PAYMENT_ENV_KEYS, 'Payment provider keys', '# Fill in the keys for payment providers you want to use.');
947
- return;
948
- }
236
+ # Database
237
+ NEXUS_DB_URI=${vars.mongoUri}
238
+ NEXUS_REDIS_URL=${vars.redisUrl}
949
239
 
950
- const secret = randomBytes(32).toString('base64url');
951
- const line = `NEXUS_AUTH_JWT_SECRET=${secret}`;
952
- let next = /^NEXUS_AUTH_JWT_SECRET\s*=.*$/m.test(existing)
953
- ? existing.replace(/^NEXUS_AUTH_JWT_SECRET\s*=.*$/m, line)
954
- : `${existing.trimEnd()}${existing.trimEnd() ? '\n' : ''}${line}\n`;
955
- writeFileSync(envPath, next);
956
- console.log(` ${GREEN}${existing ? 'update' : 'create'}${RESET} .env (JWT secret generated)`);
957
- ensureEnvKeys(target, PAYMENT_ENV_KEYS, 'Payment provider keys', '# Fill in the keys for payment providers you want to use.');
958
- }
240
+ # Auth
241
+ NEXUS_AUTH_JWT_SECRET=${vars.jwtSecret}
959
242
 
960
- /** Ensure all env-var keys (payments) exist in .env (empty). Idempotent. */
961
- function ensureEnvKeys(target: string, keys: string[], sectionTitle: string, sectionComment: string): void {
962
- const envPath = join(target, '.env');
963
- let content = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
964
- let added = false;
965
-
966
- if (keys.some((k) => new RegExp(`^${k}\\s*=`, 'm').test(content))) {
967
- // Section exists - add any missing keys individually.
968
- for (const key of keys) {
969
- if (!new RegExp(`^${key}\\s*=`, 'm').test(content)) {
970
- content = `${content.trimEnd()}\n${key}=\n`;
971
- added = true;
972
- }
973
- }
974
- } else {
975
- // Add the full block.
976
- const block = [
977
- '',
978
- `# \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`,
979
- sectionComment,
980
- '',
981
- ...keys.map((k) => `${k}=`),
982
- '',
983
- ].join('\n');
984
- content = `${content.trimEnd()}${block}`;
985
- added = true;
986
- }
243
+ # AI providers (uncomment as needed)
244
+ ${vars.aiProviders.map((p) => `NEXUS_AI_${p.toUpperCase()}_API_KEY=`).join('\n') || '# NEXUS_AI_<PROVIDER>_API_KEY='}
987
245
 
988
- if (added) {
989
- writeFileSync(envPath, content);
990
- console.log(` ${GREEN}update${RESET} .env (${sectionTitle} placeholders added)`);
991
- }
246
+ # Storage (uncomment for S3)
247
+ # NEXUS_STORAGE_S3_BUCKET=
248
+ # NEXUS_STORAGE_S3_REGION=
249
+ # NEXUS_STORAGE_S3_ENDPOINT=
250
+ # NEXUS_STORAGE_S3_CDN=
251
+ `;
992
252
  }
993
253
 
994
- function mergePackageManifest(targetPath: string, templatePath: string): void {
995
- const current = JSON.parse(readFileSync(targetPath, 'utf8')) as Record<string, any>;
996
- const template = JSON.parse(readFileSync(templatePath, 'utf8')) as Record<string, any>;
997
- const merged = {
998
- ...template,
999
- ...current,
1000
- type: template.type ?? current.type,
1001
- workspaces: template.workspaces ?? current.workspaces,
1002
- scripts: { ...(current.scripts ?? {}), ...(template.scripts ?? {}) },
1003
- // Keep project-specific versions and local file: dependencies when an
1004
- // existing manifest is upgraded; the template only supplies missing keys.
1005
- dependencies: { ...(template.dependencies ?? {}), ...(current.dependencies ?? {}) },
1006
- devDependencies: { ...(template.devDependencies ?? {}), ...(current.devDependencies ?? {}) },
1007
- };
1008
- writeFileSync(targetPath, `${JSON.stringify(merged, null, 2)}\n`);
1009
- }
254
+ export const description = 'Scaffold a new Nexus project (quick or interactive)';
255
+ export const usage = 'nexus init [dir] [--example <name>] [--interactive] [--name <name>]';