@bhooai/nexus-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/PLAN.md +141 -0
  2. package/README.md +34 -0
  3. package/package.json +25 -0
  4. package/src/commands/cluster.ts +133 -0
  5. package/src/commands/dev.ts +133 -0
  6. package/src/commands/doctor.ts +199 -0
  7. package/src/commands/init.ts +960 -0
  8. package/src/commands/node.ts +101 -0
  9. package/src/commands/pysetup.ts +136 -0
  10. package/src/commands/sync.ts +116 -0
  11. package/src/commands/uninstall.ts +287 -0
  12. package/src/config-sync.ts +384 -0
  13. package/src/dotenv.ts +39 -0
  14. package/src/index.ts +94 -0
  15. package/src/supervisor.ts +384 -0
  16. package/src/util.ts +123 -0
  17. package/src/wizard.ts +149 -0
  18. package/templates/Dockerfile +60 -0
  19. package/templates/README.md +69 -0
  20. package/templates/apps/admin/index.html +12 -0
  21. package/templates/apps/admin/package.json +24 -0
  22. package/templates/apps/admin/postcss.config.js +6 -0
  23. package/templates/apps/admin/src/main.tsx +10 -0
  24. package/templates/apps/admin/src/vite-env.d.ts +18 -0
  25. package/templates/apps/admin/tailwind.config.js +9 -0
  26. package/templates/apps/admin/tsconfig.json +17 -0
  27. package/templates/apps/admin/vite.config.ts +64 -0
  28. package/templates/apps/ai-server/main.py +43 -0
  29. package/templates/apps/ai-server/providers/__init__.py +3 -0
  30. package/templates/apps/ai-server/providers/base.py +111 -0
  31. package/templates/apps/ai-server/requirements.txt +3 -0
  32. package/templates/apps/ai-server/routers/__init__.py +3 -0
  33. package/templates/apps/ai-server/routers/chat.py +47 -0
  34. package/templates/apps/ai-server/routers/embeddings.py +30 -0
  35. package/templates/apps/ai-server/routers/lint.py +167 -0
  36. package/templates/apps/ai-server/routers/models.py +23 -0
  37. package/templates/apps/ai-server/routers/preflight.py +169 -0
  38. package/templates/apps/ai-server/settings.py +48 -0
  39. package/templates/apps/backend/package.json +33 -0
  40. package/templates/apps/backend/src/main.ts +375 -0
  41. package/templates/apps/backend/src/modules/admin/adminRoutes.ts +732 -0
  42. package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +391 -0
  43. package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +161 -0
  44. package/templates/apps/backend/src/modules/admin/lintProxy.ts +89 -0
  45. package/templates/apps/backend/src/modules/admin/preflightProxy.ts +242 -0
  46. package/templates/apps/backend/src/modules/admin/roleCatalog.ts +78 -0
  47. package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +449 -0
  48. package/templates/apps/backend/src/modules/ai/aiProxy.ts +265 -0
  49. package/templates/apps/backend/src/modules/auth/authRoutes.ts +220 -0
  50. package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +100 -0
  51. package/templates/apps/backend/src/modules/payments/paymentStore.ts +172 -0
  52. package/templates/apps/backend/src/modules/requests/requestLog.ts +175 -0
  53. package/templates/apps/backend/src/modules/users/userGraph.ts +83 -0
  54. package/templates/apps/backend/src/modules/users/userModel.ts +88 -0
  55. package/templates/apps/backend/src/plugins/CronScheduler.ts +69 -0
  56. package/templates/apps/backend/src/plugins/loadPlugins.ts +107 -0
  57. package/templates/apps/backend/tsconfig.json +14 -0
  58. package/templates/apps/frontend/index.html +12 -0
  59. package/templates/apps/frontend/package.json +19 -0
  60. package/templates/apps/frontend/src/main.tsx +64 -0
  61. package/templates/apps/frontend/vite.config.ts +63 -0
  62. package/templates/bin/nexus.js +35 -0
  63. package/templates/bin/serve-all.mjs +45 -0
  64. package/templates/dockerignore +15 -0
  65. package/templates/gitignore +12 -0
  66. package/templates/nexus.config.ts +69 -0
  67. package/templates/package.json +47 -0
  68. package/templates/tsconfig.json +17 -0
  69. package/templates/uploads/.gitkeep +0 -0
  70. package/tests/cli.test.ts +45 -0
  71. package/tests/config-sync.test.ts +201 -0
  72. package/tests/dotenv.test.ts +51 -0
  73. package/tsconfig.json +9 -0
  74. package/vitest.config.ts +9 -0
  75. package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +13 -0
