@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,391 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile, writeFile, rename, unlink } from 'node:fs/promises';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { spawn, execFile, type ChildProcess } from 'node:child_process';
5
+ import { networkInterfaces } from 'node:os';
6
+ import { join, resolve } from 'node:path';
7
+ import type { Router, Middleware } from '@bhooai/nexus-core/http';
8
+ import { ClusterManager, nodeIdFor } from '@bhooai/nexus-cluster';
9
+ import type { DeepPartial, NexusConfig } from '@bhooai/nexus-core';
10
+ import { mergeConfig } from '@bhooai/nexus-core';
11
+ import {
12
+ listClusterNodes,
13
+ upsertClusterNode,
14
+ deleteClusterNode,
15
+ syncClusterNodes,
16
+ type ClusterNodeRecord,
17
+ } from '@bhooai/nexus-data';
18
+
19
+ /**
20
+ * Mount /admin/cluster/* — mesh orchestration for the admin UI. All routes are
21
+ * guarded (bearer + 'admin' role) by the `guard` passed from adminRoutes.
22
+ *
23
+ * GET /admin/cluster/overview linked nodes + metrics + LB RPS + autoscale config + running status
24
+ * GET /admin/cluster/setup master (this server) + slave defaults and commands
25
+ * POST /admin/cluster/setup save master/slave host+port overrides (runtime config)
26
+ * POST /admin/cluster/link { nodeUrl } handshake + register a node
27
+ * POST /admin/cluster/unlink { id }
28
+ * POST /admin/cluster/generate-token generate a new pairing token (slave mode)
29
+ * POST /admin/cluster/exec { id, action } start|stop|restart|kill
30
+ * POST /admin/cluster/scale { target } scale to n backend nodes
31
+ * POST /admin/cluster/poll refresh every node once
32
+ * POST /admin/cluster/start start LB + autoscaler, persist enabled=true
33
+ * POST /admin/cluster/stop stop LB + autoscaler, persist enabled=false
34
+ */
35
+ export function registerClusterRoutes(
36
+ router: Router,
37
+ guard: Middleware[],
38
+ deps: { root: string; config: import('@bhooai/nexus-core').NexusConfig; manager?: ClusterManager },
39
+ ): void {
40
+ const manager = deps.manager ?? (() => {
41
+ const m = new ClusterManager({ config: deps.config.cluster, root: deps.root, aiServerUrl: deps.config.ai.serverUrl });
42
+ deps.manager = m;
43
+ return m;
44
+ })();
45
+ let nodeAgentProcess: ChildProcess | undefined;
46
+ let nodeAgentStartedAt: string | undefined;
47
+
48
+ router.get('/admin/cluster/overview', async (ctx) => {
49
+ const rps = manager.rps();
50
+ const nodes = manager.list().map((n) => ({
51
+ id: n.identity.id,
52
+ role: n.identity.role,
53
+ tier: n.identity.tier,
54
+ version: n.identity.version,
55
+ baseUrl: n.identity.baseUrl,
56
+ serviceUrl: n.identity.services[n.identity.role],
57
+ status: n.status,
58
+ enabled: n.enabled,
59
+ registeredAt: n.registeredAt,
60
+ lastSeenAt: n.lastSeenAt,
61
+ rps: rps[n.identity.id] ?? 0,
62
+ metrics: n.lastMetrics,
63
+ health: n.lastHealth,
64
+ }));
65
+ syncClusterNodes(nodes.map(toClusterNodeRecord)).catch(() => { /* best-effort */ });
66
+ ctx.json({
67
+ enabled: deps.config.cluster.enabled,
68
+ running: manager.lb.server.listening,
69
+ lbHost: deps.config.cluster.lbHost,
70
+ lbPort: deps.config.cluster.lbPort,
71
+ nodeAgentHost: deps.config.cluster.nodeAgentHost,
72
+ controlPort: deps.config.cluster.nodeAgentPort,
73
+ autoscale: deps.config.cluster.autoscale,
74
+ nodes,
75
+ });
76
+ }, guard);
77
+
78
+ /** Master + slave topology and the run/link commands the operators copy. */
79
+ router.get('/admin/cluster/setup', async (ctx) => {
80
+ const c = deps.config.cluster;
81
+ // This server's own agent URL — resolved to a LAN address when bound to
82
+ // 0.0.0.0 so it can be pasted into a remote master (`nexus cluster link`).
83
+ const selfHost = resolveAdvertisedHost(c.nodeAgentHost, c.nodeAgentPort);
84
+ const selfUrl = `http://${selfHost}:${c.nodeAgentPort}`;
85
+ const slaves = [
86
+ { role: 'backend', label: 'API core' },
87
+ { role: 'files', label: 'Static + uploads' },
88
+ { role: 'database', label: 'Mongo + Redis' },
89
+ { role: 'ai', label: 'AI inference' },
90
+ ].map((s) => ({
91
+ role: s.role,
92
+ label: s.label,
93
+ host: c.nodeAgentHost === '0.0.0.0' ? '127.0.0.1' : c.nodeAgentHost,
94
+ port: c.nodeAgentPort,
95
+ serve: `nexus node serve --role=${s.role} --port=${c.nodeAgentPort}`,
96
+ link: `http://${c.nodeAgentHost === '0.0.0.0' ? '127.0.0.1' : c.nodeAgentHost}:${c.nodeAgentPort}`,
97
+ }));
98
+ ctx.json({
99
+ master: {
100
+ enabled: c.enabled,
101
+ lbHost: c.lbHost,
102
+ lbPort: c.lbPort,
103
+ nodeAgentHost: c.nodeAgentHost,
104
+ nodeAgentPort: c.nodeAgentPort,
105
+ registryFile: c.registryFile,
106
+ serverUrl: `http://${c.lbHost}:${c.lbPort}`,
107
+ },
108
+ self: {
109
+ host: selfHost,
110
+ port: c.nodeAgentPort,
111
+ agentUrl: selfUrl,
112
+ serve: `nexus node serve --role=backend --port=${c.nodeAgentPort}`,
113
+ link: `nexus cluster link ${selfUrl}`,
114
+ token: c.token || '<empty — set cluster.token>',
115
+ },
116
+ slaves,
117
+ runtime: await readRuntime(deps.root),
118
+ note: 'Save here rewrites the runtime overrides; restart nexus cluster serve / node serve to apply new ports.',
119
+ });
120
+ }, guard);
121
+
122
+ /** Persist master/slave host/port overrides into nexus.runtime.json (validated). */
123
+ router.post('/admin/cluster/setup', async (ctx) => {
124
+ const body = (ctx.body ?? {}) as {
125
+ master?: { enabled?: boolean; lbHost?: string; lbPort?: number; nodeAgentHost?: string; nodeAgentPort?: number };
126
+ };
127
+ const patch: DeepPartial<NexusConfig> = {};
128
+ const c = deps.config.cluster;
129
+ const master = body.master ?? {};
130
+ if (typeof master.enabled === 'boolean') patch.cluster = { ...(patch.cluster ?? {}), enabled: master.enabled };
131
+ if (typeof master.lbHost === 'string' && master.lbHost.trim()) patch.cluster = { ...(patch.cluster ?? {}), lbHost: master.lbHost.trim() };
132
+ if (Number.isFinite(Number(master.lbPort)) && Number(master.lbPort) >= 1 && Number(master.lbPort) <= 65535) patch.cluster = { ...(patch.cluster ?? {}), lbPort: Number(master.lbPort) };
133
+ if (typeof master.nodeAgentHost === 'string' && master.nodeAgentHost.trim()) patch.cluster = { ...(patch.cluster ?? {}), nodeAgentHost: master.nodeAgentHost.trim() };
134
+ if (Number.isFinite(Number(master.nodeAgentPort)) && Number(master.nodeAgentPort) >= 1 && Number(master.nodeAgentPort) <= 65535) patch.cluster = { ...(patch.cluster ?? {}), nodeAgentPort: Number(master.nodeAgentPort) };
135
+ try {
136
+ // Merge with existing runtime overrides so unrelated settings survive.
137
+ const existing = await readRuntime(deps.root);
138
+ const merged = mergeRuntime(existing, patch);
139
+ await writeRuntime(deps.root, merged);
140
+ ctx.json({ ok: true, note: 'master/serve overrides saved — restart the cluster/node processes to apply' });
141
+ } catch (err) {
142
+ ctx.json({ error: (err as Error).message }, 400);
143
+ }
144
+ }, guard);
145
+
146
+ router.get('/admin/cluster/node/status', async (ctx) => {
147
+ const c = deps.config.cluster;
148
+ let running = nodeAgentProcess?.exitCode === null && !nodeAgentProcess.killed;
149
+ if (!running) {
150
+ try {
151
+ const res = await fetch(`http://127.0.0.1:${c.nodeAgentPort}/health`, {
152
+ headers: { authorization: `Bearer ${c.token}` },
153
+ signal: AbortSignal.timeout(800),
154
+ });
155
+ running = res.ok;
156
+ } catch { /* agent is not running */ }
157
+ }
158
+ ctx.json({
159
+ running,
160
+ pid: running ? nodeAgentProcess?.pid : undefined,
161
+ nodeId: nodeIdFor(deps.root, 'backend'),
162
+ role: 'backend',
163
+ port: c.nodeAgentPort,
164
+ agentUrl: `http://127.0.0.1:${c.nodeAgentPort}`,
165
+ token: c.token || '<empty — set cluster.token>',
166
+ startedAt: nodeAgentStartedAt,
167
+ });
168
+ }, guard);
169
+
170
+ router.post('/admin/cluster/node/start', async (ctx) => {
171
+ const c = deps.config.cluster;
172
+ if (nodeAgentProcess?.exitCode === null && !nodeAgentProcess.killed) {
173
+ ctx.json({ ok: true, running: true, pid: nodeAgentProcess.pid, port: c.nodeAgentPort });
174
+ return;
175
+ }
176
+ try {
177
+ const cli = resolve(deps.root, 'bin', 'nexus.js');
178
+ const child = spawn(process.execPath, [cli, 'node', 'serve', '--role=backend', `--port=${c.nodeAgentPort}`], {
179
+ cwd: deps.root,
180
+ env: process.env,
181
+ stdio: ['ignore', 'pipe', 'pipe'],
182
+ windowsHide: true,
183
+ });
184
+ nodeAgentProcess = child;
185
+ nodeAgentStartedAt = new Date().toISOString();
186
+ child.stdout?.on('data', () => undefined);
187
+ child.stderr?.on('data', () => undefined);
188
+ child.once('exit', () => { nodeAgentProcess = undefined; });
189
+ child.once('error', () => { nodeAgentProcess = undefined; });
190
+ ctx.json({ ok: true, running: true, pid: child.pid, port: c.nodeAgentPort, token: c.token });
191
+ } catch (err) {
192
+ ctx.json({ error: (err as Error).message }, 400);
193
+ }
194
+ }, guard);
195
+
196
+ router.post('/admin/cluster/node/stop', async (ctx) => {
197
+ const child = nodeAgentProcess;
198
+ if (!child || child.exitCode !== null || child.killed) {
199
+ nodeAgentProcess = undefined;
200
+ ctx.json({ ok: true, running: false });
201
+ return;
202
+ }
203
+ try {
204
+ if (process.platform === 'win32' && child.pid) {
205
+ execFile('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true }, () => undefined);
206
+ } else {
207
+ child.kill('SIGTERM');
208
+ }
209
+ nodeAgentProcess = undefined;
210
+ ctx.json({ ok: true, running: false });
211
+ } catch (err) {
212
+ ctx.json({ error: (err as Error).message }, 400);
213
+ }
214
+ }, guard);
215
+
216
+ router.post('/admin/cluster/link', async (ctx) => {
217
+ const { nodeUrl } = (ctx.body ?? {}) as { nodeUrl?: string };
218
+ if (!nodeUrl) { ctx.json({ error: 'nodeUrl is required' }, 400); return; }
219
+ try {
220
+ const node = await manager.link(nodeUrl);
221
+ upsertClusterNode(toClusterNodeRecord({
222
+ id: node.identity.id, role: node.identity.role, tier: node.identity.tier, version: node.identity.version,
223
+ baseUrl: node.identity.baseUrl, services: node.identity.services, status: node.status, enabled: node.enabled,
224
+ registeredAt: node.registeredAt, lastSeenAt: node.lastSeenAt, lastHealth: node.lastHealth, lastMetrics: node.lastMetrics,
225
+ })).catch(() => { /* best-effort */ });
226
+ ctx.json({ node });
227
+ } catch (err) {
228
+ ctx.json({ error: (err as Error).message }, 400);
229
+ }
230
+ }, guard);
231
+
232
+ router.post('/admin/cluster/unlink', async (ctx) => {
233
+ const { id } = (ctx.body ?? {}) as { id?: string };
234
+ const ok = id ? manager.unlink(id) : false;
235
+ if (ok && id) deleteClusterNode(id).catch(() => { /* best-effort */ });
236
+ ctx.json({ ok });
237
+ }, guard);
238
+
239
+ /** Generate a new pairing token (slave mode) — persists to nexus.runtime.json
240
+ * and updates the in-memory config + registry so the node agent uses it
241
+ * immediately. Returns the plaintext token once for the operator to copy. */
242
+ router.post('/admin/cluster/generate-token', async (ctx) => {
243
+ try {
244
+ const token = randomBytes(16).toString('hex');
245
+ const existing = await readRuntime(deps.root);
246
+ const patched = mergeRuntime(existing, { cluster: { token } });
247
+ await writeRuntime(deps.root, patched);
248
+ deps.config.cluster.token = token;
249
+ manager.registry.setToken(token);
250
+ ctx.json({ token });
251
+ } catch (err) {
252
+ ctx.json({ error: (err as Error).message }, 400);
253
+ }
254
+ }, guard);
255
+
256
+ router.post('/admin/cluster/exec', async (ctx) => {
257
+ const { id, action } = (ctx.body ?? {}) as { id?: string; action?: string };
258
+ if (!id || !action) { ctx.json({ error: 'id + action are required' }, 400); return; }
259
+ const map: Record<string, string> = { start: 'start', stop: 'stop', restart: 'restart', kill: 'stop' };
260
+ const command = map[action];
261
+ if (!command) { ctx.json({ error: `unknown action: ${action}` }, 400); return; }
262
+ try {
263
+ const result = await manager.exec(id, command);
264
+ ctx.json({ result });
265
+ } catch (err) {
266
+ ctx.json({ error: (err as Error).message }, 400);
267
+ }
268
+ }, guard);
269
+
270
+ router.post('/admin/cluster/scale', async (ctx) => {
271
+ const { target } = (ctx.body ?? {}) as { target?: number };
272
+ const n = Number(target);
273
+ if (!Number.isFinite(n)) { ctx.json({ error: 'target is a required number' }, 400); return; }
274
+ try {
275
+ await manager.scaleTo(n);
276
+ ctx.json({ ok: true, target: n });
277
+ } catch (err) {
278
+ ctx.json({ error: (err as Error).message }, 400);
279
+ }
280
+ }, guard);
281
+
282
+ router.post('/admin/cluster/poll', async (ctx) => {
283
+ await manager.pollAll();
284
+ syncClusterNodes(manager.list().map((n) => toClusterNodeRecord({
285
+ id: n.identity.id, role: n.identity.role, tier: n.identity.tier, version: n.identity.version,
286
+ baseUrl: n.identity.baseUrl, services: n.identity.services, status: n.status, enabled: n.enabled,
287
+ registeredAt: n.registeredAt, lastSeenAt: n.lastSeenAt, lastHealth: n.lastHealth, lastMetrics: n.lastMetrics,
288
+ }))).catch(() => { /* best-effort */ });
289
+ ctx.json({ ok: true });
290
+ }, guard);
291
+
292
+ // ── cluster start / stop ─────────────────────────────────────────
293
+
294
+ router.post('/admin/cluster/start', async (ctx) => {
295
+ try {
296
+ // Persist enabled=true into nexus.runtime.json so it survives restarts.
297
+ const existing = await readRuntime(deps.root);
298
+ const patched = mergeRuntime(existing, { cluster: { enabled: true } });
299
+ await writeRuntime(deps.root, patched);
300
+ // Mutate the in-memory config so listenLb picks up enabled=true.
301
+ deps.config.cluster.enabled = true;
302
+ await manager.listenLb(deps.config.cluster.lbHost);
303
+ manager.autoscaler.start(10_000);
304
+ ctx.json({ ok: true, running: true });
305
+ } catch (err) {
306
+ ctx.json({ error: (err as Error).message }, 400);
307
+ }
308
+ }, guard);
309
+
310
+ router.post('/admin/cluster/stop', async (ctx) => {
311
+ try {
312
+ manager.autoscaler.stop();
313
+ await manager.close();
314
+ // Persist enabled=false.
315
+ const existing = await readRuntime(deps.root);
316
+ const patched = mergeRuntime(existing, { cluster: { enabled: false } });
317
+ await writeRuntime(deps.root, patched);
318
+ deps.config.cluster.enabled = false;
319
+ ctx.json({ ok: true, running: false });
320
+ } catch (err) {
321
+ ctx.json({ error: (err as Error).message }, 400);
322
+ }
323
+ }, guard);
324
+ }
325
+
326
+ /** Read the project's runtime config overrides (nexus.runtime.json). */
327
+ async function readRuntime(root: string): Promise<DeepPartial<NexusConfig>> {
328
+ const path = join(root, 'nexus.runtime.json');
329
+ if (!existsSync(path)) return {};
330
+ try { return JSON.parse(await readFile(path, 'utf8')) as DeepPartial<NexusConfig>; }
331
+ catch { return {}; }
332
+ }
333
+
334
+ /** Shallow-merge a DeepPartial patch into existing overrides. */
335
+ function mergeRuntime(existing: DeepPartial<NexusConfig>, patch: DeepPartial<NexusConfig>): DeepPartial<NexusConfig> {
336
+ return {
337
+ ...existing,
338
+ ...patch,
339
+ cluster: { ...(existing.cluster ?? {}), ...(patch.cluster ?? {}) },
340
+ };
341
+ }
342
+
343
+ /** Validate + atomically write nexus.runtime.json. */
344
+ async function writeRuntime(root: string, overrides: DeepPartial<NexusConfig>): Promise<void> {
345
+ mergeConfig(overrides); // validate before persisting
346
+ const path = resolve(root, 'nexus.runtime.json');
347
+ const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`;
348
+ await writeFile(tempPath, JSON.stringify(overrides, null, 2) + '\n', 'utf8');
349
+ try {
350
+ await rename(tempPath, path);
351
+ } catch (error) {
352
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST' && (error as NodeJS.ErrnoException).code !== 'EPERM') throw error;
353
+ await unlink(path).catch(() => undefined);
354
+ await rename(tempPath, path);
355
+ }
356
+ }
357
+
358
+ /** Address a remote master should use to reach THIS node's agent. A bind of
359
+ * 0.0.0.0 / :: means "every interface" — advertise the first LAN IPv4 so the
360
+ * pasteable URL actually resolves on the network; fall back to a nested link
361
+ * for the `NEXUS_NODE_ADVERTISED_URL` env override. */
362
+ function resolveAdvertisedHost(nodeAgentHost: string, _port: number): string {
363
+ if (process.env.NEXUS_NODE_ADVERTISED_URL) {
364
+ try { return new URL(process.env.NEXUS_NODE_ADVERTISED_URL).hostname; } catch { /* ignore */ }
365
+ }
366
+ const isWildcard = nodeAgentHost === '0.0.0.0' || nodeAgentHost === '::' || nodeAgentHost === '';
367
+ if (!isWildcard) return nodeAgentHost;
368
+ for (const entries of Object.values(networkInterfaces())) {
369
+ for (const net of entries ?? []) {
370
+ if (net.family === 'IPv4' && !net.internal) return net.address;
371
+ }
372
+ }
373
+ return '127.0.0.1';
374
+ }
375
+
376
+ /** Shape a registry node view (from manager.list()) into a ClusterNodeRecord
377
+ * for Mongo persistence. */
378
+ function toClusterNodeRecord(n: {
379
+ id: string; role: string; tier: string; version?: string;
380
+ baseUrl: string; services?: Record<string, string>;
381
+ status: string; enabled: boolean; registeredAt: string; lastSeenAt: string;
382
+ lastHealth?: unknown; lastMetrics?: unknown;
383
+ }): ClusterNodeRecord {
384
+ return {
385
+ id: n.id, role: n.role, tier: n.tier, version: n.version,
386
+ baseUrl: n.baseUrl, services: n.services, status: n.status, enabled: n.enabled,
387
+ registeredAt: n.registeredAt, lastSeenAt: n.lastSeenAt,
388
+ lastHealth: n.lastHealth, lastMetrics: n.lastMetrics,
389
+ updatedAt: new Date().toISOString(),
390
+ };
391
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Admin routes for MongoDB database / collection administration.
3
+ *
4
+ * The admin app uses these to create, rename and drop databases and
5
+ * collections, and to preview documents. Collections can be created with a
6
+ * `$jsonSchema` validator (used by the AI schema generator).
7
+ *
8
+ * Routes (all guarded by the caller):
9
+ * GET /admin/databases → { databases: [{ name, sizeOnDisk, collections: [{ name, count }] }] }
10
+ * POST /admin/databases → { name } → create
11
+ * DELETE /admin/databases/:db → drop database
12
+ * POST /admin/databases/:db/collections → { name, jsonSchema? } → create
13
+ * PUT /admin/databases/:db/collections/:name → { newName? | validator? } → rename / collMod
14
+ * DELETE /admin/databases/:db/collections/:name → drop collection
15
+ * GET /admin/databases/:db/collections/:name/docs → { count, docs } (preview, max 10)
16
+ */
17
+ import type { Router, Middleware } from '@bhooai/nexus-core';
18
+ import { ValidationError as BadRequest } from '@bhooai/nexus-core';
19
+ import { getConnection } from '@bhooai/nexus-data';
20
+
21
+ const DOC_PREVIEW_LIMIT = 10;
22
+
23
+ /** Ugly but real: dropping a db's only collection deletes the whole database. */
24
+ const BOOTSTRAP_COLLECTION = '_nexus_bootstrap';
25
+
26
+ /** Databases managed by MongoDB itself — hidden from the admin surface. */
27
+ const SYSTEM_DATABASES = new Set(['admin', 'local', 'config']);
28
+
29
+ export function registerDatabaseRoutes(router: Router, guard: Middleware[]): void {
30
+ const client = () => getConnection().client;
31
+
32
+ router.get('/admin/databases', async (ctx) => {
33
+ const list = await client().db().admin().listDatabases();
34
+ const databases = [];
35
+ for (const d of list.databases) {
36
+ if (!d.name || SYSTEM_DATABASES.has(d.name)) continue;
37
+ const db = client().db(d.name);
38
+ const raw = await db.listCollections().toArray();
39
+ const collections = [];
40
+ for (const c of raw) {
41
+ if (c.name.startsWith(BOOTSTRAP_COLLECTION)) continue;
42
+ let count = 0;
43
+ try { count = await db.collection(c.name).countDocuments({}, { maxTimeMS: 5000 }); } catch { /* stats unavailable */ }
44
+ collections.push({ name: c.name, count });
45
+ }
46
+ databases.push({ name: d.name, sizeOnDisk: d.sizeOnDisk ?? 0, collections });
47
+ }
48
+ ctx.json({ databases });
49
+ }, guard);
50
+
51
+ router.post('/admin/databases', async (ctx) => {
52
+ const name = assertDbName((ctx.body as { name?: unknown } | undefined)?.name);
53
+ // Mongo creates a database lazily on first write. Keep a hidden bootstrap
54
+ // collection so the database exists immediately; internal collections are
55
+ // filtered out of the listing.
56
+ await client().db(name).createCollection(BOOTSTRAP_COLLECTION);
57
+ ctx.json({ ok: true, name });
58
+ }, guard);
59
+
60
+ router.delete('/admin/databases/:db', async (ctx) => {
61
+ const db = assertDbName(ctx.params.db);
62
+ await client().db(db).dropDatabase();
63
+ ctx.json({ ok: true, dropped: db });
64
+ }, guard);
65
+
66
+ router.post('/admin/databases/:db/collections', async (ctx) => {
67
+ const db = assertDbName(ctx.params.db);
68
+ const body = (ctx.body ?? {}) as { name?: unknown; jsonSchema?: unknown };
69
+ const name = assertCollectionName(body.name);
70
+ const validator = parseJsonSchema(body.jsonSchema);
71
+ const mongoDb = client().db(db);
72
+ if (validator) {
73
+ await mongoDb.createCollection(name, {
74
+ validator: { $jsonSchema: validator },
75
+ validationLevel: 'strict',
76
+ validationAction: 'error',
77
+ });
78
+ } else {
79
+ await mongoDb.createCollection(name);
80
+ }
81
+ ctx.json({ ok: true, db, collection: name, validator: validator ? { $jsonSchema: validator } : null });
82
+ }, guard);
83
+
84
+ router.put('/admin/databases/:db/collections/:name', async (ctx) => {
85
+ const db = assertDbName(ctx.params.db);
86
+ const name = assertCollectionName(ctx.params.name);
87
+ const body = (ctx.body ?? {}) as { newName?: unknown; validator?: unknown };
88
+ const mongoDb = client().db(db);
89
+ const changed: string[] = [];
90
+ if (body.newName !== undefined) {
91
+ const newName = assertCollectionName(body.newName);
92
+ if (newName !== name) {
93
+ await mongoDb.collection(name).rename(newName);
94
+ changed.push(`renamed to ${newName}`);
95
+ }
96
+ }
97
+ if (body.validator !== undefined) {
98
+ const validator = parseJsonSchema(body.validator);
99
+ await mongoDb.command({
100
+ collMod: changed.length ? String(body.newName) : name,
101
+ validator: { $jsonSchema: validator },
102
+ validationLevel: 'strict',
103
+ });
104
+ changed.push('validator updated');
105
+ }
106
+ if (!changed.length) {
107
+ ctx.json({ error: 'nothing to modify — send newName and/or validator' }, 400);
108
+ return;
109
+ }
110
+ ctx.json({ ok: true, db, collection: name, changed });
111
+ }, guard);
112
+
113
+ router.delete('/admin/databases/:db/collections/:name', async (ctx) => {
114
+ const db = assertDbName(ctx.params.db);
115
+ const name = assertCollectionName(ctx.params.name);
116
+ await client().db(db).dropCollection(name);
117
+ ctx.json({ ok: true, dropped: { db, collection: name } });
118
+ }, guard);
119
+
120
+ router.get('/admin/databases/:db/collections/:name/docs', async (ctx) => {
121
+ const db = assertDbName(ctx.params.db);
122
+ const name = assertCollectionName(ctx.params.name);
123
+ const coll = client().db(db).collection(name);
124
+ const count = await coll.countDocuments();
125
+ const docs = await coll.find({}).limit(DOC_PREVIEW_LIMIT).toArray();
126
+ ctx.json({ db, collection: name, count, docs });
127
+ }, guard);
128
+ }
129
+
130
+ function assertDbName(v: unknown): string {
131
+ const name = typeof v === 'string' ? v.trim() : '';
132
+ if (!name || name.length > 63) throw new BadRequest('database name is required (max 63 chars)');
133
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(name) || name.includes('..')) {
134
+ throw new BadRequest(`invalid database name: ${name}`);
135
+ }
136
+ return name;
137
+ }
138
+
139
+ function assertCollectionName(v: unknown): string {
140
+ const name = typeof v === 'string' ? v.trim() : '';
141
+ if (!name || name.length > 255) throw new BadRequest('collection name is required (max 255 chars)');
142
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(name) || name.startsWith('system.')) {
143
+ throw new BadRequest(`invalid collection name: ${name}`);
144
+ }
145
+ return name;
146
+ }
147
+
148
+ function parseJsonSchema(v: unknown): Record<string, unknown> | null {
149
+ if (v === undefined || v === null) return null;
150
+ if (typeof v === 'string') {
151
+ try { v = JSON.parse(v); } catch { throw new BadRequest('jsonSchema is not valid JSON'); }
152
+ }
153
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) {
154
+ throw new BadRequest('jsonSchema must be an object');
155
+ }
156
+ const schema = v as Record<string, unknown>;
157
+ if (schema.bsonType !== 'object' || typeof schema.properties !== 'object' || schema.properties === null) {
158
+ throw new BadRequest('jsonSchema must have bsonType: "object" and a properties object');
159
+ }
160
+ return schema;
161
+ }
@@ -0,0 +1,89 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import type { Router, Middleware, NexusConfig } from '@bhooai/nexus-core';
5
+
6
+ /**
7
+ * Config / .env linter proxy.
8
+ *
9
+ * Node reads the raw files (auth + file access boundary) and posts the text to
10
+ * the Python AI server, which computes the lint report. This keeps linting
11
+ * logic in Python and file/secret ownership in Node.
12
+ *
13
+ * POST /admin/lint/env { envText } -> report
14
+ * POST /admin/lint/config { content } -> report
15
+ */
16
+
17
+ export interface LintCheck { key: string; severity: 'error' | 'warning' | 'info' | 'ok'; kind: string; message: string; errorCategory?: string }
18
+ export interface LintReport { ranAt: string; engineOk?: boolean; summary: { error: number; warning: number; info: number; ok: number }; checks: LintCheck[] }
19
+
20
+ function envPathOf(root: string, name: string): string {
21
+ const safe = /^\.env(?:\.\w+)*$/.test(name ?? '.env') ? name : '.env';
22
+ return join(root, safe);
23
+ }
24
+
25
+ export function registerLintRoutes(router: Router, config: NexusConfig, root: string, guard: Middleware[]): void {
26
+ const serverUrlRaw = (config.ai?.serverUrl as string) ?? 'http://localhost:8000';
27
+ const serverUrl = serverUrlRaw.replace(/\/+$/, '');
28
+ const timeoutMs = (config.ai?.timeoutMs as number) ?? 60_000;
29
+
30
+ const toPython = async (path: string, body: Record<string, unknown>): Promise<LintReport> => {
31
+ const controller = new AbortController();
32
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
33
+ try {
34
+ const res = await fetch(`${serverUrl}${path}`, {
35
+ method: 'POST',
36
+ headers: { 'content-type': 'application/json' },
37
+ body: JSON.stringify(body),
38
+ signal: controller.signal,
39
+ });
40
+ if (!res.ok) throw new Error(`AI server returned HTTP ${res.status}`);
41
+ return (await res.json()) as LintReport;
42
+ } finally {
43
+ clearTimeout(timer);
44
+ }
45
+ };
46
+
47
+ const failed = (): LintReport => ({
48
+ ranAt: new Date().toISOString(),
49
+ engineOk: false,
50
+ summary: { error: 1, warning: 0, info: 0, ok: 0 },
51
+ checks: [
52
+ {
53
+ key: 'linter-engine',
54
+ severity: 'error',
55
+ kind: 'engine',
56
+ errorCategory: 'engine_offline',
57
+ message: 'Python diagnostics engine offline - start it with "python main.py" or "nexus dev"',
58
+ },
59
+ ],
60
+ });
61
+
62
+ router.post('/admin/lint/env', async (ctx) => {
63
+ const body = (ctx.body ?? {}) as { env?: unknown; text?: unknown };
64
+ const envName = typeof body.env === 'string' && body.env.trim() ? body.env.trim() : '.env';
65
+ try {
66
+ const envPath = envPathOf(root, envName);
67
+ const envText = existsSync(envPath) ? await readFile(envPath, 'utf8') : '';
68
+ try {
69
+ ctx.json(await toPython('/lint/env', { envText }));
70
+ } catch (err) {
71
+ void err;
72
+ ctx.json(failed());
73
+ }
74
+ } catch (err) {
75
+ ctx.json({ error: (err as Error).message, summary: { error: 0, warning: 0, info: 0, ok: 0 }, checks: [] });
76
+ }
77
+ }, guard);
78
+
79
+ router.post('/admin/lint/config', async (ctx) => {
80
+ const runtimePath = join(root, 'nexus.runtime.json');
81
+ const content = existsSync(runtimePath) ? await readFile(runtimePath, 'utf8') : '{}';
82
+ try {
83
+ ctx.json(await toPython('/lint/config', { content }));
84
+ } catch (err) {
85
+ void err;
86
+ ctx.json(failed());
87
+ }
88
+ }, guard);
89
+ }