bgrun 3.11.0 → 3.12.1
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 +8 -2
- package/dashboard/app/api/events/route.ts +10 -2
- package/dashboard/app/api/guard/route.ts +4 -1
- package/dashboard/app/api/guard-events/route.ts +5 -0
- package/dashboard/app/api/history/route.ts +39 -0
- package/dashboard/app/api/processes/route.ts +11 -3
- package/dashboard/app/api/restart/[name]/route.ts +7 -0
- package/dashboard/app/api/start/route.ts +5 -0
- package/dashboard/app/api/stop/[name]/route.ts +4 -1
- package/dashboard/app/api/templates/route.ts +47 -0
- package/dashboard/app/globals.css +545 -29
- package/dashboard/app/page.client.tsx +717 -61
- package/dashboard/app/page.tsx +130 -0
- package/dist/index.js +663 -184
- package/package.json +4 -3
- package/scripts/bgr-startup.ps1 +118 -0
- package/scripts/bgrun-startup.ps1 +91 -0
- package/src/bgrun.test.ts +109 -0
- package/src/commands/details.ts +17 -3
- package/src/commands/list.ts +37 -4
- package/src/commands/run.ts +21 -3
- package/src/db.ts +115 -0
- package/src/guard.ts +51 -0
- package/src/index.ts +83 -14
- package/src/index_copy.ts +614 -0
- package/src/logger.ts +12 -2
- package/src/platform.ts +87 -50
- package/src/server.ts +87 -3
- package/src/table.ts +3 -3
- package/src/utils.ts +2 -2
package/src/platform.ts
CHANGED
|
@@ -11,6 +11,28 @@ import { measure, createMeasure } from "measure-fn";
|
|
|
11
11
|
|
|
12
12
|
const plat = createMeasure('platform');
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Execute a PowerShell command with -NoProfile synchronously.
|
|
16
|
+
* Returns stdout as string, or empty string on error.
|
|
17
|
+
*
|
|
18
|
+
* Uses Bun.spawnSync to avoid async stream hanging issues on Windows
|
|
19
|
+
* where Bun's pipe reader hangs indefinitely when reading killed processes.
|
|
20
|
+
*/
|
|
21
|
+
export function psExec(command: string, _timeoutMs: number = 3000): string {
|
|
22
|
+
const tmpFile = join(os.tmpdir(), `bgr-ps-${Date.now()}.ps1`);
|
|
23
|
+
try {
|
|
24
|
+
fs.writeFileSync(tmpFile, command);
|
|
25
|
+
const result = Bun.spawnSync(
|
|
26
|
+
['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', tmpFile]
|
|
27
|
+
);
|
|
28
|
+
try { fs.unlinkSync(tmpFile); } catch { }
|
|
29
|
+
return result.stdout?.toString() || '';
|
|
30
|
+
} catch {
|
|
31
|
+
try { fs.unlinkSync(tmpFile); } catch { }
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
14
36
|
/** Detect if running on Windows - use function to prevent bundler tree-shaking */
|
|
15
37
|
export function isWindows(): boolean {
|
|
16
38
|
return process.platform === "win32";
|
|
@@ -39,8 +61,9 @@ export async function isProcessRunning(pid: number, command?: string): Promise<b
|
|
|
39
61
|
}
|
|
40
62
|
|
|
41
63
|
if (isWindows()) {
|
|
42
|
-
|
|
43
|
-
|
|
64
|
+
// process.kill(pid, 0) — signal 0 checks existence without killing
|
|
65
|
+
// 100x faster than tasklist which hangs on some Windows systems
|
|
66
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
44
67
|
} else {
|
|
45
68
|
const result = await $`ps -p ${pid}`.nothrow().text();
|
|
46
69
|
return result.includes(`${pid}`);
|
|
@@ -86,8 +109,10 @@ async function isDockerContainerRunning(command: string): Promise<boolean> {
|
|
|
86
109
|
async function getChildPids(pid: number): Promise<number[]> {
|
|
87
110
|
try {
|
|
88
111
|
if (isWindows()) {
|
|
89
|
-
|
|
90
|
-
|
|
112
|
+
const result = await psExec(
|
|
113
|
+
`Get-CimInstance Win32_Process -Filter 'ParentProcessId=${pid}' | Select-Object -ExpandProperty ProcessId`,
|
|
114
|
+
3000
|
|
115
|
+
);
|
|
91
116
|
return result
|
|
92
117
|
.split('\n')
|
|
93
118
|
.map(line => parseInt(line.trim()))
|
|
@@ -176,6 +201,39 @@ export async function isPortFree(port: number): Promise<boolean> {
|
|
|
176
201
|
}
|
|
177
202
|
}
|
|
178
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Get info about what's using a port.
|
|
206
|
+
* Returns { inUse: boolean, pid?: number, processName?: string }
|
|
207
|
+
*/
|
|
208
|
+
export async function getPortInfo(port: number): Promise<{ inUse: boolean; pid?: number; processName?: string }> {
|
|
209
|
+
try {
|
|
210
|
+
if (isWindows()) {
|
|
211
|
+
const result = await $`netstat -ano | findstr :${port}`.nothrow().quiet().text();
|
|
212
|
+
for (const line of result.split('\n')) {
|
|
213
|
+
const match = line.match(new RegExp(`:(${port})\\s+.*LISTENING\\s+(\\d+)`));
|
|
214
|
+
if (match) {
|
|
215
|
+
const pid = parseInt(match[2]);
|
|
216
|
+
if (pid > 0 && await isProcessRunning(pid)) {
|
|
217
|
+
// Get process name
|
|
218
|
+
const nameResult = await $`powershell -NoProfile -Command "(Get-Process -Id ${pid} -ErrorAction SilentlyContinue).ProcessName"`.nothrow().quiet().text();
|
|
219
|
+
return { inUse: true, pid, processName: nameResult.trim() || 'unknown' };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { inUse: false };
|
|
224
|
+
} else {
|
|
225
|
+
const result = await $`ss -tln sport = :${port}`.nothrow().quiet().text();
|
|
226
|
+
const lines = result.trim().split('\n').filter((l: string) => l.trim());
|
|
227
|
+
if (lines.length > 1) {
|
|
228
|
+
return { inUse: true };
|
|
229
|
+
}
|
|
230
|
+
return { inUse: false };
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
return { inUse: false };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
179
237
|
/**
|
|
180
238
|
* Wait for a port to become free, polling with timeout.
|
|
181
239
|
* Returns true if port is free, false if timeout reached.
|
|
@@ -265,23 +323,26 @@ export function getShellCommand(command: string): string[] {
|
|
|
265
323
|
return ["sh", "-c", command];
|
|
266
324
|
}
|
|
267
325
|
}
|
|
268
|
-
|
|
269
326
|
/**
|
|
270
327
|
* Find the actual child process PID spawned by a shell wrapper.
|
|
271
|
-
* Traverses the process tree
|
|
272
|
-
* On Windows, bgr spawn creates: cmd.exe →
|
|
273
|
-
*
|
|
328
|
+
* Traverses the process tree to find the deepest (leaf) child.
|
|
329
|
+
* On Windows, bgr spawn creates: cmd.exe → bun.exe (typically 1-2 levels)
|
|
330
|
+
*
|
|
331
|
+
* Uses PowerShell with -NoProfile and a hard timeout to prevent hangs.
|
|
274
332
|
*/
|
|
275
333
|
export async function findChildPid(parentPid: number): Promise<number> {
|
|
276
334
|
let currentPid = parentPid;
|
|
277
|
-
const maxDepth =
|
|
335
|
+
const maxDepth = 2; // cmd.exe → bun.exe is the typical chain
|
|
278
336
|
|
|
279
337
|
for (let depth = 0; depth < maxDepth; depth++) {
|
|
280
338
|
try {
|
|
281
339
|
let childPids: number[] = [];
|
|
282
340
|
|
|
283
341
|
if (isWindows()) {
|
|
284
|
-
const result = await
|
|
342
|
+
const result = await psExec(
|
|
343
|
+
`Get-CimInstance Win32_Process -Filter 'ParentProcessId=${currentPid}' | Select-Object -ExpandProperty ProcessId`,
|
|
344
|
+
3000
|
|
345
|
+
);
|
|
285
346
|
childPids = result
|
|
286
347
|
.split('\n')
|
|
287
348
|
.map((line: string) => parseInt(line.trim()))
|
|
@@ -295,12 +356,7 @@ export async function findChildPid(parentPid: number): Promise<number> {
|
|
|
295
356
|
.filter(n => !isNaN(n) && n > 0);
|
|
296
357
|
}
|
|
297
358
|
|
|
298
|
-
if (childPids.length === 0)
|
|
299
|
-
// No children — currentPid is the leaf process
|
|
300
|
-
break;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// Follow the first child deeper
|
|
359
|
+
if (childPids.length === 0) break;
|
|
304
360
|
currentPid = childPids[0];
|
|
305
361
|
} catch {
|
|
306
362
|
break;
|
|
@@ -335,14 +391,10 @@ export async function reconcileProcessPids(
|
|
|
335
391
|
let runningProcs: Array<{ pid: number; cmdLine: string }> = [];
|
|
336
392
|
|
|
337
393
|
if (isWindows()) {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const ps = Bun.spawnSync(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', tmpScript]);
|
|
344
|
-
const output = ps.stdout.toString();
|
|
345
|
-
|
|
394
|
+
const output = await psExec(
|
|
395
|
+
`Get-CimInstance Win32_Process -Filter "Name='bun.exe'" | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.CommandLine)" }`,
|
|
396
|
+
5000
|
|
397
|
+
);
|
|
346
398
|
for (const line of output.split('\n')) {
|
|
347
399
|
const sepIdx = line.indexOf('|');
|
|
348
400
|
if (sepIdx === -1) continue;
|
|
@@ -508,32 +560,17 @@ export async function getProcessBatchResources(pids: number[]): Promise<Map<numb
|
|
|
508
560
|
|
|
509
561
|
try {
|
|
510
562
|
if (isWindows()) {
|
|
511
|
-
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
const
|
|
520
|
-
if (
|
|
521
|
-
|
|
522
|
-
// CPU can sometimes be blank if process is just starting, handle that
|
|
523
|
-
let cpuStr = parts[1];
|
|
524
|
-
let memStr = parts[2];
|
|
525
|
-
if (parts.length === 2) {
|
|
526
|
-
// If CPU is missing, powershell might omit it and give just ID and WorkingSet
|
|
527
|
-
cpuStr = "0";
|
|
528
|
-
memStr = parts[1];
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
const cpu = parseFloat(cpuStr) || 0;
|
|
532
|
-
const memory = parseInt(memStr) || 0;
|
|
533
|
-
|
|
534
|
-
if (!isNaN(pid) && !isNaN(memory)) {
|
|
535
|
-
if (pidSet.has(pid)) resourceMap.set(pid, { memory, cpu });
|
|
536
|
-
}
|
|
563
|
+
// psExec(Get-Process) is fast (~2ms) vs tasklist which hangs
|
|
564
|
+
const output = psExec(
|
|
565
|
+
`Get-Process -Id ${pids.join(',')} -ErrorAction SilentlyContinue | Select-Object Id, WorkingSet64 | ForEach-Object { Write-Output "$($_.Id)|$($_.WorkingSet64)" }`
|
|
566
|
+
);
|
|
567
|
+
for (const line of output.split('\n')) {
|
|
568
|
+
const sepIdx = line.indexOf('|');
|
|
569
|
+
if (sepIdx === -1) continue;
|
|
570
|
+
const pid = parseInt(line.substring(0, sepIdx).trim());
|
|
571
|
+
const memory = parseInt(line.substring(sepIdx + 1).trim()) || 0;
|
|
572
|
+
if (!isNaN(pid) && pidSet.has(pid)) {
|
|
573
|
+
resourceMap.set(pid, { memory, cpu: 0 });
|
|
537
574
|
}
|
|
538
575
|
}
|
|
539
576
|
} else {
|
package/src/server.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* - Otherwise → defaults to 3000, falls back to next available if busy
|
|
15
15
|
*/
|
|
16
16
|
import path from 'path';
|
|
17
|
-
import { getAllProcesses, getProcess } from './db';
|
|
17
|
+
import { getAllProcesses, getProcess, addHistoryEntry } from './db';
|
|
18
18
|
import { isProcessRunning } from './platform';
|
|
19
19
|
import { handleRun } from './commands/run';
|
|
20
20
|
import { parseEnvString } from './utils';
|
|
@@ -26,8 +26,44 @@ const GUARD_SKIP_NAMES = new Set(['bgr-dashboard', 'bgr-guard']); // Don't try t
|
|
|
26
26
|
const _g = globalThis as any;
|
|
27
27
|
if (!_g.__bgrGuardRestartCounts) _g.__bgrGuardRestartCounts = new Map<string, number>();
|
|
28
28
|
if (!_g.__bgrGuardNextRestartTime) _g.__bgrGuardNextRestartTime = new Map<string, number>();
|
|
29
|
+
if (!_g.__bgrGuardEvents) _g.__bgrGuardEvents = [] as { time: number; name: string; action: string; success: boolean }[];
|
|
29
30
|
export const guardRestartCounts: Map<string, number> = _g.__bgrGuardRestartCounts;
|
|
30
31
|
const guardNextRestartTime: Map<string, number> = _g.__bgrGuardNextRestartTime;
|
|
32
|
+
export const guardEvents: { time: number; name: string; action: string; success: boolean }[] = _g.__bgrGuardEvents;
|
|
33
|
+
|
|
34
|
+
/** Try to free a port from zombie processes (dead PIDs holding sockets) */
|
|
35
|
+
async function cleanupPort(port: number): Promise<number> {
|
|
36
|
+
if (process.platform !== 'win32') return port;
|
|
37
|
+
try {
|
|
38
|
+
const proc = Bun.spawn(['powershell', '-NoProfile', '-Command',
|
|
39
|
+
`Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess`
|
|
40
|
+
], { stdout: 'pipe', stderr: 'pipe' });
|
|
41
|
+
const text = await new Response(proc.stdout).text();
|
|
42
|
+
const pid = parseInt(text.trim(), 10);
|
|
43
|
+
if (!pid || pid === process.pid) return port;
|
|
44
|
+
|
|
45
|
+
// Check if the owning process is actually dead
|
|
46
|
+
const checkProc = Bun.spawn(['powershell', '-NoProfile', '-Command',
|
|
47
|
+
`Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id`
|
|
48
|
+
], { stdout: 'pipe', stderr: 'pipe' });
|
|
49
|
+
const checkText = await new Response(checkProc.stdout).text();
|
|
50
|
+
if (checkText.trim()) {
|
|
51
|
+
// Process is alive — kill it to reclaim the port
|
|
52
|
+
console.log(`[server] Killing PID ${pid} holding port ${port}`);
|
|
53
|
+
Bun.spawn(['taskkill', '/F', '/PID', String(pid)], { stdout: 'pipe', stderr: 'pipe' });
|
|
54
|
+
await Bun.sleep(1000);
|
|
55
|
+
return port;
|
|
56
|
+
} else {
|
|
57
|
+
// Process is dead but socket is zombie — use fallback port
|
|
58
|
+
const fallback = port + 1;
|
|
59
|
+
console.log(`[server] ⚠ Port ${port} held by zombie PID ${pid} — falling back to port ${fallback}`);
|
|
60
|
+
return fallback;
|
|
61
|
+
}
|
|
62
|
+
} catch { return port; /* best-effort cleanup */ }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let _originalPort = 3000;
|
|
66
|
+
let _currentPort = 3000;
|
|
31
67
|
|
|
32
68
|
export async function startServer() {
|
|
33
69
|
// Dynamic import to avoid melina's side-effect console.log at bundle load time
|
|
@@ -36,12 +72,20 @@ export async function startServer() {
|
|
|
36
72
|
|
|
37
73
|
// Only pass port when BUN_PORT is explicitly set.
|
|
38
74
|
// When omitted, Melina defaults to 3000 with auto-fallback to next available port.
|
|
39
|
-
const
|
|
75
|
+
const requestedPort = process.env.BUN_PORT ? parseInt(process.env.BUN_PORT, 10) : 3000;
|
|
76
|
+
_originalPort = requestedPort;
|
|
77
|
+
|
|
78
|
+
// Clean up zombie port bindings before starting — may return a different fallback port
|
|
79
|
+
const resolvedPort = await cleanupPort(requestedPort);
|
|
80
|
+
_currentPort = resolvedPort;
|
|
81
|
+
|
|
82
|
+
// Pass port explicitly if user requested one OR if we had to fallback
|
|
83
|
+
const needsExplicitPort = process.env.BUN_PORT || resolvedPort !== requestedPort;
|
|
40
84
|
await start({
|
|
41
85
|
appDir,
|
|
42
86
|
defaultTitle: 'bgrun Dashboard - Process Manager',
|
|
43
87
|
globalCss: path.join(appDir, 'globals.css'),
|
|
44
|
-
...(
|
|
88
|
+
...(needsExplicitPort && { port: resolvedPort }),
|
|
45
89
|
});
|
|
46
90
|
|
|
47
91
|
// Start the built-in process guard
|
|
@@ -50,6 +94,33 @@ export async function startServer() {
|
|
|
50
94
|
// Start log rotation (prevents unbounded log file growth)
|
|
51
95
|
const { startLogRotation } = await import('./log-rotation');
|
|
52
96
|
startLogRotation(() => getAllProcesses());
|
|
97
|
+
|
|
98
|
+
// Start sticky port checker - periodically try original port if we're on a fallback
|
|
99
|
+
if (resolvedPort !== requestedPort) {
|
|
100
|
+
startStickyPortChecker();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function startStickyPortChecker() {
|
|
105
|
+
const CHECK_INTERVAL_MS = 60_000; // Check every 60 seconds
|
|
106
|
+
console.log(`[server] Starting sticky port checker (original: ${_originalPort}, current: ${_currentPort})`);
|
|
107
|
+
|
|
108
|
+
setInterval(async () => {
|
|
109
|
+
if (_currentPort === _originalPort) return; // Already on original port
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const proc = Bun.spawn(['powershell', '-NoProfile', '-Command',
|
|
113
|
+
`Get-NetTCPConnection -LocalPort ${_originalPort} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess`
|
|
114
|
+
], { stdout: 'pipe', stderr: 'pipe' });
|
|
115
|
+
const text = await new Response(proc.stdout).text();
|
|
116
|
+
const pid = parseInt(text.trim(), 10);
|
|
117
|
+
|
|
118
|
+
if (!pid) {
|
|
119
|
+
console.log(`[server] ✓ Original port ${_originalPort} is now available! Consider restarting to reclaim it.`);
|
|
120
|
+
_currentPort = _originalPort;
|
|
121
|
+
}
|
|
122
|
+
} catch { /* best-effort */ }
|
|
123
|
+
}, CHECK_INTERVAL_MS);
|
|
53
124
|
}
|
|
54
125
|
|
|
55
126
|
/**
|
|
@@ -89,6 +160,7 @@ function startGuard() {
|
|
|
89
160
|
if (now < nextRestart) continue; // Still in backoff period
|
|
90
161
|
|
|
91
162
|
console.log(`[guard] ⚠ Guarded process "${proc.name}" (PID ${proc.pid}) is dead, restarting...`);
|
|
163
|
+
let success = false;
|
|
92
164
|
try {
|
|
93
165
|
await handleRun({
|
|
94
166
|
action: 'run',
|
|
@@ -96,12 +168,22 @@ function startGuard() {
|
|
|
96
168
|
force: true,
|
|
97
169
|
remoteName: '',
|
|
98
170
|
});
|
|
171
|
+
success = true;
|
|
99
172
|
|
|
100
173
|
// Track restart count
|
|
101
174
|
const prevCount = guardRestartCounts.get(proc.name) || 0;
|
|
102
175
|
const newCount = prevCount + 1;
|
|
103
176
|
guardRestartCounts.set(proc.name, newCount);
|
|
104
177
|
|
|
178
|
+
// Record in history database
|
|
179
|
+
try {
|
|
180
|
+
addHistoryEntry(proc.name, 'restart', undefined, { by: 'guard', count: newCount });
|
|
181
|
+
} catch { /* ignore history errors */ }
|
|
182
|
+
|
|
183
|
+
// Record event for dashboard
|
|
184
|
+
guardEvents.unshift({ time: now, name: proc.name, action: 'restart', success: true });
|
|
185
|
+
if (guardEvents.length > 100) guardEvents.pop();
|
|
186
|
+
|
|
105
187
|
// Exponential backoff if it crashes repeatedly (more than 5 times)
|
|
106
188
|
if (newCount > 5) {
|
|
107
189
|
const backoffSeconds = Math.min(30 * Math.pow(2, newCount - 6), 300); // 30s, 60s, 120s, up to 5 mins
|
|
@@ -112,6 +194,8 @@ function startGuard() {
|
|
|
112
194
|
}
|
|
113
195
|
} catch (err: any) {
|
|
114
196
|
console.error(`[guard] ✗ Failed to restart "${proc.name}": ${err.message}`);
|
|
197
|
+
guardEvents.unshift({ time: now, name: proc.name, action: 'restart', success: false });
|
|
198
|
+
if (guardEvents.length > 100) guardEvents.pop();
|
|
115
199
|
}
|
|
116
200
|
} else {
|
|
117
201
|
// Reset counter if process has been stable (alive at least once during check)
|
package/src/table.ts
CHANGED
|
@@ -34,12 +34,12 @@ export function getTerminalWidth(): number {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
// Strip ANSI color codes for accurate length calculation
|
|
37
|
-
function stripAnsi(str: string): string {
|
|
37
|
+
export function stripAnsi(str: string): string {
|
|
38
38
|
return str.replace(/\u001b\[[0-9;]*m/g, "");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
// Default truncator: trims the end of a string
|
|
42
|
-
function truncateString(str: string, maxLength: number): string {
|
|
42
|
+
export function truncateString(str: string, maxLength: number): string {
|
|
43
43
|
const stripped = stripAnsi(str);
|
|
44
44
|
if (stripped.length <= maxLength) return str;
|
|
45
45
|
const ellipsis = "…";
|
|
@@ -52,7 +52,7 @@ function truncateString(str: string, maxLength: number): string {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// Path truncator: trims the middle of a string
|
|
55
|
-
function truncatePath(str: string, maxLength: number): string {
|
|
55
|
+
export function truncatePath(str: string, maxLength: number): string {
|
|
56
56
|
const stripped = stripAnsi(str);
|
|
57
57
|
if (stripped.length <= maxLength) return str;
|
|
58
58
|
const ellipsis = "…";
|
package/src/utils.ts
CHANGED
|
@@ -37,8 +37,8 @@ export async function getVersion(): Promise<string> {
|
|
|
37
37
|
|
|
38
38
|
export function validateDirectory(directory: string) {
|
|
39
39
|
if (!directory || !fs.existsSync(directory)) {
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// Throw instead of process.exit() — lets dashboard API handlers catch gracefully
|
|
41
|
+
throw new Error(`Directory not found or invalid: '${directory}'`);
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|