@bhooai/nexus-cli 0.1.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/package.json +8 -12
  2. package/src/commands/add.ts +274 -0
  3. package/src/commands/build.ts +91 -0
  4. package/src/commands/db.ts +241 -0
  5. package/src/commands/dev.ts +149 -186
  6. package/src/commands/doctor.ts +102 -184
  7. package/src/commands/init.ts +204 -958
  8. package/src/commands/maintenance-commands.ts +40 -0
  9. package/src/commands/plugin.ts +233 -0
  10. package/src/commands/queue.ts +145 -0
  11. package/src/commands/test.ts +34 -0
  12. package/src/dispatcher.ts +98 -0
  13. package/src/generators.ts +102 -0
  14. package/src/index.ts +32 -90
  15. package/src/make/generators.ts +431 -0
  16. package/src/ports.ts +85 -0
  17. package/src/prompts.ts +68 -0
  18. package/src/templating/render.ts +109 -0
  19. package/src/util.ts +74 -112
  20. package/templates/base/Dockerfile.ejs +68 -0
  21. package/templates/base/README.md.ejs +62 -0
  22. package/templates/base/apps/admin/index.html.ejs +12 -0
  23. package/templates/base/apps/admin/nginx.conf.ejs +16 -0
  24. package/templates/base/apps/admin/package.json.ejs +15 -0
  25. package/templates/base/apps/admin/src/main.tsx.ejs +7 -0
  26. package/templates/base/apps/admin/vite.config.ts.ejs +22 -0
  27. package/templates/base/apps/ai-server/main.py.ejs +30 -0
  28. package/templates/base/apps/ai-server/requirements.txt +4 -0
  29. package/templates/base/apps/backend/src/main.ts +14 -0
  30. package/templates/base/apps/backend/src/routes/index.ts.ejs +11 -0
  31. package/templates/base/apps/frontend/index.html.ejs +12 -0
  32. package/templates/base/apps/frontend/nginx.conf.ejs +33 -0
  33. package/templates/base/apps/frontend/package.json.ejs +11 -0
  34. package/templates/base/apps/frontend/src/main.tsx.ejs +16 -0
  35. package/templates/base/apps/frontend/vite.config.ts.ejs +22 -0
  36. package/templates/base/docker-compose.yml.ejs +93 -0
  37. package/templates/base/dockerignore +21 -0
  38. package/templates/base/gitignore +43 -0
  39. package/templates/base/nexus.config.ts.ejs +97 -0
  40. package/templates/base/package.json.ejs +40 -0
  41. package/templates/{tsconfig.json → base/tsconfig.json} +2 -3
  42. package/tsconfig.json +5 -4
  43. package/PLAN.md +0 -141
  44. package/README.md +0 -34
  45. package/src/commands/cluster.ts +0 -133
  46. package/src/commands/node.ts +0 -106
  47. package/src/commands/pysetup.ts +0 -142
  48. package/src/commands/sync.ts +0 -116
  49. package/src/commands/uninstall.ts +0 -287
  50. package/src/config-sync.ts +0 -384
  51. package/src/dotenv.ts +0 -39
  52. package/src/supervisor.ts +0 -394
  53. package/src/wizard.ts +0 -149
  54. package/templates/Dockerfile +0 -60
  55. package/templates/README.md +0 -69
  56. package/templates/apps/admin/index.html +0 -12
  57. package/templates/apps/admin/package.json +0 -22
  58. package/templates/apps/admin/postcss.config.js +0 -3
  59. package/templates/apps/admin/src/main.tsx +0 -10
  60. package/templates/apps/admin/src/vite-env.d.ts +0 -18
  61. package/templates/apps/admin/tailwind.config.js +0 -9
  62. package/templates/apps/admin/tsconfig.json +0 -17
  63. package/templates/apps/admin/vite.config.ts +0 -64
  64. package/templates/apps/ai-server/main.py +0 -43
  65. package/templates/apps/ai-server/providers/__init__.py +0 -3
  66. package/templates/apps/ai-server/providers/base.py +0 -111
  67. package/templates/apps/ai-server/requirements.txt +0 -3
  68. package/templates/apps/ai-server/routers/__init__.py +0 -3
  69. package/templates/apps/ai-server/routers/chat.py +0 -47
  70. package/templates/apps/ai-server/routers/embeddings.py +0 -30
  71. package/templates/apps/ai-server/routers/lint.py +0 -167
  72. package/templates/apps/ai-server/routers/models.py +0 -23
  73. package/templates/apps/ai-server/routers/preflight.py +0 -169
  74. package/templates/apps/ai-server/settings.py +0 -48
  75. package/templates/apps/backend/package.json +0 -34
  76. package/templates/apps/backend/src/main.ts +0 -375
  77. package/templates/apps/backend/src/modules/admin/adminRoutes.ts +0 -732
  78. package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +0 -391
  79. package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +0 -161
  80. package/templates/apps/backend/src/modules/admin/lintProxy.ts +0 -89
  81. package/templates/apps/backend/src/modules/admin/preflightProxy.ts +0 -242
  82. package/templates/apps/backend/src/modules/admin/roleCatalog.ts +0 -78
  83. package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +0 -449
  84. package/templates/apps/backend/src/modules/ai/aiProxy.ts +0 -265
  85. package/templates/apps/backend/src/modules/auth/authRoutes.ts +0 -220
  86. package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +0 -100
  87. package/templates/apps/backend/src/modules/payments/paymentStore.ts +0 -172
  88. package/templates/apps/backend/src/modules/requests/requestLog.ts +0 -175
  89. package/templates/apps/backend/src/modules/users/userGraph.ts +0 -83
  90. package/templates/apps/backend/src/modules/users/userModel.ts +0 -88
  91. package/templates/apps/backend/src/plugins/CronScheduler.ts +0 -69
  92. package/templates/apps/backend/src/plugins/loadPlugins.ts +0 -107
  93. package/templates/apps/backend/tsconfig.json +0 -14
  94. package/templates/apps/frontend/index.html +0 -12
  95. package/templates/apps/frontend/package.json +0 -20
  96. package/templates/apps/frontend/postcss.config.js +0 -3
  97. package/templates/apps/frontend/src/index.css +0 -44
  98. package/templates/apps/frontend/src/main.tsx +0 -65
  99. package/templates/apps/frontend/tailwind.config.js +0 -9
  100. package/templates/apps/frontend/vite.config.ts +0 -72
  101. package/templates/bin/nexus.js +0 -35
  102. package/templates/bin/serve-all.mjs +0 -45
  103. package/templates/dockerignore +0 -15
  104. package/templates/gitignore +0 -12
  105. package/templates/nexus.config.ts +0 -69
  106. package/templates/package.json +0 -47
  107. package/tests/cli.test.ts +0 -52
  108. package/tests/config-sync.test.ts +0 -201
  109. package/tests/dotenv.test.ts +0 -51
  110. package/vitest.config.ts +0 -9
  111. package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +0 -13
  112. /package/templates/{uploads → base/apps/backend/storage}/.gitkeep +0 -0
