@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.
- package/PLAN.md +141 -0
- package/README.md +34 -0
- package/package.json +25 -0
- package/src/commands/cluster.ts +133 -0
- package/src/commands/dev.ts +133 -0
- package/src/commands/doctor.ts +199 -0
- package/src/commands/init.ts +960 -0
- package/src/commands/node.ts +101 -0
- package/src/commands/pysetup.ts +136 -0
- package/src/commands/sync.ts +116 -0
- package/src/commands/uninstall.ts +287 -0
- package/src/config-sync.ts +384 -0
- package/src/dotenv.ts +39 -0
- package/src/index.ts +94 -0
- package/src/supervisor.ts +384 -0
- package/src/util.ts +123 -0
- package/src/wizard.ts +149 -0
- package/templates/Dockerfile +60 -0
- package/templates/README.md +69 -0
- package/templates/apps/admin/index.html +12 -0
- package/templates/apps/admin/package.json +24 -0
- package/templates/apps/admin/postcss.config.js +6 -0
- package/templates/apps/admin/src/main.tsx +10 -0
- package/templates/apps/admin/src/vite-env.d.ts +18 -0
- package/templates/apps/admin/tailwind.config.js +9 -0
- package/templates/apps/admin/tsconfig.json +17 -0
- package/templates/apps/admin/vite.config.ts +64 -0
- package/templates/apps/ai-server/main.py +43 -0
- package/templates/apps/ai-server/providers/__init__.py +3 -0
- package/templates/apps/ai-server/providers/base.py +111 -0
- package/templates/apps/ai-server/requirements.txt +3 -0
- package/templates/apps/ai-server/routers/__init__.py +3 -0
- package/templates/apps/ai-server/routers/chat.py +47 -0
- package/templates/apps/ai-server/routers/embeddings.py +30 -0
- package/templates/apps/ai-server/routers/lint.py +167 -0
- package/templates/apps/ai-server/routers/models.py +23 -0
- package/templates/apps/ai-server/routers/preflight.py +169 -0
- package/templates/apps/ai-server/settings.py +48 -0
- package/templates/apps/backend/package.json +33 -0
- package/templates/apps/backend/src/main.ts +375 -0
- package/templates/apps/backend/src/modules/admin/adminRoutes.ts +732 -0
- package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +391 -0
- package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +161 -0
- package/templates/apps/backend/src/modules/admin/lintProxy.ts +89 -0
- package/templates/apps/backend/src/modules/admin/preflightProxy.ts +242 -0
- package/templates/apps/backend/src/modules/admin/roleCatalog.ts +78 -0
- package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +449 -0
- package/templates/apps/backend/src/modules/ai/aiProxy.ts +265 -0
- package/templates/apps/backend/src/modules/auth/authRoutes.ts +220 -0
- package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +100 -0
- package/templates/apps/backend/src/modules/payments/paymentStore.ts +172 -0
- package/templates/apps/backend/src/modules/requests/requestLog.ts +175 -0
- package/templates/apps/backend/src/modules/users/userGraph.ts +83 -0
- package/templates/apps/backend/src/modules/users/userModel.ts +88 -0
- package/templates/apps/backend/src/plugins/CronScheduler.ts +69 -0
- package/templates/apps/backend/src/plugins/loadPlugins.ts +107 -0
- package/templates/apps/backend/tsconfig.json +14 -0
- package/templates/apps/frontend/index.html +12 -0
- package/templates/apps/frontend/package.json +19 -0
- package/templates/apps/frontend/src/main.tsx +64 -0
- package/templates/apps/frontend/vite.config.ts +63 -0
- package/templates/bin/nexus.js +35 -0
- package/templates/bin/serve-all.mjs +45 -0
- package/templates/dockerignore +15 -0
- package/templates/gitignore +12 -0
- package/templates/nexus.config.ts +69 -0
- package/templates/package.json +47 -0
- package/templates/tsconfig.json +17 -0
- package/templates/uploads/.gitkeep +0 -0
- package/tests/cli.test.ts +45 -0
- package/tests/config-sync.test.ts +201 -0
- package/tests/dotenv.test.ts +51 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +9 -0
- package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +13 -0
|
@@ -0,0 +1,384 @@
|
|
|
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
|
+
const proc = spawn(cmd, args, {
|
|
125
|
+
cwd: cwdAbs,
|
|
126
|
+
env: childEnv,
|
|
127
|
+
shell: process.platform === 'win32',
|
|
128
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
129
|
+
});
|
|
130
|
+
this.procs.set(spec.name, proc);
|
|
131
|
+
state.status = 'running';
|
|
132
|
+
state.pid = proc.pid;
|
|
133
|
+
state.startedAt = Date.now();
|
|
134
|
+
|
|
135
|
+
const prefix = `${spec.color}[${spec.name}]${EOL}${'\x1b[0m'}`;
|
|
136
|
+
const onChunk = (stream: NodeJS.ReadableStream, isErr: boolean) => {
|
|
137
|
+
let buf = '';
|
|
138
|
+
stream.on('data', (chunk: Buffer) => {
|
|
139
|
+
buf += chunk.toString();
|
|
140
|
+
// Split on CRLF or LF. Children mix line endings on Windows: our own
|
|
141
|
+
// Logger writes os.EOL (\r\n), but Vite/uvicorn/third-party CLIs write
|
|
142
|
+
// LF only. Splitting on os.EOL alone left LF-terminated output stuck in
|
|
143
|
+
// `buf` forever (Vite's whole startup banner never appeared). Re-emit
|
|
144
|
+
// with the platform EOL so the host terminal gets clean line breaks.
|
|
145
|
+
const lines = buf.split(/\r?\n/);
|
|
146
|
+
buf = lines.pop() ?? '';
|
|
147
|
+
for (const line of lines) {
|
|
148
|
+
const target = isErr ? process.stderr : process.stdout;
|
|
149
|
+
target.write(`${spec.color}[${spec.name}]\x1b[0m ${line}${EOL}`);
|
|
150
|
+
this.pushLog(spec.name, line, isErr ? 'stderr' : 'stdout');
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
onChunk(proc.stdout!, false);
|
|
155
|
+
onChunk(proc.stderr!, true);
|
|
156
|
+
|
|
157
|
+
proc.on('exit', (code, signal) => {
|
|
158
|
+
// An intentional stop sets status to 'stopped' first, so don't flip a
|
|
159
|
+
// stopped service to 'errored' when its process tree finally exits.
|
|
160
|
+
state.status = this.shuttingDown || state.status === 'stopped' ? 'stopped' : 'errored';
|
|
161
|
+
state.lastExitCode = code;
|
|
162
|
+
state.pid = undefined;
|
|
163
|
+
this.pushLog(spec.name, `exited code=${code} signal=${signal}`, 'system');
|
|
164
|
+
if (!this.shuttingDown && !spec.optional) {
|
|
165
|
+
this.pushLog(spec.name, `required service stopped - supervisor continuing (run 'nexus dev restart ${spec.name}')`, 'system');
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
proc.on('error', (err) => {
|
|
169
|
+
state.status = 'errored';
|
|
170
|
+
this.pushLog(spec.name, `failed to start: ${err.message}`, 'system');
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private pushLog(name: string, line: string, source: LogEntry['source'] = 'stdout'): void {
|
|
175
|
+
const state = this.states.get(name);
|
|
176
|
+
if (!state) return;
|
|
177
|
+
state.logTail.push(line);
|
|
178
|
+
if (state.logTail.length > MAX_TAIL) state.logTail.shift();
|
|
179
|
+
|
|
180
|
+
// Also add to the aggregated structured buffer for /logs/all.
|
|
181
|
+
const entry: LogEntry = {
|
|
182
|
+
service: name,
|
|
183
|
+
ts: Date.now(),
|
|
184
|
+
source,
|
|
185
|
+
level: detectLevel(line),
|
|
186
|
+
line,
|
|
187
|
+
};
|
|
188
|
+
this.allLogs.push(entry);
|
|
189
|
+
if (this.allLogs.length > MAX_ALL_LOGS) this.allLogs.shift();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
stopService(name: string): void {
|
|
193
|
+
const proc = this.procs.get(name);
|
|
194
|
+
if (!proc) return;
|
|
195
|
+
const state = this.states.get(name)!;
|
|
196
|
+
state.status = 'stopped';
|
|
197
|
+
// Windows: services spawn with `shell: true`, so `proc` IS cmd.exe and
|
|
198
|
+
// `proc.kill()` only terminates that one wrapper - the tsx/vite/python
|
|
199
|
+
// process tree below it survives as an orphan and the port stays bound.
|
|
200
|
+
// `taskkill /T /F` kills the whole tree. POSIX: signal the process group.
|
|
201
|
+
try {
|
|
202
|
+
if (process.platform === 'win32' && proc.pid) {
|
|
203
|
+
execFile('taskkill', ['/PID', String(proc.pid), '/T', '/F'], { windowsHide: true }, () => {});
|
|
204
|
+
} else if (proc.pid) {
|
|
205
|
+
try {
|
|
206
|
+
process.kill(-proc.pid, 'SIGTERM');
|
|
207
|
+
} catch {
|
|
208
|
+
proc.kill('SIGTERM');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
/* ignore */
|
|
213
|
+
}
|
|
214
|
+
this.procs.delete(name);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
restartService(name: string): void {
|
|
218
|
+
const spec = this.services.find((s) => s.name === name);
|
|
219
|
+
if (!spec) return;
|
|
220
|
+
this.stopService(name);
|
|
221
|
+
setTimeout(() => this.startService(spec), 300);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
status(): ServiceState[] {
|
|
225
|
+
return this.services.map((s) => ({ ...this.states.get(s.name)! }));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Start all services and the control API. Resolves when started. */
|
|
229
|
+
async start(): Promise<void> {
|
|
230
|
+
// Bind the control API first so auto-allotted ports are known before the
|
|
231
|
+
// admin service boots (VITE_SUPERVISOR_URL can point at the real port).
|
|
232
|
+
await this.startControlServer();
|
|
233
|
+
this.injectControlEnv();
|
|
234
|
+
for (const s of this.services) this.startService(s);
|
|
235
|
+
process.on('SIGINT', () => this.shutdown());
|
|
236
|
+
process.on('SIGTERM', () => this.shutdown());
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Give the admin service (and any other) the actual control URL to call back. */
|
|
240
|
+
private injectControlEnv(): void {
|
|
241
|
+
if (!this.boundControlPort) return;
|
|
242
|
+
for (const s of this.services) {
|
|
243
|
+
if (s.name === 'admin') {
|
|
244
|
+
s.env = { ...(s.env ?? {}), VITE_SUPERVISOR_URL: `http://127.0.0.1:${this.boundControlPort}` };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Bind the control API on 127.0.0.1. When the requested port is already
|
|
251
|
+
* taken (EADDRINUSE - e.g. another `nexus dev` is running), automatically
|
|
252
|
+
* keep trying the next free port, then persist the actual bound port to
|
|
253
|
+
* `supervisor.json` at the project root so the backend/admin can discover it.
|
|
254
|
+
*/
|
|
255
|
+
private async startControlServer(): Promise<void> {
|
|
256
|
+
const server = createServer((req, res) => this.handleControl(req, res));
|
|
257
|
+
const requested = this.controlPort;
|
|
258
|
+
const MAX_ATTEMPTS = 100;
|
|
259
|
+
let bound = false;
|
|
260
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
261
|
+
const port = requested + attempt;
|
|
262
|
+
try {
|
|
263
|
+
await this.tryListen(server, port);
|
|
264
|
+
this.boundControlPort = port;
|
|
265
|
+
this.controlPort = port;
|
|
266
|
+
server.on('error', () => { /* ignore post-bind errors on the control socket */ });
|
|
267
|
+
process.stdout.write(`\x1b[36m[supervisor]\x1b[0m control API on http://127.0.0.1:${port}${EOL}`);
|
|
268
|
+
if (port !== requested) {
|
|
269
|
+
process.stdout.write(`\x1b[36m[supervisor]\x1b[0m note: ${requested} was busy - control API moved to port ${port}${EOL}`);
|
|
270
|
+
}
|
|
271
|
+
this.controlServer = server;
|
|
272
|
+
await this.writeSupervisorInfo();
|
|
273
|
+
bound = true;
|
|
274
|
+
break;
|
|
275
|
+
} catch (err) {
|
|
276
|
+
// Keep scanning upward on port collisions; give up on anything else.
|
|
277
|
+
if ((err as NodeJS.ErrnoException).code !== 'EADDRINUSE') {
|
|
278
|
+
process.stdout.write(`\x1b[31m[supervisor]\x1b[0m control API failed to start: ${(err as Error).message}${EOL}`);
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (!bound) {
|
|
284
|
+
process.stdout.write(`\x1b[31m[supervisor]\x1b[0m no free control port near ${requested} (tried ${MAX_ATTEMPTS} ports)${EOL}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Write `supervisor.json` with the actual control endpoint for the project. */
|
|
289
|
+
private async writeSupervisorInfo(): Promise<void> {
|
|
290
|
+
try {
|
|
291
|
+
await writeFile(
|
|
292
|
+
join(this.root, 'supervisor.json'),
|
|
293
|
+
JSON.stringify({
|
|
294
|
+
port: this.boundControlPort,
|
|
295
|
+
url: this.boundControlPort ? `http://127.0.0.1:${this.boundControlPort}` : undefined,
|
|
296
|
+
writtenAt: new Date().toISOString(),
|
|
297
|
+
}, null, 2) + '\n',
|
|
298
|
+
'utf8',
|
|
299
|
+
);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
process.stdout.write(`\x1b[33m[supervisor]\x1b[0m could not write supervisor.json: ${(err as Error).message}${EOL}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private handleControl(req: IncomingMessage, res: ServerResponse): void {
|
|
306
|
+
const url = new URL(req.url ?? '/', `http://127.0.0.1:${this.controlPort}`);
|
|
307
|
+
// Permissive CORS so the browser-based admin (localhost:3001) can call us.
|
|
308
|
+
res.setHeader('access-control-allow-origin', '*');
|
|
309
|
+
res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
|
|
310
|
+
res.setHeader('access-control-allow-headers', 'content-type, authorization');
|
|
311
|
+
if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); return; }
|
|
312
|
+
res.setHeader('content-type', 'application/json');
|
|
313
|
+
if (req.method === 'GET' && url.pathname === '/status') {
|
|
314
|
+
res.end(JSON.stringify({ services: this.status() }));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (req.method === 'POST' && url.pathname === '/start') {
|
|
318
|
+
const name = url.searchParams.get('name');
|
|
319
|
+
const spec = this.services.find((s) => s.name === name);
|
|
320
|
+
if (spec && name) this.startService(spec);
|
|
321
|
+
res.end(JSON.stringify({ ok: !!spec }));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (req.method === 'POST' && url.pathname === '/restart') {
|
|
325
|
+
const name = url.searchParams.get('name');
|
|
326
|
+
if (name) this.restartService(name);
|
|
327
|
+
res.end(JSON.stringify({ ok: !!name }));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (req.method === 'POST' && url.pathname === '/stop') {
|
|
331
|
+
const name = url.searchParams.get('name');
|
|
332
|
+
if (name) this.stopService(name);
|
|
333
|
+
res.end(JSON.stringify({ ok: !!name }));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (req.method === 'GET' && url.pathname === '/logs') {
|
|
337
|
+
const name = url.searchParams.get('name');
|
|
338
|
+
const state = name ? this.states.get(name) : undefined;
|
|
339
|
+
res.end(JSON.stringify({ logs: state?.logTail ?? [] }));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (req.method === 'GET' && url.pathname === '/logs/all') {
|
|
343
|
+
const service = url.searchParams.get('service') ?? '';
|
|
344
|
+
const level = url.searchParams.get('level') ?? '';
|
|
345
|
+
const q = url.searchParams.get('q') ?? '';
|
|
346
|
+
let entries = this.allLogs;
|
|
347
|
+
if (service) entries = entries.filter((e) => e.service === service);
|
|
348
|
+
if (level) entries = entries.filter((e) => e.level === level);
|
|
349
|
+
if (q) {
|
|
350
|
+
const lower = q.toLowerCase();
|
|
351
|
+
entries = entries.filter((e) => e.line.toLowerCase().includes(lower));
|
|
352
|
+
}
|
|
353
|
+
res.end(JSON.stringify({ logs: entries }));
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (req.method === 'POST' && url.pathname === '/logs/clear') {
|
|
357
|
+
for (const state of this.states.values()) state.logTail = [];
|
|
358
|
+
this.allLogs = [];
|
|
359
|
+
res.end(JSON.stringify({ ok: true }));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
res.statusCode = 404;
|
|
363
|
+
res.end(JSON.stringify({ error: 'not found' }));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async shutdown(): Promise<void> {
|
|
367
|
+
if (this.shuttingDown) return;
|
|
368
|
+
this.shuttingDown = true;
|
|
369
|
+
process.stdout.write(`\x1b[36m[supervisor]\x1b[0m shutting down...${EOL}`);
|
|
370
|
+
for (const name of this.procs.keys()) this.stopService(name);
|
|
371
|
+
this.controlServer?.close();
|
|
372
|
+
// Give processes a moment to exit.
|
|
373
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
374
|
+
process.exit(0);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Classify a log line's severity from its content. */
|
|
379
|
+
function detectLevel(line: string): LogEntry['level'] {
|
|
380
|
+
const lower = line.toLowerCase();
|
|
381
|
+
if (/\berror\b/.test(lower) || /\bfatal\b/.test(lower) || /\bfail(ed|ure)?\b/.test(lower) || /\bexception\b/.test(lower)) return 'error';
|
|
382
|
+
if (/\bwarn(ing)?\b/.test(lower) || /\bunauthorized\b/.test(lower) || /\bforbidden\b/.test(lower)) return 'warn';
|
|
383
|
+
return 'info';
|
|
384
|
+
}
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createConnection } from 'node:net';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
export interface CheckResult {
|
|
10
|
+
name: string;
|
|
11
|
+
ok: boolean;
|
|
12
|
+
detail: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Try a TCP connect to host:port within a timeout. */
|
|
16
|
+
export function tcpReachable(host: string, port: number, timeoutMs = 1500): Promise<boolean> {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const socket = createConnection({ host, port }, () => {
|
|
19
|
+
socket.destroy();
|
|
20
|
+
resolve(true);
|
|
21
|
+
});
|
|
22
|
+
socket.setTimeout(timeoutMs);
|
|
23
|
+
socket.on('error', () => resolve(false));
|
|
24
|
+
socket.on('timeout', () => {
|
|
25
|
+
socket.destroy();
|
|
26
|
+
resolve(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True if nothing is listening on `port` (quick TCP connect check). */
|
|
32
|
+
export async function isPortFree(host: string, port: number, timeoutMs = 700): Promise<boolean> {
|
|
33
|
+
return !(await tcpReachable(host === '0.0.0.0' ? '127.0.0.1' : host, port, timeoutMs));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Parse host/port from common connection-string formats. */
|
|
37
|
+
export function parseHostPort(url: string, defaultPort: number): { host: string; port: number } {
|
|
38
|
+
try {
|
|
39
|
+
const u = new URL(url);
|
|
40
|
+
return { host: u.hostname || 'localhost', port: u.port ? Number(u.port) : defaultPort };
|
|
41
|
+
} catch {
|
|
42
|
+
return { host: 'localhost', port: defaultPort };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function versionOf(cmd: string, args: string[] = ['--version']): Promise<string> {
|
|
47
|
+
try {
|
|
48
|
+
const { stdout } = await execFileAsync(cmd, args, { shell: process.platform === 'win32' });
|
|
49
|
+
return stdout.trim();
|
|
50
|
+
} catch {
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A prerequisite check (runtime or service). */
|
|
56
|
+
export interface Prereq extends CheckResult {
|
|
57
|
+
/** Critical = the wizard should refuse to proceed. Warnings ask to continue. */
|
|
58
|
+
critical?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Detect installed runtimes: node, npm, python, git. Returns a list of checks
|
|
63
|
+
* suitable for printing. node/npm/python are critical; git is a warning.
|
|
64
|
+
* Shared by `nexus doctor` and `nexus init` step 1.
|
|
65
|
+
*/
|
|
66
|
+
export async function scanRuntimes(): Promise<Prereq[]> {
|
|
67
|
+
const out: Prereq[] = [];
|
|
68
|
+
out.push({ name: 'node', ok: !!process.versions.node, detail: `v${process.versions.node}`, critical: true });
|
|
69
|
+
const npmVer = await versionOf(process.platform === 'win32' ? 'npm' : 'npm');
|
|
70
|
+
out.push({ name: 'npm', ok: !!npmVer, detail: npmVer ? `v${npmVer}` : 'not found', critical: true });
|
|
71
|
+
const pyVer = await versionOf(process.platform === 'win32' ? 'python' : 'python3');
|
|
72
|
+
out.push({ name: 'python', ok: !!pyVer, detail: pyVer || 'not found (ai-server disabled)', critical: true });
|
|
73
|
+
const gitVer = await versionOf('git', ['--version']);
|
|
74
|
+
out.push({ name: 'git', ok: !!gitVer, detail: gitVer ? gitVer.replace(/^git version /, 'v') : 'not found' });
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Lightweight config shape used by `scanServices` (avoids importing nexus-core here). */
|
|
79
|
+
export interface ServiceConfig {
|
|
80
|
+
db: { uri: string };
|
|
81
|
+
redis: { url: string };
|
|
82
|
+
ai: { serverUrl: string };
|
|
83
|
+
server: { host: string; port: number };
|
|
84
|
+
frontend: { port: number };
|
|
85
|
+
admin: { port: number };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Probe Mongo, Redis, and the AI server (TCP reachability) plus check that the
|
|
90
|
+
* backend/frontend/admin ports are free. Returns a list of checks.
|
|
91
|
+
* Shared by `nexus doctor` and `nexus init` step 1 / step 18 (verify).
|
|
92
|
+
*/
|
|
93
|
+
export async function scanServices(cfg: ServiceConfig): Promise<Prereq[]> {
|
|
94
|
+
const out: Prereq[] = [];
|
|
95
|
+
const db = parseHostPort(cfg.db.uri, 27017);
|
|
96
|
+
const dbOk = await tcpReachable(db.host, db.port);
|
|
97
|
+
out.push({ name: 'mongodb', ok: dbOk, detail: `${db.host}:${db.port} ${dbOk ? 'reachable' : 'unreachable'}` });
|
|
98
|
+
|
|
99
|
+
const redis = parseHostPort(cfg.redis.url, 6379);
|
|
100
|
+
const redisOk = await tcpReachable(redis.host, redis.port);
|
|
101
|
+
out.push({ name: 'redis', ok: redisOk, detail: `${redis.host}:${redis.port} ${redisOk ? 'reachable' : 'unreachable'}` });
|
|
102
|
+
|
|
103
|
+
const ai = parseHostPort(cfg.ai.serverUrl, 8000);
|
|
104
|
+
const aiOk = await tcpReachable(ai.host, ai.port);
|
|
105
|
+
out.push({ name: 'ai server', ok: aiOk, detail: `${ai.host}:${ai.port} ${aiOk ? 'reachable' : 'unreachable'}` });
|
|
106
|
+
|
|
107
|
+
const backendFree = await isPortFree(cfg.server.host, cfg.server.port, 500);
|
|
108
|
+
out.push({ name: 'backend port', ok: backendFree, detail: `:${cfg.server.port} ${backendFree ? 'free' : 'IN USE'}` });
|
|
109
|
+
|
|
110
|
+
const frontendFree = await isPortFree('127.0.0.1', cfg.frontend.port, 500);
|
|
111
|
+
out.push({ name: 'frontend port', ok: frontendFree, detail: `:${cfg.frontend.port} ${frontendFree ? 'free' : 'in use'}` });
|
|
112
|
+
|
|
113
|
+
const adminFree = await isPortFree('127.0.0.1', cfg.admin.port, 500);
|
|
114
|
+
out.push({ name: 'admin port', ok: adminFree, detail: `:${cfg.admin.port} ${adminFree ? 'free' : 'in use'}` });
|
|
115
|
+
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Returns true if `path` exists and its directory looks like a nexus project root. */
|
|
120
|
+
export function looksLikeProject(root: string): boolean {
|
|
121
|
+
return existsSync(join(root, 'package.json')) &&
|
|
122
|
+
['ts', 'js', 'mjs', 'cjs'].some((ext) => existsSync(join(root, `nexus.config.${ext}`)));
|
|
123
|
+
}
|
package/src/wizard.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
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 };
|