@@ -0,0 +1,384 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { readFile, writeFile } from 'node:fs/promises';
4
+ import { basename, join } from 'node:path';
5
+ import type { NexusConfig } from '../../nexus-core/src/index.js';
6
+
7
+ /**
8
+ * Config sync - propagate the single `nexus.config.ts` source of truth into
9
+ * every derived artifact that still hardcodes a host/port/URL:
10
+ *
11
+ * - Dockerfile (EXPOSE, healthcheck, docker-run comments)
12
+ * - docker.ps1 / docker.sh / docker.bat (port vars, -p mappings, URLs)
13
+ * - bin/serve-all.mjs (cluster node port + frontend/admin preview ports)
14
+ * - apps/admin/package.json (the `vite --port NNNN` dev script)
15
+ * - shared project-info database (upsertProjectInfo so the DB record shows
16
+ * the same value the admin dashboard reads)
17
+ *
18
+ * A `.nexus-sync.json` fingerprint at the project root lets `nexus dev` detect
19
+ * "config changed since last sync" and re-sync every artifact before booting.
20
+ */
21
+
22
+ export interface SyncValues {
23
+ /** Docker image / container name (package.json `name`). */
24
+ image: string;
25
+ serverPort: string;
26
+ frontendPort: string;
27
+ adminPort: string;
28
+ nodePort: string;
29
+ lbPort: string;
30
+ aiPort: string;
31
+ /** Host-service URLs as the docker helpers should reference them. */
32
+ dbUri: string;
33
+ redisUri: string;
34
+ aiUrl: string;
35
+ /** Mongo database name parsed from `db.uri`. */
36
+ dbName: string;
37
+ }
38
+
39
+ export interface FileSyncReport {
40
+ file: string;
41
+ status: 'updated' | 'in-sync' | 'no-match' | 'missing';
42
+ changes: string[];
43
+ }
44
+
45
+ export interface SyncReport {
46
+ values: SyncValues;
47
+ fingerprint: string;
48
+ files: FileSyncReport[];
49
+ db: 'updated' | 'skipped' | 'failed';
50
+ }
51
+
52
+ export interface SyncOptions {
53
+ /** Write files (default true). False = dry-run / diff-only audit. */
54
+ write?: boolean;
55
+ /** Upsert the project-info MongoDB record (default true). */
56
+ db?: boolean;
57
+ }
58
+
59
+ /* -- helpers -------------------------------------------------------------- */
60
+
61
+ /** Stable hash of the values that drive every derived artifact. */
62
+ export function fingerprintOf(values: SyncValues): string {
63
+ return createHash('sha256')
64
+ .update(
65
+ [
66
+ values.image,
67
+ values.serverPort,
68
+ values.frontendPort,
69
+ values.adminPort,
70
+ values.nodePort,
71
+ values.lbPort,
72
+ values.aiPort,
73
+ values.dbUri,
74
+ values.redisUri,
75
+ values.aiUrl,
76
+ values.dbName,
77
+ ].join('|'),
78
+ )
79
+ .digest('hex')
80
+ .slice(0, 16);
81
+ }
82
+
83
+ /** Path to the `.nexus-sync.json` fingerprint file. */
84
+ export function syncFingerprintPath(root: string): string {
85
+ return join(root, '.nexus-sync.json');
86
+ }
87
+
88
+ /** Read the last-synced record, or undefined. */
89
+ export function readFingerprint(root: string): { fingerprint: string; values: SyncValues; syncedAt?: string } | undefined {
90
+ const file = syncFingerprintPath(root);
91
+ if (!existsSync(file)) return undefined;
92
+ try {
93
+ return JSON.parse(readFileSync(file, 'utf8')) as { fingerprint: string; values: SyncValues; syncedAt?: string };
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ }
98
+
99
+ /** MongoDB database name parsed out of a connection string. */
100
+ export function dbNameFromUrl(uri: string): string {
101
+ try {
102
+ const url = new URL(uri);
103
+ const name = decodeURIComponent(url.pathname.replace(/^\//, ''));
104
+ return name || 'platform';
105
+ } catch {
106
+ return 'platform';
107
+ }
108
+ }
109
+
110
+ /** Rewrite a host-service URL so a container reaches it via the host gateway. */
111
+ function dockerHost(url: string): string {
112
+ return url.replace(/\/\/(localhost|127\.0\.0\.1|\[::1\])([:/])/g, (m, _h, sep) => `//host.docker.internal${sep}`);
113
+ }
114
+
115
+ /** Port parsed from a URL (falls back on a sensible default). */
116
+ function portFrom(url: string, fallback: string): string {
117
+ try {
118
+ return new URL(url).port || fallback;
119
+ } catch {
120
+ return fallback;
121
+ }
122
+ }
123
+
124
+ /** Package.json `name`, else the project folder name. */
125
+ function projectImageName(root: string): string {
126
+ try {
127
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { name?: string };
128
+ if (pkg.name) return pkg.name;
129
+ } catch {
130
+ /* no valid package.json - fall through */
131
+ }
132
+ return basename(root);
133
+ }
134
+
135
+ /** Role order used when scanning a contiguous `-p <port>:<port>` run. */
136
+ const PORT_ROLES = ['server', 'frontend', 'admin', 'node'] as const;
137
+
138
+ /** Value for a role before a `-p` token maps to. */
139
+ function forRole(role: string, v: SyncValues): string {
140
+ switch (role) {
141
+ case 'server':
142
+ return v.serverPort;
143
+ case 'frontend':
144
+ return v.frontendPort;
145
+ case 'admin':
146
+ return v.adminPort;
147
+ case 'node':
148
+ return v.nodePort;
149
+ default:
150
+ return role;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Rewrite every group of contiguous `-p <port>:<port>` mapping tokens into the
156
+ * configured ports. Roles are assigned positionally (server, frontend, admin,
157
+ * node) so the rewrite is idempotent even after an earlier run wrote a custom
158
+ * port (e.g. 3120) that role-splitting cannot recognize by number alone.
159
+ *
160
+ * The tokens in one `docker run` may be separated by whitespace, line-continuation
161
+ * carets (`^` in .bat / `\` in .sh), and newlines, but never by another flag.
162
+ */
163
+ function syncPortMap(content: string, v: SyncValues): string {
164
+ return content.replace(/-p\s+\d+:\d+(?:[ \t\r\n^\\]*-p\s+\d+:\d+)*/g, (run) => {
165
+ let idx = 0;
166
+ return run.replace(/-p\s+\d+:\d+/g, (token) => {
167
+ const p = forRole(PORT_ROLES[idx % PORT_ROLES.length], v);
168
+ idx++;
169
+ return token.replace(/\d+:\d+/, `${p}:${p}`);
170
+ });
171
+ });
172
+ }
173
+
174
+ /** Run a value-aware pipeline over file content. */
175
+ function render(content: string, v: SyncValues, steps: Array<(c: string) => string>): { out: string; changed: boolean } {
176
+ let out = content;
177
+ for (const step of steps) out = step(out);
178
+ return { out, changed: out !== content };
179
+ }
180
+
181
+ /** Show the human-readable diff between two file versions. */
182
+ function diffLines(before: string, after: string): string[] {
183
+ const changes: string[] = [];
184
+ const b = before.split('\n');
185
+ const a = after.split('\n');
186
+ const max = Math.max(b.length, a.length);
187
+ for (let i = 0; i < max; i++) {
188
+ if (b[i] !== a[i]) {
189
+ changes.push(`line ${i + 1}: ${(b[i] ?? '').trim()} -> ${(a[i] ?? '').trim()}`);
190
+ }
191
+ }
192
+ return changes;
193
+ }
194
+
195
+ /* -- per-file renderers --------------------------------------------------- */
196
+
197
+ function renderDockerfile(content: string, v: SyncValues): string {
198
+ return render(content, v, [
199
+ (c) => c.replace(/^EXPOSE .*$/m, `EXPOSE ${v.frontendPort} ${v.adminPort} ${v.serverPort} ${v.nodePort}`),
200
+ (c) => c.replace(/NEXUS_SERVER_PORT:-?\d+/g, `NEXUS_SERVER_PORT:-${v.serverPort}`),
201
+ (c) => syncPortMap(c, v),
202
+ (c) => c.replace(/-e NEXUS_SERVER_PORT=\d+/g, `-e NEXUS_SERVER_PORT=${v.serverPort}`),
203
+ (c) => c.replace(/-e NEXUS_NODE_PORT=\d+/g, `-e NEXUS_NODE_PORT=${v.nodePort}`),
204
+ (c) => c.replace(/(frontend\s+http:\/\/localhost:)\d+/g, `$1${v.frontendPort}`),
205
+ (c) => c.replace(/(admin\s+http:\/\/localhost:)\d+/g, `$1${v.adminPort}`),
206
+ (c) => c.replace(/(backend\s+http:\/\/localhost:)\d+/g, `$1${v.serverPort}`),
207
+ (c) => c.replace(/(node\s+http:\/\/localhost:)\d+/g, `$1${v.nodePort}`),
208
+ ]).out;
209
+ }
210
+
211
+ function renderServeAll(content: string, v: SyncValues): string {
212
+ return render(content, v, [
213
+ (c) => c.replace(/process\.env\.NEXUS_NODE_PORT\s*\?\?\s*'[^']+'/, `process.env.NEXUS_NODE_PORT ?? '${v.nodePort}'`),
214
+ // Each service's preview port: match inside the `run('frontend'/'admin'` line.
215
+ (c) => c.replace(/(run\('frontend',[^\n]*?'--port',\s*')[0-9]+(')/, `$1${v.frontendPort}$2`),
216
+ (c) => c.replace(/(run\('admin',[^\n]*?'--port',\s*')[0-9]+(')/, `$1${v.adminPort}$2`),
217
+ (c) => c.replace(/(frontend.*?on\s+:)[0-9]+/g, `$1${v.frontendPort}`),
218
+ (c) => c.replace(/(admin.*?on\s+:)[0-9]+/g, `$1${v.adminPort}`),
219
+ ]).out;
220
+ }
221
+
222
+ function renderAdminPackage(content: string, v: SyncValues): string {
223
+ return render(content, v, [
224
+ (c) => c.replace(/"dev":\s*"vite[^"]*"/, `"dev": "vite --port ${v.adminPort}"`),
225
+ ]).out;
226
+ }
227
+
228
+ function renderDockerPs1(content: string, v: SyncValues): string {
229
+ const set = (c: string, key: string, value: string) =>
230
+ c.replace(new RegExp(`(\\$${key}\\s*=\\s*')[0-9]+(')`, 'g'), `$1${value}$2`);
231
+ return render(content, v, [
232
+ (c) => set(c, 'serverPort', v.serverPort),
233
+ (c) => set(c, 'frontendPort', v.frontendPort),
234
+ (c) => set(c, 'adminPort', v.adminPort),
235
+ (c) => set(c, 'nodePort', v.nodePort),
236
+ (c) => c.replace(/(\$dbUri\s*=\s*')([^']*)(')/g, `$1${v.dbUri}$3`),
237
+ (c) => c.replace(/(\$redisUrl\s*=\s*')([^']*)(')/g, `$1${v.redisUri}$3`),
238
+ (c) => c.replace(/(\$aiUrl\s*=\s*')([^']*)(')/g, `$1${v.aiUrl}$3`),
239
+ (c) => syncPortMap(c, v),
240
+ ]).out;
241
+ }
242
+
243
+ function renderDockerSh(content: string, v: SyncValues): string {
244
+ return render(content, v, [
245
+ (c) => c.replace(/(DB_URI=")([^"]*)(")/g, `$1${v.dbUri}$3`),
246
+ (c) => c.replace(/(REDIS_URL=")([^"]*)(")/g, `$1${v.redisUri}$3`),
247
+ (c) => c.replace(/(AI_URL=")([^"]*)(")/g, `$1${v.aiUrl}$3`),
248
+ (c) => c.replace(/(NODE_PORT=")([^"]*)(")/g, `$1${v.nodePort}$3`),
249
+ (c) => syncPortMap(c, v),
250
+ (c) => c.replace(/(frontend http:\/\/localhost:)\d+/g, `$1${v.frontendPort}`),
251
+ (c) => c.replace(/(admin\s+http:\/\/localhost:)\d+/g, `$1${v.adminPort}`),
252
+ (c) => c.replace(/(backend\s+http:\/\/localhost:)\d+/g, `$1${v.serverPort}`),
253
+ ]).out;
254
+ }
255
+
256
+ function renderDockerBat(content: string, v: SyncValues): string {
257
+ return render(content, v, [
258
+ (c) => syncPortMap(c, v),
259
+ (c) => c.replace(/NEXUS_DB_URI=mongodb:\/\/[^\s^&]+/g, `NEXUS_DB_URI=${v.dbUri}`),
260
+ (c) => c.replace(/NEXUS_REDIS_URL=redis:\/\/[^\s^&]+/g, `NEXUS_REDIS_URL=${v.redisUri}`),
261
+ (c) => c.replace(/NEXUS_AI_SERVER_URL=http:\/\/[^\s^&]+/g, `NEXUS_AI_SERVER_URL=${v.aiUrl}`),
262
+ (c) => c.replace(/NEXUS_NODE_PORT=\d+/g, `NEXUS_NODE_PORT=${v.nodePort}`),
263
+ (c) => c.replace(/(frontend http:\/\/localhost:)\d+/g, `$1${v.frontendPort}`),
264
+ (c) => c.replace(/(admin\s+http:\/\/localhost:)\d+/g, `$1${v.adminPort}`),
265
+ (c) => c.replace(/(backend\s+http:\/\/localhost:)\d+/g, `$1${v.serverPort}`),
266
+ ]).out;
267
+ }
268
+
269
+ /* -- targets -------------------------------------------------------------- */
270
+
271
+ type FileRenderer = (content: string, v: SyncValues) => string;
272
+
273
+ const TARGETS: Array<{ rel: string; render: FileRenderer }> = [
274
+ { rel: 'Dockerfile', render: renderDockerfile },
275
+ { rel: 'bin/serve-all.mjs', render: renderServeAll },
276
+ { rel: 'apps/admin/package.json', render: renderAdminPackage },
277
+ { rel: 'docker.ps1', render: renderDockerPs1 },
278
+ { rel: 'docker.sh', render: renderDockerSh },
279
+ { rel: 'docker.bat', render: renderDockerBat },
280
+ ];
281
+
282
+ /* -- public API ----------------------------------------------------------- */
283
+
284
+ /** Derive the full set of printable / embeddable values from the effective config. */
285
+ export function deriveValues(cfg: NexusConfig, image: string): SyncValues {
286
+ return {
287
+ image,
288
+ serverPort: String(cfg.server.port),
289
+ frontendPort: String(cfg.frontend.port),
290
+ adminPort: String(cfg.admin.port),
291
+ nodePort: String(cfg.cluster.nodeAgentPort ?? 7575),
292
+ lbPort: String(cfg.cluster.lbPort ?? 8080),
293
+ aiPort: portFrom(cfg.ai.serverUrl, '8000'),
294
+ dbUri: dockerHost(cfg.db.uri),
295
+ redisUri: dockerHost(cfg.redis.url),
296
+ aiUrl: dockerHost(cfg.ai.serverUrl),
297
+ dbName: dbNameFromUrl(cfg.db.uri),
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Sync the effective config into every derived file + the project-info DB.
303
+ * Returns a field-level report; with `opts.write === false` it only audits.
304
+ */
305
+ export async function syncConfig(root: string, cfg: NexusConfig, opts: SyncOptions = {}): Promise<SyncReport> {
306
+ const write = opts.write !== false;
307
+ const image = projectImageName(root);
308
+ const values = deriveValues(cfg, image);
309
+ const fingerprint = fingerprintOf(values);
310
+
311
+ const files: FileSyncReport[] = [];
312
+ for (const target of TARGETS) {
313
+ const abs = join(root, target.rel);
314
+ if (!existsSync(abs)) {
315
+ files.push({ file: target.rel, status: 'missing', changes: [] });
316
+ continue;
317
+ }
318
+ try {
319
+ const current = await readFile(abs, 'utf8');
320
+ const next = target.render(current, values);
321
+ if (next === current) {
322
+ files.push({ file: target.rel, status: 'in-sync', changes: [] });
323
+ continue;
324
+ }
325
+ if (write) await writeFile(abs, next, 'utf8');
326
+ files.push({ file: target.rel, status: 'updated', changes: diffLines(current, next) });
327
+ } catch (err) {
328
+ files.push({ file: target.rel, status: 'no-match', changes: [String((err as Error).message)] });
329
+ }
330
+ }
331
+
332
+ if (write) {
333
+ const record = { fingerprint, values, syncedAt: new Date().toISOString() };
334
+ writeFileSync(syncFingerprintPath(root), JSON.stringify(record, null, 2), 'utf8');
335
+ }
336
+
337
+ const db = opts.db === false ? 'skipped' : await syncDatabase(root, cfg, values, write);
338
+
339
+ return { values, fingerprint, files, db };
340
+ }
341
+
342
+ /** Upsert the shared `nexus_projects` record so the DB mirrors the config. */
343
+ async function syncDatabase(root: string, cfg: NexusConfig, values: SyncValues, write: boolean): Promise<SyncReport['db']> {
344
+ if (!write) return 'skipped';
345
+ try {
346
+ const { resolveProjectInfo, connectProjectInfo, upsertProjectInfo, closeProjectInfo } = await import(
347
+ '../../../nexus-data/src/index.js'
348
+ );
349
+ const project = await resolveProjectInfo(root);
350
+ connectProjectInfo(cfg.db.uri, { autoIndex: false });
351
+ await upsertProjectInfo({
352
+ ...project,
353
+ status: 'stopped',
354
+ settings: {
355
+ env: cfg.env,
356
+ host: cfg.server.host,
357
+ serverPort: cfg.server.port,
358
+ frontendPort: cfg.frontend.port,
359
+ adminPort: cfg.admin.port,
360
+ lbPort: cfg.cluster?.lbPort,
361
+ nodePort: cfg.cluster?.nodeAgentPort,
362
+ aiPort: Number(values.aiPort),
363
+ database: values.dbName,
364
+ dbUri: cfg.db.uri,
365
+ redisUrl: cfg.redis.url,
366
+ aiUrl: cfg.ai.serverUrl,
367
+ graphqlPath: cfg.graphql.path,
368
+ wsPath: cfg.ws.path,
369
+ },
370
+ });
371
+ await closeProjectInfo();
372
+ return 'updated';
373
+ } catch {
374
+ return 'failed';
375
+ }
376
+ }
377
+
378
+ /** True when the stored fingerprint differs from the current config. */
379
+ export function configChangedSinceSync(root: string, cfg: NexusConfig): boolean {
380
+ const image = projectImageName(root);
381
+ const current = fingerprintOf(deriveValues(cfg, image));
382
+ const saved = readFingerprint(root);
383
+ return !saved || saved.fingerprint !== current;
384
+ }
package/src/dotenv.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ /**
5
+ * Minimal dependency-free `.env` loader for the CLI and supervised services.
6
+ *
7
+ * The framework's config maps every `NEXUS_*` variable onto the config tree
8
+ * (`env.ts`), but nothing ever loaded a `.env` file - so keys like
9
+ * `NEXUS_PAYMENTS_RAZORPAY_KEY_ID` in the project's `.env` were silently
10
+ * ignored. We parse `<root>/.env` once at CLI startup and merge it into
11
+ * `process.env` WITHOUT overriding variables that are already set (a real
12
+ * environment value always wins).
13
+ *
14
+ * Grammar: `KEY=VALUE` (optional `export ` prefix), `#` comments, blank lines,
15
+ * and optional single/double quotes. Placeholder policies are left to the app.
16
+ */
17
+ export function loadDotEnv(root: string, env: NodeJS.ProcessEnv = process.env): void {
18
+ const file = resolve(root, '.env');
19
+ if (!existsSync(file)) return;
20
+ let content: string;
21
+ try {
22
+ content = readFileSync(file, 'utf8');
23
+ } catch {
24
+ return;
25
+ }
26
+ for (const rawLine of content.split(/\r?\n/)) {
27
+ const line = rawLine.trim();
28
+ if (!line || line.startsWith('#')) continue;
29
+ const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
30
+ if (!m) continue;
31
+ const key = m[1]!;
32
+ if (env[key] !== undefined) continue; // real env always wins
33
+ let value = m[2]!.trim();
34
+ if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
35
+ value = value.slice(1, -1);
36
+ }
37
+ env[key] = value;
38
+ }
39
+ }
package/src/index.ts ADDED
@@ -0,0 +1,94 @@
1
+ import { doctor } from './commands/doctor.js';
2
+ import { init } from './commands/init.js';
3
+ import { dev } from './commands/dev.js';
4
+ import { pysetup } from './commands/pysetup.js';
5
+ import { cluster } from './commands/cluster.js';
6
+ import { node } from './commands/node.js';
7
+ import { syncCommand } from './commands/sync.js';
8
+ import { uninstall } from './commands/uninstall.js';
9
+ import { loadDotEnv } from './dotenv.js';
10
+
11
+ /** Dispatch a CLI command. Returns the exit code. */
12
+ export async function run(cmd: string, args: string[]): Promise<number> {
13
+ // Load the project .env once so NEXUS_* keys reach the config loader AND the
14
+ // supervised child processes (they inherit process.env).
15
+ loadDotEnv(process.cwd());
16
+
17
+ switch (cmd) {
18
+ case 'doctor':
19
+ return doctor();
20
+ case 'init':
21
+ return init({}, args);
22
+ case 'dev':
23
+ return dev(args);
24
+ case 'build':
25
+ console.log('nexus build - implemented in a later phase (tsc across workspaces).');
26
+ return 0;
27
+ case 'test':
28
+ console.log('nexus test - run `npm test` (vitest + pytest) for now.');
29
+ return 0;
30
+ case 'plugin':
31
+ console.log('nexus plugin <new|install> - implemented in Phase 8.');
32
+ return 0;
33
+ case 'add':
34
+ console.log('nexus add <subgraph|module> - implemented in Phase 5/6.');
35
+ return 0;
36
+ case 'pysetup':
37
+ return pysetup(args);
38
+ case 'uninstall':
39
+ return uninstall(args);
40
+ case 'cluster':
41
+ return cluster(args);
42
+ case 'node':
43
+ return node(args);
44
+ case 'sync':
45
+ return syncCommand(args);
46
+ case 'help':
47
+ case '--help':
48
+ case '-h':
49
+ printHelp();
50
+ return 0;
51
+ default:
52
+ console.error(`Unknown command: ${cmd}`);
53
+ printHelp();
54
+ return 1;
55
+ }
56
+ }
57
+
58
+ function printHelp(): void {
59
+ console.log(`
60
+ BhooAI Nexus CLI
61
+
62
+ Usage: nexus <command> [args]
63
+
64
+ Commands:
65
+ init [target] [--as=root|node] [--role=R] [--name=N] [--mongo-uri=URI]
66
+ [--redis-url=URL] [--ai-providers=a,b] [--ai-key id=val]
67
+ [--cluster-token=T] [--venv|--no-venv] [--no-interactive] [--no-install] [--skip-mongo-check]
68
+ Scaffold a project; one-command wizard sets up
69
+ Node + frontend + admin + Python AI + Mongo/Redis
70
+ dev [--only a,b] Start the four terminals under the supervisor
71
+ build Build all TypeScript workspaces
72
+ test Run all test suites
73
+ doctor Verify the environment (node, python, mongo, redis)
74
+ plugin <new|install> Manage plugins
75
+ add <subgraph|module> Scaffold a new subgraph or module
76
+ pysetup [pkgs...] [--venv] [--interactive]
77
+ Install the AI-server Python deps (requirements.txt) + extras
78
+ uninstall [--target <path>] [--purge] [--force] [--dry-run]
79
+ Drop the project DB, delete its nexus_projects record,
80
+ optionally remove the project directory (--purge)
81
+ node <id|serve> Run this machine as a cluster node (agent + role service)
82
+ cluster <sub> Root-side mesh: status, link, scale, serve, etc.
83
+ sync [--check] [--no-db] Rewrite Dockerfiles, docker.*, serve-all.mjs & DB from nexus.config.ts
84
+
85
+ Options:
86
+ -h, --help Show this help
87
+ `);
88
+ }
89
+
90
+ // Re-exports for programmatic use.
91
+ export { doctor, init, dev, pysetup, cluster, node, syncCommand };
92
+ export { syncConfig, configChangedSinceSync, deriveValues, fingerprintOf } from './config-sync.js';
93
+ export { Supervisor } from './supervisor.js';
94
+ export type { LogEntry } from './supervisor.js';