@bhooai/nexus-cli 0.1.5 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +26 -27
- package/src/commands/add.ts +274 -0
- package/src/commands/build.ts +91 -0
- package/src/commands/db.ts +241 -0
- package/src/commands/dev.ts +149 -186
- package/src/commands/doctor.ts +102 -184
- package/src/commands/init.ts +204 -958
- package/src/commands/maintenance-commands.ts +40 -0
- package/src/commands/plugin.ts +233 -0
- package/src/commands/queue.ts +145 -0
- package/src/commands/test.ts +34 -0
- package/src/dispatcher.ts +98 -0
- package/src/generators.ts +102 -0
- package/src/index.ts +32 -90
- package/src/make/generators.ts +431 -0
- package/src/ports.ts +85 -0
- package/src/prompts.ts +68 -0
- package/src/templating/render.ts +109 -0
- package/src/util.ts +74 -112
- package/templates/base/Dockerfile.ejs +68 -0
- package/templates/base/README.md.ejs +62 -0
- package/templates/base/apps/admin/index.html.ejs +12 -0
- package/templates/base/apps/admin/nginx.conf.ejs +16 -0
- package/templates/base/apps/admin/package.json.ejs +15 -0
- package/templates/base/apps/admin/src/main.tsx.ejs +7 -0
- package/templates/base/apps/admin/vite.config.ts.ejs +22 -0
- package/templates/base/apps/ai-server/main.py.ejs +30 -0
- package/templates/base/apps/ai-server/requirements.txt +4 -0
- package/templates/base/apps/backend/src/main.ts +14 -0
- package/templates/base/apps/backend/src/routes/index.ts.ejs +11 -0
- package/templates/base/apps/frontend/index.html.ejs +12 -0
- package/templates/base/apps/frontend/nginx.conf.ejs +33 -0
- package/templates/base/apps/frontend/package.json.ejs +11 -0
- package/templates/base/apps/frontend/src/main.tsx.ejs +16 -0
- package/templates/base/apps/frontend/vite.config.ts.ejs +22 -0
- package/templates/base/docker-compose.yml.ejs +93 -0
- package/templates/base/dockerignore +21 -0
- package/templates/base/gitignore +43 -0
- package/templates/base/nexus.config.ts.ejs +97 -0
- package/templates/base/package.json.ejs +40 -0
- package/templates/{tsconfig.json → base/tsconfig.json} +2 -3
- package/tsconfig.json +5 -4
- package/PLAN.md +0 -141
- package/README.md +0 -34
- package/src/commands/cluster.ts +0 -133
- package/src/commands/node.ts +0 -106
- package/src/commands/pysetup.ts +0 -142
- package/src/commands/sync.ts +0 -116
- package/src/commands/uninstall.ts +0 -287
- package/src/config-sync.ts +0 -384
- package/src/dotenv.ts +0 -39
- package/src/supervisor.ts +0 -394
- package/src/wizard.ts +0 -149
- package/templates/Dockerfile +0 -60
- package/templates/README.md +0 -69
- package/templates/apps/admin/index.html +0 -12
- package/templates/apps/admin/package.json +0 -22
- package/templates/apps/admin/postcss.config.js +0 -3
- package/templates/apps/admin/src/main.tsx +0 -10
- package/templates/apps/admin/src/vite-env.d.ts +0 -18
- package/templates/apps/admin/tailwind.config.js +0 -9
- package/templates/apps/admin/tsconfig.json +0 -17
- package/templates/apps/admin/vite.config.ts +0 -64
- package/templates/apps/ai-server/main.py +0 -43
- package/templates/apps/ai-server/providers/__init__.py +0 -3
- package/templates/apps/ai-server/providers/base.py +0 -111
- package/templates/apps/ai-server/requirements.txt +0 -3
- package/templates/apps/ai-server/routers/__init__.py +0 -3
- package/templates/apps/ai-server/routers/chat.py +0 -47
- package/templates/apps/ai-server/routers/embeddings.py +0 -30
- package/templates/apps/ai-server/routers/lint.py +0 -167
- package/templates/apps/ai-server/routers/models.py +0 -23
- package/templates/apps/ai-server/routers/preflight.py +0 -169
- package/templates/apps/ai-server/settings.py +0 -48
- package/templates/apps/backend/package.json +0 -34
- package/templates/apps/backend/src/main.ts +0 -375
- package/templates/apps/backend/src/modules/admin/adminRoutes.ts +0 -732
- package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +0 -391
- package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +0 -161
- package/templates/apps/backend/src/modules/admin/lintProxy.ts +0 -89
- package/templates/apps/backend/src/modules/admin/preflightProxy.ts +0 -242
- package/templates/apps/backend/src/modules/admin/roleCatalog.ts +0 -78
- package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +0 -449
- package/templates/apps/backend/src/modules/ai/aiProxy.ts +0 -265
- package/templates/apps/backend/src/modules/auth/authRoutes.ts +0 -220
- package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +0 -100
- package/templates/apps/backend/src/modules/payments/paymentStore.ts +0 -172
- package/templates/apps/backend/src/modules/requests/requestLog.ts +0 -175
- package/templates/apps/backend/src/modules/users/userGraph.ts +0 -83
- package/templates/apps/backend/src/modules/users/userModel.ts +0 -88
- package/templates/apps/backend/src/plugins/CronScheduler.ts +0 -69
- package/templates/apps/backend/src/plugins/loadPlugins.ts +0 -107
- package/templates/apps/backend/tsconfig.json +0 -14
- package/templates/apps/frontend/index.html +0 -12
- package/templates/apps/frontend/package.json +0 -20
- package/templates/apps/frontend/postcss.config.js +0 -3
- package/templates/apps/frontend/src/index.css +0 -44
- package/templates/apps/frontend/src/main.tsx +0 -65
- package/templates/apps/frontend/tailwind.config.js +0 -9
- package/templates/apps/frontend/vite.config.ts +0 -72
- package/templates/bin/nexus.js +0 -35
- package/templates/bin/serve-all.mjs +0 -45
- package/templates/dockerignore +0 -15
- package/templates/gitignore +0 -12
- package/templates/nexus.config.ts +0 -69
- package/templates/package.json +0 -47
- package/tests/cli.test.ts +0 -52
- package/tests/config-sync.test.ts +0 -201
- package/tests/dotenv.test.ts +0 -51
- package/vitest.config.ts +0 -9
- package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +0 -13
- /package/templates/{uploads → base/apps/backend/storage}/.gitkeep +0 -0
package/src/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 };
|
package/templates/Dockerfile
DELETED
|
@@ -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"]
|