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/guard.ts
CHANGED
|
@@ -18,6 +18,11 @@ import { getAllProcesses, getProcess } from './db';
|
|
|
18
18
|
import { isProcessRunning, getProcessPorts, findChildPid } from './platform';
|
|
19
19
|
import { handleRun } from './commands/run';
|
|
20
20
|
import { parseEnvString } from './utils';
|
|
21
|
+
import { createHmac } from 'crypto';
|
|
22
|
+
|
|
23
|
+
// Webhook configuration via environment variables
|
|
24
|
+
const WEBHOOK_URL = process.env.BGR_WEBHOOK_URL || '';
|
|
25
|
+
const WEBHOOK_SECRET = process.env.BGR_WEBHOOK_SECRET || '';
|
|
21
26
|
|
|
22
27
|
const DEFAULT_INTERVAL_MS = 30_000;
|
|
23
28
|
const MAX_BACKOFF_MS = 5 * 60_000; // 5 minutes max
|
|
@@ -36,6 +41,42 @@ const state: GuardState = {
|
|
|
36
41
|
lastSeenAlive: new Map(),
|
|
37
42
|
};
|
|
38
43
|
|
|
44
|
+
async function notifyWebhook(event: 'crash' | 'restart' | 'restart_failed', name: string, details: Record<string, any>) {
|
|
45
|
+
if (!WEBHOOK_URL) return;
|
|
46
|
+
try {
|
|
47
|
+
const payload = JSON.stringify({
|
|
48
|
+
event,
|
|
49
|
+
process: name,
|
|
50
|
+
timestamp: new Date().toISOString(),
|
|
51
|
+
...details,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const headers: Record<string, string> = {
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
'User-Agent': 'bgrun-guard/1.0',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// HMAC signature if secret is configured
|
|
60
|
+
if (WEBHOOK_SECRET) {
|
|
61
|
+
const sig = createHmac('sha256', WEBHOOK_SECRET).update(payload).digest('hex');
|
|
62
|
+
headers['X-BGR-Signature'] = `sha256=${sig}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Fire and forget with timeout
|
|
66
|
+
const controller = new AbortController();
|
|
67
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
68
|
+
await fetch(WEBHOOK_URL, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers,
|
|
71
|
+
body: payload,
|
|
72
|
+
signal: controller.signal,
|
|
73
|
+
});
|
|
74
|
+
clearTimeout(timeout);
|
|
75
|
+
} catch (err: any) {
|
|
76
|
+
console.error(`[guard] Webhook failed: ${err.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
39
80
|
async function restartProcess(name: string): Promise<boolean> {
|
|
40
81
|
try {
|
|
41
82
|
await handleRun({
|
|
@@ -94,6 +135,9 @@ async function guardCycle(): Promise<void> {
|
|
|
94
135
|
|
|
95
136
|
console.log(`[guard] ⚠ "${proc.name}" (PID ${proc.pid}) is dead — restarting...`);
|
|
96
137
|
|
|
138
|
+
// Notify crash detected
|
|
139
|
+
notifyWebhook('crash', proc.name, { pid: proc.pid, isDashboard });
|
|
140
|
+
|
|
97
141
|
const success = await restartProcess(proc.name);
|
|
98
142
|
if (success) {
|
|
99
143
|
const count = (state.restartCounts.get(proc.name) || 0) + 1;
|
|
@@ -108,6 +152,12 @@ async function guardCycle(): Promise<void> {
|
|
|
108
152
|
console.log(`[guard] ✓ Restarted "${proc.name}" (#${count})`);
|
|
109
153
|
}
|
|
110
154
|
restarted++;
|
|
155
|
+
|
|
156
|
+
// Notify restart success
|
|
157
|
+
notifyWebhook('restart', proc.name, { pid: proc.pid, restartCount: count, backoffMs: backoff });
|
|
158
|
+
} else {
|
|
159
|
+
// Notify restart failed
|
|
160
|
+
notifyWebhook('restart_failed', proc.name, { pid: proc.pid });
|
|
111
161
|
}
|
|
112
162
|
} else if (alive) {
|
|
113
163
|
// Track stability — if alive for STABILITY_WINDOW, reset counters
|
|
@@ -146,6 +196,7 @@ export async function startGuardLoop(intervalMs: number = DEFAULT_INTERVAL_MS) {
|
|
|
146
196
|
console.log(`[guard] Crash backoff threshold: ${CRASH_THRESHOLD} restarts`);
|
|
147
197
|
console.log(`[guard] Stability window: ${STABILITY_WINDOW_MS / 1000}s`);
|
|
148
198
|
console.log(`[guard] Monitoring: BGR_KEEP_ALIVE=true + bgr-dashboard`);
|
|
199
|
+
console.log(`[guard] Webhook: ${WEBHOOK_URL || '(none — set BGR_WEBHOOK_URL to enable)'}`);
|
|
149
200
|
console.log(`[guard] Started: ${new Date().toLocaleString()}`);
|
|
150
201
|
console.log(`[guard] ═══════════════════════════════════════════`);
|
|
151
202
|
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { CommandOptions } from "./types";
|
|
|
12
12
|
import { error, announce } from "./logger";
|
|
13
13
|
// startServer is dynamically imported only when --_serve is used
|
|
14
14
|
// to avoid loading melina (which has side-effects) on every bgrun command
|
|
15
|
-
import { getHomeDir, getShellCommand, findChildPid, isProcessRunning, terminateProcess, getProcessPorts, killProcessOnPort, waitForPortFree, isPortFree } from "./platform";
|
|
15
|
+
import { getHomeDir, getShellCommand, findChildPid, isProcessRunning, terminateProcess, getProcessPorts, killProcessOnPort, waitForPortFree, isPortFree, findPidByPort } from "./platform";
|
|
16
16
|
import { insertProcess, removeProcessByName, getProcess, retryDatabaseOperation, getDbInfo } from "./db";
|
|
17
17
|
import dedent from "dedent";
|
|
18
18
|
import chalk from "chalk";
|
|
@@ -26,6 +26,48 @@ if (!Bun.argv.includes("--_serve")) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Redirect console.log/warn/error to log files when running detached.
|
|
31
|
+
* The parent spawner passes file paths via BGR_STDOUT/BGR_STDERR env vars.
|
|
32
|
+
* Appends timestamped lines so `bgrun <name> --logs` shows real output.
|
|
33
|
+
*/
|
|
34
|
+
function redirectConsoleToFiles() {
|
|
35
|
+
const stdoutPath = Bun.env.BGR_STDOUT;
|
|
36
|
+
const stderrPath = Bun.env.BGR_STDERR;
|
|
37
|
+
if (!stdoutPath && !stderrPath) return; // Not detached, keep normal console
|
|
38
|
+
|
|
39
|
+
const { appendFileSync } = require('fs');
|
|
40
|
+
|
|
41
|
+
// Strip ANSI escape codes for clean log files
|
|
42
|
+
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
43
|
+
|
|
44
|
+
const timestamp = () => new Date().toISOString().replace('T', ' ').substring(0, 19);
|
|
45
|
+
|
|
46
|
+
if (stdoutPath) {
|
|
47
|
+
const origLog = console.log;
|
|
48
|
+
const origWarn = console.warn;
|
|
49
|
+
console.log = (...args: any[]) => {
|
|
50
|
+
const line = `[${timestamp()}] ${stripAnsi(args.map(String).join(' '))}\n`;
|
|
51
|
+
try { appendFileSync(stdoutPath, line); } catch { }
|
|
52
|
+
origLog.apply(console, args); // Also keep original (goes to /dev/null when detached, but useful if attached)
|
|
53
|
+
};
|
|
54
|
+
console.warn = (...args: any[]) => {
|
|
55
|
+
const line = `[${timestamp()}] WARN: ${stripAnsi(args.map(String).join(' '))}\n`;
|
|
56
|
+
try { appendFileSync(stdoutPath, line); } catch { }
|
|
57
|
+
origWarn.apply(console, args);
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (stderrPath) {
|
|
62
|
+
const origError = console.error;
|
|
63
|
+
console.error = (...args: any[]) => {
|
|
64
|
+
const line = `[${timestamp()}] ERROR: ${stripAnsi(args.map(String).join(' '))}\n`;
|
|
65
|
+
try { appendFileSync(stderrPath, line); } catch { }
|
|
66
|
+
origError.apply(console, args);
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
29
71
|
async function showHelp() {
|
|
30
72
|
const usage = dedent`
|
|
31
73
|
${chalk.bold('bgrun — Bun Background Runner')}
|
|
@@ -120,6 +162,9 @@ async function run() {
|
|
|
120
162
|
// Port is NOT passed explicitly — Melina auto-detects from BUN_PORT env
|
|
121
163
|
// or defaults to 3000 with fallback to next available port.
|
|
122
164
|
if (values['_serve']) {
|
|
165
|
+
// Redirect console output to log files when running detached
|
|
166
|
+
// The spawner passes paths via BGR_STDOUT/BGR_STDERR env vars
|
|
167
|
+
redirectConsoleToFiles();
|
|
123
168
|
const { startServer } = await import("./server");
|
|
124
169
|
await startServer();
|
|
125
170
|
return;
|
|
@@ -127,6 +172,8 @@ async function run() {
|
|
|
127
172
|
|
|
128
173
|
// Internal: actually run the guard loop (spawned by --guard)
|
|
129
174
|
if (values['_guard-loop']) {
|
|
175
|
+
// Redirect console output to log files when running detached
|
|
176
|
+
redirectConsoleToFiles();
|
|
130
177
|
const { startGuardLoop } = await import("./guard");
|
|
131
178
|
const intervalStr = positionals[0];
|
|
132
179
|
const intervalMs = intervalStr ? parseInt(intervalStr) * 1000 : undefined;
|
|
@@ -193,10 +240,13 @@ async function run() {
|
|
|
193
240
|
await Bun.write(stderrPath, '');
|
|
194
241
|
|
|
195
242
|
// Pass BUN_PORT env var only if user explicitly requested a port
|
|
196
|
-
const spawnEnv = { ...Bun.env };
|
|
243
|
+
const spawnEnv: Record<string, string> = { ...Bun.env } as any;
|
|
197
244
|
if (requestedPort) {
|
|
198
245
|
spawnEnv.BUN_PORT = requestedPort;
|
|
199
246
|
}
|
|
247
|
+
// Pass log paths so the detached process can redirect its own console output
|
|
248
|
+
spawnEnv.BGR_STDOUT = stdoutPath;
|
|
249
|
+
spawnEnv.BGR_STDERR = stderrPath;
|
|
200
250
|
|
|
201
251
|
// Resolve the target port: --port flag > BUN_PORT env > default 3000
|
|
202
252
|
const targetPort = parseInt(requestedPort || Bun.env.BUN_PORT || '3000');
|
|
@@ -216,16 +266,18 @@ async function run() {
|
|
|
216
266
|
const newProcess = Bun.spawn(getShellCommand(spawnCommand), {
|
|
217
267
|
env: spawnEnv,
|
|
218
268
|
cwd: bgrDir,
|
|
219
|
-
stdout:
|
|
220
|
-
stderr:
|
|
221
|
-
|
|
269
|
+
stdout: "ignore",
|
|
270
|
+
stderr: "ignore",
|
|
271
|
+
detached: true, // Windows: new process group outside parent's Job Object — survives terminal close
|
|
272
|
+
} as any);
|
|
222
273
|
|
|
223
274
|
newProcess.unref();
|
|
224
275
|
|
|
225
|
-
//
|
|
226
|
-
//
|
|
276
|
+
// With detached: cmd.exe wrapper exits immediately, so findChildPid won't work.
|
|
277
|
+
// Instead, wait for the server to bind a port and find the PID from there.
|
|
227
278
|
await sleep(2000); // Give the server time to start and bind a port
|
|
228
|
-
const
|
|
279
|
+
const resolvedPort = parseInt(requestedPort || Bun.env.BUN_PORT || '3000');
|
|
280
|
+
const actualPid = await findPidByPort(resolvedPort, 10000) ?? await findChildPid(newProcess.pid);
|
|
229
281
|
|
|
230
282
|
// Detect the port the server actually bound to
|
|
231
283
|
let actualPort: number | null = null;
|
|
@@ -310,15 +362,27 @@ async function run() {
|
|
|
310
362
|
await Bun.write(stderrPath, '');
|
|
311
363
|
|
|
312
364
|
const newProcess = Bun.spawn(getShellCommand(spawnCommand), {
|
|
313
|
-
env: { ...Bun.env },
|
|
365
|
+
env: { ...Bun.env, BGR_STDOUT: stdoutPath, BGR_STDERR: stderrPath },
|
|
314
366
|
cwd: bgrDir,
|
|
315
|
-
stdout:
|
|
316
|
-
stderr:
|
|
317
|
-
|
|
367
|
+
stdout: "ignore",
|
|
368
|
+
stderr: "ignore",
|
|
369
|
+
detached: true, // Windows: new process group outside parent's Job Object — survives terminal close
|
|
370
|
+
} as any);
|
|
318
371
|
|
|
319
372
|
newProcess.unref();
|
|
320
373
|
await sleep(1000);
|
|
321
|
-
|
|
374
|
+
// With detached: cmd.exe exits immediately. Search for the guard by command line.
|
|
375
|
+
let actualPid = await findChildPid(newProcess.pid);
|
|
376
|
+
if (!(await isProcessRunning(actualPid))) {
|
|
377
|
+
// cmd.exe already died — search for the bun process running --_guard-loop
|
|
378
|
+
const { psExec: ps } = await import('./platform');
|
|
379
|
+
const result = ps(
|
|
380
|
+
`Get-CimInstance Win32_Process -Filter "Name='bun.exe'" | Where-Object { $_.CommandLine -match '_guard-loop' } | Sort-Object -Property CreationDate -Descending | Select-Object -First 1 -ExpandProperty ProcessId`,
|
|
381
|
+
3000
|
|
382
|
+
);
|
|
383
|
+
const foundPid = parseInt(result.trim());
|
|
384
|
+
if (!isNaN(foundPid) && foundPid > 0) actualPid = foundPid;
|
|
385
|
+
}
|
|
322
386
|
|
|
323
387
|
await retryDatabaseOperation(() =>
|
|
324
388
|
insertProcess({
|
|
@@ -541,5 +605,10 @@ async function run() {
|
|
|
541
605
|
}
|
|
542
606
|
|
|
543
607
|
run().catch(err => {
|
|
544
|
-
error(
|
|
608
|
+
// BgrunError was already printed by error() — just exit
|
|
609
|
+
// For unexpected errors, print and exit
|
|
610
|
+
if (err.name !== 'BgrunError') {
|
|
611
|
+
console.error(err);
|
|
612
|
+
}
|
|
613
|
+
process.exit(1);
|
|
545
614
|
});
|