@bhooai/nexus-cli 2.0.3 → 2.0.5
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/add.ts +1 -1
- package/src/commands/dev.ts +3 -3
- package/src/commands/init.ts +87 -136
- package/src/devPanel.ts +602 -346
- package/src/devServiceManager.ts +50 -4
- package/src/dispatcher.ts +7 -1
- package/src/examples.ts +90 -0
- package/src/features.ts +261 -0
- package/src/launcher.ts +164 -0
- package/src/layout.ts +101 -0
- package/src/templating/tree.ts +66 -0
- package/src/tui.ts +170 -0
- package/src/wizard.ts +691 -0
- package/templates/base/Dockerfile.ejs +1 -0
- package/templates/base/apps/admin/nginx.conf.ejs +30 -1
- package/templates/base/apps/admin/package.json.ejs +7 -2
- package/templates/base/apps/admin/postcss.config.js +5 -0
- package/templates/base/apps/admin/src/App.tsx +4127 -0
- package/templates/base/apps/admin/src/alertCenter.tsx +150 -0
- package/templates/base/apps/admin/src/api.ts +474 -0
- package/templates/base/apps/admin/src/assets/bhooai-nexus-logo.svg +25 -0
- package/templates/base/apps/admin/src/index.css +3481 -0
- package/templates/base/apps/admin/src/main.tsx.ejs +3 -3
- package/templates/base/apps/admin/src/vite-env.d.ts +19 -0
- package/templates/base/apps/admin/tailwind.config.js +9 -0
- package/templates/base/apps/admin/vite.config.ts.ejs +21 -2
- package/templates/base/apps/ai-server/main.py.ejs +94 -6
- package/templates/base/apps/backend/package.json.ejs +27 -0
- package/templates/base/apps/frontend/package.json.ejs +7 -0
- package/templates/base/apps/frontend/vite.config.ts.ejs +0 -1
- package/templates/base/docker-compose.yml.ejs +6 -1
- package/templates/base/nexus.config.ts.ejs +4 -4
- package/templates/features/auth/apps/backend/src/models/User.ts +21 -0
- package/templates/features/auth/apps/backend/src/routes/auth.ts +95 -0
- package/templates/features/email/apps/backend/src/mail/mailables/WelcomeMail.ts +25 -0
- package/templates/features/email/apps/backend/src/mail/templates/welcome.ejs.ejs +10 -0
- package/templates/features/graphql/apps/backend/src/graphql/post.graph.ts +61 -0
- package/templates/features/graphql/apps/backend/src/models/Post.ts +15 -0
- package/templates/features/payments/apps/backend/src/routes/payments.ts +45 -0
- package/templates/features/queue/apps/backend/src/events/JobQueued.ts +14 -0
- package/templates/features/queue/apps/backend/src/jobs/ExampleJob.ts +18 -0
- package/templates/features/queue/apps/backend/src/listeners/OnJobQueued.ts +12 -0
- package/templates/features/realtime/apps/backend/src/models/Message.ts +14 -0
- package/templates/features/realtime/apps/backend/src/ws/chat.room.ts +56 -0
- package/templates/features/storage/apps/backend/src/routes/uploads.ts +91 -0
package/src/devPanel.ts
CHANGED
|
@@ -1,49 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* devPanel — full-screen Nexus Console TUI.
|
|
2
|
+
* devPanel — full-screen Nexus Console TUI (redesigned).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* 3. Output — selected service logs OR command output
|
|
4
|
+
* Two-pane layout:
|
|
5
|
+
* LEFT tab rail — Services · Logs · Commands · Features · Settings
|
|
6
|
+
* RIGHT content for the active tab
|
|
8
7
|
*
|
|
9
8
|
* Keybindings:
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* s / t / r
|
|
14
|
-
* l
|
|
15
|
-
*
|
|
16
|
-
*
|
|
9
|
+
* Tab / ← / → switch tab
|
|
10
|
+
* ↑/↓ move selection in the active tab
|
|
11
|
+
* Enter run selected command / start service / install feature
|
|
12
|
+
* s / t / r start / stop / restart selected service
|
|
13
|
+
* l jump to Logs
|
|
14
|
+
* a install selected feature (Features tab)
|
|
15
|
+
* d run doctor (Settings tab)
|
|
16
|
+
* ? help
|
|
17
|
+
* q / Esc quit (kills all services)
|
|
17
18
|
*
|
|
18
|
-
* Zero dependencies: raw ANSI + readline keypress.
|
|
19
|
+
* Zero dependencies: raw ANSI + readline keypress via src/tui.ts.
|
|
19
20
|
*/
|
|
20
|
-
import { stdin as input, stdout as output } from 'node:process';
|
|
21
|
-
import { spawn } from 'node:child_process';
|
|
22
21
|
import { existsSync } from 'node:fs';
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { stdout as output } from 'node:process';
|
|
24
|
+
import { ANSI, Tui, stripAnsi, resolveCliBin, spawnNode, type KeyInfo } from './tui.js';
|
|
25
|
+
import { fitCell, clipVisible, divider, boxAround } from './layout.js';
|
|
26
26
|
import { getCommands, runCommand } from './dispatcher.js';
|
|
27
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
|
-
};
|
|
28
|
+
import { FEATURES, detectFeatures, installFeature, readRegistry } from './features.js';
|
|
47
29
|
|
|
48
30
|
export interface DevPanelOptions {
|
|
49
31
|
services: ServiceManager;
|
|
@@ -51,12 +33,27 @@ export interface DevPanelOptions {
|
|
|
51
33
|
onQuit?: () => void;
|
|
52
34
|
}
|
|
53
35
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
36
|
+
type Tab = 'services' | 'logs' | 'commands' | 'features' | 'settings';
|
|
37
|
+
|
|
38
|
+
const TABS: Array<{ id: Tab; label: string }> = [
|
|
39
|
+
{ id: 'services', label: 'Services' },
|
|
40
|
+
{ id: 'logs', label: 'Logs' },
|
|
41
|
+
{ id: 'commands', label: 'Commands' },
|
|
42
|
+
{ id: 'features', label: 'Features' },
|
|
43
|
+
{ id: 'settings', label: 'Settings' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** Per-tab color theme: active tab fills with `bg` + `fg` text; content tints with `accent`. */
|
|
47
|
+
const TAB_THEME: Record<Tab, { bg: string; fg: string; accent: string }> = {
|
|
48
|
+
services: { bg: ANSI.bgCyan, fg: ANSI.black, accent: ANSI.cyan },
|
|
49
|
+
logs: { bg: ANSI.bgYellow, fg: ANSI.black, accent: ANSI.yellow },
|
|
50
|
+
commands: { bg: ANSI.bgMagenta, fg: ANSI.white, accent: ANSI.magenta },
|
|
51
|
+
features: { bg: ANSI.bgGreen, fg: ANSI.black, accent: ANSI.green },
|
|
52
|
+
settings: { bg: ANSI.bgBlue, fg: ANSI.white, accent: ANSI.blue },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function tabAccent(tab: Tab): string {
|
|
56
|
+
return TAB_THEME[tab]?.accent ?? ANSI.cyan;
|
|
60
57
|
}
|
|
61
58
|
|
|
62
59
|
/** Commands that need a real TTY (their own prompts) — suspend the panel. */
|
|
@@ -68,35 +65,394 @@ const ARG_COMMANDS = new Set([
|
|
|
68
65
|
'make:middleware', 'make:validator', 'make:job', 'make:event', 'make:listener',
|
|
69
66
|
'make:policy', 'make:resource', 'make:request', 'make:mail', 'make:room',
|
|
70
67
|
'make:subgraph', 'make:seeder', 'make:migration', 'make:provider', 'make:plugin',
|
|
71
|
-
'db:seed', 'db:migrate', 'db:rollback', 'queue:retry',
|
|
68
|
+
'db:seed', 'db:migrate', 'db:rollback', 'queue:retry', 'add',
|
|
72
69
|
]);
|
|
73
70
|
|
|
71
|
+
interface CommandItem {
|
|
72
|
+
name: string;
|
|
73
|
+
desc: string;
|
|
74
|
+
needsArgs: boolean;
|
|
75
|
+
interactive: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface PanelState {
|
|
79
|
+
tab: Tab;
|
|
80
|
+
svcIndex: number;
|
|
81
|
+
cmdIndex: number;
|
|
82
|
+
featIndex: number;
|
|
83
|
+
installed: Set<string>;
|
|
84
|
+
featureMsg: string[];
|
|
85
|
+
settingsLines: string[];
|
|
86
|
+
commandOutput: string[];
|
|
87
|
+
argMode: { cmd: CommandItem; buffer: string } | null;
|
|
88
|
+
showHelp: boolean;
|
|
89
|
+
confirmQuit: boolean;
|
|
90
|
+
quitIndex: number;
|
|
91
|
+
quit: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function startDevPanel(opts: DevPanelOptions): Promise<void> {
|
|
95
|
+
const { services } = opts;
|
|
96
|
+
const projectRoot = process.cwd();
|
|
97
|
+
const commands = commandItems();
|
|
98
|
+
const grouped = groupCommands(commands);
|
|
99
|
+
const palette = flattenPalette(grouped);
|
|
100
|
+
|
|
101
|
+
const installed = await detectFeatures(projectRoot);
|
|
102
|
+
const state: PanelState = {
|
|
103
|
+
tab: 'services',
|
|
104
|
+
svcIndex: 0,
|
|
105
|
+
cmdIndex: 0,
|
|
106
|
+
featIndex: 0,
|
|
107
|
+
installed,
|
|
108
|
+
featureMsg: [],
|
|
109
|
+
settingsLines: [],
|
|
110
|
+
commandOutput: [],
|
|
111
|
+
argMode: null,
|
|
112
|
+
showHelp: false,
|
|
113
|
+
confirmQuit: false,
|
|
114
|
+
quitIndex: 0,
|
|
115
|
+
quit: false,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
let lastRender = 0;
|
|
119
|
+
|
|
120
|
+
function throttledRender(): void {
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
if (now - lastRender < 80) return;
|
|
123
|
+
lastRender = now;
|
|
124
|
+
render();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function render(): void {
|
|
128
|
+
if (state.quit) return;
|
|
129
|
+
tui.draw(buildRows(state, services, palette, projectRoot), helpText(state));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const tui = new Tui((str, key) => {
|
|
133
|
+
onKey(state, str, key, { services, palette, projectRoot, tui, redraw: render });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// wire service log redraws
|
|
137
|
+
services.onLog = () => throttledRender();
|
|
138
|
+
|
|
139
|
+
await refreshSettings(state, projectRoot, render);
|
|
140
|
+
tui.enter();
|
|
141
|
+
render();
|
|
142
|
+
|
|
143
|
+
await tui.wait();
|
|
144
|
+
tui.exit();
|
|
145
|
+
services.onLog = null;
|
|
146
|
+
opts.onQuit?.();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface Ctx {
|
|
150
|
+
services: ServiceManager;
|
|
151
|
+
palette: PaletteRow[];
|
|
152
|
+
projectRoot: string;
|
|
153
|
+
tui: Tui;
|
|
154
|
+
redraw: () => void;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function onKey(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
|
|
158
|
+
// ---- quit confirmation card ----
|
|
159
|
+
if (state.confirmQuit) {
|
|
160
|
+
if (key.name === 'escape' || key.name === 'q') {
|
|
161
|
+
state.confirmQuit = false;
|
|
162
|
+
ctx.redraw();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (key.name === 'up' || key.name === 'left') {
|
|
166
|
+
state.quitIndex = 0;
|
|
167
|
+
ctx.redraw();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (key.name === 'down' || key.name === 'right') {
|
|
171
|
+
state.quitIndex = 1;
|
|
172
|
+
ctx.redraw();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
176
|
+
if (state.quitIndex === 0) {
|
|
177
|
+
state.quit = true; // Yes — quit
|
|
178
|
+
ctx.tui.quit = true;
|
|
179
|
+
} else {
|
|
180
|
+
state.confirmQuit = false; // No — stay
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (key.name === 'c' && key.ctrl) {
|
|
185
|
+
// Second Ctrl+C inside the card = force quit immediately.
|
|
186
|
+
state.quit = true;
|
|
187
|
+
ctx.tui.quit = true;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (state.argMode) {
|
|
194
|
+
handleArgMode(state, str, key, ctx);
|
|
195
|
+
ctx.redraw();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (key.name === 'c' && key.ctrl) {
|
|
199
|
+
state.confirmQuit = true;
|
|
200
|
+
state.quitIndex = 0;
|
|
201
|
+
ctx.redraw();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (key.name === 'q') {
|
|
205
|
+
state.confirmQuit = true;
|
|
206
|
+
state.quitIndex = 0;
|
|
207
|
+
ctx.redraw();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (key.name === 'escape') {
|
|
211
|
+
if (state.showHelp) { state.showHelp = false; ctx.redraw(); }
|
|
212
|
+
else { state.confirmQuit = true; state.quitIndex = 0; ctx.redraw(); }
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (key.name === '?') {
|
|
216
|
+
state.showHelp = !state.showHelp;
|
|
217
|
+
ctx.redraw();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (state.showHelp) return;
|
|
221
|
+
|
|
222
|
+
switch (key.name) {
|
|
223
|
+
case 'tab': {
|
|
224
|
+
const idx = TABS.findIndex((t) => t.id === state.tab);
|
|
225
|
+
state.tab = TABS[(idx + 1) % TABS.length]?.id ?? 'services';
|
|
226
|
+
if (state.tab === 'settings') void refreshSettings(state, ctx.projectRoot, ctx.redraw);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
case 'left': {
|
|
230
|
+
const idx = TABS.findIndex((t) => t.id === state.tab);
|
|
231
|
+
state.tab = TABS[(idx - 1 + TABS.length) % TABS.length]?.id ?? 'services';
|
|
232
|
+
if (state.tab === 'settings') void refreshSettings(state, ctx.projectRoot, ctx.redraw);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case 'right': {
|
|
236
|
+
const idx = TABS.findIndex((t) => t.id === state.tab);
|
|
237
|
+
state.tab = TABS[(idx + 1) % TABS.length]?.id ?? 'services';
|
|
238
|
+
if (state.tab === 'settings') void refreshSettings(state, ctx.projectRoot, ctx.redraw);
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
case 'up':
|
|
242
|
+
move(state, -1, ctx);
|
|
243
|
+
break;
|
|
244
|
+
case 'down':
|
|
245
|
+
move(state, 1, ctx);
|
|
246
|
+
break;
|
|
247
|
+
case 'return':
|
|
248
|
+
case 'enter':
|
|
249
|
+
void activate(state, ctx);
|
|
250
|
+
break;
|
|
251
|
+
case 's':
|
|
252
|
+
ctx.services.start(ctx.services.services[state.svcIndex]?.name ?? '');
|
|
253
|
+
break;
|
|
254
|
+
case 't':
|
|
255
|
+
ctx.services.stop(ctx.services.services[state.svcIndex]?.name ?? '');
|
|
256
|
+
break;
|
|
257
|
+
case 'r':
|
|
258
|
+
ctx.services.restart(ctx.services.services[state.svcIndex]?.name ?? '');
|
|
259
|
+
break;
|
|
260
|
+
case 'l':
|
|
261
|
+
state.tab = 'logs';
|
|
262
|
+
break;
|
|
263
|
+
case 'a':
|
|
264
|
+
if (state.tab === 'features') void installSelected(state, ctx);
|
|
265
|
+
break;
|
|
266
|
+
case 'd':
|
|
267
|
+
if (state.tab === 'settings') void runSpawned('doctor', [], ctx);
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
ctx.redraw();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function move(state: PanelState, dir: number, ctx: Ctx): void {
|
|
274
|
+
switch (state.tab) {
|
|
275
|
+
case 'services':
|
|
276
|
+
case 'logs':
|
|
277
|
+
state.svcIndex = clamp(state.svcIndex + dir, 0, Math.max(0, ctx.services.services.length - 1));
|
|
278
|
+
break;
|
|
279
|
+
case 'commands':
|
|
280
|
+
state.cmdIndex = prevSelectable(ctx.palette, state.cmdIndex, dir);
|
|
281
|
+
break;
|
|
282
|
+
case 'features':
|
|
283
|
+
state.featIndex = clamp(state.featIndex + dir, 0, FEATURES.length - 1);
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function handleArgMode(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
|
|
289
|
+
const argMode = state.argMode;
|
|
290
|
+
if (!argMode) return;
|
|
291
|
+
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
|
292
|
+
state.argMode = null;
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
296
|
+
const cmd = argMode.cmd;
|
|
297
|
+
state.argMode = null;
|
|
298
|
+
void runSelectedCommand(state, cmd, argMode.buffer, ctx);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (key.name === 'backspace') {
|
|
302
|
+
argMode.buffer = argMode.buffer.slice(0, -1);
|
|
303
|
+
} else if (str && !key.ctrl && !key.meta) {
|
|
304
|
+
argMode.buffer += str;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function activate(state: PanelState, ctx: Ctx): Promise<void> {
|
|
309
|
+
switch (state.tab) {
|
|
310
|
+
case 'services':
|
|
311
|
+
case 'logs': {
|
|
312
|
+
const svc = ctx.services.services[state.svcIndex];
|
|
313
|
+
if (svc) {
|
|
314
|
+
if (svc.status === 'stopped' || svc.status === 'crashed') ctx.services.start(svc.name);
|
|
315
|
+
else ctx.services.restart(svc.name);
|
|
316
|
+
}
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
case 'commands': {
|
|
320
|
+
const row = ctx.palette[state.cmdIndex];
|
|
321
|
+
if (row && row.kind === 'cmd') {
|
|
322
|
+
if (row.cmd.needsArgs) state.argMode = { cmd: row.cmd, buffer: '' };
|
|
323
|
+
else void runSelectedCommand(state, row.cmd, '', ctx);
|
|
324
|
+
}
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
case 'features':
|
|
328
|
+
await installSelected(state, ctx);
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function installSelected(state: PanelState, ctx: Ctx): Promise<void> {
|
|
334
|
+
const id = FEATURES[state.featIndex]?.id;
|
|
335
|
+
if (!id) return;
|
|
336
|
+
if (state.installed.has(id)) {
|
|
337
|
+
state.featureMsg = [`${id} is already installed.`];
|
|
338
|
+
ctx.redraw();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const res = await installFeature(process.cwd(), id);
|
|
342
|
+
state.featureMsg = [...res.messages];
|
|
343
|
+
if (res.addedApps.length > 0) {
|
|
344
|
+
state.featureMsg.push('', 'Restart the console (q, then npm run dev) to pick up new apps.');
|
|
345
|
+
}
|
|
346
|
+
state.installed = await detectFeatures(process.cwd());
|
|
347
|
+
ctx.redraw();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function runSelectedCommand(state: PanelState, cmd: CommandItem, inlineArgs: string, ctx: Ctx): Promise<void> {
|
|
351
|
+
state.commandOutput = [];
|
|
352
|
+
ctx.redraw();
|
|
353
|
+
|
|
354
|
+
if (cmd.interactive) {
|
|
355
|
+
// Suspend panel → real TTY → resume.
|
|
356
|
+
ctx.tui.suspend();
|
|
357
|
+
try {
|
|
358
|
+
const bin = resolveCliBin();
|
|
359
|
+
const args = inlineArgs ? [cmd.name, ...inlineArgs.trim().split(/\s+/)] : [cmd.name];
|
|
360
|
+
await spawnNode(bin, args);
|
|
361
|
+
} catch (err) {
|
|
362
|
+
push(state, `✗ ${cmd.name} failed: ${(err as Error).message}`);
|
|
363
|
+
}
|
|
364
|
+
ctx.tui.resume();
|
|
365
|
+
ctx.redraw();
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const argv = inlineArgs ? inlineArgs.trim().split(/\s+/) : [];
|
|
370
|
+
const origOut = process.stdout.write.bind(process.stdout);
|
|
371
|
+
const origErr = process.stderr.write.bind(process.stderr);
|
|
372
|
+
const sink = (chunk: string | Buffer) => {
|
|
373
|
+
push(state, String(chunk));
|
|
374
|
+
return true;
|
|
375
|
+
};
|
|
376
|
+
(process.stdout as unknown as { write: Function }).write = sink;
|
|
377
|
+
(process.stderr as unknown as { write: Function }).write = sink;
|
|
378
|
+
try {
|
|
379
|
+
await runCommand(cmd.name, argv);
|
|
380
|
+
push(state, `\n${ANSI.green}✓ ${cmd.name}${ANSI.reset} finished`);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
push(state, `\n${ANSI.red}✗ ${cmd.name} failed:${ANSI.reset} ${(err as Error).message}`);
|
|
383
|
+
} finally {
|
|
384
|
+
(process.stdout as unknown as { write: Function }).write = origOut;
|
|
385
|
+
(process.stderr as unknown as { write: Function }).write = origErr;
|
|
386
|
+
}
|
|
387
|
+
ctx.redraw();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function push(state: PanelState, text: string): void {
|
|
391
|
+
for (const line of text.split('\n')) {
|
|
392
|
+
state.commandOutput.push(stripAnsi(line));
|
|
393
|
+
}
|
|
394
|
+
if (state.commandOutput.length > 200) state.commandOutput.splice(0, state.commandOutput.length - 200);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function runSpawned(cmd: string, args: string[], ctx: Ctx): Promise<void> {
|
|
398
|
+
ctx.tui.suspend();
|
|
399
|
+
try {
|
|
400
|
+
const bin = resolveCliBin();
|
|
401
|
+
await spawnNode(bin, [cmd, ...args]);
|
|
402
|
+
} catch { /* ignore */ }
|
|
403
|
+
ctx.tui.resume();
|
|
404
|
+
ctx.redraw();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// Settings
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
async function refreshSettings(state: PanelState, projectRoot: string, redraw: () => void): Promise<void> {
|
|
412
|
+
const reg = await readRegistry(projectRoot);
|
|
413
|
+
const lines: string[] = [
|
|
414
|
+
`${ANSI.bold}Project${ANSI.reset}`,
|
|
415
|
+
` ${projectRoot}`,
|
|
416
|
+
'',
|
|
417
|
+
`${ANSI.bold}Ports (.nexus-ports.json)${ANSI.reset}`,
|
|
418
|
+
];
|
|
419
|
+
for (const [app, port] of Object.entries(reg)) {
|
|
420
|
+
lines.push(` ${app.padEnd(18)} ${port}`);
|
|
421
|
+
}
|
|
422
|
+
if (Object.keys(reg).length === 0) lines.push(` ${ANSI.dim}No ports registered.${ANSI.reset}`);
|
|
423
|
+
lines.push('', `${ANSI.bold}Environment${ANSI.reset}`);
|
|
424
|
+
lines.push(` ${existsSync(join(projectRoot, '.env')) ? `${ANSI.dim}.env present${ANSI.reset}` : `${ANSI.dim}.env not found${ANSI.reset}`}`);
|
|
425
|
+
state.settingsLines = lines;
|
|
426
|
+
redraw();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// Commands palette
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
|
|
74
433
|
function commandItems(): CommandItem[] {
|
|
75
434
|
return getCommands().map((c) => ({
|
|
76
435
|
name: c.name,
|
|
77
|
-
label: c.name,
|
|
78
436
|
desc: c.description ?? '',
|
|
79
437
|
needsArgs: ARG_COMMANDS.has(c.name),
|
|
80
438
|
interactive: INTERACTIVE_COMMANDS.has(c.name),
|
|
81
439
|
}));
|
|
82
440
|
}
|
|
83
441
|
|
|
84
|
-
/** Categorize commands for the palette grouping. */
|
|
85
442
|
function groupOf(name: string): string {
|
|
86
443
|
if (name.startsWith('make:')) return 'make:*';
|
|
87
444
|
if (name.startsWith('db:')) return 'data';
|
|
88
445
|
if (name.startsWith('queue:')) return 'queue';
|
|
89
446
|
if (name.startsWith('plugin')) return 'plugins';
|
|
90
447
|
if (name === 'down' || name === 'up') return 'maintenance';
|
|
91
|
-
if (['init', 'dev', 'build', 'test', 'doctor'].includes(name)) return 'project';
|
|
92
448
|
return 'project';
|
|
93
449
|
}
|
|
94
450
|
|
|
95
451
|
const GROUPS = ['project', 'make:*', 'data', 'queue', 'plugins', 'maintenance'];
|
|
96
452
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
453
|
+
type PaletteRow = { kind: 'header'; label: string } | { kind: 'cmd'; cmd: CommandItem };
|
|
454
|
+
|
|
455
|
+
function groupCommands(items: CommandItem[]): Map<string, CommandItem[]> {
|
|
100
456
|
const grouped = new Map<string, CommandItem[]>();
|
|
101
457
|
for (const g of GROUPS) grouped.set(g, []);
|
|
102
458
|
for (const item of items) {
|
|
@@ -104,337 +460,237 @@ export async function startDevPanel(opts: DevPanelOptions): Promise<void> {
|
|
|
104
460
|
if (!grouped.has(g)) grouped.set(g, []);
|
|
105
461
|
grouped.get(g)!.push(item);
|
|
106
462
|
}
|
|
463
|
+
return grouped;
|
|
464
|
+
}
|
|
107
465
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const palette: Row[] = [];
|
|
466
|
+
function flattenPalette(grouped: Map<string, CommandItem[]>): PaletteRow[] {
|
|
467
|
+
const palette: PaletteRow[] = [];
|
|
111
468
|
for (const g of GROUPS) {
|
|
112
469
|
const list = grouped.get(g) ?? [];
|
|
113
470
|
if (list.length === 0) continue;
|
|
114
471
|
palette.push({ kind: 'header', label: g });
|
|
115
472
|
for (const cmd of list) palette.push({ kind: 'cmd', cmd });
|
|
116
473
|
}
|
|
474
|
+
return palette;
|
|
475
|
+
}
|
|
117
476
|
|
|
118
|
-
|
|
119
|
-
let
|
|
120
|
-
let
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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;
|
|
477
|
+
function prevSelectable(list: PaletteRow[], current: number, dir: number): number {
|
|
478
|
+
let i = current;
|
|
479
|
+
for (let step = 0; step < list.length; step++) {
|
|
480
|
+
i = (i + dir + list.length) % list.length;
|
|
481
|
+
const row = list[i];
|
|
482
|
+
if (row && row.kind === 'cmd') return i;
|
|
222
483
|
}
|
|
484
|
+
return current;
|
|
485
|
+
}
|
|
223
486
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
// Rendering
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
const RAIL_W = 20;
|
|
492
|
+
|
|
493
|
+
function buildRows(state: PanelState, services: ServiceManager, palette: PaletteRow[], projectRoot: string): string[] {
|
|
494
|
+
const W = output.columns || 80;
|
|
495
|
+
const contentW = Math.max(W - 2, 20);
|
|
496
|
+
const rows: string[] = [];
|
|
497
|
+
rows.push(`${ANSI.bold}${ANSI.cyan} Nexus Console${ANSI.reset}${ANSI.dim} — BhooAI Nexus · ${services.services.length} service(s)${ANSI.reset}`);
|
|
498
|
+
rows.push('');
|
|
499
|
+
rows.push(renderTabBar(state, W));
|
|
500
|
+
rows.push(divider(W));
|
|
501
|
+
rows.push('');
|
|
502
|
+
|
|
503
|
+
const content = renderContent(state, services, palette, projectRoot, contentW);
|
|
504
|
+
for (const line of content) rows.push(clipVisible(line, contentW));
|
|
505
|
+
|
|
506
|
+
// Crash banner: show if any service is in crashed state.
|
|
507
|
+
const crashed = services.services.filter((s) => s.status === 'crashed');
|
|
508
|
+
if (crashed.length > 0) {
|
|
509
|
+
rows.push('');
|
|
510
|
+
const msg = ` ⚠ ${crashed.length} service(s) crashed — select and press s to restart `;
|
|
511
|
+
rows.push(`${ANSI.bgRed}${ANSI.white}${ANSI.bold}${fitCell(msg, contentW)}${ANSI.reset}`);
|
|
229
512
|
}
|
|
230
513
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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();
|
|
514
|
+
if (state.confirmQuit) {
|
|
515
|
+
rows.push('');
|
|
516
|
+
rows.push(...renderQuitCard(state, contentW));
|
|
271
517
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
518
|
+
if (state.argMode) {
|
|
519
|
+
rows.push('');
|
|
520
|
+
rows.push(`${ANSI.cyan}${state.argMode.cmd.name}${ANSI.reset} ${state.argMode.buffer}█`);
|
|
275
521
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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();
|
|
522
|
+
if (state.showHelp) {
|
|
523
|
+
rows.push('');
|
|
524
|
+
rows.push(helpBody());
|
|
284
525
|
}
|
|
526
|
+
return rows;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** A bordered confirmation card asking whether to quit (kills all services). */
|
|
530
|
+
function renderQuitCard(state: PanelState, contentW: number): string[] {
|
|
531
|
+
const innerW = Math.min(Math.max(contentW - 6, 30), 50);
|
|
532
|
+
const inner: string[] = [
|
|
533
|
+
`${ANSI.bold}${ANSI.yellow}Quit the dev console?${ANSI.reset}`,
|
|
534
|
+
'',
|
|
535
|
+
`${ANSI.dim}All running services will be stopped.${ANSI.reset}`,
|
|
536
|
+
'',
|
|
537
|
+
];
|
|
538
|
+
const options = ['Yes, quit', 'Cancel'];
|
|
539
|
+
options.forEach((label, i) => {
|
|
540
|
+
const sel = i === state.quitIndex;
|
|
541
|
+
const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
|
|
542
|
+
const text = sel ? `${ANSI.bold}${label}${ANSI.reset}` : label;
|
|
543
|
+
inner.push(`${marker} ${text}`);
|
|
544
|
+
});
|
|
545
|
+
return boxAround(inner, innerW);
|
|
546
|
+
}
|
|
285
547
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
548
|
+
/** Horizontal tab bar across the top. Active tab = filled background in its theme; others = dim. */
|
|
549
|
+
function renderTabBar(state: PanelState, W: number): string {
|
|
550
|
+
const cellW = Math.max(Math.floor(W / TABS.length), 8);
|
|
551
|
+
const parts: string[] = [];
|
|
552
|
+
for (const t of TABS) {
|
|
553
|
+
const theme = TAB_THEME[t.id];
|
|
554
|
+
const active = t.id === state.tab;
|
|
555
|
+
if (active && theme) {
|
|
556
|
+
// Filled cell: bg + readable fg + bold label, padded with bg spaces.
|
|
557
|
+
const label = ` ${t.label} `;
|
|
558
|
+
const cell = `${theme.bg}${theme.fg}${ANSI.bold}${fitCell(label, cellW)}${ANSI.reset}`;
|
|
559
|
+
parts.push(cell);
|
|
560
|
+
} else {
|
|
561
|
+
const accent = theme?.accent ?? ANSI.dimGray;
|
|
562
|
+
const label = ` ${t.label} `;
|
|
563
|
+
const cell = `${ANSI.dim}${accent}${fitCell(label, cellW)}${ANSI.reset}`;
|
|
564
|
+
parts.push(cell);
|
|
565
|
+
}
|
|
289
566
|
}
|
|
567
|
+
// Join with single spaces; clip to width.
|
|
568
|
+
const bar = parts.join(' ');
|
|
569
|
+
return clipVisible(bar, W);
|
|
570
|
+
}
|
|
290
571
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
572
|
+
function renderContent(state: PanelState, services: ServiceManager, palette: PaletteRow[], projectRoot: string, contentW: number): string[] {
|
|
573
|
+
const accent = tabAccent(state.tab);
|
|
574
|
+
switch (state.tab) {
|
|
575
|
+
case 'services': return renderServices(state, services, accent, contentW);
|
|
576
|
+
case 'logs': return renderLogs(state, services, accent, contentW);
|
|
577
|
+
case 'commands': return renderCommands(state, palette, accent, contentW);
|
|
578
|
+
case 'features': return renderFeaturesTab(state, accent, contentW);
|
|
579
|
+
case 'settings': return renderSettingsTab(state, accent);
|
|
295
580
|
}
|
|
581
|
+
}
|
|
296
582
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
583
|
+
function statusDot(status: string): string {
|
|
584
|
+
switch (status) {
|
|
585
|
+
case 'running': return `${ANSI.green}●${ANSI.reset}`;
|
|
586
|
+
case 'starting': return `${ANSI.yellow}◐${ANSI.reset}`;
|
|
587
|
+
case 'crashed': return `${ANSI.red}✗${ANSI.reset}`;
|
|
588
|
+
default: return `${ANSI.dimGray}○${ANSI.reset}`;
|
|
303
589
|
}
|
|
590
|
+
}
|
|
304
591
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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}`);
|
|
592
|
+
function renderServices(state: PanelState, services: ServiceManager, accent: string, _contentW: number): string[] {
|
|
593
|
+
const out = [`${ANSI.bold}${accent} SERVICES${ANSI.reset}${ANSI.dim} (s start · t stop · r restart)${ANSI.reset}`, ''];
|
|
594
|
+
services.services.forEach((svc, i) => {
|
|
595
|
+
const sel = i === state.svcIndex;
|
|
596
|
+
const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
|
|
597
|
+
const name = sel ? `${ANSI.bold}${svc.name}${ANSI.reset}` : svc.name;
|
|
598
|
+
const pid = svc.pid ? String(svc.pid) : '—';
|
|
599
|
+
const restarts = svc.restarts ? String(svc.restarts) : '0';
|
|
600
|
+
const exitCode = svc.lastExit?.code != null ? ` exit:${svc.lastExit.code}` : '';
|
|
601
|
+
const exitTag = svc.status === 'crashed' ? `${ANSI.red}${exitCode}${ANSI.reset}` : '';
|
|
602
|
+
out.push(` ${marker} ${fitCell(name, 18)} :${fitCell(String(svc.port), 6)} ${statusDot(svc.status)} ${fitCell(svc.status, 8)} ${fitCell(pid, 7)} ${restarts}${exitTag}`);
|
|
603
|
+
// Inline crash log under the crashed service row.
|
|
604
|
+
if (svc.status === 'crashed' && svc.lastCrashLog.length > 0) {
|
|
605
|
+
for (const line of svc.lastCrashLog.slice(-4)) {
|
|
606
|
+
out.push(` ${ANSI.red}│${ANSI.reset} ${clipVisible(line, _contentW - 6)}`);
|
|
341
607
|
}
|
|
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
608
|
}
|
|
609
|
+
});
|
|
610
|
+
if (services.services.length === 0) out.push(` ${ANSI.dim}No services discovered.${ANSI.reset}`);
|
|
611
|
+
return out;
|
|
612
|
+
}
|
|
363
613
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
614
|
+
function renderLogs(state: PanelState, services: ServiceManager, accent: string, contentW: number): string[] {
|
|
615
|
+
const out = [`${ANSI.bold}${accent} LOGS${ANSI.reset}${ANSI.dim} (↑/↓ pick service)${ANSI.reset}`, ''];
|
|
616
|
+
services.services.forEach((svc, i) => {
|
|
617
|
+
const sel = i === state.svcIndex;
|
|
618
|
+
const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
|
|
619
|
+
const name = sel ? `${ANSI.bold}${svc.name}${ANSI.reset}` : svc.name;
|
|
620
|
+
const exitTag = svc.status === 'crashed' && svc.lastExit?.code != null ? `${ANSI.red} exit:${svc.lastExit.code}${ANSI.reset}` : '';
|
|
621
|
+
out.push(` ${marker} ${statusDot(svc.status)} ${fitCell(name, 18)} :${svc.port}${exitTag}`);
|
|
622
|
+
});
|
|
623
|
+
out.push('');
|
|
624
|
+
const source = services.services[state.svcIndex]?.name ?? '';
|
|
625
|
+
const tail = source ? services.tail(source, 20) : [];
|
|
626
|
+
for (const line of tail) out.push(` ${ANSI.dim}|${ANSI.reset} ${clipVisible(line, contentW - 3)}`);
|
|
627
|
+
if (tail.length === 0) out.push(` ${ANSI.dim}No logs yet for ${source || 'selected service'}.${ANSI.reset}`);
|
|
628
|
+
return out;
|
|
629
|
+
}
|
|
375
630
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
631
|
+
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) => {
|
|
635
|
+
if (row.kind === 'header') {
|
|
636
|
+
out.push(` ${ANSI.dim}${row.label.toUpperCase()}${ANSI.reset}`);
|
|
637
|
+
} else {
|
|
638
|
+
const isSelected = palette.indexOf(row) === state.cmdIndex;
|
|
639
|
+
const marker = isSelected ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
|
|
640
|
+
const label = isSelected ? `${ANSI.bold}${row.cmd.name}${ANSI.reset}` : row.cmd.name;
|
|
641
|
+
const arg = row.cmd.needsArgs ? `${ANSI.dim} <name>${ANSI.reset}` : '';
|
|
642
|
+
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))}`);
|
|
382
645
|
}
|
|
646
|
+
});
|
|
647
|
+
if (state.commandOutput.length > 0) {
|
|
648
|
+
out.push('');
|
|
649
|
+
out.push(`${ANSI.bold}${ANSI.yellow} OUTPUT${ANSI.reset}`);
|
|
650
|
+
for (const line of state.commandOutput.slice(-12)) out.push(` ${ANSI.dim}|${ANSI.reset} ${clipVisible(line, contentW - 3)}`);
|
|
383
651
|
}
|
|
652
|
+
return out;
|
|
653
|
+
}
|
|
384
654
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
// Wait for quit.
|
|
402
|
-
while (!quit) {
|
|
403
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
655
|
+
function renderFeaturesTab(state: PanelState, accent: string, contentW: number): string[] {
|
|
656
|
+
const out = [`${ANSI.bold}${accent} FEATURES${ANSI.reset}${ANSI.dim} (a or Enter installs)${ANSI.reset}`, ''];
|
|
657
|
+
const statusW = 12;
|
|
658
|
+
const descW = Math.max(contentW - 18 - statusW - 6, 8);
|
|
659
|
+
FEATURES.forEach((f, i) => {
|
|
660
|
+
const sel = i === state.featIndex;
|
|
661
|
+
const on = state.installed.has(f.id);
|
|
662
|
+
const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
|
|
663
|
+
const status = on ? `${ANSI.green}installed${ANSI.reset}` : `${ANSI.dimGray}available${ANSI.reset}`;
|
|
664
|
+
const name = sel ? `${ANSI.bold}${f.name}${ANSI.reset}` : f.name;
|
|
665
|
+
out.push(` ${marker} ${fitCell(name, 18)} ${fitCell(status, statusW)} ${clipVisible(`${ANSI.dim}${f.desc}${ANSI.reset}`, descW)}`);
|
|
666
|
+
});
|
|
667
|
+
if (state.featureMsg.length > 0) {
|
|
668
|
+
out.push('');
|
|
669
|
+
for (const m of state.featureMsg) out.push(` ${ANSI.dim}·${ANSI.reset} ${clipVisible(m, contentW - 3)}`);
|
|
404
670
|
}
|
|
671
|
+
return out;
|
|
672
|
+
}
|
|
405
673
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
opts.onQuit?.();
|
|
674
|
+
function renderSettingsTab(state: PanelState, accent: string): string[] {
|
|
675
|
+
return [
|
|
676
|
+
`${ANSI.bold}${accent} SETTINGS${ANSI.reset}${ANSI.dim} (d runs doctor)${ANSI.reset}`,
|
|
677
|
+
'',
|
|
678
|
+
...state.settingsLines,
|
|
679
|
+
];
|
|
413
680
|
}
|
|
414
681
|
|
|
415
|
-
function
|
|
416
|
-
return
|
|
417
|
-
|
|
418
|
-
child.on('exit', () => resolve());
|
|
419
|
-
child.on('error', () => resolve());
|
|
420
|
-
});
|
|
682
|
+
function helpBody(): string {
|
|
683
|
+
return `${ANSI.dim}Tab/←/→ switch tab · ↑/↓ select · Enter run/start/install · s/t/r service controls
|
|
684
|
+
l logs · a install feature · d doctor · ? help · q/Esc quit (kills services)${ANSI.reset}`;
|
|
421
685
|
}
|
|
422
686
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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');
|
|
687
|
+
function helpText(state: PanelState): string {
|
|
688
|
+
if (state.confirmQuit) return `${ANSI.dim}↑/↓ choose · Enter confirm · Esc cancel · Ctrl+C force quit${ANSI.reset}`;
|
|
689
|
+
if (state.showHelp) return ` ? help · q quit${ANSI.dim} (kills all services on quit)${ANSI.reset}`;
|
|
690
|
+
if (state.argMode) return `↑/↓ navigate · Enter run · Esc cancel`;
|
|
691
|
+
return `${ANSI.dim}Tab pane · ↑/↓ select · Enter run · s/t/r start/stop/restart · l logs · a add feature · q quit · ? help${ANSI.reset}`;
|
|
436
692
|
}
|
|
437
693
|
|
|
438
|
-
function
|
|
439
|
-
return
|
|
694
|
+
function clamp(n: number, lo: number, hi: number): number {
|
|
695
|
+
return Math.min(Math.max(n, lo), Math.max(lo, hi));
|
|
440
696
|
}
|