bgrun 3.9.0 → 3.10.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/dashboard/app/api/stop/[name]/route.ts +21 -3
- package/dashboard/app/globals.css +13 -0
- package/dashboard/app/page.client.tsx +8 -5
- package/dist/index.js +209 -139
- package/package.json +2 -2
- package/src/commands/cleanup.ts +5 -1
- package/src/commands/run.ts +18 -0
- package/src/platform.ts +49 -42
- package/src/server.ts +63 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* POST /api/stop/:name — Stop a running process
|
|
3
|
+
*
|
|
4
|
+
* Kills the registered PID, then kills anything remaining on the port.
|
|
5
|
+
* Sets PID to 0 to prevent reconciliation from hijacking unrelated processes.
|
|
3
6
|
*/
|
|
4
|
-
import { getProcess } from '../../../../../src/db';
|
|
5
|
-
import { isProcessRunning, terminateProcess } from '../../../../../src/platform';
|
|
7
|
+
import { getProcess, updateProcessPid } from '../../../../../src/db';
|
|
8
|
+
import { isProcessRunning, terminateProcess, getProcessPorts, killProcessOnPort } from '../../../../../src/platform';
|
|
6
9
|
import { measure } from 'measure-fn';
|
|
7
10
|
|
|
8
11
|
export async function POST(req: Request, { params }: { params: { name: string } }) {
|
|
@@ -15,9 +18,24 @@ export async function POST(req: Request, { params }: { params: { name: string }
|
|
|
15
18
|
|
|
16
19
|
const running = await isProcessRunning(proc.pid);
|
|
17
20
|
if (!running) {
|
|
18
|
-
|
|
21
|
+
// Already dead — mark PID as 0 to prevent reconciliation
|
|
22
|
+
updateProcessPid(name, 0);
|
|
23
|
+
return Response.json({ success: true, already_stopped: true });
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
// Detect ports BEFORE killing so we can clean them up
|
|
27
|
+
const ports = await getProcessPorts(proc.pid);
|
|
28
|
+
|
|
21
29
|
await measure(`Stop "${name}" (PID ${proc.pid})`, () => terminateProcess(proc.pid));
|
|
30
|
+
|
|
31
|
+
// Also kill anything still on the ports
|
|
32
|
+
for (const port of ports) {
|
|
33
|
+
await killProcessOnPort(port);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Mark PID as 0 — prevents reconcileProcessPids from re-attaching
|
|
37
|
+
// a random matching process as this one
|
|
38
|
+
updateProcessPid(name, 0);
|
|
39
|
+
|
|
22
40
|
return Response.json({ success: true });
|
|
23
41
|
}
|
|
@@ -898,6 +898,19 @@ tr.animate-in:nth-child(10) {
|
|
|
898
898
|
border: 1px solid var(--info-border);
|
|
899
899
|
}
|
|
900
900
|
|
|
901
|
+
a.port-link {
|
|
902
|
+
text-decoration: none;
|
|
903
|
+
transition: all var(--transition-fast);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
a.port-link:hover {
|
|
907
|
+
text-decoration: underline;
|
|
908
|
+
background: rgba(56, 189, 248, 0.15);
|
|
909
|
+
/* Slightly brighter info-bg on hover */
|
|
910
|
+
color: #7dd3fc;
|
|
911
|
+
/* Brighter info color */
|
|
912
|
+
}
|
|
913
|
+
|
|
901
914
|
.command {
|
|
902
915
|
font-family: var(--font-mono);
|
|
903
916
|
font-size: 0.75rem;
|
|
@@ -164,7 +164,7 @@ function ProcessRow({ p, animate }: { p: ProcessData; animate?: boolean }) {
|
|
|
164
164
|
<td className="pid">{String(p.pid)}</td>
|
|
165
165
|
<td>
|
|
166
166
|
{p.port
|
|
167
|
-
? <
|
|
167
|
+
? <a className="port-num port-link" href={`http://localhost:${p.port}`} target="_blank" rel="noopener" title={`Open localhost:${p.port}`} onClick={(e: Event) => e.stopPropagation()}>:{p.port}</a>
|
|
168
168
|
: <span style={{ color: 'var(--text-muted)' }}>–</span>
|
|
169
169
|
}
|
|
170
170
|
</td>
|
|
@@ -228,7 +228,7 @@ function ProcessCard({ p }: { p: ProcessData }) {
|
|
|
228
228
|
</div>
|
|
229
229
|
<div className="card-details">
|
|
230
230
|
<div className="card-detail"><span className="card-label">PID</span><span>{p.pid}</span></div>
|
|
231
|
-
<div className="card-detail"><span className="card-label">Port</span
|
|
231
|
+
<div className="card-detail"><span className="card-label">Port</span>{p.port ? <a className="port-link" href={`http://localhost:${p.port}`} target="_blank" rel="noopener" onClick={(e: Event) => e.stopPropagation()}>:{p.port}</a> : <span>–</span>}</div>
|
|
232
232
|
<div className="card-detail"><span className="card-label">Memory</span><span>{p.memory > 0 ? formatMemory(p.memory) : '–'}</span></div>
|
|
233
233
|
<div className="card-detail"><span className="card-label">Runtime</span><span>{formatRuntime(p.runtime)}</span></div>
|
|
234
234
|
</div>
|
|
@@ -845,7 +845,7 @@ export default function mount(): () => void {
|
|
|
845
845
|
const metaItems = [
|
|
846
846
|
{ label: 'Status', value: proc.running ? '● Running' : '○ Stopped' },
|
|
847
847
|
{ label: 'PID', value: String(proc.pid) },
|
|
848
|
-
{ label: 'Port', value: proc.port ? `:${proc.port}` : '–' },
|
|
848
|
+
{ label: 'Port', value: proc.port ? `:${proc.port}` : '–', href: proc.port ? `http://localhost:${proc.port}` : undefined },
|
|
849
849
|
{ label: 'Runtime', value: formatRuntime(proc.runtime) },
|
|
850
850
|
{ label: 'Command', value: proc.command },
|
|
851
851
|
{ label: 'Directory', value: proc.directory || '–' },
|
|
@@ -853,10 +853,13 @@ export default function mount(): () => void {
|
|
|
853
853
|
{ label: 'Group', value: proc.group || '–' },
|
|
854
854
|
];
|
|
855
855
|
|
|
856
|
-
const items = metaItems.map(m => (
|
|
856
|
+
const items = metaItems.map((m: any) => (
|
|
857
857
|
<div className="meta-item">
|
|
858
858
|
<span className="meta-label">{m.label}</span>
|
|
859
|
-
|
|
859
|
+
{m.href
|
|
860
|
+
? <a className="meta-value port-link" href={m.href} target="_blank" rel="noopener">{m.value}</a>
|
|
861
|
+
: <span className="meta-value">{m.value}</span>
|
|
862
|
+
}
|
|
860
863
|
</div>
|
|
861
864
|
) as unknown as Node);
|
|
862
865
|
meta.replaceChildren(...items);
|
package/dist/index.js
CHANGED
|
@@ -25,6 +25,8 @@ function getHomeDir() {
|
|
|
25
25
|
return os.homedir();
|
|
26
26
|
}
|
|
27
27
|
async function isProcessRunning(pid, command) {
|
|
28
|
+
if (pid <= 0)
|
|
29
|
+
return false;
|
|
28
30
|
return plat.measure(`PID ${pid} alive?`, async () => {
|
|
29
31
|
try {
|
|
30
32
|
if (command && (command.includes("docker run") || command.includes("docker-compose up") || command.includes("docker compose up"))) {
|
|
@@ -78,36 +80,24 @@ async function getChildPids(pid) {
|
|
|
78
80
|
}
|
|
79
81
|
async function terminateProcess(pid, force = false) {
|
|
80
82
|
await plat.measure(`Terminate PID ${pid}`, async (m) => {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const signal = force ? "KILL" : "TERM";
|
|
92
|
-
await $`kill -${signal} ${childPid}`.nothrow();
|
|
83
|
+
try {
|
|
84
|
+
if (isWindows()) {
|
|
85
|
+
await $`taskkill /F /T /PID ${pid}`.nothrow().quiet();
|
|
86
|
+
} else {
|
|
87
|
+
const children = await m("Get children", () => getChildPids(pid)) ?? [];
|
|
88
|
+
const signal = force ? "KILL" : "TERM";
|
|
89
|
+
for (const childPid of children) {
|
|
90
|
+
try {
|
|
91
|
+
await $`kill -${signal} ${childPid}`.nothrow();
|
|
92
|
+
} catch {}
|
|
93
93
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
await Bun.sleep(500);
|
|
97
|
-
if (await isProcessRunning(pid)) {
|
|
98
|
-
try {
|
|
99
|
-
if (isWindows()) {
|
|
100
|
-
if (force) {
|
|
101
|
-
await $`taskkill /F /PID ${pid}`.nothrow().quiet();
|
|
102
|
-
} else {
|
|
103
|
-
await $`taskkill /PID ${pid}`.nothrow().quiet();
|
|
104
|
-
}
|
|
105
|
-
} else {
|
|
106
|
-
const signal = force ? "KILL" : "TERM";
|
|
94
|
+
await Bun.sleep(500);
|
|
95
|
+
if (await isProcessRunning(pid)) {
|
|
107
96
|
await $`kill -${signal} ${pid}`.nothrow();
|
|
108
97
|
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
98
|
+
}
|
|
99
|
+
} catch {}
|
|
100
|
+
await Bun.sleep(300);
|
|
111
101
|
});
|
|
112
102
|
}
|
|
113
103
|
async function isPortFree(port) {
|
|
@@ -116,9 +106,13 @@ async function isPortFree(port) {
|
|
|
116
106
|
const result = await $`netstat -ano | findstr :${port}`.nothrow().quiet().text();
|
|
117
107
|
for (const line of result.split(`
|
|
118
108
|
`)) {
|
|
119
|
-
const match = line.match(new RegExp(`:(${port})\\s+.*LISTENING`));
|
|
120
|
-
if (match)
|
|
121
|
-
|
|
109
|
+
const match = line.match(new RegExp(`:(${port})\\s+.*LISTENING\\s+(\\d+)`));
|
|
110
|
+
if (match) {
|
|
111
|
+
const pid = parseInt(match[2]);
|
|
112
|
+
if (pid > 0 && await isProcessRunning(pid)) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
122
116
|
}
|
|
123
117
|
return true;
|
|
124
118
|
} else {
|
|
@@ -157,8 +151,11 @@ async function killProcessOnPort(port) {
|
|
|
157
151
|
}
|
|
158
152
|
}
|
|
159
153
|
for (const pid of pids) {
|
|
160
|
-
|
|
161
|
-
|
|
154
|
+
const alive = await isProcessRunning(pid);
|
|
155
|
+
if (alive) {
|
|
156
|
+
await $`taskkill /F /T /PID ${pid}`.nothrow().quiet();
|
|
157
|
+
console.log(`Killed process ${pid} using port ${port}`);
|
|
158
|
+
}
|
|
162
159
|
}
|
|
163
160
|
} else {
|
|
164
161
|
const result = await $`lsof -ti :${port}`.nothrow().text();
|
|
@@ -324,6 +321,87 @@ var init_platform = __esm(() => {
|
|
|
324
321
|
plat = createMeasure("platform");
|
|
325
322
|
});
|
|
326
323
|
|
|
324
|
+
// src/utils.ts
|
|
325
|
+
import * as fs2 from "fs";
|
|
326
|
+
import chalk from "chalk";
|
|
327
|
+
function parseEnvString(envString) {
|
|
328
|
+
const env = {};
|
|
329
|
+
envString.split(",").forEach((pair) => {
|
|
330
|
+
const [key, value] = pair.split("=");
|
|
331
|
+
if (key && value)
|
|
332
|
+
env[key] = value;
|
|
333
|
+
});
|
|
334
|
+
return env;
|
|
335
|
+
}
|
|
336
|
+
function calculateRuntime(startTime) {
|
|
337
|
+
const start = new Date(startTime).getTime();
|
|
338
|
+
const now = new Date().getTime();
|
|
339
|
+
const diffInMinutes = Math.floor((now - start) / (1000 * 60));
|
|
340
|
+
return `${diffInMinutes} minutes`;
|
|
341
|
+
}
|
|
342
|
+
async function getVersion() {
|
|
343
|
+
try {
|
|
344
|
+
const { join } = await import("path");
|
|
345
|
+
const pkgPath = join(import.meta.dir, "../package.json");
|
|
346
|
+
const pkg = await Bun.file(pkgPath).json();
|
|
347
|
+
return pkg.version || "0.0.0";
|
|
348
|
+
} catch {
|
|
349
|
+
return "0.0.0";
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function validateDirectory(directory) {
|
|
353
|
+
if (!directory || !fs2.existsSync(directory)) {
|
|
354
|
+
console.log(chalk.red("\u274C Error: 'directory' must be a valid path."));
|
|
355
|
+
process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function tailFile(path, prefix, colorFn, lines) {
|
|
359
|
+
let position = 0;
|
|
360
|
+
let lastPartial = "";
|
|
361
|
+
if (!fs2.existsSync(path)) {
|
|
362
|
+
return () => {};
|
|
363
|
+
}
|
|
364
|
+
const fd = fs2.openSync(path, "r");
|
|
365
|
+
const printNewContent = () => {
|
|
366
|
+
try {
|
|
367
|
+
const stats = fs2.statSync(path);
|
|
368
|
+
if (stats.size <= position)
|
|
369
|
+
return;
|
|
370
|
+
const buffer = Buffer.alloc(stats.size - position);
|
|
371
|
+
fs2.readSync(fd, buffer, 0, buffer.length, position);
|
|
372
|
+
let content = buffer.toString();
|
|
373
|
+
content = lastPartial + content;
|
|
374
|
+
lastPartial = "";
|
|
375
|
+
const lineArray = content.split(/\r?\n/);
|
|
376
|
+
if (!content.endsWith(`
|
|
377
|
+
`)) {
|
|
378
|
+
lastPartial = lineArray.pop() || "";
|
|
379
|
+
}
|
|
380
|
+
lineArray.forEach((line) => {
|
|
381
|
+
if (line) {
|
|
382
|
+
console.log(colorFn(prefix + line));
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
position = stats.size;
|
|
386
|
+
} catch (e) {}
|
|
387
|
+
};
|
|
388
|
+
const watcher = fs2.watch(path, { persistent: true }, (event) => {
|
|
389
|
+
if (event === "change") {
|
|
390
|
+
printNewContent();
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
printNewContent();
|
|
394
|
+
return () => {
|
|
395
|
+
watcher.close();
|
|
396
|
+
try {
|
|
397
|
+
fs2.closeSync(fd);
|
|
398
|
+
} catch {}
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
var init_utils = __esm(() => {
|
|
402
|
+
init_platform();
|
|
403
|
+
});
|
|
404
|
+
|
|
327
405
|
// src/db.ts
|
|
328
406
|
var exports_db = {};
|
|
329
407
|
__export(exports_db, {
|
|
@@ -439,111 +517,6 @@ var init_db = __esm(() => {
|
|
|
439
517
|
});
|
|
440
518
|
});
|
|
441
519
|
|
|
442
|
-
// src/server.ts
|
|
443
|
-
var exports_server = {};
|
|
444
|
-
__export(exports_server, {
|
|
445
|
-
startServer: () => startServer
|
|
446
|
-
});
|
|
447
|
-
import path2 from "path";
|
|
448
|
-
async function startServer() {
|
|
449
|
-
const { start } = await import("melina");
|
|
450
|
-
const appDir = path2.join(import.meta.dir, "../dashboard/app");
|
|
451
|
-
const explicitPort = process.env.BUN_PORT ? parseInt(process.env.BUN_PORT, 10) : undefined;
|
|
452
|
-
await start({
|
|
453
|
-
appDir,
|
|
454
|
-
defaultTitle: "bgrun Dashboard - Process Manager",
|
|
455
|
-
globalCss: path2.join(appDir, "globals.css"),
|
|
456
|
-
...explicitPort !== undefined && { port: explicitPort }
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
var init_server = () => {};
|
|
460
|
-
|
|
461
|
-
// src/index.ts
|
|
462
|
-
import { parseArgs } from "util";
|
|
463
|
-
|
|
464
|
-
// src/utils.ts
|
|
465
|
-
init_platform();
|
|
466
|
-
import * as fs2 from "fs";
|
|
467
|
-
import chalk from "chalk";
|
|
468
|
-
function parseEnvString(envString) {
|
|
469
|
-
const env = {};
|
|
470
|
-
envString.split(",").forEach((pair) => {
|
|
471
|
-
const [key, value] = pair.split("=");
|
|
472
|
-
if (key && value)
|
|
473
|
-
env[key] = value;
|
|
474
|
-
});
|
|
475
|
-
return env;
|
|
476
|
-
}
|
|
477
|
-
function calculateRuntime(startTime) {
|
|
478
|
-
const start = new Date(startTime).getTime();
|
|
479
|
-
const now = new Date().getTime();
|
|
480
|
-
const diffInMinutes = Math.floor((now - start) / (1000 * 60));
|
|
481
|
-
return `${diffInMinutes} minutes`;
|
|
482
|
-
}
|
|
483
|
-
async function getVersion() {
|
|
484
|
-
try {
|
|
485
|
-
const { join } = await import("path");
|
|
486
|
-
const pkgPath = join(import.meta.dir, "../package.json");
|
|
487
|
-
const pkg = await Bun.file(pkgPath).json();
|
|
488
|
-
return pkg.version || "0.0.0";
|
|
489
|
-
} catch {
|
|
490
|
-
return "0.0.0";
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
function validateDirectory(directory) {
|
|
494
|
-
if (!directory || !fs2.existsSync(directory)) {
|
|
495
|
-
console.log(chalk.red("\u274C Error: 'directory' must be a valid path."));
|
|
496
|
-
process.exit(1);
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
function tailFile(path, prefix, colorFn, lines) {
|
|
500
|
-
let position = 0;
|
|
501
|
-
let lastPartial = "";
|
|
502
|
-
if (!fs2.existsSync(path)) {
|
|
503
|
-
return () => {};
|
|
504
|
-
}
|
|
505
|
-
const fd = fs2.openSync(path, "r");
|
|
506
|
-
const printNewContent = () => {
|
|
507
|
-
try {
|
|
508
|
-
const stats = fs2.statSync(path);
|
|
509
|
-
if (stats.size <= position)
|
|
510
|
-
return;
|
|
511
|
-
const buffer = Buffer.alloc(stats.size - position);
|
|
512
|
-
fs2.readSync(fd, buffer, 0, buffer.length, position);
|
|
513
|
-
let content = buffer.toString();
|
|
514
|
-
content = lastPartial + content;
|
|
515
|
-
lastPartial = "";
|
|
516
|
-
const lineArray = content.split(/\r?\n/);
|
|
517
|
-
if (!content.endsWith(`
|
|
518
|
-
`)) {
|
|
519
|
-
lastPartial = lineArray.pop() || "";
|
|
520
|
-
}
|
|
521
|
-
lineArray.forEach((line) => {
|
|
522
|
-
if (line) {
|
|
523
|
-
console.log(colorFn(prefix + line));
|
|
524
|
-
}
|
|
525
|
-
});
|
|
526
|
-
position = stats.size;
|
|
527
|
-
} catch (e) {}
|
|
528
|
-
};
|
|
529
|
-
const watcher = fs2.watch(path, { persistent: true }, (event) => {
|
|
530
|
-
if (event === "change") {
|
|
531
|
-
printNewContent();
|
|
532
|
-
}
|
|
533
|
-
});
|
|
534
|
-
printNewContent();
|
|
535
|
-
return () => {
|
|
536
|
-
watcher.close();
|
|
537
|
-
try {
|
|
538
|
-
fs2.closeSync(fd);
|
|
539
|
-
} catch {}
|
|
540
|
-
};
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// src/commands/run.ts
|
|
544
|
-
init_db();
|
|
545
|
-
init_platform();
|
|
546
|
-
|
|
547
520
|
// src/logger.ts
|
|
548
521
|
import boxen from "boxen";
|
|
549
522
|
import chalk2 from "chalk";
|
|
@@ -568,6 +541,7 @@ function error(message) {
|
|
|
568
541
|
}));
|
|
569
542
|
process.exit(1);
|
|
570
543
|
}
|
|
544
|
+
var init_logger = () => {};
|
|
571
545
|
|
|
572
546
|
// src/config.ts
|
|
573
547
|
function formatEnvKey(key) {
|
|
@@ -605,8 +579,6 @@ var {$: $2 } = globalThis.Bun;
|
|
|
605
579
|
var {sleep: sleep2 } = globalThis.Bun;
|
|
606
580
|
import { join as join2 } from "path";
|
|
607
581
|
import { createMeasure as createMeasure2 } from "measure-fn";
|
|
608
|
-
var homePath2 = getHomeDir();
|
|
609
|
-
var run = createMeasure2("run");
|
|
610
582
|
async function handleRun(options) {
|
|
611
583
|
const { command, directory, env, name, configPath, force, fetch, stdout, stderr } = options;
|
|
612
584
|
const existingProcess = name ? getProcess(name) : null;
|
|
@@ -660,6 +632,22 @@ async function handleRun(options) {
|
|
|
660
632
|
}
|
|
661
633
|
});
|
|
662
634
|
}
|
|
635
|
+
const cmdToMatch = existingProcess.command;
|
|
636
|
+
if (cmdToMatch) {
|
|
637
|
+
await run.measure("Zombie sweep", async () => {
|
|
638
|
+
try {
|
|
639
|
+
const result = await $2`powershell -Command "Get-Process -Name bun -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match '${cmdToMatch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").split(" ")[1] || cmdToMatch}' } | Select-Object -ExpandProperty Id"`.nothrow().text();
|
|
640
|
+
const zombiePids = result.split(`
|
|
641
|
+
`).map((l) => parseInt(l.trim())).filter((n) => !isNaN(n) && n > 0);
|
|
642
|
+
for (const zPid of zombiePids) {
|
|
643
|
+
await $2`taskkill /F /T /PID ${zPid}`.nothrow().quiet();
|
|
644
|
+
}
|
|
645
|
+
if (zombiePids.length > 0) {
|
|
646
|
+
announce(`\uD83E\uDDF9 Swept ${zombiePids.length} zombie process(es)`, "Zombie Cleanup");
|
|
647
|
+
}
|
|
648
|
+
} catch {}
|
|
649
|
+
});
|
|
650
|
+
}
|
|
663
651
|
await retryDatabaseOperation(() => removeProcessByName(name));
|
|
664
652
|
} else {
|
|
665
653
|
if (!directory || !name || !command) {
|
|
@@ -727,6 +715,77 @@ async function handleRun(options) {
|
|
|
727
715
|
}));
|
|
728
716
|
announce(`${existingProcess ? "\uD83D\uDD04 Restarted" : "\uD83D\uDE80 Launched"} process "${name}" with PID ${actualPid}`, "Process Started");
|
|
729
717
|
}
|
|
718
|
+
var homePath2, run;
|
|
719
|
+
var init_run = __esm(() => {
|
|
720
|
+
init_db();
|
|
721
|
+
init_platform();
|
|
722
|
+
init_logger();
|
|
723
|
+
init_utils();
|
|
724
|
+
homePath2 = getHomeDir();
|
|
725
|
+
run = createMeasure2("run");
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
// src/server.ts
|
|
729
|
+
var exports_server = {};
|
|
730
|
+
__export(exports_server, {
|
|
731
|
+
startServer: () => startServer
|
|
732
|
+
});
|
|
733
|
+
import path2 from "path";
|
|
734
|
+
async function startServer() {
|
|
735
|
+
const { start } = await import("melina");
|
|
736
|
+
const appDir = path2.join(import.meta.dir, "../dashboard/app");
|
|
737
|
+
const explicitPort = process.env.BUN_PORT ? parseInt(process.env.BUN_PORT, 10) : undefined;
|
|
738
|
+
await start({
|
|
739
|
+
appDir,
|
|
740
|
+
defaultTitle: "bgrun Dashboard - Process Manager",
|
|
741
|
+
globalCss: path2.join(appDir, "globals.css"),
|
|
742
|
+
...explicitPort !== undefined && { port: explicitPort }
|
|
743
|
+
});
|
|
744
|
+
startGuard();
|
|
745
|
+
}
|
|
746
|
+
function startGuard() {
|
|
747
|
+
console.log(`[guard] \u2713 Built-in process guard started (checking every ${GUARD_INTERVAL_MS / 1000}s)`);
|
|
748
|
+
setInterval(async () => {
|
|
749
|
+
try {
|
|
750
|
+
const processes = getAllProcesses();
|
|
751
|
+
if (processes.length === 0)
|
|
752
|
+
return;
|
|
753
|
+
for (const proc of processes) {
|
|
754
|
+
if (GUARD_SKIP_NAMES.has(proc.name))
|
|
755
|
+
continue;
|
|
756
|
+
const alive = await isProcessRunning(proc.pid, proc.command);
|
|
757
|
+
if (!alive) {
|
|
758
|
+
console.log(`[guard] \u26A0 Process "${proc.name}" (PID ${proc.pid}) is dead, restarting...`);
|
|
759
|
+
try {
|
|
760
|
+
await handleRun({
|
|
761
|
+
action: "run",
|
|
762
|
+
name: proc.name,
|
|
763
|
+
force: true,
|
|
764
|
+
remoteName: ""
|
|
765
|
+
});
|
|
766
|
+
console.log(`[guard] \u2713 Restarted "${proc.name}"`);
|
|
767
|
+
} catch (err) {
|
|
768
|
+
console.error(`[guard] \u2717 Failed to restart "${proc.name}": ${err.message}`);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
} catch (err) {
|
|
773
|
+
console.error(`[guard] Error in guard loop: ${err.message}`);
|
|
774
|
+
}
|
|
775
|
+
}, GUARD_INTERVAL_MS);
|
|
776
|
+
}
|
|
777
|
+
var GUARD_INTERVAL_MS = 30000, GUARD_SKIP_NAMES;
|
|
778
|
+
var init_server = __esm(() => {
|
|
779
|
+
init_db();
|
|
780
|
+
init_platform();
|
|
781
|
+
init_run();
|
|
782
|
+
GUARD_SKIP_NAMES = new Set(["bgr-dashboard"]);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
// src/index.ts
|
|
786
|
+
init_utils();
|
|
787
|
+
init_run();
|
|
788
|
+
import { parseArgs } from "util";
|
|
730
789
|
|
|
731
790
|
// src/commands/list.ts
|
|
732
791
|
import chalk4 from "chalk";
|
|
@@ -896,6 +955,8 @@ function renderProcessTable(processes, options) {
|
|
|
896
955
|
|
|
897
956
|
// src/commands/list.ts
|
|
898
957
|
init_db();
|
|
958
|
+
init_logger();
|
|
959
|
+
init_utils();
|
|
899
960
|
init_platform();
|
|
900
961
|
function formatMemory(bytes) {
|
|
901
962
|
if (bytes === 0)
|
|
@@ -972,6 +1033,7 @@ async function showAll(opts) {
|
|
|
972
1033
|
// src/commands/cleanup.ts
|
|
973
1034
|
init_db();
|
|
974
1035
|
init_platform();
|
|
1036
|
+
init_logger();
|
|
975
1037
|
import * as fs3 from "fs";
|
|
976
1038
|
async function handleDelete(name) {
|
|
977
1039
|
const process2 = getProcess(name);
|
|
@@ -1041,6 +1103,7 @@ async function handleStop(name) {
|
|
|
1041
1103
|
for (const port of ports) {
|
|
1042
1104
|
await killProcessOnPort(port);
|
|
1043
1105
|
}
|
|
1106
|
+
updateProcessPid(name, 0);
|
|
1044
1107
|
announce(`Process '${name}' has been stopped (kept in registry).`, "Process Stopped");
|
|
1045
1108
|
}
|
|
1046
1109
|
async function handleDeleteAll() {
|
|
@@ -1090,6 +1153,9 @@ async function handleDeleteAll() {
|
|
|
1090
1153
|
// src/commands/watch.ts
|
|
1091
1154
|
init_db();
|
|
1092
1155
|
init_platform();
|
|
1156
|
+
init_logger();
|
|
1157
|
+
init_utils();
|
|
1158
|
+
init_run();
|
|
1093
1159
|
import * as fs4 from "fs";
|
|
1094
1160
|
import path from "path";
|
|
1095
1161
|
import chalk5 from "chalk";
|
|
@@ -1279,6 +1345,7 @@ SIGINT received...`));
|
|
|
1279
1345
|
|
|
1280
1346
|
// src/commands/logs.ts
|
|
1281
1347
|
init_db();
|
|
1348
|
+
init_logger();
|
|
1282
1349
|
init_platform();
|
|
1283
1350
|
import chalk6 from "chalk";
|
|
1284
1351
|
import * as fs5 from "fs";
|
|
@@ -1323,7 +1390,9 @@ async function showLogs(name, logType = "both", lines) {
|
|
|
1323
1390
|
}
|
|
1324
1391
|
|
|
1325
1392
|
// src/commands/details.ts
|
|
1393
|
+
init_logger();
|
|
1326
1394
|
init_db();
|
|
1395
|
+
init_utils();
|
|
1327
1396
|
init_platform();
|
|
1328
1397
|
import chalk7 from "chalk";
|
|
1329
1398
|
async function showDetails(name) {
|
|
@@ -1360,6 +1429,7 @@ ${Object.entries(envVars).map(([key, value]) => `${chalk7.cyan.bold(key)} = ${ch
|
|
|
1360
1429
|
}
|
|
1361
1430
|
|
|
1362
1431
|
// src/index.ts
|
|
1432
|
+
init_logger();
|
|
1363
1433
|
init_platform();
|
|
1364
1434
|
init_db();
|
|
1365
1435
|
import dedent from "dedent";
|
package/package.json
CHANGED
package/src/commands/cleanup.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import { getProcess, removeProcessByName, removeProcess, getAllProcesses, removeAllProcesses } from "../db";
|
|
2
|
+
import { getProcess, removeProcessByName, removeProcess, getAllProcesses, removeAllProcesses, updateProcessPid } from "../db";
|
|
3
3
|
import { isProcessRunning, terminateProcess, getProcessPorts, killProcessOnPort, waitForPortFree } from "../platform";
|
|
4
4
|
import { announce, error } from "../logger";
|
|
5
5
|
import * as fs from "fs";
|
|
@@ -82,6 +82,10 @@ export async function handleStop(name: string) {
|
|
|
82
82
|
await killProcessOnPort(port);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// Mark PID as 0 — prevents reconcileProcessPids from re-attaching
|
|
86
|
+
// a random matching process as this one
|
|
87
|
+
updateProcessPid(name, 0);
|
|
88
|
+
|
|
85
89
|
announce(`Process '${name}' has been stopped (kept in registry).`, "Process Stopped");
|
|
86
90
|
}
|
|
87
91
|
|
package/src/commands/run.ts
CHANGED
|
@@ -76,6 +76,24 @@ export async function handleRun(options: CommandOptions) {
|
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// Zombie sweep: kill any remaining bun processes matching this command
|
|
80
|
+
// This catches orphaned children that survived taskkill when the parent shell exited
|
|
81
|
+
const cmdToMatch = existingProcess.command;
|
|
82
|
+
if (cmdToMatch) {
|
|
83
|
+
await run.measure('Zombie sweep', async () => {
|
|
84
|
+
try {
|
|
85
|
+
const result = await $`powershell -Command "Get-Process -Name bun -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match '${cmdToMatch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').split(' ')[1] || cmdToMatch}' } | Select-Object -ExpandProperty Id"`.nothrow().text();
|
|
86
|
+
const zombiePids = result.split('\n').map((l: string) => parseInt(l.trim())).filter((n: number) => !isNaN(n) && n > 0);
|
|
87
|
+
for (const zPid of zombiePids) {
|
|
88
|
+
await $`taskkill /F /T /PID ${zPid}`.nothrow().quiet();
|
|
89
|
+
}
|
|
90
|
+
if (zombiePids.length > 0) {
|
|
91
|
+
announce(`🧹 Swept ${zombiePids.length} zombie process(es)`, 'Zombie Cleanup');
|
|
92
|
+
}
|
|
93
|
+
} catch { /* best effort */ }
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
79
97
|
await retryDatabaseOperation(() =>
|
|
80
98
|
removeProcessByName(name!)
|
|
81
99
|
);
|
package/src/platform.ts
CHANGED
|
@@ -28,6 +28,9 @@ export function getHomeDir(): string {
|
|
|
28
28
|
* For Docker containers, checks container status instead of PID
|
|
29
29
|
*/
|
|
30
30
|
export async function isProcessRunning(pid: number, command?: string): Promise<boolean> {
|
|
31
|
+
// PID 0 means intentionally stopped — never alive
|
|
32
|
+
if (pid <= 0) return false;
|
|
33
|
+
|
|
31
34
|
return plat.measure(`PID ${pid} alive?`, async () => {
|
|
32
35
|
try {
|
|
33
36
|
// Docker container detection
|
|
@@ -109,51 +112,38 @@ async function getChildPids(pid: number): Promise<number[]> {
|
|
|
109
112
|
*/
|
|
110
113
|
export async function terminateProcess(pid: number, force: boolean = false): Promise<void> {
|
|
111
114
|
await plat.measure(`Terminate PID ${pid}`, async (m) => {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
await $`kill -${signal} ${childPid}`.nothrow();
|
|
115
|
+
try {
|
|
116
|
+
if (isWindows()) {
|
|
117
|
+
// Always use /T (tree kill) on Windows to kill the entire process tree
|
|
118
|
+
// This prevents grandchild processes from surviving as zombies
|
|
119
|
+
await $`taskkill /F /T /PID ${pid}`.nothrow().quiet();
|
|
120
|
+
} else {
|
|
121
|
+
// On Unix, kill children first, then parent
|
|
122
|
+
const children = await m('Get children', () => getChildPids(pid)) ?? [];
|
|
123
|
+
const signal = force ? 'KILL' : 'TERM';
|
|
124
|
+
for (const childPid of children) {
|
|
125
|
+
try {
|
|
126
|
+
await $`kill -${signal} ${childPid}`.nothrow();
|
|
127
|
+
} catch { /* already dead */ }
|
|
126
128
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Wait a bit for graceful shutdown
|
|
133
|
-
await Bun.sleep(500);
|
|
134
|
-
|
|
135
|
-
// Then kill the parent if still running
|
|
136
|
-
if (await isProcessRunning(pid)) {
|
|
137
|
-
try {
|
|
138
|
-
if (isWindows()) {
|
|
139
|
-
if (force) {
|
|
140
|
-
await $`taskkill /F /PID ${pid}`.nothrow().quiet();
|
|
141
|
-
} else {
|
|
142
|
-
await $`taskkill /PID ${pid}`.nothrow().quiet();
|
|
143
|
-
}
|
|
144
|
-
} else {
|
|
145
|
-
const signal = force ? 'KILL' : 'TERM';
|
|
129
|
+
await Bun.sleep(500);
|
|
130
|
+
if (await isProcessRunning(pid)) {
|
|
146
131
|
await $`kill -${signal} ${pid}`.nothrow();
|
|
147
132
|
}
|
|
148
|
-
} catch {
|
|
149
|
-
// Ignore errors
|
|
150
133
|
}
|
|
134
|
+
} catch {
|
|
135
|
+
// Ignore errors for already-dead processes
|
|
151
136
|
}
|
|
137
|
+
|
|
138
|
+
// Wait for process to fully exit
|
|
139
|
+
await Bun.sleep(300);
|
|
152
140
|
});
|
|
153
141
|
}
|
|
154
142
|
|
|
155
143
|
/**
|
|
156
144
|
* Check if a port is free by attempting to bind to it.
|
|
145
|
+
* On Windows, also checks whether the process holding the port is actually alive
|
|
146
|
+
* (zombie sockets from dead processes don't block new binds on 0.0.0.0).
|
|
157
147
|
*/
|
|
158
148
|
export async function isPortFree(port: number): Promise<boolean> {
|
|
159
149
|
try {
|
|
@@ -162,14 +152,22 @@ export async function isPortFree(port: number): Promise<boolean> {
|
|
|
162
152
|
const result = await $`netstat -ano | findstr :${port}`.nothrow().quiet().text();
|
|
163
153
|
for (const line of result.split('\n')) {
|
|
164
154
|
// Only match exact port (avoid :35560 matching :3556)
|
|
165
|
-
const match = line.match(new RegExp(`:(${port})\\s+.*LISTENING`));
|
|
166
|
-
if (match)
|
|
155
|
+
const match = line.match(new RegExp(`:(${port})\\s+.*LISTENING\\s+(\\d+)`));
|
|
156
|
+
if (match) {
|
|
157
|
+
const pid = parseInt(match[2]);
|
|
158
|
+
// If the PID behind the socket is dead, it's a zombie socket
|
|
159
|
+
// A new process can still bind to the port on 0.0.0.0
|
|
160
|
+
if (pid > 0 && await isProcessRunning(pid)) {
|
|
161
|
+
return false; // Real process holding the port
|
|
162
|
+
}
|
|
163
|
+
// else: zombie socket — consider port free
|
|
164
|
+
}
|
|
167
165
|
}
|
|
168
166
|
return true;
|
|
169
167
|
} else {
|
|
170
168
|
const result = await $`ss -tln sport = :${port}`.nothrow().quiet().text();
|
|
171
169
|
// If output has more than the header line, port is in use
|
|
172
|
-
const lines = result.trim().split('\n').filter(l => l.trim());
|
|
170
|
+
const lines = result.trim().split('\n').filter((l: string) => l.trim());
|
|
173
171
|
return lines.length <= 1;
|
|
174
172
|
}
|
|
175
173
|
} catch {
|
|
@@ -199,6 +197,8 @@ export async function waitForPortFree(port: number, timeoutMs: number = 5000): P
|
|
|
199
197
|
/**
|
|
200
198
|
* Kill processes using a specific port.
|
|
201
199
|
* Force-kills all processes bound to the port and verifies they're gone.
|
|
200
|
+
* On Windows, filters out zombie PIDs (sockets orphaned by dead processes)
|
|
201
|
+
* since taskkill can't kill those — they require a reboot or TCP stack reset.
|
|
202
202
|
*/
|
|
203
203
|
export async function killProcessOnPort(port: number): Promise<void> {
|
|
204
204
|
try {
|
|
@@ -218,9 +218,14 @@ export async function killProcessOnPort(port: number): Promise<void> {
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
for (const pid of pids) {
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
// Check if the process actually exists before trying to kill it
|
|
222
|
+
// This avoids the zombie PID issue where sockets linger after process death
|
|
223
|
+
const alive = await isProcessRunning(pid);
|
|
224
|
+
if (alive) {
|
|
225
|
+
await $`taskkill /F /T /PID ${pid}`.nothrow().quiet();
|
|
226
|
+
console.log(`Killed process ${pid} using port ${port}`);
|
|
227
|
+
}
|
|
228
|
+
// else: zombie socket — PID no longer exists but socket lingers in kernel
|
|
224
229
|
}
|
|
225
230
|
} else {
|
|
226
231
|
// On Unix, use lsof
|
|
@@ -318,7 +323,9 @@ export async function reconcileProcessPids(
|
|
|
318
323
|
): Promise<Map<string, number>> {
|
|
319
324
|
return await plat.measure('Reconcile PIDs', async () => {
|
|
320
325
|
const result = new Map<string, number>();
|
|
321
|
-
|
|
326
|
+
// Skip processes with PID=0 — these were intentionally stopped
|
|
327
|
+
// and should NOT be reconciled to avoid hijacking unrelated processes
|
|
328
|
+
const needsReconciliation = processes.filter(p => deadPids.has(p.pid) && p.pid > 0);
|
|
322
329
|
if (needsReconciliation.length === 0) return result;
|
|
323
330
|
|
|
324
331
|
try {
|
package/src/server.ts
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* BGR Dashboard Server
|
|
2
|
+
* BGR Dashboard Server + Built-in Process Guard
|
|
3
3
|
*
|
|
4
4
|
* Uses Melina.js to serve the dashboard app with file-based routing.
|
|
5
5
|
* All API endpoints and page rendering are handled by the dashboard/app/ directory.
|
|
6
6
|
*
|
|
7
|
+
* v3.0: Built-in guard loop — the dashboard now monitors ALL registered
|
|
8
|
+
* processes and auto-restarts any that die. This eliminates the need for
|
|
9
|
+
* external guard scripts. The dashboard itself survives because bgrun
|
|
10
|
+
* registers it as a managed process on launch.
|
|
11
|
+
*
|
|
7
12
|
* Port selection is handled entirely by Melina:
|
|
8
13
|
* - If BUN_PORT env var is set → uses that (explicit, will fail if busy)
|
|
9
14
|
* - Otherwise → defaults to 3000, falls back to next available if busy
|
|
10
15
|
*/
|
|
11
16
|
import path from 'path';
|
|
17
|
+
import { getAllProcesses, getProcess } from './db';
|
|
18
|
+
import { isProcessRunning } from './platform';
|
|
19
|
+
import { handleRun } from './commands/run';
|
|
20
|
+
|
|
21
|
+
const GUARD_INTERVAL_MS = 30_000; // Check every 30 seconds
|
|
22
|
+
const GUARD_SKIP_NAMES = new Set(['bgr-dashboard']); // Don't try to restart ourselves
|
|
12
23
|
|
|
13
24
|
export async function startServer() {
|
|
14
25
|
// Dynamic import to avoid melina's side-effect console.log at bundle load time
|
|
@@ -24,4 +35,55 @@ export async function startServer() {
|
|
|
24
35
|
globalCss: path.join(appDir, 'globals.css'),
|
|
25
36
|
...(explicitPort !== undefined && { port: explicitPort }),
|
|
26
37
|
});
|
|
38
|
+
|
|
39
|
+
// Start the built-in process guard
|
|
40
|
+
startGuard();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Built-in Process Guard
|
|
45
|
+
*
|
|
46
|
+
* Runs as a background loop inside the dashboard process.
|
|
47
|
+
* Every GUARD_INTERVAL_MS, it checks all registered bgrun processes.
|
|
48
|
+
* If any process is dead (PID not running), it auto-restarts it using
|
|
49
|
+
* handleRun with force=true (same as `bgrun --restart <name>`).
|
|
50
|
+
*
|
|
51
|
+
* Why here and not as a separate process?
|
|
52
|
+
* - No external dependency — the dashboard IS the guardian
|
|
53
|
+
* - If the dashboard is running, all processes are monitored
|
|
54
|
+
* - If the dashboard dies, `bgrun --dashboard` will restart everything
|
|
55
|
+
* - Zero configuration — just works
|
|
56
|
+
*/
|
|
57
|
+
function startGuard() {
|
|
58
|
+
console.log(`[guard] ✓ Built-in process guard started (checking every ${GUARD_INTERVAL_MS / 1000}s)`);
|
|
59
|
+
|
|
60
|
+
setInterval(async () => {
|
|
61
|
+
try {
|
|
62
|
+
const processes = getAllProcesses();
|
|
63
|
+
if (processes.length === 0) return;
|
|
64
|
+
|
|
65
|
+
for (const proc of processes) {
|
|
66
|
+
// Skip the dashboard itself
|
|
67
|
+
if (GUARD_SKIP_NAMES.has(proc.name)) continue;
|
|
68
|
+
|
|
69
|
+
const alive = await isProcessRunning(proc.pid, proc.command);
|
|
70
|
+
if (!alive) {
|
|
71
|
+
console.log(`[guard] ⚠ Process "${proc.name}" (PID ${proc.pid}) is dead, restarting...`);
|
|
72
|
+
try {
|
|
73
|
+
await handleRun({
|
|
74
|
+
action: 'run',
|
|
75
|
+
name: proc.name,
|
|
76
|
+
force: true,
|
|
77
|
+
remoteName: '',
|
|
78
|
+
});
|
|
79
|
+
console.log(`[guard] ✓ Restarted "${proc.name}"`);
|
|
80
|
+
} catch (err: any) {
|
|
81
|
+
console.error(`[guard] ✗ Failed to restart "${proc.name}": ${err.message}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch (err: any) {
|
|
86
|
+
console.error(`[guard] Error in guard loop: ${err.message}`);
|
|
87
|
+
}
|
|
88
|
+
}, GUARD_INTERVAL_MS);
|
|
27
89
|
}
|