@openbuilder/cli 0.31.11
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/README.md +1053 -0
- package/bin/openbuilder.js +31 -0
- package/dist/chunks/Banner-D4tqKfzA.js +113 -0
- package/dist/chunks/Banner-D4tqKfzA.js.map +1 -0
- package/dist/chunks/auto-update-Dj3lWPWO.js +350 -0
- package/dist/chunks/auto-update-Dj3lWPWO.js.map +1 -0
- package/dist/chunks/build-D0qYqIq0.js +116 -0
- package/dist/chunks/build-D0qYqIq0.js.map +1 -0
- package/dist/chunks/cleanup-qVTsA3tk.js +141 -0
- package/dist/chunks/cleanup-qVTsA3tk.js.map +1 -0
- package/dist/chunks/cli-error-BjQwvWtK.js +140 -0
- package/dist/chunks/cli-error-BjQwvWtK.js.map +1 -0
- package/dist/chunks/config-BGP1jZJ4.js +167 -0
- package/dist/chunks/config-BGP1jZJ4.js.map +1 -0
- package/dist/chunks/config-manager-BkbjtN-H.js +133 -0
- package/dist/chunks/config-manager-BkbjtN-H.js.map +1 -0
- package/dist/chunks/database-BvAbD4sP.js +68 -0
- package/dist/chunks/database-BvAbD4sP.js.map +1 -0
- package/dist/chunks/database-setup-BYjIRAmT.js +253 -0
- package/dist/chunks/database-setup-BYjIRAmT.js.map +1 -0
- package/dist/chunks/exports-ij9sv4UM.js +7793 -0
- package/dist/chunks/exports-ij9sv4UM.js.map +1 -0
- package/dist/chunks/init-CZoN6soU.js +468 -0
- package/dist/chunks/init-CZoN6soU.js.map +1 -0
- package/dist/chunks/init-tui-BNzk_7Yx.js +1127 -0
- package/dist/chunks/init-tui-BNzk_7Yx.js.map +1 -0
- package/dist/chunks/logger-ZpJi7chw.js +38 -0
- package/dist/chunks/logger-ZpJi7chw.js.map +1 -0
- package/dist/chunks/main-tui-Cq1hLCx-.js +644 -0
- package/dist/chunks/main-tui-Cq1hLCx-.js.map +1 -0
- package/dist/chunks/manager-CvGX9qqe.js +1161 -0
- package/dist/chunks/manager-CvGX9qqe.js.map +1 -0
- package/dist/chunks/port-allocator-BRFzgH9b.js +749 -0
- package/dist/chunks/port-allocator-BRFzgH9b.js.map +1 -0
- package/dist/chunks/process-killer-CaUL7Kpl.js +87 -0
- package/dist/chunks/process-killer-CaUL7Kpl.js.map +1 -0
- package/dist/chunks/prompts-1QbE_bRr.js +128 -0
- package/dist/chunks/prompts-1QbE_bRr.js.map +1 -0
- package/dist/chunks/repo-cloner-CpOQjFSo.js +219 -0
- package/dist/chunks/repo-cloner-CpOQjFSo.js.map +1 -0
- package/dist/chunks/repo-detector-B_oj696o.js +66 -0
- package/dist/chunks/repo-detector-B_oj696o.js.map +1 -0
- package/dist/chunks/run-D23hg4xy.js +630 -0
- package/dist/chunks/run-D23hg4xy.js.map +1 -0
- package/dist/chunks/runner-logger-instance-nDWv2h2T.js +899 -0
- package/dist/chunks/runner-logger-instance-nDWv2h2T.js.map +1 -0
- package/dist/chunks/spinner-BJL9zWAJ.js +53 -0
- package/dist/chunks/spinner-BJL9zWAJ.js.map +1 -0
- package/dist/chunks/start-BygPCbvw.js +1708 -0
- package/dist/chunks/start-BygPCbvw.js.map +1 -0
- package/dist/chunks/start-traditional-uoLZXdxm.js +255 -0
- package/dist/chunks/start-traditional-uoLZXdxm.js.map +1 -0
- package/dist/chunks/status-cS8YwtUx.js +97 -0
- package/dist/chunks/status-cS8YwtUx.js.map +1 -0
- package/dist/chunks/theme-DhorI2Hb.js +44 -0
- package/dist/chunks/theme-DhorI2Hb.js.map +1 -0
- package/dist/chunks/upgrade-CT6w0lKp.js +323 -0
- package/dist/chunks/upgrade-CT6w0lKp.js.map +1 -0
- package/dist/chunks/useBuildState-CdBSu9y_.js +331 -0
- package/dist/chunks/useBuildState-CdBSu9y_.js.map +1 -0
- package/dist/cli/index.js +694 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.js +14358 -0
- package/dist/index.js.map +1 -0
- package/dist/instrument.js +64226 -0
- package/dist/instrument.js.map +1 -0
- package/dist/templates.json +295 -0
- package/package.json +98 -0
- package/scripts/install-vendor-deps.js +34 -0
- package/scripts/install-vendor.js +167 -0
- package/scripts/prepare-release.js +71 -0
- package/templates/config.template.json +18 -0
- package/templates.json +295 -0
- package/vendor/ai-sdk-provider-claude-code-LOCAL.tgz +0 -0
- package/vendor/sentry-core-LOCAL.tgz +0 -0
- package/vendor/sentry-nextjs-LOCAL.tgz +0 -0
- package/vendor/sentry-node-LOCAL.tgz +0 -0
- package/vendor/sentry-node-core-LOCAL.tgz +0 -0
|
@@ -0,0 +1,1708 @@
|
|
|
1
|
+
// OpenBuilder CLI - Built with Rollup
|
|
2
|
+
import { existsSync, mkdirSync, createWriteStream, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { Box, Text, useApp, useStdout, useInput, render } from 'ink';
|
|
5
|
+
import React, { useState, useMemo, useEffect } from 'react';
|
|
6
|
+
import * as p from '@clack/prompts';
|
|
7
|
+
import pc from 'picocolors';
|
|
8
|
+
import { c as configManager } from './config-manager-BkbjtN-H.js';
|
|
9
|
+
import { i as isInsideMonorepo } from './repo-detector-B_oj696o.js';
|
|
10
|
+
import { killProcessOnPort, killProcessTree } from './process-killer-CaUL7Kpl.js';
|
|
11
|
+
import { e as errors, C as CLIError } from './cli-error-BjQwvWtK.js';
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import EventEmitter from 'node:events';
|
|
14
|
+
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
15
|
+
import TextInput from 'ink-text-input';
|
|
16
|
+
import { exec } from 'child_process';
|
|
17
|
+
import { platform } from 'os';
|
|
18
|
+
import { u as useBuildState, B as BuildPanel } from './useBuildState-CdBSu9y_.js';
|
|
19
|
+
import 'chalk';
|
|
20
|
+
import { i as initRunnerLogger, s as setFileLoggerTuiMode } from './runner-logger-instance-nDWv2h2T.js';
|
|
21
|
+
import 'conf';
|
|
22
|
+
import 'node:os';
|
|
23
|
+
import 'node:fs/promises';
|
|
24
|
+
import 'node:util';
|
|
25
|
+
import './theme-DhorI2Hb.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* ServiceManager - Manages lifecycle and state of Web App, Broker, and Runner
|
|
29
|
+
* Provides state updates for TUI dashboard
|
|
30
|
+
*/
|
|
31
|
+
class ServiceManager extends EventEmitter {
|
|
32
|
+
constructor() {
|
|
33
|
+
super();
|
|
34
|
+
this.services = new Map();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Register a service configuration
|
|
38
|
+
*/
|
|
39
|
+
register(config) {
|
|
40
|
+
this.services.set(config.name, {
|
|
41
|
+
config,
|
|
42
|
+
state: {
|
|
43
|
+
name: config.name,
|
|
44
|
+
displayName: config.displayName,
|
|
45
|
+
status: 'stopped',
|
|
46
|
+
port: config.port,
|
|
47
|
+
uptime: 0,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Start a specific service
|
|
53
|
+
*/
|
|
54
|
+
async start(name) {
|
|
55
|
+
const service = this.services.get(name);
|
|
56
|
+
if (!service) {
|
|
57
|
+
throw new Error(`Service ${name} not registered`);
|
|
58
|
+
}
|
|
59
|
+
if (service.process && !service.process.killed) {
|
|
60
|
+
throw new Error(`Service ${name} is already running`);
|
|
61
|
+
}
|
|
62
|
+
// Update state to starting
|
|
63
|
+
service.state.status = 'starting';
|
|
64
|
+
this.emit('service:status-change', name, 'starting');
|
|
65
|
+
try {
|
|
66
|
+
// Spawn the process
|
|
67
|
+
const proc = spawn(service.config.command, service.config.args, {
|
|
68
|
+
cwd: service.config.cwd,
|
|
69
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
70
|
+
shell: true,
|
|
71
|
+
env: {
|
|
72
|
+
...process.env,
|
|
73
|
+
...service.config.env,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
service.process = proc;
|
|
77
|
+
service.state.pid = proc.pid;
|
|
78
|
+
service.startTime = Date.now();
|
|
79
|
+
// Handle stdout - ONLY emit events, NO console.log
|
|
80
|
+
proc.stdout?.on('data', (data) => {
|
|
81
|
+
const output = data.toString();
|
|
82
|
+
service.state.lastOutput = output.trim();
|
|
83
|
+
// Emit event - TUI will handle display
|
|
84
|
+
this.emit('service:output', name, output, 'stdout');
|
|
85
|
+
// Detect when service is ready
|
|
86
|
+
if (this.isServiceReady(name, output)) {
|
|
87
|
+
service.state.status = 'running';
|
|
88
|
+
this.emit('service:status-change', name, 'running');
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
// Handle stderr - ONLY emit events, NO console.log
|
|
92
|
+
proc.stderr?.on('data', (data) => {
|
|
93
|
+
const output = data.toString();
|
|
94
|
+
// Emit event - TUI will handle display
|
|
95
|
+
this.emit('service:output', name, output, 'stderr');
|
|
96
|
+
// Check for errors in stderr
|
|
97
|
+
if (output.toLowerCase().includes('error') && !output.includes('warn')) {
|
|
98
|
+
service.state.error = output.trim();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
// Handle process exit
|
|
102
|
+
proc.on('exit', (code, signal) => {
|
|
103
|
+
if (code !== 0 && code !== null && code !== 130 && code !== 143) {
|
|
104
|
+
// Abnormal exit
|
|
105
|
+
service.state.status = 'error';
|
|
106
|
+
service.state.error = `Exited with code ${code}`;
|
|
107
|
+
this.emit('service:status-change', name, 'error');
|
|
108
|
+
this.emit('service:error', name, new Error(`Process exited with code ${code}`));
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
// Normal exit
|
|
112
|
+
service.state.status = 'stopped';
|
|
113
|
+
this.emit('service:status-change', name, 'stopped');
|
|
114
|
+
}
|
|
115
|
+
service.process = undefined;
|
|
116
|
+
service.state.pid = undefined;
|
|
117
|
+
service.startTime = undefined;
|
|
118
|
+
});
|
|
119
|
+
proc.on('error', (error) => {
|
|
120
|
+
service.state.status = 'error';
|
|
121
|
+
service.state.error = error.message;
|
|
122
|
+
this.emit('service:status-change', name, 'error');
|
|
123
|
+
this.emit('service:error', name, error);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
service.state.status = 'error';
|
|
128
|
+
service.state.error = error instanceof Error ? error.message : 'Unknown error';
|
|
129
|
+
this.emit('service:status-change', name, 'error');
|
|
130
|
+
this.emit('service:error', name, error instanceof Error ? error : new Error(String(error)));
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Start all registered services in sequence
|
|
136
|
+
*/
|
|
137
|
+
async startAll(delayBetween = 2000) {
|
|
138
|
+
const services = Array.from(this.services.keys());
|
|
139
|
+
for (const name of services) {
|
|
140
|
+
await this.start(name);
|
|
141
|
+
if (delayBetween > 0) {
|
|
142
|
+
await new Promise(resolve => setTimeout(resolve, delayBetween));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Start periodic updates for uptime/stats
|
|
146
|
+
this.startUpdates();
|
|
147
|
+
this.emit('all:started');
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Stop a specific service
|
|
151
|
+
*/
|
|
152
|
+
async stop(name, signal = 'SIGTERM') {
|
|
153
|
+
const service = this.services.get(name);
|
|
154
|
+
if (!service) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const proc = service.process;
|
|
158
|
+
const port = service.config.port;
|
|
159
|
+
const pid = proc?.pid;
|
|
160
|
+
// If no process, just try to kill by port as cleanup
|
|
161
|
+
if (!proc) {
|
|
162
|
+
if (port) {
|
|
163
|
+
await killProcessOnPort(port);
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
return new Promise(async (resolve) => {
|
|
168
|
+
// Set timeout for force kill
|
|
169
|
+
const timeout = setTimeout(async () => {
|
|
170
|
+
// Try killing the process tree first
|
|
171
|
+
if (pid) {
|
|
172
|
+
await killProcessTree(pid, 'SIGKILL');
|
|
173
|
+
}
|
|
174
|
+
// Also kill by port as final fallback
|
|
175
|
+
if (port) {
|
|
176
|
+
await killProcessOnPort(port);
|
|
177
|
+
}
|
|
178
|
+
resolve();
|
|
179
|
+
}, 2000);
|
|
180
|
+
proc.once('exit', () => {
|
|
181
|
+
clearTimeout(timeout);
|
|
182
|
+
resolve();
|
|
183
|
+
});
|
|
184
|
+
// First try graceful SIGTERM via process tree
|
|
185
|
+
if (pid) {
|
|
186
|
+
await killProcessTree(pid, signal);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
proc.kill(signal);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Stop all running services
|
|
195
|
+
*/
|
|
196
|
+
async stopAll() {
|
|
197
|
+
this.stopUpdates();
|
|
198
|
+
const services = Array.from(this.services.keys()).reverse(); // Stop in reverse order
|
|
199
|
+
// Collect all ports for final cleanup
|
|
200
|
+
const ports = [];
|
|
201
|
+
for (const name of services) {
|
|
202
|
+
const service = this.services.get(name);
|
|
203
|
+
if (service?.config.port) {
|
|
204
|
+
ports.push(service.config.port);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Stop each service
|
|
208
|
+
for (const name of services) {
|
|
209
|
+
await this.stop(name);
|
|
210
|
+
}
|
|
211
|
+
// Final port cleanup - ensure no zombie processes remain
|
|
212
|
+
// Small delay to let processes actually terminate
|
|
213
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
214
|
+
for (const port of ports) {
|
|
215
|
+
await killProcessOnPort(port);
|
|
216
|
+
}
|
|
217
|
+
this.emit('all:stopped');
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Get current state of a service
|
|
221
|
+
*/
|
|
222
|
+
getState(name) {
|
|
223
|
+
return this.services.get(name)?.state;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Get current state of all services
|
|
227
|
+
*/
|
|
228
|
+
getAllStates() {
|
|
229
|
+
return Array.from(this.services.values()).map(s => s.state);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Check if a service is running
|
|
233
|
+
*/
|
|
234
|
+
isRunning(name) {
|
|
235
|
+
return this.services.get(name)?.state.status === 'running';
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Check if all services are running
|
|
239
|
+
*/
|
|
240
|
+
areAllRunning() {
|
|
241
|
+
return Array.from(this.services.values()).every(s => s.state.status === 'running');
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Restart a service
|
|
245
|
+
*/
|
|
246
|
+
async restart(name) {
|
|
247
|
+
await this.stop(name);
|
|
248
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
249
|
+
await this.start(name);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Restart all services
|
|
253
|
+
*/
|
|
254
|
+
async restartAll() {
|
|
255
|
+
await this.stopAll();
|
|
256
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
257
|
+
await this.startAll();
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Start periodic state updates (uptime, memory, cpu)
|
|
261
|
+
*/
|
|
262
|
+
startUpdates() {
|
|
263
|
+
this.updateInterval = setInterval(() => {
|
|
264
|
+
for (const [name, service] of this.services) {
|
|
265
|
+
if (service.startTime) {
|
|
266
|
+
service.state.uptime = Date.now() - service.startTime;
|
|
267
|
+
}
|
|
268
|
+
// TODO: Add memory/CPU monitoring using ps or similar
|
|
269
|
+
// For now, just update uptime
|
|
270
|
+
}
|
|
271
|
+
}, 1000); // Update every second
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Stop periodic updates
|
|
275
|
+
*/
|
|
276
|
+
stopUpdates() {
|
|
277
|
+
if (this.updateInterval) {
|
|
278
|
+
clearInterval(this.updateInterval);
|
|
279
|
+
this.updateInterval = undefined;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Detect if service is ready based on output
|
|
284
|
+
*/
|
|
285
|
+
isServiceReady(name, output) {
|
|
286
|
+
const lowerOutput = output.toLowerCase();
|
|
287
|
+
switch (name) {
|
|
288
|
+
case 'web':
|
|
289
|
+
return lowerOutput.includes('ready') || lowerOutput.includes('started server');
|
|
290
|
+
case 'broker':
|
|
291
|
+
return lowerOutput.includes('listening') || lowerOutput.includes('ready');
|
|
292
|
+
case 'runner':
|
|
293
|
+
return lowerOutput.includes('connected') || lowerOutput.includes('ready');
|
|
294
|
+
default:
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Create a Cloudflare tunnel for a service
|
|
300
|
+
*/
|
|
301
|
+
async createTunnel(name) {
|
|
302
|
+
const service = this.services.get(name);
|
|
303
|
+
if (!service || !service.state.port) {
|
|
304
|
+
throw new Error(`Service ${name} not found or has no port`);
|
|
305
|
+
}
|
|
306
|
+
// Update state to creating
|
|
307
|
+
service.state.tunnelStatus = 'creating';
|
|
308
|
+
this.emit('service:tunnel-change', name, null, 'creating');
|
|
309
|
+
try {
|
|
310
|
+
// Import tunnel manager
|
|
311
|
+
const { tunnelManager } = await import('./manager-CvGX9qqe.js');
|
|
312
|
+
// Enable silent mode for TUI
|
|
313
|
+
tunnelManager.setSilent(true);
|
|
314
|
+
// Create tunnel
|
|
315
|
+
const tunnelUrl = await tunnelManager.createTunnel(service.state.port);
|
|
316
|
+
// Update state
|
|
317
|
+
service.state.tunnelUrl = tunnelUrl;
|
|
318
|
+
service.state.tunnelStatus = 'active';
|
|
319
|
+
this.emit('service:tunnel-change', name, tunnelUrl, 'active');
|
|
320
|
+
return tunnelUrl;
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
service.state.tunnelStatus = 'failed';
|
|
324
|
+
service.state.error = error instanceof Error ? error.message : 'Tunnel creation failed';
|
|
325
|
+
this.emit('service:tunnel-change', name, null, 'failed');
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Close tunnel for a service
|
|
331
|
+
*/
|
|
332
|
+
async closeTunnel(name) {
|
|
333
|
+
const service = this.services.get(name);
|
|
334
|
+
if (!service || !service.state.port) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// Update state immediately
|
|
338
|
+
service.state.tunnelUrl = undefined;
|
|
339
|
+
service.state.tunnelStatus = undefined;
|
|
340
|
+
this.emit('service:tunnel-change', name, null, 'active');
|
|
341
|
+
try {
|
|
342
|
+
const { tunnelManager } = await import('./manager-CvGX9qqe.js');
|
|
343
|
+
await tunnelManager.closeTunnel(service.state.port);
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
// Best effort
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Get tunnel URL for a service
|
|
351
|
+
*/
|
|
352
|
+
getTunnelUrl(name) {
|
|
353
|
+
return this.services.get(name)?.state.tunnelUrl || null;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Cleanup and remove all listeners
|
|
357
|
+
*/
|
|
358
|
+
destroy() {
|
|
359
|
+
this.stopUpdates();
|
|
360
|
+
this.removeAllListeners();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Theme colors matching init TUI
|
|
365
|
+
const colors = {
|
|
366
|
+
cyan: '#06b6d4',
|
|
367
|
+
brightPurple: '#c084fc',
|
|
368
|
+
};
|
|
369
|
+
/**
|
|
370
|
+
* ASCII art banner component - centered with cyan/purple gradient
|
|
371
|
+
*/
|
|
372
|
+
function Banner() {
|
|
373
|
+
const lines = [
|
|
374
|
+
{ open: ' ██████╗ ██████╗ ███████╗███╗ ██╗', builder: '██████╗ ██╗ ██╗██╗██╗ ██████╗ ███████╗██████╗ ' },
|
|
375
|
+
{ open: '██╔═══██╗██╔══██╗██╔════╝████╗ ██║', builder: '██╔══██╗██║ ██║██║██║ ██╔══██╗██╔════╝██╔══██╗' },
|
|
376
|
+
{ open: '██║ ██║██████╔╝█████╗ ██╔██╗ ██║', builder: '██████╔╝██║ ██║██║██║ ██║ ██║█████╗ ██████╔╝' },
|
|
377
|
+
{ open: '██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║', builder: '██╔══██╗██║ ██║██║██║ ██║ ██║██╔══╝ ██╔══██╗' },
|
|
378
|
+
{ open: '╚██████╔╝██║ ███████╗██║ ╚████║', builder: '██████╔╝╚██████╔╝██║███████╗██████╔╝███████╗██║ ██║' },
|
|
379
|
+
{ open: ' ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝', builder: '╚═════╝ ╚═════╝ ╚═╝╚══════╝╚═════╝ ╚══════╝╚═╝ ╚═╝' },
|
|
380
|
+
];
|
|
381
|
+
return (jsx(Box, { flexDirection: "column", alignItems: "center", marginTop: 2, children: lines.map((line, index) => (jsxs(Box, { children: [jsx(Text, { color: colors.cyan, children: line.open }), jsx(Text, { color: colors.brightPurple, children: line.builder })] }, index))) }));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Base colors that don't change with theme
|
|
385
|
+
const baseColors = {
|
|
386
|
+
success: '#22c55e',
|
|
387
|
+
error: '#ef4444',
|
|
388
|
+
warning: '#f59e0b',
|
|
389
|
+
cyan: '#06b6d4',
|
|
390
|
+
white: '#ffffff',
|
|
391
|
+
gray: '#6b7280',
|
|
392
|
+
dimGray: '#4b5563'};
|
|
393
|
+
const symbols = {
|
|
394
|
+
filledDot: '●',
|
|
395
|
+
hollowDot: '○',
|
|
396
|
+
check: '✓',
|
|
397
|
+
cross: '✗'};
|
|
398
|
+
const THEMES = {
|
|
399
|
+
sentry: {
|
|
400
|
+
name: 'sentry',
|
|
401
|
+
label: 'Sentry',
|
|
402
|
+
description: 'Purple-pink gradient',
|
|
403
|
+
colors: {
|
|
404
|
+
primary: '#a855f7',
|
|
405
|
+
secondary: '#ec4899',
|
|
406
|
+
accent: '#c084fc',
|
|
407
|
+
muted: '#7c3aed',
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
ocean: {
|
|
411
|
+
name: 'ocean',
|
|
412
|
+
label: 'Ocean',
|
|
413
|
+
description: 'Cool blue & teal',
|
|
414
|
+
colors: {
|
|
415
|
+
primary: '#3b82f6',
|
|
416
|
+
secondary: '#22d3ee',
|
|
417
|
+
accent: '#60a5fa',
|
|
418
|
+
muted: '#2563eb',
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
ember: {
|
|
422
|
+
name: 'ember',
|
|
423
|
+
label: 'Ember',
|
|
424
|
+
description: 'Warm orange & red',
|
|
425
|
+
colors: {
|
|
426
|
+
primary: '#f97316',
|
|
427
|
+
secondary: '#ef4444',
|
|
428
|
+
accent: '#fb923c',
|
|
429
|
+
muted: '#ea580c',
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
forest: {
|
|
433
|
+
name: 'forest',
|
|
434
|
+
label: 'Forest',
|
|
435
|
+
description: 'Green & earth tones',
|
|
436
|
+
colors: {
|
|
437
|
+
primary: '#10b981',
|
|
438
|
+
secondary: '#84cc16',
|
|
439
|
+
accent: '#34d399',
|
|
440
|
+
muted: '#059669',
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
noir: {
|
|
444
|
+
name: 'noir',
|
|
445
|
+
label: 'Noir',
|
|
446
|
+
description: 'Monochrome dark',
|
|
447
|
+
colors: {
|
|
448
|
+
primary: '#ffffff',
|
|
449
|
+
secondary: '#a1a1aa',
|
|
450
|
+
accent: '#e4e4e7',
|
|
451
|
+
muted: '#71717a',
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
const THEME_ORDER = ['sentry', 'ocean', 'ember', 'forest', 'noir'];
|
|
456
|
+
function formatTime(date) {
|
|
457
|
+
return date.toLocaleTimeString('en-US', {
|
|
458
|
+
hour12: false,
|
|
459
|
+
hour: '2-digit',
|
|
460
|
+
minute: '2-digit',
|
|
461
|
+
second: '2-digit',
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
function getLogLevel(message, stream) {
|
|
465
|
+
if (stream === 'stderr')
|
|
466
|
+
return 'warn';
|
|
467
|
+
const lower = message.toLowerCase();
|
|
468
|
+
if (lower.includes('error') || lower.includes('failed') || lower.includes('exception'))
|
|
469
|
+
return 'error';
|
|
470
|
+
if (lower.includes('warn') || lower.includes('warning'))
|
|
471
|
+
return 'warn';
|
|
472
|
+
if (lower.includes('success') || lower.includes('ready') || lower.includes('started') || lower.includes('connected') || lower.includes('✅') || lower.includes('✓'))
|
|
473
|
+
return 'success';
|
|
474
|
+
return 'info';
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Determines if a log message is internal/debug and should be hidden by default.
|
|
478
|
+
* These are implementation details that aren't useful for end users.
|
|
479
|
+
*/
|
|
480
|
+
function isInternalLog(message) {
|
|
481
|
+
const internalPatterns = [
|
|
482
|
+
// Internal broadcasting/event system
|
|
483
|
+
/Broadcasting/i,
|
|
484
|
+
/Event emitted/i,
|
|
485
|
+
/📡.*Broadcasting/,
|
|
486
|
+
// Session/ID tracking details
|
|
487
|
+
/sessionId=/,
|
|
488
|
+
/todoIndex=/,
|
|
489
|
+
/buildId=/,
|
|
490
|
+
/commandId=/,
|
|
491
|
+
// Internal processor notes
|
|
492
|
+
/NOTE:.*DB writes/,
|
|
493
|
+
/NOTE:.*HTTP/,
|
|
494
|
+
/Registering build.*for WebSocket/,
|
|
495
|
+
// Verbose implementation details
|
|
496
|
+
/from database\)/,
|
|
497
|
+
/immutable/,
|
|
498
|
+
/\(waiting for runner/,
|
|
499
|
+
// Duplicate/skip messages
|
|
500
|
+
/Skipping duplicate/,
|
|
501
|
+
// Internal state
|
|
502
|
+
/SSE stream closed/,
|
|
503
|
+
/persistent processor continues/,
|
|
504
|
+
// Cost/optimization notes (internal)
|
|
505
|
+
/Cost savings:/,
|
|
506
|
+
];
|
|
507
|
+
return internalPatterns.some(pattern => pattern.test(message));
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Transforms a raw log message into a user-friendly format.
|
|
511
|
+
* Extracts meaningful information and presents it clearly.
|
|
512
|
+
*/
|
|
513
|
+
function transformLogMessage(message) {
|
|
514
|
+
// NEW FORMAT: Server now logs "🔧 Edit: /path/to/file.ts" or "🔧 Run: npm install"
|
|
515
|
+
// Match: 🔧 Action: details
|
|
516
|
+
const newToolFormat = message.match(/🔧\s*(Read|Edit|Write|Run|Find|Search|Fetch|Update tasks):\s*(.+)/i);
|
|
517
|
+
if (newToolFormat) {
|
|
518
|
+
const action = newToolFormat[1];
|
|
519
|
+
const detail = newToolFormat[2].trim();
|
|
520
|
+
return { display: `${action}: ${detail}`, isUserFacing: true, toolAction: action };
|
|
521
|
+
}
|
|
522
|
+
// Also match bare tool names for backwards compat: "🔧 Read" (no colon/details)
|
|
523
|
+
const bareToolMatch = message.match(/^🔧\s*(Read|Edit|Write|Bash|Glob|Grep|WebFetch|TodoWrite)$/i);
|
|
524
|
+
if (bareToolMatch) {
|
|
525
|
+
return { display: bareToolMatch[1], isUserFacing: true, toolAction: bareToolMatch[1] };
|
|
526
|
+
}
|
|
527
|
+
// OLD FORMAT: Tool calls with parentheses like "🔧 Read (todoIndex=..."
|
|
528
|
+
// Try to extract any useful info
|
|
529
|
+
const oldToolFormat = message.match(/🔧\s*(Read|Edit|Write|Bash|Glob|Grep)\s*\(/i);
|
|
530
|
+
if (oldToolFormat) {
|
|
531
|
+
const toolName = oldToolFormat[1];
|
|
532
|
+
// Try to extract file path from the message
|
|
533
|
+
const pathMatch = message.match(/(?:path|file)[:=]\s*["']?([^"'\s,)]+)/i);
|
|
534
|
+
const cmdMatch = message.match(/(?:command|cmd)[:=]\s*["']?([^"'\n]+)/i);
|
|
535
|
+
if (toolName.toLowerCase() === 'bash' && cmdMatch) {
|
|
536
|
+
const cmd = cmdMatch[1].trim().substring(0, 50);
|
|
537
|
+
return { display: `Run: ${cmd}${cmdMatch[1].length > 50 ? '...' : ''}`, isUserFacing: true, toolAction: 'Run' };
|
|
538
|
+
}
|
|
539
|
+
else if (pathMatch) {
|
|
540
|
+
return { display: `${toolName}: ${pathMatch[1]}`, isUserFacing: true, toolAction: toolName };
|
|
541
|
+
}
|
|
542
|
+
// No details found, just show the tool name
|
|
543
|
+
return { display: toolName, isUserFacing: true, toolAction: toolName };
|
|
544
|
+
}
|
|
545
|
+
// Template/framework selection - always show
|
|
546
|
+
const templateMatch = message.match(/Template (?:selected|from tag):\s*(.+)/i);
|
|
547
|
+
if (templateMatch) {
|
|
548
|
+
return { display: `Using template: ${templateMatch[1]}`, isUserFacing: true };
|
|
549
|
+
}
|
|
550
|
+
const frameworkMatch = message.match(/Framework.*?:\s*(\w+)/i);
|
|
551
|
+
if (frameworkMatch && !message.includes('emit')) {
|
|
552
|
+
return { display: `Framework: ${frameworkMatch[1]}`, isUserFacing: true };
|
|
553
|
+
}
|
|
554
|
+
// Build start
|
|
555
|
+
if (message.includes('start event received') || message.includes('build-started')) {
|
|
556
|
+
return { display: 'Build started', isUserFacing: true };
|
|
557
|
+
}
|
|
558
|
+
// Build complete
|
|
559
|
+
if (message.includes('marked complete') || message.includes('Build complete')) {
|
|
560
|
+
return { display: 'Build complete', isUserFacing: true };
|
|
561
|
+
}
|
|
562
|
+
// Agent selection - clean it up
|
|
563
|
+
const agentMatch = message.match(/(?:Using agent|Agent).*?:\s*(\S+)/i);
|
|
564
|
+
if (agentMatch && !message.includes('NOTE:')) {
|
|
565
|
+
return { display: `Agent: ${agentMatch[1].replace(/[()]/g, '')}`, isUserFacing: true };
|
|
566
|
+
}
|
|
567
|
+
// Generic success messages
|
|
568
|
+
if (message.includes('✅') && !isInternalLog(message)) {
|
|
569
|
+
// Clean up the message
|
|
570
|
+
const cleaned = message.replace(/\[[\w-]+\]\s*/g, '').replace(/✅\s*/, '').trim();
|
|
571
|
+
if (cleaned.length > 10) {
|
|
572
|
+
return { display: cleaned, isUserFacing: true };
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
// Default: not user-facing if it's internal
|
|
576
|
+
return { display: message, isUserFacing: !isInternalLog(message) };
|
|
577
|
+
}
|
|
578
|
+
function parseLogMessage(message) {
|
|
579
|
+
let tag;
|
|
580
|
+
let emoji;
|
|
581
|
+
let toolName;
|
|
582
|
+
let content = message;
|
|
583
|
+
const tagMatch = content.match(/^\[([^\]]+)\]\s*/);
|
|
584
|
+
if (tagMatch) {
|
|
585
|
+
tag = tagMatch[1];
|
|
586
|
+
content = content.substring(tagMatch[0].length);
|
|
587
|
+
}
|
|
588
|
+
const emojiMatch = content.match(/^([\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|✅|✓|📜|📤|🔧|📡|⚠️|❌)\s*/u);
|
|
589
|
+
if (emojiMatch) {
|
|
590
|
+
emoji = emojiMatch[1];
|
|
591
|
+
content = content.substring(emojiMatch[0].length);
|
|
592
|
+
}
|
|
593
|
+
const toolMatch = content.match(/^tool-input-available:\s*(\w+)/i);
|
|
594
|
+
if (toolMatch) {
|
|
595
|
+
toolName = toolMatch[1];
|
|
596
|
+
emoji = '🔧';
|
|
597
|
+
}
|
|
598
|
+
// Also detect tool calls in format: "🔧 ToolName (..."
|
|
599
|
+
const toolCallMatch = content.match(/^(Read|Edit|Write|Bash|Glob|Grep|TodoWrite)\s*\(/i);
|
|
600
|
+
if (toolCallMatch) {
|
|
601
|
+
toolName = toolCallMatch[1];
|
|
602
|
+
emoji = '🔧';
|
|
603
|
+
}
|
|
604
|
+
return { tag, emoji, toolName, content: content.trim(), isInternal: isInternalLog(message) };
|
|
605
|
+
}
|
|
606
|
+
function openBrowser(url) {
|
|
607
|
+
const os = platform();
|
|
608
|
+
let command;
|
|
609
|
+
if (os === 'darwin') {
|
|
610
|
+
command = `open "${url}"`;
|
|
611
|
+
}
|
|
612
|
+
else if (os === 'win32') {
|
|
613
|
+
command = `start "" "${url}"`;
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
command = `xdg-open "${url}"`;
|
|
617
|
+
}
|
|
618
|
+
exec(command, () => { });
|
|
619
|
+
}
|
|
620
|
+
function Dashboard({ serviceManager, apiUrl, webPort, logFilePath }) {
|
|
621
|
+
const { exit } = useApp();
|
|
622
|
+
const { stdout } = useStdout();
|
|
623
|
+
const [view, setView] = useState('dashboard');
|
|
624
|
+
const [services, setServices] = useState([]);
|
|
625
|
+
const [logs, setLogs] = useState([]);
|
|
626
|
+
const [isShuttingDown, setIsShuttingDown] = useState(false);
|
|
627
|
+
const [serviceFilter, setServiceFilter] = useState(null);
|
|
628
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
629
|
+
const [autoScroll, setAutoScroll] = useState(true);
|
|
630
|
+
const [searchMode, setSearchMode] = useState(false);
|
|
631
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
632
|
+
const [isVerbose, setIsVerbose] = useState(false);
|
|
633
|
+
const [logIdCounter, setLogIdCounter] = useState(0);
|
|
634
|
+
const [filterMode, setFilterMode] = useState('all');
|
|
635
|
+
const [fullLogSearchMode, setFullLogSearchMode] = useState(false);
|
|
636
|
+
const [fullLogSearchQuery, setFullLogSearchQuery] = useState('');
|
|
637
|
+
const [fullLogScrollOffset, setFullLogScrollOffset] = useState(0);
|
|
638
|
+
// Theme state - load from config, default to 'sentry'
|
|
639
|
+
const savedTheme = configManager.get('ui')?.theme;
|
|
640
|
+
const [selectedTheme, setSelectedTheme] = useState(savedTheme || 'sentry');
|
|
641
|
+
const [previewTheme, setPreviewTheme] = useState(null);
|
|
642
|
+
const [themeBeforePreview, setThemeBeforePreview] = useState(null);
|
|
643
|
+
// Build state - tracks active builds and todos from the RunnerLogger
|
|
644
|
+
const [buildState] = useBuildState();
|
|
645
|
+
// Get current theme colors - use preview theme if active, otherwise selected theme
|
|
646
|
+
const activeThemeName = previewTheme || selectedTheme;
|
|
647
|
+
const theme = THEMES[activeThemeName];
|
|
648
|
+
const themeColors = useMemo(() => ({
|
|
649
|
+
primary: theme.colors.primary,
|
|
650
|
+
secondary: theme.colors.secondary,
|
|
651
|
+
accent: theme.colors.accent,
|
|
652
|
+
muted: theme.colors.muted,
|
|
653
|
+
// Map to semantic colors
|
|
654
|
+
highlight: theme.colors.primary,
|
|
655
|
+
border: theme.colors.muted,
|
|
656
|
+
text: baseColors.white,
|
|
657
|
+
textDim: baseColors.gray,
|
|
658
|
+
textMuted: baseColors.dimGray,
|
|
659
|
+
}), [theme]);
|
|
660
|
+
const terminalHeight = stdout?.rows || 40;
|
|
661
|
+
const terminalWidth = stdout?.columns || 80;
|
|
662
|
+
const bannerHeight = 7;
|
|
663
|
+
const headerHeight = 3;
|
|
664
|
+
const statusBarHeight = 3;
|
|
665
|
+
const contentHeight = Math.max(1, terminalHeight - bannerHeight - headerHeight - statusBarHeight);
|
|
666
|
+
// Show build panel only when there's an active build
|
|
667
|
+
const showBuildPanel = buildState.currentBuild !== null;
|
|
668
|
+
// 20/80 split when build panel is shown, otherwise full width
|
|
669
|
+
const buildPanelWidth = Math.floor(terminalWidth * 0.2);
|
|
670
|
+
const logPanelWidth = showBuildPanel ? terminalWidth - buildPanelWidth : terminalWidth;
|
|
671
|
+
const allServicesRunning = useMemo(() => {
|
|
672
|
+
return services.length > 0 && services.every(s => s.status === 'running');
|
|
673
|
+
}, [services]);
|
|
674
|
+
useEffect(() => {
|
|
675
|
+
const handleStatusChange = () => {
|
|
676
|
+
setServices(serviceManager.getAllStates());
|
|
677
|
+
};
|
|
678
|
+
setServices(serviceManager.getAllStates());
|
|
679
|
+
serviceManager.on('service:status-change', handleStatusChange);
|
|
680
|
+
return () => {
|
|
681
|
+
serviceManager.off('service:status-change', handleStatusChange);
|
|
682
|
+
};
|
|
683
|
+
}, [serviceManager]);
|
|
684
|
+
useEffect(() => {
|
|
685
|
+
const handleServiceOutput = (name, output, stream) => {
|
|
686
|
+
const lines = output.split('\n').filter(line => line.trim());
|
|
687
|
+
setLogIdCounter(prev => {
|
|
688
|
+
const newLogs = lines.map((line, idx) => {
|
|
689
|
+
const trimmed = line.trim();
|
|
690
|
+
const parsed = parseLogMessage(trimmed);
|
|
691
|
+
const transformed = transformLogMessage(trimmed);
|
|
692
|
+
return {
|
|
693
|
+
id: `${Date.now()}-${prev + idx}`,
|
|
694
|
+
timestamp: new Date(),
|
|
695
|
+
service: name,
|
|
696
|
+
message: trimmed,
|
|
697
|
+
stream,
|
|
698
|
+
level: getLogLevel(trimmed, stream),
|
|
699
|
+
tag: parsed.tag,
|
|
700
|
+
emoji: parsed.emoji,
|
|
701
|
+
toolName: parsed.toolName,
|
|
702
|
+
content: parsed.content,
|
|
703
|
+
isInternal: parsed.isInternal,
|
|
704
|
+
displayMessage: transformed.isUserFacing ? transformed.display : undefined,
|
|
705
|
+
};
|
|
706
|
+
});
|
|
707
|
+
if (newLogs.length > 0) {
|
|
708
|
+
setLogs(prevLogs => {
|
|
709
|
+
const combined = [...prevLogs, ...newLogs];
|
|
710
|
+
return combined.slice(-1e4);
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
return prev + lines.length;
|
|
714
|
+
});
|
|
715
|
+
};
|
|
716
|
+
serviceManager.on('service:output', handleServiceOutput);
|
|
717
|
+
return () => {
|
|
718
|
+
serviceManager.off('service:output', handleServiceOutput);
|
|
719
|
+
};
|
|
720
|
+
}, [serviceManager]);
|
|
721
|
+
const filteredLogs = useMemo(() => {
|
|
722
|
+
let filtered = logs;
|
|
723
|
+
// By default, hide internal/debug logs unless verbose mode is on
|
|
724
|
+
if (!isVerbose) {
|
|
725
|
+
filtered = filtered.filter(log => !log.isInternal &&
|
|
726
|
+
!log.message.toLowerCase().includes('debug') &&
|
|
727
|
+
!log.message.toLowerCase().includes('trace'));
|
|
728
|
+
}
|
|
729
|
+
if (serviceFilter) {
|
|
730
|
+
filtered = filtered.filter(log => log.service === serviceFilter);
|
|
731
|
+
}
|
|
732
|
+
if (searchQuery.trim()) {
|
|
733
|
+
const query = searchQuery.toLowerCase();
|
|
734
|
+
filtered = filtered.filter(log => log.message.toLowerCase().includes(query) ||
|
|
735
|
+
log.service.toLowerCase().includes(query));
|
|
736
|
+
}
|
|
737
|
+
return filtered;
|
|
738
|
+
}, [logs, serviceFilter, searchQuery, isVerbose]);
|
|
739
|
+
const fullLogFilteredLogs = useMemo(() => {
|
|
740
|
+
let filtered = logs;
|
|
741
|
+
if (filterMode === 'errors') {
|
|
742
|
+
filtered = filtered.filter(log => log.level === 'error' || log.level === 'warn');
|
|
743
|
+
}
|
|
744
|
+
else if (filterMode === 'tools') {
|
|
745
|
+
filtered = filtered.filter(log => log.toolName || log.message.includes('tool-input'));
|
|
746
|
+
}
|
|
747
|
+
else if (filterMode === 'verbose') ;
|
|
748
|
+
else {
|
|
749
|
+
// Default 'all' mode - hide internal logs
|
|
750
|
+
filtered = filtered.filter(log => !log.isInternal &&
|
|
751
|
+
!log.message.toLowerCase().includes('debug') &&
|
|
752
|
+
!log.message.toLowerCase().includes('trace'));
|
|
753
|
+
}
|
|
754
|
+
if (fullLogSearchQuery.trim()) {
|
|
755
|
+
const query = fullLogSearchQuery.toLowerCase();
|
|
756
|
+
filtered = filtered.filter(log => log.message.toLowerCase().includes(query) ||
|
|
757
|
+
log.service.toLowerCase().includes(query) ||
|
|
758
|
+
(log.tag && log.tag.toLowerCase().includes(query)) ||
|
|
759
|
+
(log.toolName && log.toolName.toLowerCase().includes(query)));
|
|
760
|
+
}
|
|
761
|
+
return filtered;
|
|
762
|
+
}, [logs, filterMode, fullLogSearchQuery]);
|
|
763
|
+
const visibleLines = Math.max(1, contentHeight - 3);
|
|
764
|
+
const fullLogVisibleLines = Math.max(1, terminalHeight - 6);
|
|
765
|
+
useEffect(() => {
|
|
766
|
+
if (autoScroll && filteredLogs.length > 0) {
|
|
767
|
+
const maxScroll = Math.max(0, filteredLogs.length - visibleLines);
|
|
768
|
+
setScrollOffset(maxScroll);
|
|
769
|
+
}
|
|
770
|
+
}, [filteredLogs.length, autoScroll, visibleLines]);
|
|
771
|
+
useEffect(() => {
|
|
772
|
+
if (view === 'fullLog') {
|
|
773
|
+
const maxScroll = Math.max(0, fullLogFilteredLogs.length - fullLogVisibleLines);
|
|
774
|
+
setFullLogScrollOffset(maxScroll);
|
|
775
|
+
}
|
|
776
|
+
}, [fullLogFilteredLogs.length, view, fullLogVisibleLines]);
|
|
777
|
+
const displayedLogs = useMemo(() => {
|
|
778
|
+
return filteredLogs.slice(scrollOffset, scrollOffset + visibleLines);
|
|
779
|
+
}, [filteredLogs, scrollOffset, visibleLines]);
|
|
780
|
+
const fullLogDisplayedLogs = useMemo(() => {
|
|
781
|
+
return fullLogFilteredLogs.slice(fullLogScrollOffset, fullLogScrollOffset + fullLogVisibleLines);
|
|
782
|
+
}, [fullLogFilteredLogs, fullLogScrollOffset, fullLogVisibleLines]);
|
|
783
|
+
// Save theme to config and update state
|
|
784
|
+
const saveTheme = (newTheme) => {
|
|
785
|
+
setSelectedTheme(newTheme);
|
|
786
|
+
setPreviewTheme(null);
|
|
787
|
+
setThemeBeforePreview(null);
|
|
788
|
+
// Persist to config
|
|
789
|
+
configManager.set('ui', { theme: newTheme });
|
|
790
|
+
};
|
|
791
|
+
// Open theme selector
|
|
792
|
+
const openThemeSelector = () => {
|
|
793
|
+
setThemeBeforePreview(selectedTheme);
|
|
794
|
+
setPreviewTheme(selectedTheme);
|
|
795
|
+
setView('themeSelector');
|
|
796
|
+
};
|
|
797
|
+
// Cancel theme selection (revert to previous)
|
|
798
|
+
const cancelThemeSelection = () => {
|
|
799
|
+
setPreviewTheme(null);
|
|
800
|
+
setThemeBeforePreview(null);
|
|
801
|
+
setView('dashboard');
|
|
802
|
+
};
|
|
803
|
+
// Confirm theme selection
|
|
804
|
+
const confirmThemeSelection = () => {
|
|
805
|
+
if (previewTheme) {
|
|
806
|
+
saveTheme(previewTheme);
|
|
807
|
+
}
|
|
808
|
+
setView('dashboard');
|
|
809
|
+
};
|
|
810
|
+
useInput((input, key) => {
|
|
811
|
+
if (isShuttingDown)
|
|
812
|
+
return;
|
|
813
|
+
// Theme selector view
|
|
814
|
+
if (view === 'themeSelector') {
|
|
815
|
+
if (key.escape) {
|
|
816
|
+
cancelThemeSelection();
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (key.return) {
|
|
820
|
+
confirmThemeSelection();
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (key.upArrow && previewTheme) {
|
|
824
|
+
const currentIndex = THEME_ORDER.indexOf(previewTheme);
|
|
825
|
+
const prevIndex = (currentIndex - 1 + THEME_ORDER.length) % THEME_ORDER.length;
|
|
826
|
+
setPreviewTheme(THEME_ORDER[prevIndex]);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (key.downArrow && previewTheme) {
|
|
830
|
+
const currentIndex = THEME_ORDER.indexOf(previewTheme);
|
|
831
|
+
const nextIndex = (currentIndex + 1) % THEME_ORDER.length;
|
|
832
|
+
setPreviewTheme(THEME_ORDER[nextIndex]);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
// Help/Menu view
|
|
838
|
+
if (view === 'help') {
|
|
839
|
+
if (key.escape) {
|
|
840
|
+
setView('dashboard');
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
if (input === 'b') {
|
|
844
|
+
openBrowser(`http://localhost:${webPort}`);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (input === 'q') {
|
|
848
|
+
setIsShuttingDown(true);
|
|
849
|
+
serviceManager.stopAll().then(() => exit());
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
// Full log view mode
|
|
855
|
+
if (view === 'fullLog') {
|
|
856
|
+
if (fullLogSearchMode) {
|
|
857
|
+
if (key.escape || key.return) {
|
|
858
|
+
setFullLogSearchMode(false);
|
|
859
|
+
}
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
if (key.escape) {
|
|
863
|
+
setView('dashboard');
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (input === 'l' || input === 't') {
|
|
867
|
+
setView('dashboard');
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (input === '/') {
|
|
871
|
+
setFullLogSearchMode(true);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
if (input === 'f') {
|
|
875
|
+
const modes = ['all', 'errors', 'tools', 'verbose'];
|
|
876
|
+
const currentIndex = modes.indexOf(filterMode);
|
|
877
|
+
setFilterMode(modes[(currentIndex + 1) % modes.length]);
|
|
878
|
+
setFullLogScrollOffset(0);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
if (key.upArrow) {
|
|
882
|
+
setFullLogScrollOffset(prev => Math.max(0, prev - 1));
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (key.downArrow) {
|
|
886
|
+
const maxScroll = Math.max(0, fullLogFilteredLogs.length - fullLogVisibleLines);
|
|
887
|
+
setFullLogScrollOffset(prev => Math.min(maxScroll, prev + 1));
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (key.pageUp) {
|
|
891
|
+
setFullLogScrollOffset(prev => Math.max(0, prev - fullLogVisibleLines));
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
if (key.pageDown) {
|
|
895
|
+
const maxScroll = Math.max(0, fullLogFilteredLogs.length - fullLogVisibleLines);
|
|
896
|
+
setFullLogScrollOffset(prev => Math.min(maxScroll, prev + fullLogVisibleLines));
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (input === 'q') {
|
|
900
|
+
setIsShuttingDown(true);
|
|
901
|
+
serviceManager.stopAll().then(() => exit());
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
if (input === 'b') {
|
|
905
|
+
openBrowser(`http://localhost:${webPort}`);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
// Dashboard mode
|
|
911
|
+
if (key.escape) {
|
|
912
|
+
if (searchMode) {
|
|
913
|
+
setSearchMode(false);
|
|
914
|
+
setSearchQuery('');
|
|
915
|
+
}
|
|
916
|
+
else if (view !== 'dashboard') {
|
|
917
|
+
setView('dashboard');
|
|
918
|
+
}
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
if (searchMode)
|
|
922
|
+
return;
|
|
923
|
+
if (input === '/' && view === 'dashboard') {
|
|
924
|
+
setSearchMode(true);
|
|
925
|
+
setSearchQuery('');
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
// Ctrl+T to open theme selector
|
|
929
|
+
if (key.ctrl && input === 't') {
|
|
930
|
+
openThemeSelector();
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
if (input === 'q' || (key.ctrl && input === 'c')) {
|
|
934
|
+
setIsShuttingDown(true);
|
|
935
|
+
serviceManager.stopAll().then(() => exit());
|
|
936
|
+
}
|
|
937
|
+
else if (input === 'b') {
|
|
938
|
+
openBrowser(`http://localhost:${webPort}`);
|
|
939
|
+
}
|
|
940
|
+
else if (input === 'v') {
|
|
941
|
+
setIsVerbose(!isVerbose);
|
|
942
|
+
}
|
|
943
|
+
else if (input === 'r' && view === 'dashboard') {
|
|
944
|
+
serviceManager.restartAll();
|
|
945
|
+
}
|
|
946
|
+
else if (input === 'c' && view === 'dashboard') {
|
|
947
|
+
setLogs([]);
|
|
948
|
+
setScrollOffset(0);
|
|
949
|
+
setAutoScroll(true);
|
|
950
|
+
}
|
|
951
|
+
else if (input === 'l' || input === 't') {
|
|
952
|
+
setView('fullLog');
|
|
953
|
+
setFullLogScrollOffset(Math.max(0, fullLogFilteredLogs.length - fullLogVisibleLines));
|
|
954
|
+
}
|
|
955
|
+
else if (input === 'f') {
|
|
956
|
+
setServiceFilter(current => {
|
|
957
|
+
if (!current)
|
|
958
|
+
return 'web';
|
|
959
|
+
if (current === 'web')
|
|
960
|
+
return 'runner';
|
|
961
|
+
return null;
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
else if (key.upArrow) {
|
|
965
|
+
setAutoScroll(false);
|
|
966
|
+
setScrollOffset(prev => Math.max(0, prev - 1));
|
|
967
|
+
}
|
|
968
|
+
else if (key.downArrow) {
|
|
969
|
+
const maxScroll = Math.max(0, filteredLogs.length - visibleLines);
|
|
970
|
+
setScrollOffset(prev => Math.min(maxScroll, prev + 1));
|
|
971
|
+
if (scrollOffset >= maxScroll - 1) {
|
|
972
|
+
setAutoScroll(true);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
else if (key.pageUp) {
|
|
976
|
+
setAutoScroll(false);
|
|
977
|
+
setScrollOffset(prev => Math.max(0, prev - visibleLines));
|
|
978
|
+
}
|
|
979
|
+
else if (key.pageDown) {
|
|
980
|
+
const maxScroll = Math.max(0, filteredLogs.length - visibleLines);
|
|
981
|
+
setScrollOffset(prev => Math.min(maxScroll, prev + visibleLines));
|
|
982
|
+
}
|
|
983
|
+
else if (input === '?') {
|
|
984
|
+
setView('help');
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
const highlightSearch = (text, query) => {
|
|
988
|
+
if (!query)
|
|
989
|
+
return text;
|
|
990
|
+
const lowerText = text.toLowerCase();
|
|
991
|
+
const lowerQuery = query.toLowerCase();
|
|
992
|
+
const index = lowerText.indexOf(lowerQuery);
|
|
993
|
+
if (index === -1)
|
|
994
|
+
return text;
|
|
995
|
+
return (jsxs(Fragment, { children: [text.slice(0, index), jsx(Text, { backgroundColor: baseColors.warning, color: "black", children: text.slice(index, index + query.length) }), text.slice(index + query.length)] }));
|
|
996
|
+
};
|
|
997
|
+
// Full log view
|
|
998
|
+
if (view === 'fullLog') {
|
|
999
|
+
return (jsxs(Box, { flexDirection: "column", height: terminalHeight, children: [jsxs(Box, { borderStyle: "single", borderColor: themeColors.muted, paddingX: 1, justifyContent: "space-between", children: [jsx(Text, { color: themeColors.primary, bold: true, children: "LOGS" }), jsxs(Box, { children: [jsx(Text, { color: themeColors.textMuted, children: "Search: " }), fullLogSearchMode ? (jsx(Box, { borderStyle: "round", borderColor: themeColors.primary, paddingX: 1, children: jsx(TextInput, { value: fullLogSearchQuery, onChange: setFullLogSearchQuery, placeholder: "type to search..." }) })) : (jsxs(Text, { color: fullLogSearchQuery ? themeColors.text : themeColors.textMuted, children: ["[", fullLogSearchQuery || 'none', "]"] })), jsx(Text, { color: themeColors.textMuted, children: " [/]" })] })] }), jsx(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "single", borderColor: themeColors.muted, borderTop: false, borderBottom: false, paddingX: 1, children: fullLogDisplayedLogs.map((log) => (jsx(FullLogEntryRow, { log: log, maxWidth: terminalWidth - 4, searchQuery: fullLogSearchQuery, highlightSearch: highlightSearch, themeColors: themeColors }, log.id))) }), jsxs(Box, { borderStyle: "single", borderColor: themeColors.muted, paddingX: 1, justifyContent: "space-between", children: [jsxs(Box, { children: [jsx(Shortcut, { letter: "l", label: "dashboard", color: themeColors.primary }), jsx(Shortcut, { letter: "/", label: "search", color: themeColors.primary }), jsx(Shortcut, { letter: "f", label: `filter: ${filterMode}`, color: themeColors.primary }), jsx(Shortcut, { letter: "b", label: "browser", color: themeColors.primary }), jsx(Shortcut, { letter: "q", label: "quit", color: themeColors.primary })] }), jsxs(Text, { color: themeColors.textMuted, children: [fullLogScrollOffset + 1, "-", Math.min(fullLogScrollOffset + fullLogVisibleLines, fullLogFilteredLogs.length), "/", fullLogFilteredLogs.length] })] })] }));
|
|
1000
|
+
}
|
|
1001
|
+
// Theme Selector overlay
|
|
1002
|
+
if (view === 'themeSelector') {
|
|
1003
|
+
return (jsxs(Box, { flexDirection: "column", height: terminalHeight, width: terminalWidth, children: [jsx(Banner, {}), jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: themeColors.primary, paddingX: 3, paddingY: 1, width: 40, children: [jsx(Box, { justifyContent: "center", marginBottom: 1, children: jsx(Text, { color: themeColors.primary, bold: true, children: "Select Theme" }) }), THEME_ORDER.map((themeName) => {
|
|
1004
|
+
const t = THEMES[themeName];
|
|
1005
|
+
const isSelected = themeName === previewTheme;
|
|
1006
|
+
return (jsxs(Box, { paddingY: 0, children: [jsx(Text, { color: isSelected ? t.colors.primary : themeColors.textMuted, children: isSelected ? '▸ ' : ' ' }), jsx(Text, { color: isSelected ? t.colors.primary : themeColors.textDim, bold: isSelected, children: t.label }), jsxs(Text, { color: themeColors.textMuted, children: [" - ", t.description] })] }, themeName));
|
|
1007
|
+
}), jsx(Box, { marginTop: 1, justifyContent: "center", children: jsxs(Text, { color: themeColors.textMuted, children: [jsx(Text, { color: themeColors.primary, children: "\u2191\u2193" }), " navigate", jsx(Text, { color: themeColors.primary, children: " Enter" }), " select", jsx(Text, { color: themeColors.primary, children: " Esc" }), " cancel"] }) })] }) })] }));
|
|
1008
|
+
}
|
|
1009
|
+
// Help/Menu view
|
|
1010
|
+
if (view === 'help') {
|
|
1011
|
+
return (jsxs(Box, { flexDirection: "column", height: terminalHeight, children: [jsx(Banner, {}), jsxs(Box, { flexDirection: "column", padding: 2, children: [jsx(Text, { color: themeColors.primary, bold: true, children: "Help & Keyboard Shortcuts" }), jsx(Text, { children: " " }), jsxs(Text, { color: themeColors.textDim, children: [" ", jsx(Text, { color: themeColors.primary, children: "Ctrl+T" }), " Change theme"] }), jsxs(Text, { color: themeColors.textDim, children: [" ", jsx(Text, { color: themeColors.primary, children: "b" }), " Open in browser"] }), jsxs(Text, { color: themeColors.textDim, children: [" ", jsx(Text, { color: themeColors.primary, children: "l" }), " Full log view"] }), jsxs(Text, { color: themeColors.textDim, children: [" ", jsx(Text, { color: themeColors.primary, children: "/" }), " Search logs"] }), jsxs(Text, { color: themeColors.textDim, children: [" ", jsx(Text, { color: themeColors.primary, children: "f" }), " Filter by service"] }), jsxs(Text, { color: themeColors.textDim, children: [" ", jsx(Text, { color: themeColors.primary, children: "v" }), " Toggle verbose mode"] }), jsxs(Text, { color: themeColors.textDim, children: [" ", jsx(Text, { color: themeColors.primary, children: "r" }), " Restart services"] }), jsxs(Text, { color: themeColors.textDim, children: [" ", jsx(Text, { color: themeColors.primary, children: "c" }), " Clear logs"] }), jsxs(Text, { color: themeColors.textDim, children: [" ", jsx(Text, { color: themeColors.primary, children: "q" }), " Quit"] }), jsx(Text, { children: " " }), jsxs(Text, { color: themeColors.textMuted, children: ["Current theme: ", jsx(Text, { color: themeColors.primary, children: theme.label })] }), jsx(Text, { children: " " }), jsxs(Text, { color: themeColors.textMuted, children: ["Press ", jsx(Text, { color: themeColors.primary, children: "Esc" }), " to return to dashboard"] })] })] }));
|
|
1012
|
+
}
|
|
1013
|
+
// Check for available update (set by auto-update check in index.ts)
|
|
1014
|
+
const updateAvailable = process.env.OPENBUILDER_UPDATE_AVAILABLE;
|
|
1015
|
+
// Main dashboard view
|
|
1016
|
+
return (jsxs(Box, { flexDirection: "column", height: terminalHeight, width: terminalWidth, children: [jsx(Banner, {}), updateAvailable && (jsxs(Box, { justifyContent: "center", paddingY: 0, children: [jsx(Text, { color: baseColors.cyan, children: "\u2B06 Update available: " }), jsx(Text, { color: baseColors.success, children: updateAvailable }), jsx(Text, { color: themeColors.textMuted, children: " \u2014 Run " }), jsx(Text, { color: baseColors.cyan, children: "openbuilder upgrade" }), jsx(Text, { color: themeColors.textMuted, children: " to update" })] })), jsxs(Box, { borderStyle: "single", borderColor: themeColors.muted, paddingX: 1, justifyContent: "space-between", children: [jsxs(Text, { color: themeColors.textMuted, children: ["Web: ", jsxs(Text, { color: themeColors.primary, children: ["localhost:", webPort] }), ' • ', "Mode: ", jsx(Text, { color: themeColors.primary, children: "Local" }), ' • ', "Theme: ", jsx(Text, { color: themeColors.primary, children: theme.label })] }), jsxs(Box, { children: [jsx(Text, { color: allServicesRunning ? baseColors.success : baseColors.warning, children: allServicesRunning ? symbols.filledDot : symbols.hollowDot }), jsxs(Text, { color: themeColors.textDim, children: [' ', allServicesRunning ? 'All Services Running' : 'Starting...'] })] })] }), jsxs(Box, { flexGrow: 1, height: contentHeight, children: [showBuildPanel && (jsx(BuildPanel, { build: buildState.currentBuild, width: buildPanelWidth, height: contentHeight })), jsxs(Box, { flexDirection: "column", width: logPanelWidth, height: contentHeight, borderStyle: "single", borderColor: themeColors.muted, paddingX: 1, children: [jsxs(Box, { justifyContent: "space-between", marginBottom: 0, children: [jsx(Text, { color: themeColors.primary, bold: true, children: "LOGS" }), jsxs(Box, { children: [serviceFilter && (jsxs(Text, { color: themeColors.textMuted, children: ["filter: ", jsx(Text, { color: baseColors.warning, children: serviceFilter }), " "] })), jsxs(Text, { color: themeColors.textMuted, children: ["[verbose: ", isVerbose ? 'on' : 'off', "]"] })] })] }), jsx(Box, { flexDirection: "column", flexGrow: 1, children: displayedLogs.length === 0 ? (jsx(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, children: jsx(Text, { color: themeColors.textMuted, children: "Waiting for logs..." }) })) : (displayedLogs.map((log) => (jsx(LogEntryRow, { log: log, maxWidth: logPanelWidth - 4, themeColors: themeColors }, log.id)))) }), filteredLogs.length > visibleLines && (jsx(Box, { justifyContent: "flex-end", children: jsxs(Text, { color: themeColors.textMuted, children: [scrollOffset + 1, "-", Math.min(scrollOffset + visibleLines, filteredLogs.length), "/", filteredLogs.length, autoScroll ? ' (auto)' : ''] }) }))] })] }), jsxs(Box, { borderStyle: "single", borderColor: themeColors.muted, paddingX: 1, justifyContent: "space-between", children: [jsxs(Box, { children: [jsx(Text, { color: allServicesRunning ? baseColors.success : baseColors.warning, children: allServicesRunning ? symbols.filledDot : symbols.hollowDot }), jsxs(Text, { color: themeColors.textDim, children: [' ', isShuttingDown ? 'Shutting down...' : allServicesRunning ? 'Ready' : 'Starting'] })] }), searchMode ? (jsxs(Box, { children: [jsx(Text, { color: themeColors.primary, children: "/" }), jsx(TextInput, { value: searchQuery, onChange: setSearchQuery, onSubmit: () => setSearchMode(false), placeholder: "Search... (Enter to apply, Esc to cancel)" })] })) : (jsxs(Box, { children: [jsx(Shortcut, { letter: "b", label: "browser", color: themeColors.primary }), jsx(Shortcut, { letter: "l", label: "logs", color: themeColors.primary }), jsx(Shortcut, { letter: "/", label: "search", color: themeColors.primary }), jsx(Shortcut, { letter: "?", label: "menu", color: themeColors.primary }), jsx(Shortcut, { letter: "q", label: "quit", color: themeColors.primary })] }))] })] }));
|
|
1017
|
+
}
|
|
1018
|
+
function LogEntryRow({ log, maxWidth, themeColors }) {
|
|
1019
|
+
const levelColors = {
|
|
1020
|
+
info: themeColors.primary,
|
|
1021
|
+
success: baseColors.success,
|
|
1022
|
+
warn: baseColors.warning,
|
|
1023
|
+
error: baseColors.error,
|
|
1024
|
+
};
|
|
1025
|
+
const levelIcons = {
|
|
1026
|
+
info: symbols.filledDot,
|
|
1027
|
+
success: symbols.check,
|
|
1028
|
+
warn: '⚠',
|
|
1029
|
+
error: symbols.cross,
|
|
1030
|
+
};
|
|
1031
|
+
const serviceColor = log.service === 'web' ? themeColors.primary : themeColors.secondary;
|
|
1032
|
+
const icon = log.emoji || levelIcons[log.level];
|
|
1033
|
+
const color = levelColors[log.level];
|
|
1034
|
+
// Use displayMessage if available (user-friendly transformed message)
|
|
1035
|
+
let displayContent = log.displayMessage || log.content || log.message;
|
|
1036
|
+
const availableWidth = maxWidth - 16;
|
|
1037
|
+
const truncatedMessage = displayContent.length > availableWidth
|
|
1038
|
+
? displayContent.substring(0, availableWidth - 3) + '...'
|
|
1039
|
+
: displayContent;
|
|
1040
|
+
// Tool calls get special formatting
|
|
1041
|
+
if (log.toolName) {
|
|
1042
|
+
// If we have a displayMessage, use it (it's already user-friendly)
|
|
1043
|
+
if (log.displayMessage) {
|
|
1044
|
+
return (jsxs(Box, { children: [jsx(Text, { color: themeColors.textMuted, children: formatTime(log.timestamp) }), jsxs(Text, { color: serviceColor, children: [" [", log.service.substring(0, 3), "]"] }), jsx(Text, { color: themeColors.primary, children: " \uD83D\uDD27 " }), jsx(Text, { color: themeColors.text, children: log.displayMessage })] }));
|
|
1045
|
+
}
|
|
1046
|
+
// Fallback to showing tool name
|
|
1047
|
+
return (jsxs(Box, { children: [jsx(Text, { color: themeColors.textMuted, children: formatTime(log.timestamp) }), jsxs(Text, { color: serviceColor, children: [" [", log.service.substring(0, 3), "]"] }), jsx(Text, { color: themeColors.primary, children: " \uD83D\uDD27 " }), jsx(Text, { color: themeColors.text, children: log.toolName })] }));
|
|
1048
|
+
}
|
|
1049
|
+
return (jsxs(Box, { children: [jsx(Text, { color: themeColors.textMuted, children: formatTime(log.timestamp) }), jsxs(Text, { color: serviceColor, children: [" [", log.service.substring(0, 3), "]"] }), jsxs(Text, { color: color, children: [" ", icon, " "] }), jsx(Text, { color: log.level === 'error' || log.level === 'warn' ? color : themeColors.text, children: truncatedMessage })] }));
|
|
1050
|
+
}
|
|
1051
|
+
function FullLogEntryRow({ log, maxWidth, searchQuery, highlightSearch, themeColors }) {
|
|
1052
|
+
const levelColors = {
|
|
1053
|
+
info: themeColors.primary,
|
|
1054
|
+
success: baseColors.success,
|
|
1055
|
+
warn: baseColors.warning,
|
|
1056
|
+
error: baseColors.error,
|
|
1057
|
+
};
|
|
1058
|
+
const levelIcons = {
|
|
1059
|
+
info: symbols.filledDot,
|
|
1060
|
+
success: symbols.check,
|
|
1061
|
+
warn: '⚠',
|
|
1062
|
+
error: symbols.cross,
|
|
1063
|
+
};
|
|
1064
|
+
const serviceColor = log.service === 'web' ? themeColors.primary : themeColors.secondary;
|
|
1065
|
+
const icon = log.emoji || levelIcons[log.level];
|
|
1066
|
+
const color = levelColors[log.level];
|
|
1067
|
+
if (log.toolName) {
|
|
1068
|
+
return (jsxs(Box, { children: [jsx(Text, { color: themeColors.textMuted, children: formatTime(log.timestamp) }), jsxs(Text, { color: serviceColor, children: [" [", log.service, "]"] }), jsx(Text, { color: themeColors.primary, children: " \uD83D\uDD27 " }), jsx(Text, { color: themeColors.text, children: highlightSearch(log.toolName, searchQuery) }), log.content && (jsxs(Text, { color: themeColors.textDim, children: [" ", highlightSearch(log.content.replace(`tool-input-available: ${log.toolName}`, '').trim(), searchQuery)] }))] }));
|
|
1069
|
+
}
|
|
1070
|
+
return (jsxs(Box, { children: [jsx(Text, { color: themeColors.textMuted, children: formatTime(log.timestamp) }), jsxs(Text, { color: serviceColor, children: [" [", log.service, "]"] }), jsxs(Text, { color: color, children: [" ", icon, " "] }), log.tag && jsxs(Text, { color: themeColors.textMuted, children: ["[", log.tag, "] "] }), jsx(Text, { color: log.level === 'error' || log.level === 'warn' ? color : themeColors.text, children: highlightSearch(log.content || log.message, searchQuery) })] }));
|
|
1071
|
+
}
|
|
1072
|
+
function Shortcut({ letter, label, color }) {
|
|
1073
|
+
return (jsxs(Box, { marginRight: 2, children: [jsx(Text, { color: baseColors.dimGray, children: "[" }), jsx(Text, { color: color, children: letter }), jsx(Text, { color: baseColors.dimGray, children: "]" }), jsx(Text, { color: baseColors.gray, children: label })] }));
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Console Interceptor for TUI Mode
|
|
1078
|
+
* Intercepts stdout/stderr writes to write to log file
|
|
1079
|
+
* Prevents logs from bleeding above the TUI
|
|
1080
|
+
*/
|
|
1081
|
+
class ConsoleInterceptor {
|
|
1082
|
+
constructor(serviceManager, logFileManager) {
|
|
1083
|
+
this.isActive = false;
|
|
1084
|
+
this.serviceManager = serviceManager;
|
|
1085
|
+
this.logFileManager = logFileManager;
|
|
1086
|
+
// Save original stdout/stderr write methods
|
|
1087
|
+
// Console methods (log, error, warn, info) all use these under the hood
|
|
1088
|
+
this.originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
1089
|
+
this.originalStderrWrite = process.stderr.write.bind(process.stderr);
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Start intercepting console output and writing to log file
|
|
1093
|
+
* We only intercept stdout/stderr writes since console.* methods call these under the hood
|
|
1094
|
+
*/
|
|
1095
|
+
start() {
|
|
1096
|
+
if (this.isActive)
|
|
1097
|
+
return;
|
|
1098
|
+
this.isActive = true;
|
|
1099
|
+
// Intercept stdout writes - this catches console.log, console.info, and direct writes
|
|
1100
|
+
process.stdout.write = (chunk, encoding, callback) => {
|
|
1101
|
+
const message = typeof chunk === 'string' ? chunk : chunk.toString();
|
|
1102
|
+
// ONLY pass through ANSI escape codes (for Ink rendering)
|
|
1103
|
+
// Block all other output from reaching the terminal
|
|
1104
|
+
if (message.match(/^\x1b\[/)) {
|
|
1105
|
+
// This is Ink's control sequence - let it through
|
|
1106
|
+
return this.originalStdoutWrite(chunk, encoding, callback);
|
|
1107
|
+
}
|
|
1108
|
+
// Everything else: write to log file only (don't show on terminal)
|
|
1109
|
+
// Don't trim or filter - write the raw message to preserve formatting
|
|
1110
|
+
if (message && message.length > 0) {
|
|
1111
|
+
const serviceName = this.detectService(message);
|
|
1112
|
+
this.logFileManager.write(serviceName, message.trim(), 'stdout');
|
|
1113
|
+
}
|
|
1114
|
+
// Report success but don't actually write to terminal
|
|
1115
|
+
if (callback)
|
|
1116
|
+
callback();
|
|
1117
|
+
return true;
|
|
1118
|
+
};
|
|
1119
|
+
// Intercept stderr writes
|
|
1120
|
+
process.stderr.write = (chunk, encoding, callback) => {
|
|
1121
|
+
const message = typeof chunk === 'string' ? chunk : chunk.toString();
|
|
1122
|
+
// ONLY pass through ANSI escape codes (for Ink rendering)
|
|
1123
|
+
// Block all other output from reaching the terminal
|
|
1124
|
+
if (message.match(/^\x1b\[/)) {
|
|
1125
|
+
// This is Ink's control sequence - let it through
|
|
1126
|
+
return this.originalStderrWrite(chunk, encoding, callback);
|
|
1127
|
+
}
|
|
1128
|
+
// Everything else: write to log file only (don't show on terminal)
|
|
1129
|
+
// Don't trim or filter - write the raw message to preserve formatting
|
|
1130
|
+
if (message && message.length > 0) {
|
|
1131
|
+
const serviceName = this.detectService(message);
|
|
1132
|
+
this.logFileManager.write(serviceName, message.trim(), 'stderr');
|
|
1133
|
+
}
|
|
1134
|
+
// Report success but don't actually write to terminal
|
|
1135
|
+
if (callback)
|
|
1136
|
+
callback();
|
|
1137
|
+
return true;
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Get the log file path (null if logging is disabled)
|
|
1142
|
+
*/
|
|
1143
|
+
getLogFilePath() {
|
|
1144
|
+
return this.logFileManager.getLogFilePath();
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Stop intercepting and restore original stdout/stderr
|
|
1148
|
+
*/
|
|
1149
|
+
stop() {
|
|
1150
|
+
if (!this.isActive)
|
|
1151
|
+
return;
|
|
1152
|
+
this.isActive = false;
|
|
1153
|
+
// Restore stdout/stderr - this also restores console.* methods since they use these
|
|
1154
|
+
process.stdout.write = this.originalStdoutWrite;
|
|
1155
|
+
process.stderr.write = this.originalStderrWrite;
|
|
1156
|
+
// Stop log file writing
|
|
1157
|
+
this.logFileManager.stop();
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Detect which service the log is from based on content
|
|
1161
|
+
*/
|
|
1162
|
+
detectService(message) {
|
|
1163
|
+
const lower = message.toLowerCase();
|
|
1164
|
+
// Check for explicit service tags
|
|
1165
|
+
if (message.startsWith('[web]'))
|
|
1166
|
+
return 'web';
|
|
1167
|
+
if (message.startsWith('[broker]'))
|
|
1168
|
+
return 'broker';
|
|
1169
|
+
if (message.startsWith('[runner]') || message.startsWith('[build]') || message.startsWith('[orchestrator]') || message.startsWith('[engine]')) {
|
|
1170
|
+
return 'runner';
|
|
1171
|
+
}
|
|
1172
|
+
// Infer from content
|
|
1173
|
+
if (lower.includes('broker') || lower.includes('websocket'))
|
|
1174
|
+
return 'broker';
|
|
1175
|
+
if (lower.includes('next.js') || lower.includes('compiled') || lower.includes('ready in'))
|
|
1176
|
+
return 'web';
|
|
1177
|
+
// Default to runner (most console.log calls come from runner)
|
|
1178
|
+
return 'runner';
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Log File Manager
|
|
1184
|
+
* Writes logs to a file for TUI to read periodically
|
|
1185
|
+
* Creates a new log file each time the service starts
|
|
1186
|
+
*/
|
|
1187
|
+
class LogFileManager {
|
|
1188
|
+
constructor() {
|
|
1189
|
+
this.logFile = null;
|
|
1190
|
+
this.writeStream = null;
|
|
1191
|
+
// Create logs directory if it doesn't exist
|
|
1192
|
+
const logsDir = join(process.cwd(), 'logs');
|
|
1193
|
+
if (!existsSync(logsDir)) {
|
|
1194
|
+
mkdirSync(logsDir, { recursive: true });
|
|
1195
|
+
}
|
|
1196
|
+
// Create log file with timestamp
|
|
1197
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1198
|
+
this.logFile = join(logsDir, `openbuilder-${timestamp}.log`);
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Check if logging is enabled (always true now)
|
|
1202
|
+
*/
|
|
1203
|
+
isEnabled() {
|
|
1204
|
+
return true;
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Start writing to log file
|
|
1208
|
+
*/
|
|
1209
|
+
start() {
|
|
1210
|
+
if (!this.logFile)
|
|
1211
|
+
return;
|
|
1212
|
+
this.writeStream = createWriteStream(this.logFile, { flags: 'a' });
|
|
1213
|
+
// Write a startup marker
|
|
1214
|
+
this.writeStream.write(`[${new Date().toISOString()}] [system] [stdout] === Log file started ===\n`);
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Write a log entry to file
|
|
1218
|
+
* Format: [timestamp] [service] [stream] message
|
|
1219
|
+
*/
|
|
1220
|
+
write(service, message, stream) {
|
|
1221
|
+
if (!this.writeStream)
|
|
1222
|
+
return;
|
|
1223
|
+
const timestamp = new Date().toISOString();
|
|
1224
|
+
const logLine = `[${timestamp}] [${service}] [${stream}] ${message}\n`;
|
|
1225
|
+
this.writeStream.write(logLine, (err) => {
|
|
1226
|
+
if (err) {
|
|
1227
|
+
// Fallback to console if write fails (but this shouldn't happen often)
|
|
1228
|
+
console.error('Failed to write to log file:', err);
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Get the log file path (null if logging is disabled)
|
|
1234
|
+
*/
|
|
1235
|
+
getLogFilePath() {
|
|
1236
|
+
return this.logFile;
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Stop writing and close the file stream
|
|
1240
|
+
*/
|
|
1241
|
+
stop() {
|
|
1242
|
+
if (this.writeStream) {
|
|
1243
|
+
this.writeStream.end();
|
|
1244
|
+
this.writeStream = null;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* Utility to extract meaningful error messages from build output
|
|
1251
|
+
* Works with TypeScript, Next.js, Turbo, and other common build tools
|
|
1252
|
+
*/
|
|
1253
|
+
// Patterns that indicate error lines
|
|
1254
|
+
const ERROR_PATTERNS = [
|
|
1255
|
+
/error TS\d+:/i, // TypeScript errors
|
|
1256
|
+
/error:/i, // General errors
|
|
1257
|
+
/Error:/, // Error messages (case sensitive for JS errors)
|
|
1258
|
+
/ERR!/, // npm/pnpm errors
|
|
1259
|
+
/failed/i, // Failed messages
|
|
1260
|
+
/Cannot find/i, // Module not found
|
|
1261
|
+
/Module not found/i, // Webpack/Next.js errors
|
|
1262
|
+
/SyntaxError/i, // Syntax errors
|
|
1263
|
+
/TypeError/i, // Type errors
|
|
1264
|
+
/ReferenceError/i, // Reference errors
|
|
1265
|
+
/ENOENT/i, // File not found
|
|
1266
|
+
/EACCES/i, // Permission errors
|
|
1267
|
+
/✖|✗|×/, // Error symbols
|
|
1268
|
+
/Type '.+' is not assignable/i, // TypeScript type errors
|
|
1269
|
+
/Property '.+' does not exist/i, // TypeScript property errors
|
|
1270
|
+
/has no exported member/i, // Export errors
|
|
1271
|
+
/Unexpected token/i, // Parse errors
|
|
1272
|
+
];
|
|
1273
|
+
// Patterns that indicate we should stop collecting (success or unrelated output)
|
|
1274
|
+
const STOP_PATTERNS = [
|
|
1275
|
+
/successfully/i,
|
|
1276
|
+
/completed/i,
|
|
1277
|
+
/✓|✔/, // Success symbols
|
|
1278
|
+
/Build succeeded/i,
|
|
1279
|
+
];
|
|
1280
|
+
/**
|
|
1281
|
+
* Extract the most relevant error lines from build output
|
|
1282
|
+
* @param output Combined stdout and stderr from build process
|
|
1283
|
+
* @param maxLines Maximum number of lines to return (default: 15)
|
|
1284
|
+
* @returns Array of relevant error lines
|
|
1285
|
+
*/
|
|
1286
|
+
function extractBuildErrors(output, maxLines = 15) {
|
|
1287
|
+
if (!output || !output.trim()) {
|
|
1288
|
+
return [];
|
|
1289
|
+
}
|
|
1290
|
+
const allLines = output.trim().split('\n');
|
|
1291
|
+
const relevantLines = [];
|
|
1292
|
+
let inErrorBlock = false;
|
|
1293
|
+
let emptyLineCount = 0;
|
|
1294
|
+
for (let i = 0; i < allLines.length; i++) {
|
|
1295
|
+
const line = allLines[i];
|
|
1296
|
+
const trimmedLine = line.trim();
|
|
1297
|
+
// Check if this line matches an error pattern
|
|
1298
|
+
const isErrorLine = ERROR_PATTERNS.some(pattern => pattern.test(line));
|
|
1299
|
+
// Check if we should stop collecting
|
|
1300
|
+
const shouldStop = STOP_PATTERNS.some(pattern => pattern.test(line));
|
|
1301
|
+
if (shouldStop && inErrorBlock) {
|
|
1302
|
+
// Don't stop immediately - there might be more errors after a success message
|
|
1303
|
+
inErrorBlock = false;
|
|
1304
|
+
emptyLineCount = 0;
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
1307
|
+
if (isErrorLine) {
|
|
1308
|
+
inErrorBlock = true;
|
|
1309
|
+
emptyLineCount = 0;
|
|
1310
|
+
// Include 1-2 lines before for context if we're starting a new error block
|
|
1311
|
+
if (relevantLines.length === 0 || !relevantLines[relevantLines.length - 1]) {
|
|
1312
|
+
// Add file path context if the previous line looks like a file reference
|
|
1313
|
+
if (i > 0) {
|
|
1314
|
+
const prevLine = allLines[i - 1].trim();
|
|
1315
|
+
// File paths often contain '/' or '\' and end with line numbers like :10:5
|
|
1316
|
+
if (prevLine && (prevLine.includes('/') || prevLine.includes('\\') || /:\d+:\d+/.test(prevLine))) {
|
|
1317
|
+
if (!relevantLines.includes(prevLine)) {
|
|
1318
|
+
relevantLines.push(prevLine);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
if (inErrorBlock) {
|
|
1325
|
+
// Track empty lines - stop after 2 consecutive empty lines
|
|
1326
|
+
if (trimmedLine === '') {
|
|
1327
|
+
emptyLineCount++;
|
|
1328
|
+
if (emptyLineCount >= 2) {
|
|
1329
|
+
inErrorBlock = false;
|
|
1330
|
+
emptyLineCount = 0;
|
|
1331
|
+
continue;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
else {
|
|
1335
|
+
emptyLineCount = 0;
|
|
1336
|
+
}
|
|
1337
|
+
// Don't add duplicate lines
|
|
1338
|
+
if (!relevantLines.includes(trimmedLine) || trimmedLine === '') {
|
|
1339
|
+
relevantLines.push(trimmedLine);
|
|
1340
|
+
}
|
|
1341
|
+
// Stop if we've collected enough
|
|
1342
|
+
if (relevantLines.length >= maxLines) {
|
|
1343
|
+
break;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
// If we didn't find specific error patterns, fall back to last N lines
|
|
1348
|
+
if (relevantLines.length === 0) {
|
|
1349
|
+
return allLines
|
|
1350
|
+
.slice(-10)
|
|
1351
|
+
.map(line => line.trim())
|
|
1352
|
+
.filter(line => line.length > 0);
|
|
1353
|
+
}
|
|
1354
|
+
// Clean up: remove trailing empty lines
|
|
1355
|
+
while (relevantLines.length > 0 && relevantLines[relevantLines.length - 1] === '') {
|
|
1356
|
+
relevantLines.pop();
|
|
1357
|
+
}
|
|
1358
|
+
// Truncate very long lines but keep enough context
|
|
1359
|
+
return relevantLines.map(line => {
|
|
1360
|
+
if (line.length > 150) {
|
|
1361
|
+
return line.substring(0, 147) + '...';
|
|
1362
|
+
}
|
|
1363
|
+
return line;
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Start command with TUI dashboard support
|
|
1369
|
+
* Provides beautiful real-time monitoring of all services
|
|
1370
|
+
*/
|
|
1371
|
+
/**
|
|
1372
|
+
* Check if we should use TUI
|
|
1373
|
+
*/
|
|
1374
|
+
function shouldUseTUI(options) {
|
|
1375
|
+
// Explicit flag
|
|
1376
|
+
if (options.noTui)
|
|
1377
|
+
return false;
|
|
1378
|
+
// CI/CD environments
|
|
1379
|
+
if (process.env.CI === '1' || process.env.CI === 'true')
|
|
1380
|
+
return false;
|
|
1381
|
+
// Not a TTY
|
|
1382
|
+
if (!process.stdout.isTTY)
|
|
1383
|
+
return false;
|
|
1384
|
+
// Explicit env var to disable
|
|
1385
|
+
if (process.env.NO_TUI === '1')
|
|
1386
|
+
return false;
|
|
1387
|
+
return true;
|
|
1388
|
+
}
|
|
1389
|
+
async function startCommand(options) {
|
|
1390
|
+
const useTUI = shouldUseTUI(options);
|
|
1391
|
+
// If TUI is disabled, use the traditional start command
|
|
1392
|
+
if (!useTUI) {
|
|
1393
|
+
const { startCommand: traditionalStart } = await import('./start-traditional-uoLZXdxm.js');
|
|
1394
|
+
return traditionalStart(options);
|
|
1395
|
+
}
|
|
1396
|
+
// ========================================
|
|
1397
|
+
// TUI MODE
|
|
1398
|
+
// ========================================
|
|
1399
|
+
const s = p.spinner();
|
|
1400
|
+
// Step 1: Find monorepo
|
|
1401
|
+
s.start('Locating OpenBuilder repository');
|
|
1402
|
+
let monorepoRoot;
|
|
1403
|
+
const config = configManager.get();
|
|
1404
|
+
if (config.monorepoPath && existsSync(config.monorepoPath)) {
|
|
1405
|
+
monorepoRoot = config.monorepoPath;
|
|
1406
|
+
}
|
|
1407
|
+
if (!monorepoRoot) {
|
|
1408
|
+
const repoCheck = await isInsideMonorepo();
|
|
1409
|
+
if (repoCheck.inside && repoCheck.root) {
|
|
1410
|
+
monorepoRoot = repoCheck.root;
|
|
1411
|
+
configManager.set('monorepoPath', monorepoRoot);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
if (!monorepoRoot) {
|
|
1415
|
+
s.stop(pc.red('✗') + ' Repository not found');
|
|
1416
|
+
throw errors.monorepoNotFound([
|
|
1417
|
+
config.monorepoPath || 'none',
|
|
1418
|
+
process.cwd(),
|
|
1419
|
+
]);
|
|
1420
|
+
}
|
|
1421
|
+
s.stop(pc.green('✓') + ' Repository found');
|
|
1422
|
+
// Step 2: Check dependencies
|
|
1423
|
+
const nodeModulesPath = join(monorepoRoot, 'node_modules');
|
|
1424
|
+
if (!existsSync(nodeModulesPath)) {
|
|
1425
|
+
s.start('Installing dependencies');
|
|
1426
|
+
const { installDependencies } = await import('./repo-cloner-CpOQjFSo.js');
|
|
1427
|
+
await installDependencies(monorepoRoot);
|
|
1428
|
+
s.stop(pc.green('✓') + ' Dependencies installed');
|
|
1429
|
+
}
|
|
1430
|
+
// Step 2.5: Check for production build (unless --dev mode)
|
|
1431
|
+
const nextBuildIdPath = join(monorepoRoot, 'apps', 'openbuilder', '.next', 'BUILD_ID');
|
|
1432
|
+
const needsProductionBuild = !options.dev && !existsSync(nextBuildIdPath);
|
|
1433
|
+
// Rebuild services if requested OR if production build is missing
|
|
1434
|
+
if (options.rebuild || needsProductionBuild) {
|
|
1435
|
+
const buildReason = options.rebuild
|
|
1436
|
+
? 'Rebuilding services'
|
|
1437
|
+
: 'Building for production (first run)';
|
|
1438
|
+
s.start(buildReason);
|
|
1439
|
+
const { spawn } = await import('child_process');
|
|
1440
|
+
// Capture build output for error reporting
|
|
1441
|
+
let buildOutput = '';
|
|
1442
|
+
let buildError = '';
|
|
1443
|
+
try {
|
|
1444
|
+
// Use turbo to build all services with caching
|
|
1445
|
+
await new Promise((resolve, reject) => {
|
|
1446
|
+
const buildProcess = spawn('pnpm', ['build:all'], {
|
|
1447
|
+
cwd: monorepoRoot,
|
|
1448
|
+
stdio: 'pipe', // Capture output
|
|
1449
|
+
});
|
|
1450
|
+
buildProcess.stdout?.on('data', (data) => {
|
|
1451
|
+
buildOutput += data.toString();
|
|
1452
|
+
});
|
|
1453
|
+
buildProcess.stderr?.on('data', (data) => {
|
|
1454
|
+
buildError += data.toString();
|
|
1455
|
+
});
|
|
1456
|
+
buildProcess.on('close', (code) => {
|
|
1457
|
+
if (code === 0) {
|
|
1458
|
+
resolve();
|
|
1459
|
+
}
|
|
1460
|
+
else {
|
|
1461
|
+
reject(new Error(`Build failed with code ${code}`));
|
|
1462
|
+
}
|
|
1463
|
+
});
|
|
1464
|
+
buildProcess.on('error', reject);
|
|
1465
|
+
});
|
|
1466
|
+
s.stop(pc.green('✓') + ' Build complete (using Turborepo cache)');
|
|
1467
|
+
}
|
|
1468
|
+
catch (error) {
|
|
1469
|
+
s.stop(pc.red('✗') + ' Build failed');
|
|
1470
|
+
// Extract meaningful error lines from build output
|
|
1471
|
+
const allOutput = (buildOutput + '\n' + buildError).trim();
|
|
1472
|
+
const errorLines = extractBuildErrors(allOutput);
|
|
1473
|
+
const suggestions = [
|
|
1474
|
+
'Check that all dependencies are installed',
|
|
1475
|
+
'Try running: pnpm build:all',
|
|
1476
|
+
'Run with --dev flag to skip build and use dev mode',
|
|
1477
|
+
];
|
|
1478
|
+
// Add error context if available
|
|
1479
|
+
if (errorLines.length > 0) {
|
|
1480
|
+
console.log(pc.red('\nBuild errors:'));
|
|
1481
|
+
console.log(pc.gray('─'.repeat(60)));
|
|
1482
|
+
errorLines.forEach(line => console.log(pc.red(` ${line}`)));
|
|
1483
|
+
console.log(pc.gray('─'.repeat(60)));
|
|
1484
|
+
console.log('');
|
|
1485
|
+
}
|
|
1486
|
+
throw new CLIError({
|
|
1487
|
+
code: 'BUILD_FAILED',
|
|
1488
|
+
message: 'Failed to build services',
|
|
1489
|
+
suggestions,
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
// Step 3: Check database configuration
|
|
1494
|
+
if (!config.databaseUrl) {
|
|
1495
|
+
throw new CLIError({
|
|
1496
|
+
code: 'MISSING_REQUIRED_CONFIG',
|
|
1497
|
+
message: 'Database URL not configured',
|
|
1498
|
+
suggestions: [
|
|
1499
|
+
'Run initialization: openbuilder init',
|
|
1500
|
+
'Or set manually: openbuilder config set databaseUrl <url>',
|
|
1501
|
+
],
|
|
1502
|
+
docs: 'https://github.com/codyde/openbuilder#database-setup',
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
// Step 4: Clean up zombie processes
|
|
1506
|
+
const webPort = Number(options.port || '3000');
|
|
1507
|
+
s.start('Checking for port conflicts');
|
|
1508
|
+
await killProcessOnPort(webPort);
|
|
1509
|
+
s.stop(pc.green('✓') + ' Ports available');
|
|
1510
|
+
// Step 5: Create ServiceManager, LogFileManager, and Console Interceptor FIRST
|
|
1511
|
+
const serviceManager = new ServiceManager();
|
|
1512
|
+
const logFileManager = new LogFileManager();
|
|
1513
|
+
logFileManager.start(); // Start log file writing immediately
|
|
1514
|
+
const consoleInterceptor = new ConsoleInterceptor(serviceManager, logFileManager);
|
|
1515
|
+
// Start intercepting IMMEDIATELY before anything else can print
|
|
1516
|
+
consoleInterceptor.start();
|
|
1517
|
+
const sharedSecret = configManager.getSecret() || 'dev-secret';
|
|
1518
|
+
// Hook up service manager output to log file (for child process logs)
|
|
1519
|
+
serviceManager.on('service:output', (name, output, stream) => {
|
|
1520
|
+
logFileManager.write(name, output.trim(), stream);
|
|
1521
|
+
});
|
|
1522
|
+
// Keep silent mode for TUI, logs go to file
|
|
1523
|
+
process.env.SILENT_MODE = '1';
|
|
1524
|
+
// Clear screen for clean TUI start
|
|
1525
|
+
console.clear();
|
|
1526
|
+
// Register web app (now handles runner WebSocket connections directly)
|
|
1527
|
+
// Default to production mode unless --dev flag is present
|
|
1528
|
+
const webCommand = options.dev ? 'dev' : 'start';
|
|
1529
|
+
// Local mode is enabled by default, can be disabled with --no-local
|
|
1530
|
+
const isLocalMode = options.local !== false;
|
|
1531
|
+
// Write .env.local file to ensure env vars are available to Next.js
|
|
1532
|
+
// This is necessary because env vars passed to spawn() may not be visible
|
|
1533
|
+
// to Next.js server components in production mode
|
|
1534
|
+
const envLocalPath = join(monorepoRoot, 'apps', 'openbuilder', '.env.local');
|
|
1535
|
+
const envContent = [
|
|
1536
|
+
'# Auto-generated by openbuilder CLI - DO NOT EDIT',
|
|
1537
|
+
`# Generated at: ${new Date().toISOString()}`,
|
|
1538
|
+
'',
|
|
1539
|
+
`OPENBUILDER_LOCAL_MODE=${isLocalMode ? 'true' : 'false'}`,
|
|
1540
|
+
`RUNNER_SHARED_SECRET=${sharedSecret}`,
|
|
1541
|
+
`WORKSPACE_ROOT=${config.workspace}`,
|
|
1542
|
+
`RUNNER_ID=${config.runner?.id || 'local'}`,
|
|
1543
|
+
`RUNNER_DEFAULT_ID=${config.runner?.id || 'local'}`,
|
|
1544
|
+
`DATABASE_URL=${config.databaseUrl || ''}`,
|
|
1545
|
+
'',
|
|
1546
|
+
].join('\n');
|
|
1547
|
+
writeFileSync(envLocalPath, envContent);
|
|
1548
|
+
// Build environment variables for the web app
|
|
1549
|
+
const webEnv = {
|
|
1550
|
+
PORT: String(webPort),
|
|
1551
|
+
RUNNER_SHARED_SECRET: sharedSecret,
|
|
1552
|
+
WORKSPACE_ROOT: config.workspace,
|
|
1553
|
+
RUNNER_ID: config.runner?.id || 'local',
|
|
1554
|
+
RUNNER_DEFAULT_ID: config.runner?.id || 'local',
|
|
1555
|
+
DATABASE_URL: config.databaseUrl,
|
|
1556
|
+
// Enable local mode - bypasses authentication requirements (default: true)
|
|
1557
|
+
OPENBUILDER_LOCAL_MODE: isLocalMode ? 'true' : 'false',
|
|
1558
|
+
};
|
|
1559
|
+
serviceManager.register({
|
|
1560
|
+
name: 'web',
|
|
1561
|
+
displayName: 'Web App',
|
|
1562
|
+
port: webPort,
|
|
1563
|
+
command: 'pnpm',
|
|
1564
|
+
args: ['--filter', 'openbuilder', webCommand],
|
|
1565
|
+
cwd: monorepoRoot,
|
|
1566
|
+
env: webEnv,
|
|
1567
|
+
});
|
|
1568
|
+
// Register runner (special handling - not spawned, imported directly)
|
|
1569
|
+
serviceManager.register({
|
|
1570
|
+
name: 'runner',
|
|
1571
|
+
displayName: 'Runner',
|
|
1572
|
+
command: 'internal', // Not actually spawned
|
|
1573
|
+
args: [],
|
|
1574
|
+
cwd: monorepoRoot,
|
|
1575
|
+
env: {},
|
|
1576
|
+
});
|
|
1577
|
+
// Step 6: Clear screen and move cursor to home before TUI renders
|
|
1578
|
+
// Use ANSI codes that will pass through our interceptor
|
|
1579
|
+
process.stdout.write('\x1b[2J\x1b[H'); // Clear screen + move cursor to top-left
|
|
1580
|
+
// Ensure stdin is in raw mode for keyboard input
|
|
1581
|
+
if (process.stdin.setRawMode) {
|
|
1582
|
+
process.stdin.setRawMode(true);
|
|
1583
|
+
}
|
|
1584
|
+
process.stdin.resume();
|
|
1585
|
+
// Track runner cleanup function and shutting down state
|
|
1586
|
+
let isShuttingDown = false;
|
|
1587
|
+
let runnerCleanupFn;
|
|
1588
|
+
// Add backup SIGINT handler for Ctrl+C (in case Ink's doesn't fire)
|
|
1589
|
+
const handleSigInt = async () => {
|
|
1590
|
+
if (isShuttingDown) {
|
|
1591
|
+
// Force exit if already shutting down
|
|
1592
|
+
process.exit(1);
|
|
1593
|
+
}
|
|
1594
|
+
isShuttingDown = true;
|
|
1595
|
+
console.log('\n⚠ Received Ctrl+C, stopping services...');
|
|
1596
|
+
// Stop runner first if cleanup function exists
|
|
1597
|
+
if (runnerCleanupFn) {
|
|
1598
|
+
await runnerCleanupFn().catch(() => { });
|
|
1599
|
+
}
|
|
1600
|
+
// Then stop other services
|
|
1601
|
+
await serviceManager.stopAll().catch(() => { });
|
|
1602
|
+
consoleInterceptor.stop();
|
|
1603
|
+
process.exit(0);
|
|
1604
|
+
};
|
|
1605
|
+
process.on('SIGINT', handleSigInt);
|
|
1606
|
+
// One final clear right before rendering
|
|
1607
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
1608
|
+
// Enable alternate screen buffer to prevent scrolling above TUI
|
|
1609
|
+
process.stdout.write('\x1b[?1049h'); // Enter alternate screen
|
|
1610
|
+
process.stdout.write('\x1b[2J\x1b[H'); // Clear and home
|
|
1611
|
+
// Initialize the RunnerLogger BEFORE rendering TUI so the TUI can subscribe to build events
|
|
1612
|
+
// This must happen before startRunner() which would create its own logger
|
|
1613
|
+
initRunnerLogger({
|
|
1614
|
+
verbose: options.verbose || false,
|
|
1615
|
+
tuiMode: true,
|
|
1616
|
+
});
|
|
1617
|
+
// Enable TUI mode in file-logger to suppress terminal output
|
|
1618
|
+
setFileLoggerTuiMode(true);
|
|
1619
|
+
// Render TUI immediately with log file path
|
|
1620
|
+
const { waitUntilExit, clear } = render(React.createElement(Dashboard, {
|
|
1621
|
+
serviceManager,
|
|
1622
|
+
apiUrl: `http://localhost:${webPort}`,
|
|
1623
|
+
webPort,
|
|
1624
|
+
logFilePath: consoleInterceptor.getLogFilePath()
|
|
1625
|
+
}), {
|
|
1626
|
+
stdin: process.stdin,
|
|
1627
|
+
stdout: process.stdout,
|
|
1628
|
+
stderr: process.stderr,
|
|
1629
|
+
exitOnCtrlC: true,
|
|
1630
|
+
patchConsole: false // Don't let Ink patch console since we already intercept
|
|
1631
|
+
});
|
|
1632
|
+
// Step 7: Start services
|
|
1633
|
+
try {
|
|
1634
|
+
// Start web app (runner will be started separately)
|
|
1635
|
+
await serviceManager.start('web');
|
|
1636
|
+
await new Promise(resolve => setTimeout(resolve, 3000)); // Wait for WebSocket server to initialize
|
|
1637
|
+
// Mark runner as starting
|
|
1638
|
+
const runnerService = serviceManager['services'].get('runner');
|
|
1639
|
+
if (runnerService) {
|
|
1640
|
+
runnerService.state.status = 'starting';
|
|
1641
|
+
runnerService.startTime = Date.now();
|
|
1642
|
+
serviceManager.emit('service:status-change', 'runner', 'starting');
|
|
1643
|
+
}
|
|
1644
|
+
// Start runner directly (this blocks until shutdown)
|
|
1645
|
+
const { startRunner } = await import('../index.js');
|
|
1646
|
+
// Mark as running
|
|
1647
|
+
if (runnerService) {
|
|
1648
|
+
runnerService.state.status = 'running';
|
|
1649
|
+
serviceManager.emit('service:status-change', 'runner', 'running');
|
|
1650
|
+
}
|
|
1651
|
+
// Start runner and get cleanup function - connects directly to Next.js WebSocket
|
|
1652
|
+
runnerCleanupFn = await startRunner({
|
|
1653
|
+
wsUrl: `ws://localhost:${webPort}/ws/runner`,
|
|
1654
|
+
sharedSecret: sharedSecret,
|
|
1655
|
+
runnerId: config.runner?.id || 'local',
|
|
1656
|
+
workspace: config.workspace,
|
|
1657
|
+
silent: false, // Changed to false - show all logs
|
|
1658
|
+
verbose: options.verbose,
|
|
1659
|
+
tuiMode: true, // TUI mode enabled
|
|
1660
|
+
});
|
|
1661
|
+
// Wait for TUI to exit
|
|
1662
|
+
await waitUntilExit();
|
|
1663
|
+
// Stop console interception and restore normal console
|
|
1664
|
+
consoleInterceptor.stop();
|
|
1665
|
+
// Clear TUI immediately
|
|
1666
|
+
clear();
|
|
1667
|
+
// Exit alternate screen buffer
|
|
1668
|
+
process.stdout.write('\x1b[?1049l');
|
|
1669
|
+
// Show shutdown message
|
|
1670
|
+
console.log();
|
|
1671
|
+
console.log(pc.yellow('⚠'), 'Stopping all services...');
|
|
1672
|
+
// Stop all services with timeout
|
|
1673
|
+
const shutdownPromise = Promise.race([
|
|
1674
|
+
(async () => {
|
|
1675
|
+
// Close any active tunnels first (give it 1s)
|
|
1676
|
+
await serviceManager.closeTunnel('web').catch(() => { });
|
|
1677
|
+
// Stop spawned services (web, broker)
|
|
1678
|
+
await serviceManager.stopAll();
|
|
1679
|
+
console.log(pc.green('✓'), 'Services stopped');
|
|
1680
|
+
// Stop runner explicitly using cleanup function
|
|
1681
|
+
if (runnerCleanupFn) {
|
|
1682
|
+
console.log(pc.yellow('⚠'), 'Stopping runner...');
|
|
1683
|
+
await runnerCleanupFn();
|
|
1684
|
+
console.log(pc.green('✓'), 'Runner stopped');
|
|
1685
|
+
}
|
|
1686
|
+
console.log(pc.green('✓'), 'All services stopped');
|
|
1687
|
+
})(),
|
|
1688
|
+
new Promise((resolve) => setTimeout(() => {
|
|
1689
|
+
console.log(pc.yellow('⚠'), 'Shutdown timeout - forcing exit');
|
|
1690
|
+
resolve(undefined);
|
|
1691
|
+
}, 5000)) // Increased from 3s to 5s to allow tunnel cleanup
|
|
1692
|
+
]);
|
|
1693
|
+
await shutdownPromise;
|
|
1694
|
+
// Force exit to ensure we return to prompt
|
|
1695
|
+
process.exit(0);
|
|
1696
|
+
}
|
|
1697
|
+
catch (error) {
|
|
1698
|
+
// Stop console interception on error
|
|
1699
|
+
consoleInterceptor.stop();
|
|
1700
|
+
clear();
|
|
1701
|
+
// Exit alternate screen buffer
|
|
1702
|
+
process.stdout.write('\x1b[?1049l');
|
|
1703
|
+
throw error;
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
export { startCommand };
|
|
1708
|
+
//# sourceMappingURL=start-BygPCbvw.js.map
|