@hatchway/cli 0.50.53

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