@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.
- package/package.json +8 -12
- package/src/commands/add.ts +274 -0
- package/src/commands/build.ts +91 -0
- package/src/commands/db.ts +241 -0
- package/src/commands/dev.ts +149 -186
- package/src/commands/doctor.ts +102 -184
- package/src/commands/init.ts +204 -958
- package/src/commands/maintenance-commands.ts +40 -0
- package/src/commands/plugin.ts +233 -0
- package/src/commands/queue.ts +145 -0
- package/src/commands/test.ts +34 -0
- package/src/dispatcher.ts +98 -0
- package/src/generators.ts +102 -0
- package/src/index.ts +32 -90
- package/src/make/generators.ts +431 -0
- package/src/ports.ts +85 -0
- package/src/prompts.ts +68 -0
- package/src/templating/render.ts +109 -0
- package/src/util.ts +74 -112
- package/templates/base/Dockerfile.ejs +68 -0
- package/templates/base/README.md.ejs +62 -0
- package/templates/base/apps/admin/index.html.ejs +12 -0
- package/templates/base/apps/admin/nginx.conf.ejs +16 -0
- package/templates/base/apps/admin/package.json.ejs +15 -0
- package/templates/base/apps/admin/src/main.tsx.ejs +7 -0
- package/templates/base/apps/admin/vite.config.ts.ejs +22 -0
- package/templates/base/apps/ai-server/main.py.ejs +30 -0
- package/templates/base/apps/ai-server/requirements.txt +4 -0
- package/templates/base/apps/backend/src/main.ts +14 -0
- package/templates/base/apps/backend/src/routes/index.ts.ejs +11 -0
- package/templates/base/apps/frontend/index.html.ejs +12 -0
- package/templates/base/apps/frontend/nginx.conf.ejs +33 -0
- package/templates/base/apps/frontend/package.json.ejs +11 -0
- package/templates/base/apps/frontend/src/main.tsx.ejs +16 -0
- package/templates/base/apps/frontend/vite.config.ts.ejs +22 -0
- package/templates/base/docker-compose.yml.ejs +93 -0
- package/templates/base/dockerignore +21 -0
- package/templates/base/gitignore +43 -0
- package/templates/base/nexus.config.ts.ejs +97 -0
- package/templates/base/package.json.ejs +40 -0
- package/templates/{tsconfig.json → base/tsconfig.json} +2 -3
- package/tsconfig.json +5 -4
- package/PLAN.md +0 -141
- package/README.md +0 -34
- package/src/commands/cluster.ts +0 -133
- package/src/commands/node.ts +0 -106
- package/src/commands/pysetup.ts +0 -142
- package/src/commands/sync.ts +0 -116
- package/src/commands/uninstall.ts +0 -287
- package/src/config-sync.ts +0 -384
- package/src/dotenv.ts +0 -39
- package/src/supervisor.ts +0 -394
- package/src/wizard.ts +0 -149
- package/templates/Dockerfile +0 -60
- package/templates/README.md +0 -69
- package/templates/apps/admin/index.html +0 -12
- package/templates/apps/admin/package.json +0 -22
- package/templates/apps/admin/postcss.config.js +0 -3
- package/templates/apps/admin/src/main.tsx +0 -10
- package/templates/apps/admin/src/vite-env.d.ts +0 -18
- package/templates/apps/admin/tailwind.config.js +0 -9
- package/templates/apps/admin/tsconfig.json +0 -17
- package/templates/apps/admin/vite.config.ts +0 -64
- package/templates/apps/ai-server/main.py +0 -43
- package/templates/apps/ai-server/providers/__init__.py +0 -3
- package/templates/apps/ai-server/providers/base.py +0 -111
- package/templates/apps/ai-server/requirements.txt +0 -3
- package/templates/apps/ai-server/routers/__init__.py +0 -3
- package/templates/apps/ai-server/routers/chat.py +0 -47
- package/templates/apps/ai-server/routers/embeddings.py +0 -30
- package/templates/apps/ai-server/routers/lint.py +0 -167
- package/templates/apps/ai-server/routers/models.py +0 -23
- package/templates/apps/ai-server/routers/preflight.py +0 -169
- package/templates/apps/ai-server/settings.py +0 -48
- package/templates/apps/backend/package.json +0 -34
- package/templates/apps/backend/src/main.ts +0 -375
- package/templates/apps/backend/src/modules/admin/adminRoutes.ts +0 -732
- package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +0 -391
- package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +0 -161
- package/templates/apps/backend/src/modules/admin/lintProxy.ts +0 -89
- package/templates/apps/backend/src/modules/admin/preflightProxy.ts +0 -242
- package/templates/apps/backend/src/modules/admin/roleCatalog.ts +0 -78
- package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +0 -449
- package/templates/apps/backend/src/modules/ai/aiProxy.ts +0 -265
- package/templates/apps/backend/src/modules/auth/authRoutes.ts +0 -220
- package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +0 -100
- package/templates/apps/backend/src/modules/payments/paymentStore.ts +0 -172
- package/templates/apps/backend/src/modules/requests/requestLog.ts +0 -175
- package/templates/apps/backend/src/modules/users/userGraph.ts +0 -83
- package/templates/apps/backend/src/modules/users/userModel.ts +0 -88
- package/templates/apps/backend/src/plugins/CronScheduler.ts +0 -69
- package/templates/apps/backend/src/plugins/loadPlugins.ts +0 -107
- package/templates/apps/backend/tsconfig.json +0 -14
- package/templates/apps/frontend/index.html +0 -12
- package/templates/apps/frontend/package.json +0 -20
- package/templates/apps/frontend/postcss.config.js +0 -3
- package/templates/apps/frontend/src/index.css +0 -44
- package/templates/apps/frontend/src/main.tsx +0 -65
- package/templates/apps/frontend/tailwind.config.js +0 -9
- package/templates/apps/frontend/vite.config.ts +0 -72
- package/templates/bin/nexus.js +0 -35
- package/templates/bin/serve-all.mjs +0 -45
- package/templates/dockerignore +0 -15
- package/templates/gitignore +0 -12
- package/templates/nexus.config.ts +0 -69
- package/templates/package.json +0 -47
- package/tests/cli.test.ts +0 -52
- package/tests/config-sync.test.ts +0 -201
- package/tests/dotenv.test.ts +0 -51
- package/vitest.config.ts +0 -9
- package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +0 -13
- /package/templates/{uploads → base/apps/backend/storage}/.gitkeep +0 -0
package/src/commands/init.ts
CHANGED
|
@@ -1,1009 +1,255 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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 {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
290
|
-
|
|
66
|
+
// Example picker
|
|
67
|
+
const examples = await listExamples();
|
|
68
|
+
example = await select('Start with:', ['empty', ...examples] as const) as string;
|
|
291
69
|
|
|
292
|
-
|
|
293
|
-
|
|
70
|
+
mongoUri = await prompt('MongoDB URI', mongoUri);
|
|
71
|
+
redisUrl = await prompt('Redis URL', redisUrl);
|
|
294
72
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
'
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
//
|
|
482
|
-
await
|
|
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
|
-
|
|
489
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
const
|
|
616
|
-
|
|
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
|
-
|
|
624
|
-
console.log(`
|
|
625
|
-
}
|
|
150
|
+
// 5. Summary
|
|
151
|
+
console.log(`
|
|
152
|
+
✓ Project scaffolded at ${targetDir}
|
|
626
153
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
-
/**
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
-
|
|
951
|
-
|
|
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
|
-
|
|
961
|
-
|
|
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
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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
|
-
|
|
995
|
-
|
|
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>]';
|