@bhooai/nexus-cli 2.0.6 → 2.0.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bhooai/nexus-cli",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
4
4
  "description": "BhooAI Nexus v2 CLI — init, dev, build, test, scaffolding, plugins, queue, db.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -174,13 +174,18 @@ async function addFrontend(ctx: CommandContext): Promise<void> {
174
174
  const backendPort = (await lookupPort(projectRoot, backend)) ?? await ensurePort(projectRoot, backend);
175
175
  await writeFile(join(appDir, 'vite.config.ts'), `import { defineConfig } from 'vite';
176
176
 
177
+ // Backend host the dev proxy targets. Override with NEXUS_BACKEND_HOST when
178
+ // deploying to a VPS (or running the backend on another machine). Defaults to
179
+ // localhost for local development.
180
+ const backendHost = process.env.NEXUS_BACKEND_HOST ?? 'localhost';
181
+
177
182
  export default defineConfig({
178
183
  server: {
179
184
  port: ${port},
180
185
  proxy: {
181
- '/api': { target: 'http://localhost:${backendPort}', changeOrigin: true },
182
- '/uploads': { target: 'http://localhost:${backendPort}', changeOrigin: true },
183
- '/ws': { target: 'ws://localhost:${backendPort}', ws: true },
186
+ '/api': { target: \`http://\${backendHost}:${backendPort}\`, changeOrigin: true },
187
+ '/uploads': { target: \`http://\${backendHost}:${backendPort}\`, changeOrigin: true },
188
+ '/ws': { target: \`ws://\${backendHost}:${backendPort}\`, ws: true },
184
189
  },
185
190
  },
186
191
  });
@@ -31,10 +31,26 @@ export async function run(ctx: CommandContext): Promise<void> {
31
31
 
32
32
  const manager = new ServiceManager(specs, projectRoot);
33
33
 
34
+ // Start the supervisor control API before any service so the admin SPA can
35
+ // discover it (port persisted to supervisor.json; backend /admin/supervisor
36
+ // reports it). Failure is non-fatal — the console still works.
37
+ let controlPort = 0;
38
+ try {
39
+ controlPort = await manager.startControlApi();
40
+ console.log(`\x1b[36m[supervisor]\x1b[0m control API on http://127.0.0.1:${controlPort}`);
41
+ } catch (err) {
42
+ console.warn(`\x1b[33m[supervisor]\x1b[0m control API unavailable: ${(err as Error).message}`);
43
+ }
44
+
45
+ const teardown = () => {
46
+ manager.killAll();
47
+ manager.closeControlApi();
48
+ };
49
+
34
50
  if (isTty && !noPanel) {
35
51
  // Full-screen Nexus Console.
36
52
  console.log(`nexus dev — ${specs.length} service(s). Opening console...\n`);
37
- await startDevPanel({ services: manager, onQuit: () => manager.killAll() });
53
+ await startDevPanel({ services: manager, onQuit: teardown });
38
54
  return;
39
55
  }
40
56
 
@@ -44,7 +60,7 @@ export async function run(ctx: CommandContext): Promise<void> {
44
60
  const shutdown = () => {
45
61
  if (shuttingDown) return;
46
62
  shuttingDown = true;
47
- manager.killAll();
63
+ teardown();
48
64
  setTimeout(() => process.exit(0), 500);
49
65
  };
50
66
  process.on('SIGINT', shutdown);
@@ -211,6 +211,8 @@ NEXUS_SERVER_PORT=${vars.backendPort}
211
211
  NEXUS_FRONTEND_PORT=${vars.frontendPort}
212
212
  NEXUS_ADMIN_PORT=${vars.adminPort}
213
213
  AI_PORT=${vars.aiPort}
214
+ # Set this to the backend host when deploying to a VPS (default: localhost)
215
+ # NEXUS_BACKEND_HOST=
214
216
 
215
217
  # Database
216
218
  NEXUS_DB_URI=${vars.mongoUri}
@@ -223,6 +225,7 @@ NEXUS_AUTH_JWT_SECRET=${vars.jwtSecret}
223
225
  NEXUS_AI_PROVIDER=ollama
224
226
  NEXUS_AI_MODEL=llama3.1:8b
225
227
  OLLAMA_MODEL=llama3.1:8b
228
+ OLLAMA_BASE_URL=http://localhost:11434
226
229
  ${vars.aiProviders.filter((p) => p !== 'ollama').map((p) => `# NEXUS_AI_${p.toUpperCase()}_API_KEY=`).join('\n')}
227
230
 
228
231
  # Storage (uncomment for S3)
package/src/devPanel.ts CHANGED
@@ -66,6 +66,7 @@ const ARG_COMMANDS = new Set([
66
66
  'make:policy', 'make:resource', 'make:request', 'make:mail', 'make:room',
67
67
  'make:subgraph', 'make:seeder', 'make:migration', 'make:provider', 'make:plugin',
68
68
  'db:seed', 'db:migrate', 'db:rollback', 'queue:retry', 'add',
69
+ 'sync', 'uninstall', 'pysetup',
69
70
  ]);
70
71
 
71
72
  interface CommandItem {
@@ -79,6 +80,8 @@ interface PanelState {
79
80
  tab: Tab;
80
81
  svcIndex: number;
81
82
  cmdIndex: number;
83
+ cmdScroll: number;
84
+ cmdFilter: string;
82
85
  featIndex: number;
83
86
  installed: Set<string>;
84
87
  featureMsg: string[];
@@ -103,6 +106,8 @@ export async function startDevPanel(opts: DevPanelOptions): Promise<void> {
103
106
  tab: 'services',
104
107
  svcIndex: 0,
105
108
  cmdIndex: 0,
109
+ cmdScroll: 0,
110
+ cmdFilter: '',
106
111
  featIndex: 0,
107
112
  installed,
108
113
  featureMsg: [],
@@ -208,6 +213,13 @@ function onKey(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
208
213
  return;
209
214
  }
210
215
  if (key.name === 'escape') {
216
+ if (state.tab === 'commands' && state.cmdFilter) {
217
+ state.cmdFilter = '';
218
+ state.cmdIndex = 0;
219
+ state.cmdScroll = 0;
220
+ ctx.redraw();
221
+ return;
222
+ }
211
223
  if (state.showHelp) { state.showHelp = false; ctx.redraw(); }
212
224
  else { state.confirmQuit = true; state.quitIndex = 0; ctx.redraw(); }
213
225
  return;
@@ -219,6 +231,31 @@ function onKey(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
219
231
  }
220
232
  if (state.showHelp) return;
221
233
 
234
+ // Type-to-filter in the Commands tab.
235
+ if (state.tab === 'commands' && str && !key.ctrl && !key.meta && str.length === 1 && str !== '?') {
236
+ state.cmdFilter += str;
237
+ const fp = filteredPalette(ctx.palette, state.cmdFilter);
238
+ state.cmdIndex = fp.findIndex((r) => r.kind === 'cmd');
239
+ if (state.cmdIndex < 0) state.cmdIndex = 0;
240
+ state.cmdScroll = 0;
241
+ const vh = commandsViewportHeight(state);
242
+ clampScroll(state, vh, fp.length);
243
+ ctx.redraw();
244
+ return;
245
+ }
246
+ // Backspace in commands tab = delete last filter char.
247
+ if (state.tab === 'commands' && key.name === 'backspace' && state.cmdFilter) {
248
+ state.cmdFilter = state.cmdFilter.slice(0, -1);
249
+ const fp = filteredPalette(ctx.palette, state.cmdFilter);
250
+ state.cmdIndex = Math.min(state.cmdIndex, fp.length - 1);
251
+ if (state.cmdIndex < 0) state.cmdIndex = 0;
252
+ state.cmdScroll = 0;
253
+ const vh = commandsViewportHeight(state);
254
+ clampScroll(state, vh, fp.length);
255
+ ctx.redraw();
256
+ return;
257
+ }
258
+
222
259
  switch (key.name) {
223
260
  case 'tab': {
224
261
  const idx = TABS.findIndex((t) => t.id === state.tab);
@@ -271,14 +308,18 @@ function onKey(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
271
308
  }
272
309
 
273
310
  function move(state: PanelState, dir: number, ctx: Ctx): void {
311
+ const fp = filteredPalette(ctx.palette, state.cmdFilter);
274
312
  switch (state.tab) {
275
313
  case 'services':
276
314
  case 'logs':
277
315
  state.svcIndex = clamp(state.svcIndex + dir, 0, Math.max(0, ctx.services.services.length - 1));
278
316
  break;
279
- case 'commands':
280
- state.cmdIndex = prevSelectable(ctx.palette, state.cmdIndex, dir);
317
+ case 'commands': {
318
+ state.cmdIndex = prevSelectable(fp, state.cmdIndex, dir);
319
+ const vh = commandsViewportHeight(state);
320
+ clampScroll(state, vh, fp.length);
281
321
  break;
322
+ }
282
323
  case 'features':
283
324
  state.featIndex = clamp(state.featIndex + dir, 0, FEATURES.length - 1);
284
325
  break;
@@ -317,7 +358,8 @@ async function activate(state: PanelState, ctx: Ctx): Promise<void> {
317
358
  break;
318
359
  }
319
360
  case 'commands': {
320
- const row = ctx.palette[state.cmdIndex];
361
+ const fp = filteredPalette(ctx.palette, state.cmdFilter);
362
+ const row = fp[state.cmdIndex];
321
363
  if (row && row.kind === 'cmd') {
322
364
  if (row.cmd.needsArgs) state.argMode = { cmd: row.cmd, buffer: '' };
323
365
  else void runSelectedCommand(state, row.cmd, '', ctx);
@@ -484,6 +526,59 @@ function prevSelectable(list: PaletteRow[], current: number, dir: number): numbe
484
526
  return current;
485
527
  }
486
528
 
529
+ /** Filter the palette by the user's type-to-filter buffer (case-insensitive). */
530
+ function filteredPalette(palette: PaletteRow[], filter: string): PaletteRow[] {
531
+ if (!filter) return palette;
532
+ const q = filter.toLowerCase();
533
+ const result: PaletteRow[] = [];
534
+ let currentHeader: PaletteRow | null = null;
535
+ let headerHasMatch = false;
536
+ for (const row of palette) {
537
+ if (row.kind === 'header') {
538
+ if (currentHeader && headerHasMatch) result.push(currentHeader);
539
+ currentHeader = row;
540
+ headerHasMatch = false;
541
+ continue;
542
+ }
543
+ if (row.cmd.name.toLowerCase().includes(q) || row.cmd.desc.toLowerCase().includes(q)) {
544
+ headerHasMatch = true;
545
+ result.push(row);
546
+ }
547
+ }
548
+ if (currentHeader && headerHasMatch) result.push(currentHeader);
549
+ // Re-headers got pushed to the end; rebuild with headers in the right place.
550
+ const ordered: PaletteRow[] = [];
551
+ let pendingHeader: PaletteRow | null = null;
552
+ for (const row of palette) {
553
+ if (row.kind === 'header') {
554
+ pendingHeader = row;
555
+ continue;
556
+ }
557
+ if (row.cmd.name.toLowerCase().includes(q) || row.cmd.desc.toLowerCase().includes(q)) {
558
+ if (pendingHeader) { ordered.push(pendingHeader); pendingHeader = null; }
559
+ ordered.push(row);
560
+ }
561
+ }
562
+ return ordered;
563
+ }
564
+
565
+ /** Available rows for the command list given terminal height and output section. */
566
+ function commandsViewportHeight(state: PanelState): number {
567
+ const H = output.rows || 24;
568
+ // Header (2) + filter line (1 if active) + output section (2 + up to 12 lines)
569
+ const outputH = state.commandOutput.length > 0 ? 2 + Math.min(state.commandOutput.length, 12) : 0;
570
+ const filterH = state.cmdFilter ? 2 : 0; // filter line + blank
571
+ return Math.max(H - 4 - filterH - outputH, 4);
572
+ }
573
+
574
+ /** Clamp cmdScroll so cmdIndex stays inside the viewport. */
575
+ function clampScroll(state: PanelState, viewportH: number, listLen: number): void {
576
+ if (state.cmdIndex < state.cmdScroll) state.cmdScroll = state.cmdIndex;
577
+ if (state.cmdIndex >= state.cmdScroll + viewportH) state.cmdScroll = state.cmdIndex - viewportH + 1;
578
+ if (state.cmdScroll < 0) state.cmdScroll = 0;
579
+ if (state.cmdScroll > Math.max(0, listLen - viewportH)) state.cmdScroll = Math.max(0, listLen - viewportH);
580
+ }
581
+
487
582
  // ---------------------------------------------------------------------------
488
583
  // Rendering
489
584
  // ---------------------------------------------------------------------------
@@ -629,21 +724,53 @@ function renderLogs(state: PanelState, services: ServiceManager, accent: string,
629
724
  }
630
725
 
631
726
  function renderCommands(state: PanelState, palette: PaletteRow[], accent: string, contentW: number): string[] {
632
- const out = [`${ANSI.bold}${accent} COMMANDS${ANSI.reset}${ANSI.dim} (Enter run · args inline)${ANSI.reset}`, ''];
633
- const visible = palette.slice(0, 18);
634
- visible.forEach((row) => {
727
+ const fp = filteredPalette(palette, state.cmdFilter);
728
+ const vh = commandsViewportHeight(state);
729
+ clampScroll(state, vh, fp.length);
730
+
731
+ const filterHint = state.cmdFilter
732
+ ? `${ANSI.dim} (type to filter · Esc clears · Backspace deletes)${ANSI.reset}`
733
+ : `${ANSI.dim} (Enter run · args inline · type to filter)${ANSI.reset}`;
734
+ const out = [`${ANSI.bold}${accent} COMMANDS${ANSI.reset}${filterHint}`, ''];
735
+
736
+ // Filter input line.
737
+ if (state.cmdFilter) {
738
+ out.push(` ${ANSI.cyan}Filter:${ANSI.reset} ${state.cmdFilter}█`);
739
+ out.push('');
740
+ }
741
+
742
+ // Scroll-up indicator.
743
+ if (state.cmdScroll > 0) {
744
+ out.push(` ${ANSI.dim}↑ ${state.cmdScroll} more above${ANSI.reset}`);
745
+ }
746
+
747
+ const start = state.cmdScroll;
748
+ const end = Math.min(start + vh, fp.length);
749
+ for (let i = start; i < end; i++) {
750
+ const row = fp[i]!;
635
751
  if (row.kind === 'header') {
636
752
  out.push(` ${ANSI.dim}${row.label.toUpperCase()}${ANSI.reset}`);
637
753
  } else {
638
- const isSelected = palette.indexOf(row) === state.cmdIndex;
754
+ const isSelected = i === state.cmdIndex;
639
755
  const marker = isSelected ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
640
756
  const label = isSelected ? `${ANSI.bold}${row.cmd.name}${ANSI.reset}` : row.cmd.name;
641
- const arg = row.cmd.needsArgs ? `${ANSI.dim} <name>${ANSI.reset}` : '';
757
+ const arg = row.cmd.needsArgs ? `${ANSI.dim} <args>${ANSI.reset}` : '';
642
758
  const desc = row.cmd.desc ? `${ANSI.dim}${row.cmd.desc}${ANSI.reset}` : '';
643
- const head = ` ${marker} ${fitCell(label, 18)}${arg}`;
644
- out.push(`${head} ${clipVisible(desc, Math.max(contentW - 22, 8))}`);
759
+ const head = ` ${marker} ${fitCell(label, 20)}${arg}`;
760
+ out.push(`${head} ${clipVisible(desc, Math.max(contentW - 24, 8))}`);
645
761
  }
646
- });
762
+ }
763
+
764
+ // Scroll-down indicator.
765
+ if (end < fp.length) {
766
+ out.push(` ${ANSI.dim}↓ ${fp.length - end} more below${ANSI.reset}`);
767
+ }
768
+
769
+ // Empty filter result.
770
+ if (fp.length === 0) {
771
+ out.push(` ${ANSI.dim}No commands match "${state.cmdFilter}"${ANSI.reset}`);
772
+ }
773
+
647
774
  if (state.commandOutput.length > 0) {
648
775
  out.push('');
649
776
  out.push(`${ANSI.bold}${ANSI.yellow} OUTPUT${ANSI.reset}`);
@@ -5,8 +5,12 @@
5
5
  * crash-loop guard. Log output is captured into a per-service ring buffer so
6
6
  * the control panel can render live logs.
7
7
  */
8
- import { spawn, type ChildProcess } from 'node:child_process';
8
+ import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
9
9
  import type { Readable } from 'node:stream';
10
+ import type { IncomingMessage, ServerResponse } from 'node:http';
11
+ import { createServer } from 'node:http';
12
+ import { writeFile } from 'node:fs/promises';
13
+ import { join } from 'node:path';
10
14
  import { nextFreePort } from './util.js';
11
15
  import { readRegistry, writeRegistry, type PortRegistry } from './ports.js';
12
16
 
@@ -25,6 +29,7 @@ export interface ServiceSpec {
25
29
  export interface ManagedService extends ServiceSpec {
26
30
  status: ServiceStatus;
27
31
  pid: number | null;
32
+ startedAt?: number;
28
33
  restarts: number;
29
34
  lastExit: { code: number | null; signal: NodeJS.Signals | null } | null;
30
35
  /** Last log lines captured before crash (for inline display in the panel). */
@@ -33,7 +38,26 @@ export interface ManagedService extends ServiceSpec {
33
38
  child: ChildProcess | null;
34
39
  }
35
40
 
41
+ /** Status view the control API reports to the admin (crashed → errored). */
42
+ export interface ServiceStateView {
43
+ name: string;
44
+ status: 'stopped' | 'starting' | 'running' | 'errored';
45
+ pid?: number;
46
+ startedAt?: number;
47
+ lastExitCode?: number | null;
48
+ }
49
+
50
+ /** Structured cross-service log entry (powers the control API /logs/all). */
51
+ export interface LogEntry {
52
+ service: string;
53
+ ts: number;
54
+ source: 'stdout' | 'stderr' | 'system';
55
+ level: 'info' | 'warn' | 'error';
56
+ line: string;
57
+ }
58
+
36
59
  const MAX_LOG_LINES = 200;
60
+ const MAX_ALL_LOGS = 2000;
37
61
  const CRASH_THRESHOLD = 5;
38
62
  const CRASH_WINDOW_MS = 30_000;
39
63
 
@@ -41,6 +65,10 @@ export class ServiceManager {
41
65
  readonly services: ManagedService[] = [];
42
66
  private crashTimes = new Map<string, number[]>();
43
67
  private projectRoot: string;
68
+ /** Aggregated structured log buffer across all services — powers /logs/all. */
69
+ private allLogs: LogEntry[] = [];
70
+ private controlServer?: ReturnType<typeof createServer>;
71
+ private boundControlPort = 0;
44
72
 
45
73
  constructor(specs: ServiceSpec[], projectRoot: string) {
46
74
  this.projectRoot = projectRoot;
@@ -103,9 +131,10 @@ export class ServiceManager {
103
131
  });
104
132
  svc.child = child;
105
133
  svc.pid = child.pid ?? null;
134
+ svc.startedAt = Date.now();
106
135
 
107
- this.pipe(child.stdout, svc);
108
- this.pipe(child.stderr, svc);
136
+ this.pipe(child.stdout, svc, 'stdout');
137
+ this.pipe(child.stderr, svc, 'stderr');
109
138
 
110
139
  child.once('spawn', () => {
111
140
  svc.status = 'running';
@@ -122,7 +151,7 @@ export class ServiceManager {
122
151
  if (/\bEADDRINUSE\b/i.test(recentLogs)) {
123
152
  const bump = await this.bumpPort(svc);
124
153
  if (bump) {
125
- this.push(svc, `⚡ Port ${bump.oldPort} busy → bumped to ${bump.newPort}, restarting ${svc.name}`);
154
+ this.push(svc, `⚡ Port ${bump.oldPort} busy → bumped to ${bump.newPort}, restarting ${svc.name}`, 'system');
126
155
  svc.status = 'stopped';
127
156
  setTimeout(() => this.start(svc.name), 300);
128
157
  return;
@@ -134,12 +163,12 @@ export class ServiceManager {
134
163
  if (wasCrashed) {
135
164
  svc.status = 'crashed';
136
165
  svc.lastCrashLog = svc.logBuffer.slice(-8);
137
- this.push(svc, `✗ ${svc.name} crashed repeatedly — press s to start again`);
166
+ this.push(svc, `✗ ${svc.name} crashed repeatedly — press s to start again`, 'system');
138
167
  return;
139
168
  }
140
169
  svc.status = 'stopped';
141
170
  if (svc.restarts > 0 && !this.explicitlyStopping) {
142
- this.push(svc, `↻ ${svc.name} exited (${code ?? signal}) — restarting in 1s`);
171
+ this.push(svc, `↻ ${svc.name} exited (${code ?? signal}) — restarting in 1s`, 'system');
143
172
  setTimeout(() => this.start(svc.name), 1000);
144
173
  }
145
174
  });
@@ -150,13 +179,29 @@ export class ServiceManager {
150
179
  private killChild(svc: ManagedService, signal: NodeJS.Signals): void {
151
180
  this.explicitlyStopping = true;
152
181
  const child = svc.child;
182
+ const pid = svc.pid;
153
183
  if (!child) return;
184
+ // On Windows the service runs as a grandchild of a cmd.exe wrapper
185
+ // (spawn shell:true), so child.kill() only kills the shell and the real
186
+ // process survives to keep holding its port. Kill the whole tree first,
187
+ // synchronously, so the port is actually released before the wrapper exits.
188
+ if (process.platform === 'win32' && pid) {
189
+ try {
190
+ spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
191
+ } catch { /* ignore */ }
192
+ }
154
193
  try {
155
194
  child.kill(signal);
156
195
  } catch { /* ignore */ }
157
196
  // Force-kill if it lingers.
158
197
  setTimeout(() => {
159
- if (svc.child && svc.pid) {
198
+ if (process.platform === 'win32') {
199
+ if (pid) {
200
+ try {
201
+ spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
202
+ } catch { /* ignore */ }
203
+ }
204
+ } else if (svc.child && svc.pid) {
160
205
  try { process.kill(svc.pid, 'SIGKILL'); } catch { /* ignore */ }
161
206
  }
162
207
  }, 3000);
@@ -165,20 +210,22 @@ export class ServiceManager {
165
210
  }, 3500);
166
211
  }
167
212
 
168
- private pipe(stream: Readable | null, svc: ManagedService): void {
213
+ private pipe(stream: Readable | null, svc: ManagedService, source: 'stdout' | 'stderr'): void {
169
214
  if (!stream) return;
170
215
  stream.on('data', (chunk: Buffer) => {
171
216
  const text = chunk.toString();
172
- this.push(svc, text);
217
+ this.push(svc, text, source);
173
218
  });
174
219
  }
175
220
 
176
- private push(svc: ManagedService, text: string): void {
177
- const lines = text.split('\n');
221
+ private push(svc: ManagedService, text: string, source: 'stdout' | 'stderr' | 'system' = 'stdout'): void {
222
+ const lines = text.split(/\r?\n/);
178
223
  for (const line of lines) {
179
224
  if (!line.trim()) continue;
180
225
  svc.logBuffer.push(line);
181
226
  if (svc.logBuffer.length > MAX_LOG_LINES) svc.logBuffer.shift();
227
+ this.allLogs.push({ service: svc.name, ts: Date.now(), source, level: detectLevel(line), line });
228
+ if (this.allLogs.length > MAX_ALL_LOGS) this.allLogs.shift();
182
229
  }
183
230
  this.onLog?.(svc.name);
184
231
  }
@@ -226,4 +273,156 @@ export class ServiceManager {
226
273
  if (!svc) return [];
227
274
  return svc.logBuffer.slice(-lines);
228
275
  }
276
+
277
+ // -------------------------------------------------------------------------
278
+ // HTTP control API (localhost only) — consumed by the admin SPA's Processes
279
+ // and Logs tabs. Binds 127.0.0.1:7474, auto-bumping upward when busy, and
280
+ // persists the real port to supervisor.json at the project root so the
281
+ // backend's /admin/supervisor can report it.
282
+ // -------------------------------------------------------------------------
283
+
284
+ /** The port the control API actually bound (0 until started). */
285
+ get controlEndpointPort(): number {
286
+ return this.boundControlPort;
287
+ }
288
+
289
+ /** Start the control API; resolves with the bound port. */
290
+ async startControlApi(preferredPort = 7474): Promise<number> {
291
+ const server = createServer((req, res) => this.handleControl(req, res));
292
+ const MAX_ATTEMPTS = 100;
293
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
294
+ const port = preferredPort + attempt;
295
+ try {
296
+ await this.tryListen(server, port);
297
+ this.boundControlPort = port;
298
+ this.controlServer = server;
299
+ await this.writeSupervisorInfo();
300
+ return port;
301
+ } catch (err) {
302
+ if ((err as NodeJS.ErrnoException).code !== 'EADDRINUSE') {
303
+ this.controlServer?.close();
304
+ throw err;
305
+ }
306
+ }
307
+ }
308
+ this.controlServer?.close();
309
+ throw new Error(`no free control port near ${preferredPort} (tried ${MAX_ATTEMPTS} ports)`);
310
+ }
311
+
312
+ /** Stop the control API (services keep running). */
313
+ closeControlApi(): void {
314
+ this.controlServer?.close();
315
+ this.controlServer = undefined;
316
+ this.boundControlPort = 0;
317
+ }
318
+
319
+ /** Report the services in the admin's expected shape. */
320
+ private controlStatus(): ServiceStateView[] {
321
+ return this.services.map((s) => ({
322
+ name: s.name,
323
+ status: s.status === 'crashed' ? 'errored' : s.status,
324
+ pid: s.pid ?? undefined,
325
+ startedAt: s.startedAt,
326
+ lastExitCode: s.lastExit?.code ?? null,
327
+ }));
328
+ }
329
+
330
+ private tryListen(server: ReturnType<typeof createServer>, port: number, host = '127.0.0.1'): Promise<void> {
331
+ return new Promise((resolve, reject) => {
332
+ const onError = (err: NodeJS.ErrnoException) => {
333
+ server.removeListener('listening', onListening);
334
+ server.close();
335
+ reject(err);
336
+ };
337
+ const onListening = () => {
338
+ server.removeListener('error', onError);
339
+ resolve();
340
+ };
341
+ server.once('error', onError);
342
+ server.once('listening', onListening);
343
+ server.listen(port, host);
344
+ });
345
+ }
346
+
347
+ private async writeSupervisorInfo(): Promise<void> {
348
+ try {
349
+ await writeFile(
350
+ join(this.projectRoot, 'supervisor.json'),
351
+ JSON.stringify({
352
+ port: this.boundControlPort,
353
+ url: this.boundControlPort ? `http://127.0.0.1:${this.boundControlPort}` : undefined,
354
+ writtenAt: new Date().toISOString(),
355
+ }, null, 2) + '\n',
356
+ 'utf8',
357
+ );
358
+ } catch { /* non-fatal — the admin falls back to the default port */ }
359
+ }
360
+
361
+ private handleControl(req: IncomingMessage, res: ServerResponse): void {
362
+ const url = new URL(req.url ?? '/', `http://127.0.0.1:${this.boundControlPort}`);
363
+ // Permissive CORS so the browser-based admin can call us directly.
364
+ res.setHeader('access-control-allow-origin', '*');
365
+ res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
366
+ res.setHeader('access-control-allow-headers', 'content-type, authorization');
367
+ if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); return; }
368
+ res.setHeader('content-type', 'application/json');
369
+ if (req.method === 'GET' && url.pathname === '/status') {
370
+ res.end(JSON.stringify({ services: this.controlStatus() }));
371
+ return;
372
+ }
373
+ if (req.method === 'POST' && url.pathname === '/start') {
374
+ const name = url.searchParams.get('name');
375
+ if (name) this.start(name);
376
+ res.end(JSON.stringify({ ok: !!name }));
377
+ return;
378
+ }
379
+ if (req.method === 'POST' && url.pathname === '/restart') {
380
+ const name = url.searchParams.get('name');
381
+ if (name) this.restart(name);
382
+ res.end(JSON.stringify({ ok: !!name }));
383
+ return;
384
+ }
385
+ if (req.method === 'POST' && url.pathname === '/stop') {
386
+ const name = url.searchParams.get('name');
387
+ if (name) this.stop(name);
388
+ res.end(JSON.stringify({ ok: !!name }));
389
+ return;
390
+ }
391
+ if (req.method === 'GET' && url.pathname === '/logs') {
392
+ const name = url.searchParams.get('name');
393
+ const svc = name ? this.get(name) : undefined;
394
+ res.end(JSON.stringify({ logs: svc?.logBuffer ?? [] }));
395
+ return;
396
+ }
397
+ if (req.method === 'GET' && url.pathname === '/logs/all') {
398
+ const service = url.searchParams.get('service') ?? '';
399
+ const level = url.searchParams.get('level') ?? '';
400
+ const q = url.searchParams.get('q') ?? '';
401
+ let entries = this.allLogs;
402
+ if (service) entries = entries.filter((e) => e.service === service);
403
+ if (level) entries = entries.filter((e) => e.level === level);
404
+ if (q) {
405
+ const lower = q.toLowerCase();
406
+ entries = entries.filter((e) => e.line.toLowerCase().includes(lower));
407
+ }
408
+ res.end(JSON.stringify({ logs: entries }));
409
+ return;
410
+ }
411
+ if (req.method === 'POST' && url.pathname === '/logs/clear') {
412
+ for (const svc of this.services) svc.logBuffer = [];
413
+ this.allLogs = [];
414
+ res.end(JSON.stringify({ ok: true }));
415
+ return;
416
+ }
417
+ res.statusCode = 404;
418
+ res.end(JSON.stringify({ error: 'not found' }));
419
+ }
420
+ }
421
+
422
+ /** Classify a log line's severity from its content. */
423
+ function detectLevel(line: string): LogEntry['level'] {
424
+ const lower = line.toLowerCase();
425
+ if (/\berror\b/.test(lower) || /\bfatal\b/.test(lower) || /\bfail(ed|ure)?\b/.test(lower) || /\bexception\b/.test(lower)) return 'error';
426
+ if (/\bwarn(ing)?\b/.test(lower) || /\bunauthorized\b/.test(lower) || /\bforbidden\b/.test(lower)) return 'warn';
427
+ return 'info';
229
428
  }
package/src/index.ts CHANGED
@@ -29,6 +29,25 @@ registerCommand({ name: 'queue:retry', description: queueCmd.description, usage:
29
29
  registerCommand(downCommand);
30
30
  registerCommand(upCommand);
31
31
 
32
+ registerCommand({
33
+ name: 'sync',
34
+ description: 'Rewrite derived files (nexus.config, docker-compose, ports)',
35
+ usage: 'nexus sync [--check|--dry-run]',
36
+ run: async () => { console.log('sync: not yet implemented — track in P6'); },
37
+ });
38
+ registerCommand({
39
+ name: 'uninstall',
40
+ description: 'Remove project files (--purge deletes everything, --keep-db preserves Mongo)',
41
+ usage: 'nexus uninstall [--purge|--dry-run|--keep-db]',
42
+ run: async () => { console.log('uninstall: not yet implemented — track in P6'); },
43
+ });
44
+ registerCommand({
45
+ name: 'pysetup',
46
+ description: 'Set up Python AI server dependencies (venv + pip install)',
47
+ usage: 'nexus pysetup [pkgs...]',
48
+ run: async () => { console.log('pysetup: not yet implemented — track in P6'); },
49
+ });
50
+
32
51
  registerMakeCommands(registerCommand);
33
52
 
34
53
  export async function run(cmd: string, argv: string[]): Promise<void> {
@@ -8,12 +8,13 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@bhooai/nexus-safe-goto": "*",
11
+ "@bhooai/nexus-admin": "^2.0.6",
12
+ "@bhooai/nexus-safe-goto": "^2.0.1",
12
13
  "react": "^18.3.1",
13
14
  "react-dom": "^18.3.1"
14
15
  },
15
16
  "devDependencies": {
16
- "@bhooai/nexus-postcss": "^2.0.2",
17
+ "@bhooai/nexus-postcss": "^2.0.6",
17
18
  "tailwindcss": "^3.4.14",
18
19
  "vite": "^5.4.10"
19
20
  }
@@ -1,6 +1,7 @@
1
- import './index.css';
2
- import { App as NexusAdmin } from './App.js';
3
1
  import React from 'react';
2
+
3
+ import { App as NexusAdmin } from '@bhooai/nexus-admin';
4
+ import '@bhooai/nexus-admin/style.css';
4
5
  import { createRoot } from 'react-dom/client';
5
6
 
6
7
  const el = document.getElementById('root');