bgrun 3.12.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/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 +384 -29
- package/dashboard/app/page.client.tsx +338 -1
- package/dashboard/app/page.tsx +98 -0
- package/dist/index.js +663 -184
- package/package.json +2 -2
- package/scripts/bgr-startup.ps1 +3 -3
- package/scripts/bgrun-startup.ps1 +91 -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/utils.ts +2 -2
package/package.json
CHANGED
package/scripts/bgr-startup.ps1
CHANGED
|
@@ -66,14 +66,14 @@ if ($Install) {
|
|
|
66
66
|
|
|
67
67
|
Register-ScheduledTask `
|
|
68
68
|
-TaskName "bgrun-guard" `
|
|
69
|
-
-Description "bgrun process manager
|
|
69
|
+
-Description "bgrun process manager - auto-starts all guarded processes on login" `
|
|
70
70
|
-Action $action `
|
|
71
71
|
-Trigger $trigger `
|
|
72
72
|
-Settings $settings `
|
|
73
73
|
-RunLevel Highest `
|
|
74
74
|
-Force
|
|
75
75
|
|
|
76
|
-
Write-Log "
|
|
76
|
+
Write-Log "[OK] Task 'bgrun-guard' registered. Will start on next login."
|
|
77
77
|
Write-Log " Script: $scriptPath"
|
|
78
78
|
exit 0
|
|
79
79
|
}
|
|
@@ -115,4 +115,4 @@ $guardProc = Start-Process -FilePath $BunPath `
|
|
|
115
115
|
-WorkingDirectory $BgrunPath
|
|
116
116
|
|
|
117
117
|
Write-Log "Guard PID: $($guardProc.Id)"
|
|
118
|
-
Write-Log "
|
|
118
|
+
Write-Log "[OK] bgrun startup complete. Dashboard: $($dashboardProc.Id), Guard: $($guardProc.Id)"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# bgrun-startup.ps1 — Persistent bgr-dashboard launcher
|
|
2
|
+
# Ensures the bgrun dashboard survives terminal closures.
|
|
3
|
+
# Individual services (geeksy, mm-dash, galaxy-canvas, etc.) are managed
|
|
4
|
+
# by bgr-guard INSIDE the dashboard — this script only keeps the dashboard alive.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# .\bgrun-startup.ps1 # Start bgr-dashboard
|
|
8
|
+
# .\bgrun-startup.ps1 -Install # Register as logon scheduled task
|
|
9
|
+
# .\bgrun-startup.ps1 -Uninstall # Remove scheduled task
|
|
10
|
+
# .\bgrun-startup.ps1 -Guard # Run guard loop (auto-restart dashboard)
|
|
11
|
+
|
|
12
|
+
param(
|
|
13
|
+
[switch]$Install,
|
|
14
|
+
[switch]$Uninstall,
|
|
15
|
+
[switch]$Guard
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
$TaskName = "bgrun-persistence"
|
|
19
|
+
$ScriptPath = $MyInvocation.MyCommand.Path
|
|
20
|
+
|
|
21
|
+
# ─── Install as Scheduled Task ────────────────────────────
|
|
22
|
+
if ($Install) {
|
|
23
|
+
$action = New-ScheduledTaskAction `
|
|
24
|
+
-Execute "pwsh.exe" `
|
|
25
|
+
-Argument "-WindowStyle Hidden -NonInteractive -File `"$ScriptPath`" -Guard"
|
|
26
|
+
|
|
27
|
+
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
|
|
28
|
+
$settings = New-ScheduledTaskSettingsSet `
|
|
29
|
+
-AllowStartIfOnBatteries `
|
|
30
|
+
-DontStopIfGoingOnBatteries `
|
|
31
|
+
-StartWhenAvailable `
|
|
32
|
+
-RestartInterval (New-TimeSpan -Minutes 1) `
|
|
33
|
+
-RestartCount 3 `
|
|
34
|
+
-ExecutionTimeLimit (New-TimeSpan -Hours 0) # No time limit
|
|
35
|
+
|
|
36
|
+
Register-ScheduledTask `
|
|
37
|
+
-TaskName $TaskName `
|
|
38
|
+
-Action $action `
|
|
39
|
+
-Trigger $trigger `
|
|
40
|
+
-Settings $settings `
|
|
41
|
+
-Description "Keeps bgr-dashboard alive across terminal closures. Dashboard's own bgr-guard handles individual services." `
|
|
42
|
+
-Force
|
|
43
|
+
|
|
44
|
+
Write-Host "✅ Scheduled task '$TaskName' registered for user $env:USERNAME"
|
|
45
|
+
Write-Host " Runs at logon — keeps bgr-dashboard alive"
|
|
46
|
+
Write-Host " To start immediately: schtasks /run /tn '$TaskName'"
|
|
47
|
+
exit 0
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# ─── Uninstall ────────────────────────────────────────────
|
|
51
|
+
if ($Uninstall) {
|
|
52
|
+
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
53
|
+
Write-Host "🗑️ Scheduled task '$TaskName' removed"
|
|
54
|
+
exit 0
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# ─── Guard mode: keep bgr-dashboard alive ─────────────────
|
|
58
|
+
if ($Guard) {
|
|
59
|
+
Write-Host "🛡️ bgrun dashboard guard started at $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
|
|
60
|
+
|
|
61
|
+
while ($true) {
|
|
62
|
+
try {
|
|
63
|
+
$dashboard = & bgrun --json 2>$null | ConvertFrom-Json | Where-Object { $_.name -eq 'bgr-dashboard' }
|
|
64
|
+
|
|
65
|
+
if (-not $dashboard -or $dashboard.status -ne "running") {
|
|
66
|
+
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] ⚠️ bgr-dashboard is DOWN — restarting..."
|
|
67
|
+
& bgrun --restart bgr-dashboard --force 2>$null
|
|
68
|
+
Start-Sleep -Seconds 3
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] ❌ Guard check failed: $_"
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
Start-Sleep -Seconds 60
|
|
75
|
+
}
|
|
76
|
+
exit 0
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# ─── Default: one-time start ──────────────────────────────
|
|
80
|
+
Write-Host "🚀 Starting bgr-dashboard..."
|
|
81
|
+
$dashboard = & bgrun --json 2>$null | ConvertFrom-Json | Where-Object { $_.name -eq 'bgr-dashboard' }
|
|
82
|
+
if ($dashboard -and $dashboard.status -eq "running") {
|
|
83
|
+
Write-Host " ✓ bgr-dashboard already running (PID $($dashboard.pid))"
|
|
84
|
+
} else {
|
|
85
|
+
& bgrun --restart bgr-dashboard --force 2>$null
|
|
86
|
+
Start-Sleep -Seconds 2
|
|
87
|
+
Write-Host " ✓ bgr-dashboard started"
|
|
88
|
+
}
|
|
89
|
+
Write-Host "`n✅ Done. Dashboard's bgr-guard handles individual services."
|
|
90
|
+
Write-Host " 💡 Run with -Install to persist across terminal closures"
|
|
91
|
+
|
package/src/commands/details.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
|
|
2
2
|
import { error, announce } from "../logger";
|
|
3
|
-
import { getProcess } from "../db";
|
|
3
|
+
import { getProcess, updateProcessPid } from "../db";
|
|
4
4
|
import { isProcessRunning, calculateRuntime, parseEnvString } from "../utils";
|
|
5
|
-
import { getProcessPorts } from "../platform";
|
|
5
|
+
import { getProcessPorts, reconcileProcessPids } from "../platform";
|
|
6
6
|
import chalk from "chalk";
|
|
7
7
|
|
|
8
8
|
export async function showDetails(name: string) {
|
|
@@ -12,7 +12,21 @@ export async function showDetails(name: string) {
|
|
|
12
12
|
return;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
let isRunning = await isProcessRunning(proc.pid, proc.command);
|
|
16
|
+
|
|
17
|
+
// Reconcile stale PID: cmd.exe wrapper may have exited while bun.exe child lives
|
|
18
|
+
if (!isRunning && proc.pid > 0) {
|
|
19
|
+
const reconciled = await reconcileProcessPids(
|
|
20
|
+
[{ name: proc.name, pid: proc.pid, command: proc.command, workdir: proc.workdir }],
|
|
21
|
+
new Set([proc.pid]),
|
|
22
|
+
);
|
|
23
|
+
const newPid = reconciled.get(proc.name);
|
|
24
|
+
if (newPid) {
|
|
25
|
+
updateProcessPid(proc.name, newPid);
|
|
26
|
+
(proc as any).pid = newPid;
|
|
27
|
+
isRunning = true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
16
30
|
const runtime = calculateRuntime(proc.timestamp);
|
|
17
31
|
const envVars = parseEnvString(proc.env);
|
|
18
32
|
|
package/src/commands/list.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { renderProcessTable } from "../table";
|
|
3
3
|
import type { ProcessTableRow } from "../table";
|
|
4
|
-
import { getAllProcesses } from "../db";
|
|
4
|
+
import { getAllProcesses, updateProcessPid } from "../db";
|
|
5
5
|
import { announce } from "../logger";
|
|
6
6
|
import { isProcessRunning, calculateRuntime, parseEnvString } from "../utils";
|
|
7
|
-
import { getProcessPorts, getProcessBatchResources } from "../platform";
|
|
7
|
+
import { getProcessPorts, getProcessBatchResources, reconcileProcessPids } from "../platform";
|
|
8
8
|
import { measure } from "measure-fn";
|
|
9
9
|
|
|
10
10
|
function formatMemory(bytes: number): string {
|
|
@@ -24,12 +24,45 @@ export async function showAll(opts?: { json?: boolean; filter?: string }) {
|
|
|
24
24
|
return envVars["BGR_GROUP"] === opts.filter;
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
+
// ─── PID Reconciliation ──────────────────────────────────────────
|
|
28
|
+
// On Windows, the stored PID may be a dead cmd.exe wrapper while the
|
|
29
|
+
// actual bun.exe child is still running. Detect dead PIDs up-front,
|
|
30
|
+
// reconcile them in one batch PowerShell call, and patch the DB so
|
|
31
|
+
// subsequent invocations are stable (no flicker).
|
|
32
|
+
const deadPids = new Set<number>();
|
|
33
|
+
const aliveCache = new Map<number, boolean>();
|
|
34
|
+
|
|
35
|
+
for (const proc of filtered) {
|
|
36
|
+
const alive = await isProcessRunning(proc.pid, proc.command);
|
|
37
|
+
aliveCache.set(proc.pid, alive);
|
|
38
|
+
if (!alive && proc.pid > 0) deadPids.add(proc.pid);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (deadPids.size > 0) {
|
|
42
|
+
const reconciled = await reconcileProcessPids(
|
|
43
|
+
filtered.map(p => ({ name: p.name, pid: p.pid, command: p.command, workdir: p.workdir })),
|
|
44
|
+
deadPids,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
for (const [name, newPid] of reconciled) {
|
|
48
|
+
updateProcessPid(name, newPid);
|
|
49
|
+
// Patch the in-memory record so the rest of this function sees
|
|
50
|
+
// the corrected PID without re-querying the DB.
|
|
51
|
+
const proc = filtered.find(p => p.name === name);
|
|
52
|
+
if (proc) {
|
|
53
|
+
(proc as any).pid = newPid;
|
|
54
|
+
aliveCache.set(newPid, true);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// ─────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
27
60
|
if (opts?.json) {
|
|
28
61
|
// JSON output with filtered env variables
|
|
29
62
|
const jsonData: any[] = [];
|
|
30
63
|
|
|
31
64
|
for (const proc of filtered) {
|
|
32
|
-
const isRunning = await isProcessRunning(proc.pid, proc.command);
|
|
65
|
+
const isRunning = aliveCache.get(proc.pid) ?? await isProcessRunning(proc.pid, proc.command);
|
|
33
66
|
const envVars = parseEnvString(proc.env);
|
|
34
67
|
|
|
35
68
|
const ports = isRunning ? await getProcessPorts(proc.pid) : [];
|
|
@@ -54,7 +87,7 @@ export async function showAll(opts?: { json?: boolean; filter?: string }) {
|
|
|
54
87
|
const resourceMap = await getProcessBatchResources(allPids);
|
|
55
88
|
|
|
56
89
|
for (const proc of filtered) {
|
|
57
|
-
const isRunning = await isProcessRunning(proc.pid, proc.command);
|
|
90
|
+
const isRunning = aliveCache.get(proc.pid) ?? await isProcessRunning(proc.pid, proc.command);
|
|
58
91
|
const runtime = calculateRuntime(proc.timestamp);
|
|
59
92
|
const mem = isRunning ? (resourceMap.get(proc.pid)?.memory || 0) : 0;
|
|
60
93
|
|
package/src/commands/run.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { CommandOptions } from "../types";
|
|
2
2
|
import { getProcess, removeProcessByName, retryDatabaseOperation, insertProcess } from "../db";
|
|
3
|
-
import { isProcessRunning, terminateProcess, getHomeDir, getShellCommand, killProcessOnPort, findChildPid, getProcessPorts, waitForPortFree } from "../platform";
|
|
3
|
+
import { isProcessRunning, terminateProcess, getHomeDir, getShellCommand, killProcessOnPort, findChildPid, getProcessPorts, waitForPortFree, psExec } from "../platform";
|
|
4
4
|
import { error, announce } from "../logger";
|
|
5
5
|
import { validateDirectory, parseEnvString } from "../utils";
|
|
6
6
|
import { parseConfigFile } from "../config";
|
|
@@ -95,12 +95,25 @@ export async function handleRun(options: CommandOptions) {
|
|
|
95
95
|
|
|
96
96
|
// Zombie sweep: kill any remaining bun processes matching this command
|
|
97
97
|
// This catches orphaned children that survived taskkill when the parent shell exited
|
|
98
|
+
// IMPORTANT: Exclude the current bgrun process and dashboard to avoid self-kill
|
|
98
99
|
const cmdToMatch = existingProcess.command;
|
|
99
100
|
if (cmdToMatch) {
|
|
100
101
|
await run.measure('Zombie sweep', async () => {
|
|
101
102
|
try {
|
|
102
|
-
const
|
|
103
|
-
|
|
103
|
+
const cmdKeyword = cmdToMatch.split(' ')[1] || cmdToMatch;
|
|
104
|
+
// Skip sweep if keyword is too generic (would match unrelated processes)
|
|
105
|
+
const GENERIC_KEYWORDS = ['dev', 'run', 'start', 'serve', 'build', 'test'];
|
|
106
|
+
if (GENERIC_KEYWORDS.includes(cmdKeyword.toLowerCase())) {
|
|
107
|
+
return; // Too dangerous — skip zombie sweep for generic commands
|
|
108
|
+
}
|
|
109
|
+
const currentPid = process.pid;
|
|
110
|
+
const result = await psExec(
|
|
111
|
+
`Get-CimInstance Win32_Process -Filter "Name='bun.exe'" | Where-Object { $_.CommandLine -match '${cmdKeyword.replace(/'/g, "''")}' -and $_.ProcessId -ne ${currentPid} } | Select-Object -ExpandProperty ProcessId`,
|
|
112
|
+
3000
|
|
113
|
+
);
|
|
114
|
+
const zombiePids = result.split('\n')
|
|
115
|
+
.map((l: string) => parseInt(l.trim()))
|
|
116
|
+
.filter((n: number) => !isNaN(n) && n > 0 && n !== currentPid);
|
|
104
117
|
for (const zPid of zombiePids) {
|
|
105
118
|
await $`taskkill /F /T /PID ${zPid}`.nothrow().quiet();
|
|
106
119
|
}
|
|
@@ -125,6 +138,11 @@ export async function handleRun(options: CommandOptions) {
|
|
|
125
138
|
const finalCommand = command || existingProcess!.command;
|
|
126
139
|
const finalDirectory = directory || (existingProcess?.workdir!);
|
|
127
140
|
let finalEnv = env || (existingProcess ? parseEnvString(existingProcess.env) : {});
|
|
141
|
+
|
|
142
|
+
// Default BGR_KEEP_ALIVE=true for new processes unless explicitly disabled
|
|
143
|
+
if (!('BGR_KEEP_ALIVE' in finalEnv)) {
|
|
144
|
+
finalEnv.BGR_KEEP_ALIVE = 'true';
|
|
145
|
+
}
|
|
128
146
|
|
|
129
147
|
let finalConfigPath: string | undefined | null;
|
|
130
148
|
if (configPath !== undefined) {
|
package/src/db.ts
CHANGED
|
@@ -18,10 +18,32 @@ export const ProcessSchema = z.object({
|
|
|
18
18
|
stdout_path: z.string(),
|
|
19
19
|
stderr_path: z.string(),
|
|
20
20
|
timestamp: z.string().default(() => new Date().toISOString()),
|
|
21
|
+
group: z.string().default(''),
|
|
21
22
|
});
|
|
22
23
|
|
|
23
24
|
export type Process = z.infer<typeof ProcessSchema> & { id: number };
|
|
24
25
|
|
|
26
|
+
export const TemplateSchema = z.object({
|
|
27
|
+
name: z.string(),
|
|
28
|
+
command: z.string(),
|
|
29
|
+
workdir: z.string().default(''),
|
|
30
|
+
env: z.string().default(''),
|
|
31
|
+
group: z.string().default(''),
|
|
32
|
+
created_at: z.string().default(() => new Date().toISOString()),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export type Template = z.infer<typeof TemplateSchema> & { id: number };
|
|
36
|
+
|
|
37
|
+
export const HistorySchema = z.object({
|
|
38
|
+
process_name: z.string(),
|
|
39
|
+
event: z.string(), // 'start', 'stop', 'restart', 'crash', 'guard_on', 'guard_off'
|
|
40
|
+
pid: z.number().optional(),
|
|
41
|
+
timestamp: z.string().default(() => new Date().toISOString()),
|
|
42
|
+
metadata: z.string().default(''), // JSON string for extra info
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export type History = z.infer<typeof HistorySchema> & { id: number };
|
|
46
|
+
|
|
25
47
|
// =============================================================================
|
|
26
48
|
// DATABASE INITIALIZATION
|
|
27
49
|
// =============================================================================
|
|
@@ -48,9 +70,13 @@ if (!existsSync(dbPath) && existsSync(legacyDbPath)) {
|
|
|
48
70
|
|
|
49
71
|
export const db = new Database(dbPath, {
|
|
50
72
|
process: ProcessSchema,
|
|
73
|
+
template: TemplateSchema,
|
|
74
|
+
history: HistorySchema,
|
|
51
75
|
}, {
|
|
52
76
|
indexes: {
|
|
53
77
|
process: ['name', 'timestamp', 'pid'],
|
|
78
|
+
template: ['name'],
|
|
79
|
+
history: ['process_name', 'timestamp'],
|
|
54
80
|
},
|
|
55
81
|
});
|
|
56
82
|
|
|
@@ -127,6 +153,95 @@ export function updateProcessEnv(name: string, envJson: string) {
|
|
|
127
153
|
}
|
|
128
154
|
}
|
|
129
155
|
|
|
156
|
+
// =============================================================================
|
|
157
|
+
// TEMPLATE FUNCTIONS
|
|
158
|
+
// =============================================================================
|
|
159
|
+
|
|
160
|
+
export function getAllTemplates() {
|
|
161
|
+
return db.template.select().all();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function getTemplate(name: string) {
|
|
165
|
+
return db.template.select().where({ name }).limit(1).get() || null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function saveTemplate(data: {
|
|
169
|
+
name: string;
|
|
170
|
+
command: string;
|
|
171
|
+
workdir?: string;
|
|
172
|
+
env?: string;
|
|
173
|
+
group?: string;
|
|
174
|
+
}) {
|
|
175
|
+
const existing = db.template.select().where({ name: data.name }).limit(1).get();
|
|
176
|
+
if (existing) {
|
|
177
|
+
db.template.update(existing.id, {
|
|
178
|
+
command: data.command,
|
|
179
|
+
workdir: data.workdir || '',
|
|
180
|
+
env: data.env || '',
|
|
181
|
+
group: data.group || '',
|
|
182
|
+
});
|
|
183
|
+
} else {
|
|
184
|
+
db.template.insert({
|
|
185
|
+
name: data.name,
|
|
186
|
+
command: data.command,
|
|
187
|
+
workdir: data.workdir || '',
|
|
188
|
+
env: data.env || '',
|
|
189
|
+
group: data.group || '',
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function deleteTemplate(name: string) {
|
|
195
|
+
const tmpl = db.template.select().where({ name }).limit(1).get();
|
|
196
|
+
if (tmpl) {
|
|
197
|
+
db.template.delete(tmpl.id);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// =============================================================================
|
|
202
|
+
// HISTORY FUNCTIONS
|
|
203
|
+
// =============================================================================
|
|
204
|
+
|
|
205
|
+
export function getProcessHistory(name: string, limit = 50) {
|
|
206
|
+
return db.history.select()
|
|
207
|
+
.where({ process_name: name })
|
|
208
|
+
.orderBy('timestamp', 'desc')
|
|
209
|
+
.limit(limit)
|
|
210
|
+
.all();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function addHistoryEntry(processName: string, event: string, pid?: number, metadata = {}) {
|
|
214
|
+
return db.history.insert({
|
|
215
|
+
process_name: processName,
|
|
216
|
+
event,
|
|
217
|
+
pid,
|
|
218
|
+
metadata: JSON.stringify(metadata),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function getRecentHistory(limit = 100) {
|
|
223
|
+
return db.history.select()
|
|
224
|
+
.orderBy('timestamp', 'desc')
|
|
225
|
+
.limit(limit)
|
|
226
|
+
.all();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function clearOldHistory(daysToKeep = 30) {
|
|
230
|
+
const cutoff = new Date();
|
|
231
|
+
cutoff.setDate(cutoff.getDate() - daysToKeep);
|
|
232
|
+
const cutoffStr = cutoff.toISOString();
|
|
233
|
+
|
|
234
|
+
const oldEntries = db.history.select()
|
|
235
|
+
.where('timestamp', '<', cutoffStr)
|
|
236
|
+
.all();
|
|
237
|
+
|
|
238
|
+
for (const entry of oldEntries) {
|
|
239
|
+
db.history.delete(entry.id);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return oldEntries.length;
|
|
243
|
+
}
|
|
244
|
+
|
|
130
245
|
// =============================================================================
|
|
131
246
|
// DEBUG / INFO
|
|
132
247
|
// =============================================================================
|
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
|
});
|