@bhooai/nexus-cli 2.0.2 → 2.0.3
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 +1 -1
- package/src/commands/dev.ts +74 -125
- package/src/devPanel.ts +440 -0
- package/src/devServiceManager.ts +183 -0
- package/src/dispatcher.ts +15 -0
package/package.json
CHANGED
package/src/commands/dev.ts
CHANGED
|
@@ -1,26 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* nexus dev — start the dev services
|
|
3
|
-
* every apps/backend-* (tsx watch), frontend(s) (vite), admin (vite), ai-server (python).
|
|
2
|
+
* nexus dev — start the dev services.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* In an interactive TTY this opens the full-screen Nexus Console control panel
|
|
5
|
+
* (services start/stop/restart + a palette that runs every other CLI command).
|
|
6
|
+
* When stdout isn't a TTY (CI, piped) it falls back to plain prefixed log
|
|
7
|
+
* streaming. Pass `--no-panel` to force plain mode in a TTY.
|
|
7
8
|
*/
|
|
8
|
-
import { spawn, type ChildProcess } from 'node:child_process';
|
|
9
9
|
import { existsSync } from 'node:fs';
|
|
10
10
|
import { readdir } from 'node:fs/promises';
|
|
11
|
-
import {
|
|
11
|
+
import { join } from 'node:path';
|
|
12
12
|
import type { CommandContext } from '../dispatcher.js';
|
|
13
13
|
import { ensurePort } from '../ports.js';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
name: string;
|
|
17
|
-
cmd: string;
|
|
18
|
-
args: string[];
|
|
19
|
-
cwd: string;
|
|
20
|
-
color: string;
|
|
21
|
-
enabled: boolean;
|
|
22
|
-
port: number;
|
|
23
|
-
}
|
|
14
|
+
import { ServiceManager, type ServiceSpec } from '../devServiceManager.js';
|
|
15
|
+
import { startDevPanel } from '../devPanel.js';
|
|
24
16
|
|
|
25
17
|
const COLORS = ['\x1b[36m', '\x1b[33m', '\x1b[35m', '\x1b[32m', '\x1b[34m', '\x1b[31m', '\x1b[37m', '\x1b[90m'];
|
|
26
18
|
const RESET = '\x1b[0m';
|
|
@@ -28,8 +20,59 @@ const RESET = '\x1b[0m';
|
|
|
28
20
|
export async function run(ctx: CommandContext): Promise<void> {
|
|
29
21
|
const only = (ctx.args.flags.only as string)?.split(',').map((s) => s.trim()) ?? null;
|
|
30
22
|
const projectRoot = process.cwd();
|
|
23
|
+
const noPanel = !!ctx.args.flags['no-panel'];
|
|
24
|
+
const isTty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
25
|
+
|
|
26
|
+
const specs = await discoverServices(projectRoot, only);
|
|
27
|
+
if (specs.length === 0) {
|
|
28
|
+
console.log('No services to start. Check that apps/* exist.');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const manager = new ServiceManager(specs);
|
|
33
|
+
|
|
34
|
+
if (isTty && !noPanel) {
|
|
35
|
+
// Full-screen Nexus Console.
|
|
36
|
+
console.log(`nexus dev — ${specs.length} service(s). Opening console...\n`);
|
|
37
|
+
await startDevPanel({ services: manager, onQuit: () => manager.killAll() });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Plain mode (piped / CI / --no-panel): start all + stream prefixed logs.
|
|
42
|
+
console.log(`nexus dev — starting ${specs.length} service(s)\n`);
|
|
43
|
+
let shuttingDown = false;
|
|
44
|
+
const shutdown = () => {
|
|
45
|
+
if (shuttingDown) return;
|
|
46
|
+
shuttingDown = true;
|
|
47
|
+
manager.killAll();
|
|
48
|
+
setTimeout(() => process.exit(0), 500);
|
|
49
|
+
};
|
|
50
|
+
process.on('SIGINT', shutdown);
|
|
51
|
+
process.on('SIGTERM', shutdown);
|
|
52
|
+
|
|
53
|
+
// Wire a plain log printer (prints only NEW lines per service).
|
|
54
|
+
const colors = new Map<string, string>();
|
|
55
|
+
specs.forEach((s, i) => colors.set(s.name, COLORS[i % COLORS.length]!));
|
|
56
|
+
const printed = new Map<string, number>();
|
|
57
|
+
manager.onLog = (name) => {
|
|
58
|
+
const svc = manager.get(name);
|
|
59
|
+
if (!svc) return;
|
|
60
|
+
const prefix = `${colors.get(name) ?? ''}[${name}]${RESET}`;
|
|
61
|
+
const from = printed.get(name) ?? 0;
|
|
62
|
+
const newLines = svc.logBuffer.slice(from);
|
|
63
|
+
printed.set(name, svc.logBuffer.length);
|
|
64
|
+
for (const line of newLines) {
|
|
65
|
+
process.stdout.write(`${prefix} ${line}\n`);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
for (const spec of specs) manager.start(spec.name);
|
|
31
70
|
|
|
32
|
-
//
|
|
71
|
+
// Keep the process alive.
|
|
72
|
+
await new Promise(() => {});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function discoverServices(projectRoot: string, only: string[] | null): Promise<ServiceSpec[]> {
|
|
33
76
|
const appsDir = join(projectRoot, 'apps');
|
|
34
77
|
const appEntries = existsSync(appsDir) ? await readdir(appsDir, { withFileTypes: true }) : [];
|
|
35
78
|
const backends = appEntries.filter((e) => e.isDirectory() && e.name.startsWith('backend')).map((e) => e.name);
|
|
@@ -37,127 +80,33 @@ export async function run(ctx: CommandContext): Promise<void> {
|
|
|
37
80
|
const admins = appEntries.filter((e) => e.isDirectory() && e.name.startsWith('admin')).map((e) => e.name);
|
|
38
81
|
const aiServers = appEntries.filter((e) => e.isDirectory() && e.name === 'ai-server').map((e) => e.name);
|
|
39
82
|
|
|
40
|
-
const
|
|
41
|
-
let colorIdx = 0;
|
|
42
|
-
|
|
83
|
+
const specs: ServiceSpec[] = [];
|
|
43
84
|
for (const b of backends) {
|
|
44
|
-
|
|
45
|
-
if (!existsSync(mainPath)) continue;
|
|
85
|
+
if (!existsSync(join(appsDir, b, 'src', 'main.ts'))) continue;
|
|
46
86
|
const port = await ensurePort(projectRoot, b);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
cmd: 'npx',
|
|
50
|
-
args: ['tsx', 'watch', 'src/main.ts'],
|
|
51
|
-
cwd: join(appsDir, b),
|
|
52
|
-
color: COLORS[colorIdx++ % COLORS.length]!,
|
|
53
|
-
enabled: !only || only.includes(b),
|
|
54
|
-
port: port,
|
|
55
|
-
});
|
|
87
|
+
if (only && !only.includes(b)) continue;
|
|
88
|
+
specs.push({ name: b, cmd: 'npx', args: ['tsx', 'watch', 'src/main.ts'], cwd: join(appsDir, b), port });
|
|
56
89
|
}
|
|
57
90
|
for (const f of frontends) {
|
|
58
91
|
if (!existsSync(join(appsDir, f, 'package.json'))) continue;
|
|
59
92
|
const port = await ensurePort(projectRoot, f);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
cmd: 'npx',
|
|
63
|
-
args: ['vite', '--port', String(port), '--strictPort'],
|
|
64
|
-
cwd: join(appsDir, f),
|
|
65
|
-
color: COLORS[colorIdx++ % COLORS.length]!,
|
|
66
|
-
enabled: !only || only.includes(f),
|
|
67
|
-
port,
|
|
68
|
-
});
|
|
93
|
+
if (only && !only.includes(f)) continue;
|
|
94
|
+
specs.push({ name: f, cmd: 'npx', args: ['vite', '--port', String(port), '--strictPort'], cwd: join(appsDir, f), port });
|
|
69
95
|
}
|
|
70
96
|
for (const a of admins) {
|
|
71
97
|
if (!existsSync(join(appsDir, a, 'package.json'))) continue;
|
|
72
98
|
const port = await ensurePort(projectRoot, a);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
cmd: 'npx',
|
|
76
|
-
args: ['vite', '--port', String(port), '--strictPort'],
|
|
77
|
-
cwd: join(appsDir, a),
|
|
78
|
-
color: COLORS[colorIdx++ % COLORS.length]!,
|
|
79
|
-
enabled: !only || only.includes(a),
|
|
80
|
-
port,
|
|
81
|
-
});
|
|
99
|
+
if (only && !only.includes(a)) continue;
|
|
100
|
+
specs.push({ name: a, cmd: 'npx', args: ['vite', '--port', String(port), '--strictPort'], cwd: join(appsDir, a), port });
|
|
82
101
|
}
|
|
83
102
|
for (const ai of aiServers) {
|
|
84
|
-
|
|
85
|
-
if (!existsSync(mainPy)) continue;
|
|
103
|
+
if (!existsSync(join(appsDir, ai, 'main.py'))) continue;
|
|
86
104
|
const port = await ensurePort(projectRoot, ai);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
cmd: 'python',
|
|
90
|
-
args: ['main.py'],
|
|
91
|
-
cwd: join(appsDir, ai),
|
|
92
|
-
color: COLORS[colorIdx++ % COLORS.length]!,
|
|
93
|
-
enabled: !only || only.includes(ai),
|
|
94
|
-
port,
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const active = services.filter((s) => s.enabled);
|
|
99
|
-
if (active.length === 0) {
|
|
100
|
-
console.log('No services to start. Check that apps/* exist.');
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
console.log(`nexus dev — starting ${active.length} service(s)\n`);
|
|
105
|
-
for (const s of active) {
|
|
106
|
-
console.log(` ${s.color}[${s.name}]${RESET} → :${s.port}`);
|
|
107
|
-
}
|
|
108
|
-
console.log('');
|
|
109
|
-
|
|
110
|
-
const procs: ChildProcess[] = [];
|
|
111
|
-
let shuttingDown = false;
|
|
112
|
-
|
|
113
|
-
const shutdown = () => {
|
|
114
|
-
if (shuttingDown) return;
|
|
115
|
-
shuttingDown = true;
|
|
116
|
-
console.log('\nShutting down...');
|
|
117
|
-
for (const p of procs) {
|
|
118
|
-
try {
|
|
119
|
-
p.kill('SIGTERM');
|
|
120
|
-
} catch { /* ignore */ }
|
|
121
|
-
}
|
|
122
|
-
setTimeout(() => process.exit(0), 500);
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
process.on('SIGINT', shutdown);
|
|
126
|
-
process.on('SIGTERM', shutdown);
|
|
127
|
-
|
|
128
|
-
for (const svc of active) {
|
|
129
|
-
const child = spawn(svc.cmd, svc.args, {
|
|
130
|
-
cwd: svc.cwd,
|
|
131
|
-
stdio: ['inherit', 'pipe', 'pipe'],
|
|
132
|
-
env: { ...process.env, FORCE_COLOR: '1', NEXUS_PORT: String(svc.port) },
|
|
133
|
-
shell: true,
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
const prefix = `${svc.color}[${svc.name}]${RESET}`;
|
|
137
|
-
child.stdout?.on('data', (chunk: Buffer) => {
|
|
138
|
-
const lines = chunk.toString().split('\n');
|
|
139
|
-
for (const line of lines) {
|
|
140
|
-
if (line.trim()) process.stdout.write(`${prefix} ${line}\n`);
|
|
141
|
-
}
|
|
142
|
-
});
|
|
143
|
-
child.stderr?.on('data', (chunk: Buffer) => {
|
|
144
|
-
const lines = chunk.toString().split('\n');
|
|
145
|
-
for (const line of lines) {
|
|
146
|
-
if (line.trim()) process.stderr.write(`${prefix} ${line}\n`);
|
|
147
|
-
}
|
|
148
|
-
});
|
|
149
|
-
child.on('exit', (code, signal) => {
|
|
150
|
-
if (shuttingDown) return;
|
|
151
|
-
console.log(`${prefix} exited (${code ?? signal}). Restarting in 1s...`);
|
|
152
|
-
setTimeout(() => {
|
|
153
|
-
if (!shuttingDown) {
|
|
154
|
-
procs.push(spawn(svc.cmd, svc.args, { cwd: svc.cwd, stdio: 'inherit', env: process.env, shell: true }));
|
|
155
|
-
}
|
|
156
|
-
}, 1000);
|
|
157
|
-
});
|
|
158
|
-
procs.push(child);
|
|
105
|
+
if (only && !only.includes(ai)) continue;
|
|
106
|
+
specs.push({ name: ai, cmd: 'python', args: ['main.py'], cwd: join(appsDir, ai), port });
|
|
159
107
|
}
|
|
108
|
+
return specs;
|
|
160
109
|
}
|
|
161
110
|
|
|
162
|
-
export const description = 'Start dev services (all apps under ./apps)';
|
|
163
|
-
export const usage = 'nexus dev [--only
|
|
111
|
+
export const description = 'Start dev services (all apps under ./apps) — interactive console in a TTY';
|
|
112
|
+
export const usage = 'nexus dev [--only a,b] [--no-panel]';
|
package/src/devPanel.ts
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* devPanel — full-screen Nexus Console TUI.
|
|
3
|
+
*
|
|
4
|
+
* Three panes:
|
|
5
|
+
* 1. Services — managed by ServiceManager (start/stop/restart/logs)
|
|
6
|
+
* 2. Commands — palette auto-built from the command registry (getCommands())
|
|
7
|
+
* 3. Output — selected service logs OR command output
|
|
8
|
+
*
|
|
9
|
+
* Keybindings:
|
|
10
|
+
* ↑/↓ move selection (within active list)
|
|
11
|
+
* Enter run selected command / start selected service
|
|
12
|
+
* Tab switch active pane (services ⇄ commands)
|
|
13
|
+
* s / t / r start / stop / restart selected service
|
|
14
|
+
* l cycle log source (service logs ⇄ command output)
|
|
15
|
+
* q / Esc quit (kills all services)
|
|
16
|
+
* ? help
|
|
17
|
+
*
|
|
18
|
+
* Zero dependencies: raw ANSI + readline keypress.
|
|
19
|
+
*/
|
|
20
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
import { existsSync } from 'node:fs';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
import * as readline from 'node:readline';
|
|
26
|
+
import { getCommands, runCommand } from './dispatcher.js';
|
|
27
|
+
import { ServiceManager } from './devServiceManager.js';
|
|
28
|
+
|
|
29
|
+
const ANSI = {
|
|
30
|
+
reset: '\x1b[0m',
|
|
31
|
+
bold: '\x1b[1m',
|
|
32
|
+
dim: '\x1b[2m',
|
|
33
|
+
clear: '\x1b[2J\x1b[H',
|
|
34
|
+
altOn: '\x1b[?1049h',
|
|
35
|
+
altOff: '\x1b[?1049l',
|
|
36
|
+
hideCursor: '\x1b[?25l',
|
|
37
|
+
showCursor: '\x1b[?25h',
|
|
38
|
+
move: (r: number, c: number) => `\x1b[${r};${c}H`,
|
|
39
|
+
cyan: '\x1b[36m',
|
|
40
|
+
green: '\x1b[32m',
|
|
41
|
+
yellow: '\x1b[33m',
|
|
42
|
+
red: '\x1b[31m',
|
|
43
|
+
blue: '\x1b[34m',
|
|
44
|
+
magenta: '\x1b[35m',
|
|
45
|
+
dimGray: '\x1b[90m',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export interface DevPanelOptions {
|
|
49
|
+
services: ServiceManager;
|
|
50
|
+
/** Invoked on quit (after the panel exits) — used to kill all services. */
|
|
51
|
+
onQuit?: () => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface CommandItem {
|
|
55
|
+
name: string;
|
|
56
|
+
label: string;
|
|
57
|
+
desc: string;
|
|
58
|
+
needsArgs: boolean;
|
|
59
|
+
interactive: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Commands that need a real TTY (their own prompts) — suspend the panel. */
|
|
63
|
+
const INTERACTIVE_COMMANDS = new Set(['init', 'add', 'plugin', 'queue:work']);
|
|
64
|
+
|
|
65
|
+
/** Commands that take a positional/flag arg we should collect inline. */
|
|
66
|
+
const ARG_COMMANDS = new Set([
|
|
67
|
+
'make:route', 'make:controller', 'make:model', 'make:service', 'make:repository',
|
|
68
|
+
'make:middleware', 'make:validator', 'make:job', 'make:event', 'make:listener',
|
|
69
|
+
'make:policy', 'make:resource', 'make:request', 'make:mail', 'make:room',
|
|
70
|
+
'make:subgraph', 'make:seeder', 'make:migration', 'make:provider', 'make:plugin',
|
|
71
|
+
'db:seed', 'db:migrate', 'db:rollback', 'queue:retry',
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
function commandItems(): CommandItem[] {
|
|
75
|
+
return getCommands().map((c) => ({
|
|
76
|
+
name: c.name,
|
|
77
|
+
label: c.name,
|
|
78
|
+
desc: c.description ?? '',
|
|
79
|
+
needsArgs: ARG_COMMANDS.has(c.name),
|
|
80
|
+
interactive: INTERACTIVE_COMMANDS.has(c.name),
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Categorize commands for the palette grouping. */
|
|
85
|
+
function groupOf(name: string): string {
|
|
86
|
+
if (name.startsWith('make:')) return 'make:*';
|
|
87
|
+
if (name.startsWith('db:')) return 'data';
|
|
88
|
+
if (name.startsWith('queue:')) return 'queue';
|
|
89
|
+
if (name.startsWith('plugin')) return 'plugins';
|
|
90
|
+
if (name === 'down' || name === 'up') return 'maintenance';
|
|
91
|
+
if (['init', 'dev', 'build', 'test', 'doctor'].includes(name)) return 'project';
|
|
92
|
+
return 'project';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const GROUPS = ['project', 'make:*', 'data', 'queue', 'plugins', 'maintenance'];
|
|
96
|
+
|
|
97
|
+
export async function startDevPanel(opts: DevPanelOptions): Promise<void> {
|
|
98
|
+
const { services } = opts;
|
|
99
|
+
const items = commandItems();
|
|
100
|
+
const grouped = new Map<string, CommandItem[]>();
|
|
101
|
+
for (const g of GROUPS) grouped.set(g, []);
|
|
102
|
+
for (const item of items) {
|
|
103
|
+
const g = groupOf(item.name);
|
|
104
|
+
if (!grouped.has(g)) grouped.set(g, []);
|
|
105
|
+
grouped.get(g)!.push(item);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Flatten palette (with group header markers).
|
|
109
|
+
type Row = { kind: 'header'; label: string } | { kind: 'cmd'; cmd: CommandItem };
|
|
110
|
+
const palette: Row[] = [];
|
|
111
|
+
for (const g of GROUPS) {
|
|
112
|
+
const list = grouped.get(g) ?? [];
|
|
113
|
+
if (list.length === 0) continue;
|
|
114
|
+
palette.push({ kind: 'header', label: g });
|
|
115
|
+
for (const cmd of list) palette.push({ kind: 'cmd', cmd });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---- state ----
|
|
119
|
+
let activePane: 'services' | 'commands' = 'services';
|
|
120
|
+
let svcIndex = 0;
|
|
121
|
+
let cmdIndex = 0;
|
|
122
|
+
let logSource: string | 'commands' = services.services[0]?.name ?? 'commands';
|
|
123
|
+
let commandOutput: string[] = [];
|
|
124
|
+
let argMode: { cmd: CommandItem; buffer: string } | null = null;
|
|
125
|
+
let showHelp = false;
|
|
126
|
+
let quit = false;
|
|
127
|
+
let lastRender = 0;
|
|
128
|
+
|
|
129
|
+
// ---- raw keyboard ----
|
|
130
|
+
input.setRawMode?.(true);
|
|
131
|
+
input.resume?.();
|
|
132
|
+
output.write(ANSI.altOn + ANSI.hideCursor);
|
|
133
|
+
|
|
134
|
+
const keypressHandler = (str: string, key: any) => {
|
|
135
|
+
if (argMode) {
|
|
136
|
+
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
|
137
|
+
argMode = null;
|
|
138
|
+
render();
|
|
139
|
+
} else if (key.name === 'return' || key.name === 'enter') {
|
|
140
|
+
const { cmd, buffer } = argMode;
|
|
141
|
+
argMode = null;
|
|
142
|
+
void runSelectedCommand(cmd, buffer);
|
|
143
|
+
} else if (key.name === 'backspace') {
|
|
144
|
+
argMode.buffer = argMode.buffer.slice(0, -1);
|
|
145
|
+
} else if (str && !key.ctrl && !key.meta) {
|
|
146
|
+
argMode.buffer += str;
|
|
147
|
+
}
|
|
148
|
+
render();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (key.name === 'c' && key.ctrl) {
|
|
153
|
+
quit = true;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (key.name === 'escape' || key.name === 'q') {
|
|
157
|
+
if (showHelp) showHelp = false;
|
|
158
|
+
else quit = true;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (key.name === '?') {
|
|
162
|
+
showHelp = !showHelp;
|
|
163
|
+
}
|
|
164
|
+
if (showHelp) return;
|
|
165
|
+
|
|
166
|
+
switch (key.name) {
|
|
167
|
+
case 'up':
|
|
168
|
+
if (activePane === 'services') svcIndex = Math.max(0, svcIndex - 1);
|
|
169
|
+
else cmdIndex = prevSelectable(cmdIndex, -1, palette);
|
|
170
|
+
break;
|
|
171
|
+
case 'down':
|
|
172
|
+
if (activePane === 'services') svcIndex = Math.min(services.services.length - 1, svcIndex + 1);
|
|
173
|
+
else cmdIndex = prevSelectable(cmdIndex, 1, palette);
|
|
174
|
+
break;
|
|
175
|
+
case 'tab':
|
|
176
|
+
activePane = activePane === 'services' ? 'commands' : 'services';
|
|
177
|
+
break;
|
|
178
|
+
case 'return':
|
|
179
|
+
case 'enter':
|
|
180
|
+
if (activePane === 'services') {
|
|
181
|
+
const svc = services.services[svcIndex];
|
|
182
|
+
if (svc) {
|
|
183
|
+
if (svc.status === 'stopped' || svc.status === 'crashed') services.start(svc.name);
|
|
184
|
+
else services.restart(svc.name);
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
const row = palette[cmdIndex];
|
|
188
|
+
if (row && row.kind === 'cmd') {
|
|
189
|
+
const cmd = row.cmd;
|
|
190
|
+
if (cmd.needsArgs) {
|
|
191
|
+
argMode = { cmd, buffer: '' };
|
|
192
|
+
} else {
|
|
193
|
+
void runSelectedCommand(cmd, '');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
break;
|
|
198
|
+
case 's':
|
|
199
|
+
services.start(services.services[svcIndex]?.name ?? '');
|
|
200
|
+
break;
|
|
201
|
+
case 't':
|
|
202
|
+
services.stop(services.services[svcIndex]?.name ?? '');
|
|
203
|
+
break;
|
|
204
|
+
case 'r':
|
|
205
|
+
services.restart(services.services[svcIndex]?.name ?? '');
|
|
206
|
+
break;
|
|
207
|
+
case 'l':
|
|
208
|
+
cycleLogSource();
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
render();
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
function prevSelectable(current: number, dir: number, list: Row[]): number {
|
|
215
|
+
let i = current;
|
|
216
|
+
for (let step = 0; step < list.length; step++) {
|
|
217
|
+
i = (i + dir + list.length) % list.length;
|
|
218
|
+
const row = list[i];
|
|
219
|
+
if (row && row.kind === 'cmd') return i;
|
|
220
|
+
}
|
|
221
|
+
return current;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function cycleLogSource(): void {
|
|
225
|
+
const names = services.services.map((s) => s.name);
|
|
226
|
+
const targets = [...names, 'commands'];
|
|
227
|
+
const idx = targets.indexOf(logSource);
|
|
228
|
+
logSource = targets[(idx + 1) % targets.length] ?? 'commands';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function runSelectedCommand(cmd: CommandItem, inlineArgs: string): Promise<void> {
|
|
232
|
+
commandOutput = [];
|
|
233
|
+
setLogSource('commands');
|
|
234
|
+
render();
|
|
235
|
+
|
|
236
|
+
if (cmd.interactive) {
|
|
237
|
+
// Suspend panel → real TTY → resume.
|
|
238
|
+
suspend();
|
|
239
|
+
try {
|
|
240
|
+
const bin = resolveCliBin();
|
|
241
|
+
const args = inlineArgs ? [cmd.name, ...inlineArgs.trim().split(/\s+/)] : [cmd.name];
|
|
242
|
+
await spawnNode(bin, args);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
pushCommandOutput(`✗ ${cmd.name} failed: ${(err as Error).message}`);
|
|
245
|
+
}
|
|
246
|
+
resume();
|
|
247
|
+
render();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const argv = inlineArgs ? inlineArgs.trim().split(/\s+/) : [];
|
|
252
|
+
const origOut = process.stdout.write.bind(process.stdout);
|
|
253
|
+
const origErr = process.stderr.write.bind(process.stderr);
|
|
254
|
+
const sink = (chunk: string | Buffer, isErr = false) => {
|
|
255
|
+
const text = String(chunk);
|
|
256
|
+
pushCommandOutput(text);
|
|
257
|
+
return true;
|
|
258
|
+
};
|
|
259
|
+
(process.stdout as unknown as { write: Function }).write = (chunk: string | Buffer) => sink(chunk, false);
|
|
260
|
+
(process.stderr as unknown as { write: Function }).write = (chunk: string | Buffer) => sink(chunk, true);
|
|
261
|
+
try {
|
|
262
|
+
await runCommand(cmd.name, argv);
|
|
263
|
+
pushCommandOutput(`\n${ANSI.green}✓ ${cmd.name}${ANSI.reset} finished`);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
pushCommandOutput(`\n${ANSI.red}✗ ${cmd.name} failed:${ANSI.reset} ${(err as Error).message}`);
|
|
266
|
+
} finally {
|
|
267
|
+
(process.stdout as unknown as { write: Function }).write = origOut;
|
|
268
|
+
(process.stderr as unknown as { write: Function }).write = origErr;
|
|
269
|
+
}
|
|
270
|
+
render();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function setLogSource(source: string): void {
|
|
274
|
+
logSource = source;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function pushCommandOutput(text: string): void {
|
|
278
|
+
const lines = text.split('\n');
|
|
279
|
+
for (const line of lines) {
|
|
280
|
+
commandOutput.push(stripAnsi(line));
|
|
281
|
+
}
|
|
282
|
+
if (commandOutput.length > 200) commandOutput.splice(0, commandOutput.length - 200);
|
|
283
|
+
throttledRender();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function suspend(): void {
|
|
287
|
+
input.setRawMode?.(false);
|
|
288
|
+
output.write(ANSI.altOff + ANSI.showCursor);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function resume(): void {
|
|
292
|
+
input.setRawMode?.(true);
|
|
293
|
+
input.resume?.();
|
|
294
|
+
output.write(ANSI.altOn + ANSI.hideCursor);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---- render ----
|
|
298
|
+
function throttledRender(): void {
|
|
299
|
+
const now = Date.now();
|
|
300
|
+
if (now - lastRender < 80) return;
|
|
301
|
+
lastRender = now;
|
|
302
|
+
render();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function render(): void {
|
|
306
|
+
if (quit) return;
|
|
307
|
+
if (!output.isTTY) return;
|
|
308
|
+
const { rows, columns } = output as unknown as { rows: number; columns: number };
|
|
309
|
+
const H = rows || 24;
|
|
310
|
+
const W = columns || 80;
|
|
311
|
+
|
|
312
|
+
const lines: string[] = [];
|
|
313
|
+
lines.push(`${ANSI.bold}${ANSI.cyan} Nexus Console ${ANSI.reset}${ANSI.dim}— BhooAI Nexus${ANSI.reset} ${ANSI.dim}(${services.services.length} services, ${palette.length - GROUPS.length} commands)${ANSI.reset}`);
|
|
314
|
+
lines.push('');
|
|
315
|
+
|
|
316
|
+
// --- Services pane ---
|
|
317
|
+
lines.push(`${ANSI.bold}${ANSI.blue} SERVICES ${ANSI.reset}`);
|
|
318
|
+
services.services.forEach((svc, i) => {
|
|
319
|
+
const sel = activePane === 'services' && i === svcIndex;
|
|
320
|
+
const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
|
|
321
|
+
const dot = statusDot(svc.status);
|
|
322
|
+
const name = sel ? `${ANSI.bold}${svc.name}${ANSI.reset}` : svc.name;
|
|
323
|
+
const pid = svc.pid ? String(svc.pid) : '—';
|
|
324
|
+
const restarts = svc.restarts ? String(svc.restarts) : '0';
|
|
325
|
+
lines.push(` ${marker} ${name.padEnd(18)} :${String(svc.port).padEnd(6)} ${dot} ${svc.status.padEnd(8)} ${pid.padEnd(7)} ${restarts}`);
|
|
326
|
+
});
|
|
327
|
+
lines.push('');
|
|
328
|
+
|
|
329
|
+
// --- Commands pane ---
|
|
330
|
+
lines.push(`${ANSI.bold}${ANSI.magenta} COMMANDS ${ANSI.reset}`);
|
|
331
|
+
const visible = palette.slice(0, Math.min(palette.length, H - lines.length - 8));
|
|
332
|
+
visible.forEach((row, i) => {
|
|
333
|
+
if (row.kind === 'header') {
|
|
334
|
+
lines.push(` ${ANSI.dim}${row.label.toUpperCase()}${ANSI.reset}`);
|
|
335
|
+
} else {
|
|
336
|
+
const isSelected = activePane === 'commands' && palette.indexOf(row) === cmdIndex;
|
|
337
|
+
const marker = isSelected ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
|
|
338
|
+
const label = isSelected ? `${ANSI.bold}${row.cmd.label}${ANSI.reset}` : row.cmd.label;
|
|
339
|
+
const desc = row.cmd.desc ? `${ANSI.dim}${row.cmd.desc}${ANSI.reset}` : '';
|
|
340
|
+
lines.push(` ${marker} ${label.padEnd(22)} ${desc}`);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
lines.push('');
|
|
344
|
+
|
|
345
|
+
// --- Output pane ---
|
|
346
|
+
const outputTitle = logSource === 'commands' ? 'COMMAND OUTPUT' : `LOGS: ${logSource}`;
|
|
347
|
+
lines.push(`${ANSI.bold}${ANSI.yellow} ${outputTitle} ${ANSI.reset}`);
|
|
348
|
+
const src = logSource === 'commands' ? commandOutput : services.tail(logSource, 60);
|
|
349
|
+
const availH = H - lines.length - 3;
|
|
350
|
+
const tail = src.slice(-Math.max(availH, 1));
|
|
351
|
+
for (const line of tail) lines.push(` ${ANSI.dim}|${ANSI.reset} ${line}`);
|
|
352
|
+
|
|
353
|
+
// --- help bar ---
|
|
354
|
+
const helpText = showHelp
|
|
355
|
+
? ` ↑/↓ select · Enter run/start · Tab pane · s start · t stop · r restart · l logs · q quit · ? help`
|
|
356
|
+
: `↑/↓ select · Enter run · Tab pane · s/t/r start/stop/restart · l logs · c cmds · q quit · ? help`;
|
|
357
|
+
|
|
358
|
+
// Arg prompt line
|
|
359
|
+
if (argMode) {
|
|
360
|
+
lines.push('');
|
|
361
|
+
lines.push(`${ANSI.cyan}${argMode.cmd.name}${ANSI.reset} ${argMode.buffer}█`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Assemble output: keep within terminal height.
|
|
365
|
+
const body = lines.slice(0, H - 1);
|
|
366
|
+
let out = ANSI.clear;
|
|
367
|
+
out += body.join('\n');
|
|
368
|
+
out += ANSI.move(H, 1);
|
|
369
|
+
out += `${ANSI.dim}${helpText}${ANSI.reset}`;
|
|
370
|
+
if (argMode) {
|
|
371
|
+
out += ANSI.move(H - 1, 1);
|
|
372
|
+
}
|
|
373
|
+
output.write(out);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function statusDot(status: string): string {
|
|
377
|
+
switch (status) {
|
|
378
|
+
case 'running': return `${ANSI.green}●${ANSI.reset}`;
|
|
379
|
+
case 'starting': return `${ANSI.yellow}◐${ANSI.reset}`;
|
|
380
|
+
case 'crashed': return `${ANSI.red}✗${ANSI.reset}`;
|
|
381
|
+
default: return `${ANSI.dimGray}○${ANSI.reset}`;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// wire service log redraws
|
|
386
|
+
services.onLog = () => throttledRender();
|
|
387
|
+
|
|
388
|
+
// ---- keypress wiring ----
|
|
389
|
+
readline.emitKeypressEvents(input);
|
|
390
|
+
input.on('keypress', keypressHandler);
|
|
391
|
+
|
|
392
|
+
// Intercept SIGINT/SIGTERM (Ctrl+C already handled by keypress ctrl-c).
|
|
393
|
+
const onSignal = () => {
|
|
394
|
+
quit = true;
|
|
395
|
+
};
|
|
396
|
+
process.on('SIGINT', onSignal);
|
|
397
|
+
process.on('SIGTERM', onSignal);
|
|
398
|
+
|
|
399
|
+
render();
|
|
400
|
+
|
|
401
|
+
// Wait for quit.
|
|
402
|
+
while (!quit) {
|
|
403
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ---- cleanup ----
|
|
407
|
+
input.removeListener('keypress', keypressHandler);
|
|
408
|
+
input.setRawMode?.(false);
|
|
409
|
+
output.write(ANSI.altOff + ANSI.showCursor);
|
|
410
|
+
process.removeListener('SIGINT', onSignal);
|
|
411
|
+
process.removeListener('SIGTERM', onSignal);
|
|
412
|
+
opts.onQuit?.();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function spawnNode(bin: string, args: string[]): Promise<void> {
|
|
416
|
+
return new Promise((resolve) => {
|
|
417
|
+
const child = spawn(process.execPath, [bin, ...args], { stdio: 'inherit', cwd: process.cwd() });
|
|
418
|
+
child.on('exit', () => resolve());
|
|
419
|
+
child.on('error', () => resolve());
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Resolve the CLI bin for suspend-mode commands.
|
|
425
|
+
*
|
|
426
|
+
* Works in the monorepo (packages/nexus-cli/src/devPanel.ts → bin/nexus.js)
|
|
427
|
+
* AND when installed as @bhooai/nexus-cli (its own bin/nexus.js), falling back
|
|
428
|
+
* to `npx` semantics.
|
|
429
|
+
*/
|
|
430
|
+
function resolveCliBin(): string {
|
|
431
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
432
|
+
// From src/devPanel.ts: ../../bin/nexus.js reaches the CLI package's own bin.
|
|
433
|
+
const ownBin = join(here, '..', '..', 'bin', 'nexus.js');
|
|
434
|
+
if (existsSync(ownBin)) return ownBin;
|
|
435
|
+
return join(here, '..', '..', '..', '..', 'bin', 'nexus.js');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function stripAnsi(text: string): string {
|
|
439
|
+
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
440
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* devServiceManager — supervises the app services started by `nexus dev`.
|
|
3
|
+
*
|
|
4
|
+
* Owns each service's process + state and provides start/stop/restart with a
|
|
5
|
+
* crash-loop guard. Log output is captured into a per-service ring buffer so
|
|
6
|
+
* the control panel can render live logs.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
9
|
+
import type { Readable } from 'node:stream';
|
|
10
|
+
|
|
11
|
+
export type ServiceStatus = 'starting' | 'running' | 'stopped' | 'crashed';
|
|
12
|
+
|
|
13
|
+
export interface ServiceSpec {
|
|
14
|
+
name: string;
|
|
15
|
+
cmd: string;
|
|
16
|
+
args: string[];
|
|
17
|
+
cwd: string;
|
|
18
|
+
port: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ManagedService extends ServiceSpec {
|
|
22
|
+
status: ServiceStatus;
|
|
23
|
+
pid: number | null;
|
|
24
|
+
restarts: number;
|
|
25
|
+
lastExit: { code: number | null; signal: NodeJS.Signals | null } | null;
|
|
26
|
+
logBuffer: string[];
|
|
27
|
+
child: ChildProcess | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MAX_LOG_LINES = 200;
|
|
31
|
+
const CRASH_THRESHOLD = 5;
|
|
32
|
+
const CRASH_WINDOW_MS = 30_000;
|
|
33
|
+
|
|
34
|
+
export class ServiceManager {
|
|
35
|
+
readonly services: ManagedService[] = [];
|
|
36
|
+
private crashTimes = new Map<string, number[]>();
|
|
37
|
+
|
|
38
|
+
constructor(specs: ServiceSpec[]) {
|
|
39
|
+
for (const spec of specs) {
|
|
40
|
+
this.services.push({ ...spec, status: 'stopped', pid: null, restarts: 0, lastExit: null, logBuffer: [], child: null });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Get a service by name. */
|
|
45
|
+
get(name: string): ManagedService | null {
|
|
46
|
+
return this.services.find((s) => s.name === name) ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Start a service (idempotent). */
|
|
50
|
+
start(name: string): void {
|
|
51
|
+
const svc = this.get(name);
|
|
52
|
+
if (!svc || svc.child) return;
|
|
53
|
+
this.spawnChild(svc);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Stop a service (SIGTERM, then SIGKILL after a grace period). */
|
|
57
|
+
stop(name: string): void {
|
|
58
|
+
const svc = this.get(name);
|
|
59
|
+
if (!svc || !svc.child) return;
|
|
60
|
+
this.killChild(svc, 'SIGTERM');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Restart a service. */
|
|
64
|
+
restart(name: string): void {
|
|
65
|
+
const svc = this.get(name);
|
|
66
|
+
if (!svc) return;
|
|
67
|
+
if (svc.child) this.killChild(svc, 'SIGTERM');
|
|
68
|
+
// Restart after a short delay so the port frees up.
|
|
69
|
+
setTimeout(() => this.start(name), 250);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Kill all services (used on panel quit). */
|
|
73
|
+
killAll(): void {
|
|
74
|
+
for (const svc of this.services) {
|
|
75
|
+
if (svc.child) this.killChild(svc, 'SIGTERM');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private spawnChild(svc: ManagedService): void {
|
|
80
|
+
svc.status = 'starting';
|
|
81
|
+
svc.restarts++;
|
|
82
|
+
this.recordCrashWindow(svc.name);
|
|
83
|
+
|
|
84
|
+
// On Windows with shell:true, a cmd path containing spaces must be quoted
|
|
85
|
+
// or the shell splits it ('C:\Program' is not recognized).
|
|
86
|
+
const cmd = process.platform === 'win32' && svc.cmd.includes(' ')
|
|
87
|
+
? `"${svc.cmd}"`
|
|
88
|
+
: svc.cmd;
|
|
89
|
+
|
|
90
|
+
const child = spawn(cmd, svc.args, {
|
|
91
|
+
cwd: svc.cwd,
|
|
92
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
93
|
+
env: { ...process.env, FORCE_COLOR: '1', NEXUS_PORT: String(svc.port) },
|
|
94
|
+
shell: process.platform === 'win32',
|
|
95
|
+
});
|
|
96
|
+
svc.child = child;
|
|
97
|
+
svc.pid = child.pid ?? null;
|
|
98
|
+
|
|
99
|
+
this.pipe(child.stdout, svc);
|
|
100
|
+
this.pipe(child.stderr, svc);
|
|
101
|
+
|
|
102
|
+
child.once('spawn', () => {
|
|
103
|
+
svc.status = 'running';
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
child.on('exit', (code, signal) => {
|
|
107
|
+
svc.child = null;
|
|
108
|
+
svc.pid = null;
|
|
109
|
+
svc.lastExit = { code, signal };
|
|
110
|
+
const wasCrashed = this.isCrashLooping(svc.name);
|
|
111
|
+
if (wasCrashed) {
|
|
112
|
+
svc.status = 'crashed';
|
|
113
|
+
this.push(svc, `✗ ${svc.name} crashed repeatedly — press s to start again`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
svc.status = 'stopped';
|
|
117
|
+
if (svc.restarts > 0 && !this.explicitlyStopping) {
|
|
118
|
+
this.push(svc, `↻ ${svc.name} exited (${code ?? signal}) — restarting in 1s`);
|
|
119
|
+
setTimeout(() => this.start(svc.name), 1000);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private explicitlyStopping = false;
|
|
125
|
+
|
|
126
|
+
private killChild(svc: ManagedService, signal: NodeJS.Signals): void {
|
|
127
|
+
this.explicitlyStopping = true;
|
|
128
|
+
const child = svc.child;
|
|
129
|
+
if (!child) return;
|
|
130
|
+
try {
|
|
131
|
+
child.kill(signal);
|
|
132
|
+
} catch { /* ignore */ }
|
|
133
|
+
// Force-kill if it lingers.
|
|
134
|
+
setTimeout(() => {
|
|
135
|
+
if (svc.child && svc.pid) {
|
|
136
|
+
try { process.kill(svc.pid, 'SIGKILL'); } catch { /* ignore */ }
|
|
137
|
+
}
|
|
138
|
+
}, 3000);
|
|
139
|
+
setTimeout(() => {
|
|
140
|
+
this.explicitlyStopping = false;
|
|
141
|
+
}, 3500);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private pipe(stream: Readable | null, svc: ManagedService): void {
|
|
145
|
+
if (!stream) return;
|
|
146
|
+
stream.on('data', (chunk: Buffer) => {
|
|
147
|
+
const text = chunk.toString();
|
|
148
|
+
this.push(svc, text);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private push(svc: ManagedService, text: string): void {
|
|
153
|
+
const lines = text.split('\n');
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
if (!line.trim()) continue;
|
|
156
|
+
svc.logBuffer.push(line);
|
|
157
|
+
if (svc.logBuffer.length > MAX_LOG_LINES) svc.logBuffer.shift();
|
|
158
|
+
}
|
|
159
|
+
this.onLog?.(svc.name);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Called whenever a service's log buffer changes (for panel redraw). */
|
|
163
|
+
onLog: ((serviceName: string) => void) | null = null;
|
|
164
|
+
|
|
165
|
+
private recordCrashWindow(name: string): void {
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
const list = (this.crashTimes.get(name) ?? []).filter((t) => now - t < CRASH_WINDOW_MS);
|
|
168
|
+
list.push(now);
|
|
169
|
+
this.crashTimes.set(name, list);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private isCrashLooping(name: string): boolean {
|
|
173
|
+
const list = this.crashTimes.get(name) ?? [];
|
|
174
|
+
return list.length >= CRASH_THRESHOLD;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Latest log lines for a service. */
|
|
178
|
+
tail(name: string, lines = 50): string[] {
|
|
179
|
+
const svc = this.get(name);
|
|
180
|
+
if (!svc) return [];
|
|
181
|
+
return svc.logBuffer.slice(-lines);
|
|
182
|
+
}
|
|
183
|
+
}
|
package/src/dispatcher.ts
CHANGED
|
@@ -25,6 +25,21 @@ export function registerCommand(cmd: Command): void {
|
|
|
25
25
|
COMMANDS.push(cmd);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** All registered commands (for the panel's command palette). */
|
|
29
|
+
export function getCommands(): Command[] {
|
|
30
|
+
return [...COMMANDS];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Invoke a registered command by name with an argv list (skips help/version). */
|
|
34
|
+
export async function runCommand(name: string, argv: string[]): Promise<void> {
|
|
35
|
+
const found = COMMANDS.find((c) => c.name === name);
|
|
36
|
+
if (!found) {
|
|
37
|
+
console.error(`Unknown command: ${name}`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
await found.run({ argv, args: parseArgs(argv) });
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
export async function run(cmd: string, argv: string[]): Promise<void> {
|
|
29
44
|
try {
|
|
30
45
|
if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|