package/src/dotenv.ts DELETED
@@ -1,39 +0,0 @@
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/supervisor.ts DELETED
@@ -1,394 +0,0 @@
1
- import { spawn, execFile, ChildProcess } from 'node:child_process';
2
- import { createServer, IncomingMessage, ServerResponse } from 'node:http';
3
- import { EOL } from 'node:os';
4
- import { dirname, join, resolve } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
- import { writeFile } from 'node:fs/promises';
7
-
8
- /**
9
- * The framework workspace root (the dir containing `packages/`, `apps/`, `bin/`), resolved
10
- * from this file so it's correct whether run from `src/` (tsx) or `dist/` (compiled), and
11
- * independent of the launcher's cwd. Used to put the framework's `node_modules/.bin` on the
12
- * child PATH so `tsx`/`vite` resolve even when the supervisor is launched directly via
13
- * `node bin/nexus.js dev` (where npm hasn't prepended `.bin` to PATH).
14
- */
15
- const FRAMEWORK_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
16
- const PATH_SEP = process.platform === 'win32' ? ';' : ':';
17
-
18
- export interface ServiceSpec {
19
- name: string;
20
- /** Command + args to run. */
21
- command: string[];
22
- /** Working directory relative to project root. */
23
- cwd: string;
24
- /** ANSI color for log prefix. */
25
- color: string;
26
- /** When true, a start failure is logged but does not stop the supervisor. */
27
- optional?: boolean;
28
- env?: Record<string, string>;
29
- }
30
-
31
- export interface ServiceState {
32
- name: string;
33
- status: 'stopped' | 'running' | 'errored';
34
- pid?: number;
35
- startedAt?: number;
36
- lastExitCode?: number | null;
37
- logTail: string[];
38
- }
39
-
40
- /** Structured log entry returned by /logs/all. */
41
- export interface LogEntry {
42
- service: string;
43
- ts: number;
44
- source: 'stdout' | 'stderr' | 'system';
45
- level: 'info' | 'warn' | 'error';
46
- line: string;
47
- }
48
-
49
- const MAX_TAIL = 200;
50
- /** Max aggregated log entries across all services. */
51
- const MAX_ALL_LOGS = 2000;
52
-
53
- /**
54
- * Inbuilt process supervisor for the four Nexus terminals.
55
- * Owns child processes, streams prefixed colored logs, handles graceful shutdown,
56
- * and exposes a localhost HTTP control API for the admin app.
57
- */
58
- export class Supervisor {
59
- private procs = new Map<string, ChildProcess>();
60
- private states = new Map<string, ServiceState>();
61
- private controlServer?: ReturnType<typeof createServer>;
62
- private shuttingDown = false;
63
- /** The port the control API actually bound (may differ when auto-allotted). */
64
- private boundControlPort = 0;
65
- /** Aggregated structured log buffer across all services - powers /logs/all. */
66
- private allLogs: LogEntry[] = [];
67
-
68
- constructor(
69
- private services: ServiceSpec[],
70
- private root: string,
71
- private controlPort = 7474,
72
- ) {
73
- for (const s of services) {
74
- this.states.set(s.name, { name: s.name, status: 'stopped', logTail: [] });
75
- }
76
- }
77
-
78
- /** The port the supervisor control API actually bound (0 until started). */
79
- get controlEndpointPort(): number {
80
- return this.boundControlPort;
81
- }
82
-
83
- private async tryListen(server: ReturnType<typeof createServer>, port: number, host = '127.0.0.1'): Promise<void> {
84
- return new Promise((resolve, reject) => {
85
- const onError = (err: NodeJS.ErrnoException) => {
86
- server.removeListener('listening', onListening);
87
- server.close();
88
- reject(err);
89
- };
90
- const onListening = () => {
91
- server.removeListener('error', onError);
92
- resolve();
93
- };
94
- server.once('error', onError);
95
- server.once('listening', onListening);
96
- server.listen(port, host);
97
- });
98
- }
99
-
100
- startService(spec: ServiceSpec): void {
101
- const state = this.states.get(spec.name)!;
102
- if (state.status === 'running') return;
103
- const [cmd, ...args] = spec.command;
104
- if (!cmd) {
105
- state.status = 'errored';
106
- this.pushLog(spec.name, `no command specified for ${spec.name}`, 'system');
107
- return;
108
- }
109
- const cwdAbs = resolve(this.root, spec.cwd);
110
- // Put local + framework `node_modules/.bin` first on PATH so bins like `tsx`/`vite`
111
- // resolve even when the supervisor is launched directly (not via `npm run dev`).
112
- // Windows keeps the original `Path` if we merely add `PATH`, so drop all case variants
113
- // before setting it.
114
- const origPath = process.env.PATH ?? process.env.Path ?? process.env.path ?? '';
115
- const binDirs = [
116
- join(cwdAbs, 'node_modules', '.bin'),
117
- join(this.root, 'node_modules', '.bin'),
118
- join(FRAMEWORK_ROOT, 'node_modules', '.bin'),
119
- ];
120
- const childEnv: NodeJS.ProcessEnv = { ...process.env, ...spec.env };
121
- for (const k of ['PATH', 'Path', 'path']) delete childEnv[k];
122
- childEnv.PATH = [...binDirs, origPath].filter(Boolean).join(PATH_SEP);
123
-
124
- // Windows: service commands are often .cmd shims (tsx/vite/npm) that need a
125
- // shell. Pass the full command line instead of a separate args array to avoid
126
- // Node's DEP0190 warning (args + shell: true are only concatenated anyway).
127
- const win = process.platform === 'win32';
128
- const proc = win
129
- ? spawn([cmd, ...args].map((t) => (/\s/.test(t) ? `"${t}"` : t)).join(' '), {
130
- cwd: cwdAbs,
131
- env: childEnv,
132
- shell: true,
133
- stdio: ['ignore', 'pipe', 'pipe'],
134
- })
135
- : spawn(cmd, args, {
136
- cwd: cwdAbs,
137
- env: childEnv,
138
- stdio: ['ignore', 'pipe', 'pipe'],
139
- });
140
- this.procs.set(spec.name, proc);
141
- state.status = 'running';
142
- state.pid = proc.pid;
143
- state.startedAt = Date.now();
144
-
145
- const prefix = `${spec.color}[${spec.name}]${EOL}${'\x1b[0m'}`;
146
- const onChunk = (stream: NodeJS.ReadableStream, isErr: boolean) => {
147
- let buf = '';
148
- stream.on('data', (chunk: Buffer) => {
149
- buf += chunk.toString();
150
- // Split on CRLF or LF. Children mix line endings on Windows: our own
151
- // Logger writes os.EOL (\r\n), but Vite/uvicorn/third-party CLIs write
152
- // LF only. Splitting on os.EOL alone left LF-terminated output stuck in
153
- // `buf` forever (Vite's whole startup banner never appeared). Re-emit
154
- // with the platform EOL so the host terminal gets clean line breaks.
155
- const lines = buf.split(/\r?\n/);
156
- buf = lines.pop() ?? '';
157
- for (const line of lines) {
158
- const target = isErr ? process.stderr : process.stdout;
159
- target.write(`${spec.color}[${spec.name}]\x1b[0m ${line}${EOL}`);
160
- this.pushLog(spec.name, line, isErr ? 'stderr' : 'stdout');
161
- }
162
- });
163
- };
164
- onChunk(proc.stdout!, false);
165
- onChunk(proc.stderr!, true);
166
-
167
- proc.on('exit', (code, signal) => {
168
- // An intentional stop sets status to 'stopped' first, so don't flip a
169
- // stopped service to 'errored' when its process tree finally exits.
170
- state.status = this.shuttingDown || state.status === 'stopped' ? 'stopped' : 'errored';
171
- state.lastExitCode = code;
172
- state.pid = undefined;
173
- this.pushLog(spec.name, `exited code=${code} signal=${signal}`, 'system');
174
- if (!this.shuttingDown && !spec.optional) {
175
- this.pushLog(spec.name, `required service stopped - supervisor continuing (run 'nexus dev restart ${spec.name}')`, 'system');
176
- }
177
- });
178
- proc.on('error', (err) => {
179
- state.status = 'errored';
180
- this.pushLog(spec.name, `failed to start: ${err.message}`, 'system');
181
- });
182
- }
183
-
184
- private pushLog(name: string, line: string, source: LogEntry['source'] = 'stdout'): void {
185
- const state = this.states.get(name);
186
- if (!state) return;
187
- state.logTail.push(line);
188
- if (state.logTail.length > MAX_TAIL) state.logTail.shift();
189
-
190
- // Also add to the aggregated structured buffer for /logs/all.
191
- const entry: LogEntry = {
192
- service: name,
193
- ts: Date.now(),
194
- source,
195
- level: detectLevel(line),
196
- line,
197
- };
198
- this.allLogs.push(entry);
199
- if (this.allLogs.length > MAX_ALL_LOGS) this.allLogs.shift();
200
- }
201
-
202
- stopService(name: string): void {
203
- const proc = this.procs.get(name);
204
- if (!proc) return;
205
- const state = this.states.get(name)!;
206
- state.status = 'stopped';
207
- // Windows: services spawn with `shell: true`, so `proc` IS cmd.exe and
208
- // `proc.kill()` only terminates that one wrapper - the tsx/vite/python
209
- // process tree below it survives as an orphan and the port stays bound.
210
- // `taskkill /T /F` kills the whole tree. POSIX: signal the process group.
211
- try {
212
- if (process.platform === 'win32' && proc.pid) {
213
- execFile('taskkill', ['/PID', String(proc.pid), '/T', '/F'], { windowsHide: true }, () => {});
214
- } else if (proc.pid) {
215
- try {
216
- process.kill(-proc.pid, 'SIGTERM');
217
- } catch {
218
- proc.kill('SIGTERM');
219
- }
220
- }
221
- } catch {
222
- /* ignore */
223
- }
224
- this.procs.delete(name);
225
- }
226
-
227
- restartService(name: string): void {
228
- const spec = this.services.find((s) => s.name === name);
229
- if (!spec) return;
230
- this.stopService(name);
231
- setTimeout(() => this.startService(spec), 300);
232
- }
233
-
234
- status(): ServiceState[] {
235
- return this.services.map((s) => ({ ...this.states.get(s.name)! }));
236
- }
237
-
238
- /** Start all services and the control API. Resolves when started. */
239
- async start(): Promise<void> {
240
- // Bind the control API first so auto-allotted ports are known before the
241
- // admin service boots (VITE_SUPERVISOR_URL can point at the real port).
242
- await this.startControlServer();
243
- this.injectControlEnv();
244
- for (const s of this.services) this.startService(s);
245
- process.on('SIGINT', () => this.shutdown());
246
- process.on('SIGTERM', () => this.shutdown());
247
- }
248
-
249
- /** Give the admin service (and any other) the actual control URL to call back. */
250
- private injectControlEnv(): void {
251
- if (!this.boundControlPort) return;
252
- for (const s of this.services) {
253
- if (s.name === 'admin') {
254
- s.env = { ...(s.env ?? {}), VITE_SUPERVISOR_URL: `http://127.0.0.1:${this.boundControlPort}` };
255
- }
256
- }
257
- }
258
-
259
- /**
260
- * Bind the control API on 127.0.0.1. When the requested port is already
261
- * taken (EADDRINUSE - e.g. another `nexus dev` is running), automatically
262
- * keep trying the next free port, then persist the actual bound port to
263
- * `supervisor.json` at the project root so the backend/admin can discover it.
264
- */
265
- private async startControlServer(): Promise<void> {
266
- const server = createServer((req, res) => this.handleControl(req, res));
267
- const requested = this.controlPort;
268
- const MAX_ATTEMPTS = 100;
269
- let bound = false;
270
- for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
271
- const port = requested + attempt;
272
- try {
273
- await this.tryListen(server, port);
274
- this.boundControlPort = port;
275
- this.controlPort = port;
276
- server.on('error', () => { /* ignore post-bind errors on the control socket */ });
277
- process.stdout.write(`\x1b[36m[supervisor]\x1b[0m control API on http://127.0.0.1:${port}${EOL}`);
278
- if (port !== requested) {
279
- process.stdout.write(`\x1b[36m[supervisor]\x1b[0m note: ${requested} was busy - control API moved to port ${port}${EOL}`);
280
- }
281
- this.controlServer = server;
282
- await this.writeSupervisorInfo();
283
- bound = true;
284
- break;
285
- } catch (err) {
286
- // Keep scanning upward on port collisions; give up on anything else.
287
- if ((err as NodeJS.ErrnoException).code !== 'EADDRINUSE') {
288
- process.stdout.write(`\x1b[31m[supervisor]\x1b[0m control API failed to start: ${(err as Error).message}${EOL}`);
289
- break;
290
- }
291
- }
292
- }
293
- if (!bound) {
294
- process.stdout.write(`\x1b[31m[supervisor]\x1b[0m no free control port near ${requested} (tried ${MAX_ATTEMPTS} ports)${EOL}`);
295
- }
296
- }
297
-
298
- /** Write `supervisor.json` with the actual control endpoint for the project. */
299
- private async writeSupervisorInfo(): Promise<void> {
300
- try {
301
- await writeFile(
302
- join(this.root, 'supervisor.json'),
303
- JSON.stringify({
304
- port: this.boundControlPort,
305
- url: this.boundControlPort ? `http://127.0.0.1:${this.boundControlPort}` : undefined,
306
- writtenAt: new Date().toISOString(),
307
- }, null, 2) + '\n',
308
- 'utf8',
309
- );
310
- } catch (err) {
311
- process.stdout.write(`\x1b[33m[supervisor]\x1b[0m could not write supervisor.json: ${(err as Error).message}${EOL}`);
312
- }
313
- }
314
-
315
- private handleControl(req: IncomingMessage, res: ServerResponse): void {
316
- const url = new URL(req.url ?? '/', `http://127.0.0.1:${this.controlPort}`);
317
- // Permissive CORS so the browser-based admin (localhost:3300) can call us.
318
- res.setHeader('access-control-allow-origin', '*');
319
- res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
320
- res.setHeader('access-control-allow-headers', 'content-type, authorization');
321
- if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); return; }
322
- res.setHeader('content-type', 'application/json');
323
- if (req.method === 'GET' && url.pathname === '/status') {
324
- res.end(JSON.stringify({ services: this.status() }));
325
- return;
326
- }
327
- if (req.method === 'POST' && url.pathname === '/start') {
328
- const name = url.searchParams.get('name');
329
- const spec = this.services.find((s) => s.name === name);
330
- if (spec && name) this.startService(spec);
331
- res.end(JSON.stringify({ ok: !!spec }));
332
- return;
333
- }
334
- if (req.method === 'POST' && url.pathname === '/restart') {
335
- const name = url.searchParams.get('name');
336
- if (name) this.restartService(name);
337
- res.end(JSON.stringify({ ok: !!name }));
338
- return;
339
- }
340
- if (req.method === 'POST' && url.pathname === '/stop') {
341
- const name = url.searchParams.get('name');
342
- if (name) this.stopService(name);
343
- res.end(JSON.stringify({ ok: !!name }));
344
- return;
345
- }
346
- if (req.method === 'GET' && url.pathname === '/logs') {
347
- const name = url.searchParams.get('name');
348
- const state = name ? this.states.get(name) : undefined;
349
- res.end(JSON.stringify({ logs: state?.logTail ?? [] }));
350
- return;
351
- }
352
- if (req.method === 'GET' && url.pathname === '/logs/all') {
353
- const service = url.searchParams.get('service') ?? '';
354
- const level = url.searchParams.get('level') ?? '';
355
- const q = url.searchParams.get('q') ?? '';
356
- let entries = this.allLogs;
357
- if (service) entries = entries.filter((e) => e.service === service);
358
- if (level) entries = entries.filter((e) => e.level === level);
359
- if (q) {
360
- const lower = q.toLowerCase();
361
- entries = entries.filter((e) => e.line.toLowerCase().includes(lower));
362
- }
363
- res.end(JSON.stringify({ logs: entries }));
364
- return;
365
- }
366
- if (req.method === 'POST' && url.pathname === '/logs/clear') {
367
- for (const state of this.states.values()) state.logTail = [];
368
- this.allLogs = [];
369
- res.end(JSON.stringify({ ok: true }));
370
- return;
371
- }
372
- res.statusCode = 404;
373
- res.end(JSON.stringify({ error: 'not found' }));
374
- }
375
-
376
- async shutdown(): Promise<void> {
377
- if (this.shuttingDown) return;
378
- this.shuttingDown = true;
379
- process.stdout.write(`\x1b[36m[supervisor]\x1b[0m shutting down...${EOL}`);
380
- for (const name of this.procs.keys()) this.stopService(name);
381
- this.controlServer?.close();
382
- // Give processes a moment to exit.
383
- await new Promise((r) => setTimeout(r, 200));
384
- process.exit(0);
385
- }
386
- }
387
-
388
- /** Classify a log line's severity from its content. */
389
- function detectLevel(line: string): LogEntry['level'] {
390
- const lower = line.toLowerCase();
391
- if (/\berror\b/.test(lower) || /\bfatal\b/.test(lower) || /\bfail(ed|ure)?\b/.test(lower) || /\bexception\b/.test(lower)) return 'error';
392
- if (/\bwarn(ing)?\b/.test(lower) || /\bunauthorized\b/.test(lower) || /\bforbidden\b/.test(lower)) return 'warn';
393
- return 'info';
394
- }
package/src/wizard.ts DELETED
@@ -1,149 +0,0 @@
1
- /**
2
- * Shared wizard primitives for interactive CLI commands (`nexus init`, `nexus pysetup --interactive`).
3
- *
4
- * Everything here is dependency-free and built on `node:readline/promises` (already used
5
- * by `chooseKind` in init.ts). When stdin is not a TTY (CI, piped input) or `--no-interactive`
6
- * is set, `isInteractive()` returns false and the prompt functions fall back to their
7
- * defaults so commands remain scriptable.
8
- */
9
- import { createInterface, type Interface } from 'node:readline/promises';
10
-
11
- const GREEN = '\x1b[32m';
12
- const RED = '\x1b[31m';
13
- const YELLOW = '\x1b[33m';
14
- const CYAN = '\x1b[36m';
15
- const DIM = '\x1b[2m';
16
- const BOLD = '\x1b[1m';
17
- const RESET = '\x1b[0m';
18
-
19
- /** True when the user can be prompted: a real TTY and not running under CI. */
20
- export function isInteractive(): boolean {
21
- return !!process.stdin.isTTY && !process.env.CI;
22
- }
23
-
24
- let rl: Interface | null = null;
25
-
26
- function rlIf(): Interface {
27
- if (!rl) {
28
- rl = createInterface({ input: process.stdin, output: process.stdout, terminal: isInteractive() });
29
- }
30
- return rl;
31
- }
32
-
33
- /** Close the readline interface (call at the end of a wizard run). */
34
- export function closeWizard(): void {
35
- if (rl) {
36
- rl.close();
37
- rl = null;
38
- }
39
- }
40
-
41
- /** Print a section banner. */
42
- export function banner(title: string, lines: string[] = []): void {
43
- console.log(`\n${BOLD} * ${title}${RESET}`);
44
- for (const line of lines) console.log(` ${DIM}${line}${RESET}`);
45
- console.log();
46
- }
47
-
48
- /** Print a status icon for a boolean result. */
49
- export function statusIcon(ok: boolean): string {
50
- return ok ? `${GREEN}[OK]${RESET}` : `${RED}[X]${RESET}`;
51
- }
52
-
53
- /** Prompt for a free-text string. Returns the default when non-interactive or empty input. */
54
- export async function prompt(question: string, defaultValue?: string): Promise<string> {
55
- if (!isInteractive()) return defaultValue ?? '';
56
- const suffix = defaultValue !== undefined ? ` ${DIM}[${defaultValue}]${RESET}` : '';
57
- const answer = (await rlIf().question(` ${question}${suffix} `)).trim();
58
- return answer || defaultValue || '';
59
- }
60
-
61
- /**
62
- * Prompt for a yes/no confirmation. Returns the default when non-interactive.
63
- * Default defaults to true. Accepts y/yes/n/no (case-insensitive).
64
- */
65
- export async function confirm(question: string, defaultValue = true): Promise<boolean> {
66
- if (!isInteractive()) return defaultValue;
67
- const hint = defaultValue ? 'Y/n' : 'y/N';
68
- const answer = (await rlIf().question(` ${question} ${DIM}[${hint}]${RESET} `)).trim().toLowerCase();
69
- if (!answer) return defaultValue;
70
- return /^[yt]/i.test(answer);
71
- }
72
-
73
- /** Prompt for a hidden (masked) string - used for API keys / secrets. */
74
- export async function promptHidden(question: string): Promise<string> {
75
- if (!isInteractive()) return '';
76
- // readline/promises has no built-in hidden mode; mute stdout by writing spaces.
77
- // For secrets we accept that the value echoes in CI-style terminals; non-interactive
78
- // paths never reach here and tests use --no-interactive so this is dev-only.
79
- const answer = (await rlIf().question(` ${question} `)).trim();
80
- return answer;
81
- }
82
-
83
- /**
84
- * Prompt for a single choice from a list. Returns the option's value.
85
- * Options are 1-indexed in the prompt; press Enter to accept the default.
86
- */
87
- export async function select<T extends string>(question: string, options: Array<{ label: string; value: T }>, defaultValue?: T): Promise<T> {
88
- if (!isInteractive()) return defaultValue ?? options[0]!.value;
89
- console.log(` ${question}`);
90
- options.forEach((o, i) => {
91
- const marker = defaultValue === o.value ? `${CYAN}*${RESET}` : ' ';
92
- console.log(` ${marker} ${i + 1}) ${o.label}`);
93
- });
94
- const hint = defaultValue !== undefined ? ` (default ${options.findIndex((o) => o.value === defaultValue) + 1})` : '';
95
- const answer = (await rlIf().question(` Choose${hint}: `)).trim();
96
- if (!answer) return defaultValue ?? options[0]!.value;
97
- const idx = parseInt(answer, 10);
98
- if (Number.isFinite(idx) && idx >= 1 && idx <= options.length) return options[idx - 1]!.value;
99
- // also accept a matching label/value
100
- const match = options.find((o) => o.value === answer || o.label.toLowerCase() === answer.toLowerCase());
101
- return match ? match.value : defaultValue ?? options[0]!.value;
102
- }
103
-
104
- /**
105
- * Prompt for multiple choices (toggle each). Returns the selected values.
106
- * Accepts comma-separated indices or labels (e.g. "1,3" or "openai,ollama").
107
- */
108
- export async function multiSelect<T extends string>(question: string, options: Array<{ label: string; value: T }>, defaultValues: T[] = []): Promise<T[]> {
109
- if (!isInteractive()) return defaultValues;
110
- console.log(` ${question} ${DIM}(comma-separated indices, Enter for defaults)${RESET}`);
111
- options.forEach((o, i) => {
112
- const marker = defaultValues.includes(o.value) ? `${CYAN}*${RESET}` : ' ';
113
- console.log(` ${marker} ${i + 1}) ${o.label}`);
114
- });
115
- const answer = (await rlIf().question(` Choose: `)).trim();
116
- if (!answer) return defaultValues;
117
- const selected: T[] = [];
118
- for (const token of answer.split(/[,\s]+/).filter(Boolean)) {
119
- const idx = parseInt(token, 10);
120
- if (Number.isFinite(idx) && idx >= 1 && idx <= options.length) {
121
- const v = options[idx - 1]!.value;
122
- if (!selected.includes(v)) selected.push(v);
123
- continue;
124
- }
125
- const match = options.find((o) => o.value === token || o.label.toLowerCase() === token.toLowerCase());
126
- if (match && !selected.includes(match.value)) selected.push(match.value);
127
- }
128
- return selected;
129
- }
130
-
131
- /** A row in a summary table. */
132
- export interface SummaryRow {
133
- label: string;
134
- value: string;
135
- ok?: boolean;
136
- }
137
-
138
- /** Render a two-column summary table (label | value). */
139
- export function summaryTable(rows: SummaryRow[]): void {
140
- const labelWidth = Math.max(8, ...rows.map((r) => r.label.length));
141
- for (const r of rows) {
142
- const icon = r.ok !== undefined ? statusIcon(r.ok) : ' ';
143
- const label = r.label.padEnd(labelWidth);
144
- const value = r.ok === false ? `${YELLOW}${r.value}${RESET}` : r.value;
145
- console.log(` ${icon} ${label} ${value}`);
146
- }
147
- }
148
-
149
- export const COLORS = { GREEN, RED, YELLOW, CYAN, DIM, BOLD, RESET };
@@ -1,60 +0,0 @@
1
- # BhooAI Nexus image - backend + built frontend + built admin in one container.
2
- #
3
- # Build:
4
- # docker build -t node-1 .
5
- # Run (pass project secrets; real env vars always win over .env):
6
- # docker run --rm -p 4000:4000 -p 3000:3000 -p 3300:3300 --env-file .env node-1
7
- # # or override the server port / cluster node agent port:
8
- # docker run --rm -p 4000:4000 -p 3000:3000 -p 3300:3300 -e NEXUS_SERVER_PORT=4000 -e NEXUS_NODE_PORT=7575 node-1
9
- #
10
- # Access:
11
- # frontend http://localhost:3000 (SPA; /health /graphql /auth /ai /ws proxied to the backend)
12
- # admin http://localhost:3300 (SPA; /admin /auth proxied to the backend)
13
- # backend http://localhost:4000 (API + /health)
14
- # node http://localhost:7575 (cluster node agent — link it from a root: `nexus cluster link http://<host>:7575`)
15
- #
16
- # The Python AI server, MongoDB, and Redis remain separate services and must
17
- # already be reachable (see nexus.config.*).
18
-
19
- ARG NODE_VERSION=24
20
-
21
- FROM node:${NODE_VERSION}-alpine AS base
22
-
23
- WORKDIR /app
24
-
25
- # npm workspaces: install deps for packages/*, apps/backend, admin, apps/frontend.
26
- # The dev deps (vite, typescript, tsx) are needed both to build the SPAs and at runtime.
27
- COPY package.json package-lock.json* ./
28
- COPY packages packages
29
- COPY apps apps
30
- COPY apps/admin apps/admin
31
- RUN npm install --no-audit --no-fund
32
-
33
- # Build the frontend + admin SPA bundles (static dist/ folders).
34
- RUN npm --prefix apps/frontend run build \
35
- && npm --prefix apps/admin run build
36
-
37
- # Full source tree (config, bin, uploads dir, tsconfig).
38
- COPY . .
39
-
40
- # The uploads dir is written at runtime (uploads / certs).
41
- RUN mkdir -p uploads && chown -R node:node /app
42
-
43
- # Default ports are informational; the real values come from nexus.config.* /
44
- # NEXUS_* env overrides at runtime.
45
- EXPOSE 3000 3300 4000 7575
46
-
47
- ENV NODE_ENV=production
48
-
49
- HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
50
- CMD wget -qO- http://127.0.0.1:${NEXUS_SERVER_PORT:-4000}/health >/dev/null || exit 1
51
-
52
- USER node
53
-
54
- # Runs the stack: cluster node agent (:7575) + backend role service via
55
- # `nexus node serve --role=backend --port=7575`, plus frontend (:3000) and
56
- # admin (:3300) previews.
57
- COPY --chown=node:node bin/serve-all.mjs bin/serve-all.mjs
58
- COPY --chown=node:node package.json package.json
59
-
60
- CMD ["node", "bin/serve-all.mjs"]