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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bgrun",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.1",
|
|
4
4
|
"description": "bgrun — A lightweight process manager for Bun",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/api.ts",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"dist",
|
|
20
20
|
"src",
|
|
21
21
|
"dashboard/app",
|
|
22
|
+
"scripts",
|
|
22
23
|
"README.md",
|
|
23
24
|
"image.png",
|
|
24
25
|
"examples/bgr-startup.sh"
|
|
@@ -50,7 +51,7 @@
|
|
|
50
51
|
"chalk": "^5.4.1",
|
|
51
52
|
"dedent": "^1.5.3",
|
|
52
53
|
"measure-fn": "3.10.1",
|
|
53
|
-
"melina": "2.3.
|
|
54
|
+
"melina": "2.3.6",
|
|
54
55
|
"react": "^19.2.4",
|
|
55
56
|
"react-dom": "^19.2.4",
|
|
56
57
|
"sqlite-zod-orm": "3.26.1"
|
|
@@ -58,4 +59,4 @@
|
|
|
58
59
|
"engines": {
|
|
59
60
|
"bun": ">=1.0.0"
|
|
60
61
|
}
|
|
61
|
-
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# bgr-startup.ps1 — Auto-start bgrun guard on Windows login
|
|
2
|
+
# Ensures all guarded processes (and the dashboard itself) start on boot.
|
|
3
|
+
#
|
|
4
|
+
# Installation:
|
|
5
|
+
# 1. Run this script once with -Install flag:
|
|
6
|
+
# powershell -ExecutionPolicy Bypass -File bgr-startup.ps1 -Install
|
|
7
|
+
#
|
|
8
|
+
# 2. Or manually create a Task Scheduler task:
|
|
9
|
+
# - Trigger: At log on
|
|
10
|
+
# - Action: powershell -WindowStyle Hidden -ExecutionPolicy Bypass -File "C:\Code\bgr\scripts\bgr-startup.ps1"
|
|
11
|
+
# - Run whether user is logged on or not: Yes
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# bgr-startup.ps1 # Start bgrun guard
|
|
15
|
+
# bgr-startup.ps1 -Install # Register Task Scheduler entry
|
|
16
|
+
|
|
17
|
+
param(
|
|
18
|
+
[switch]$Install
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
$BunPath = "$env:USERPROFILE\.bun\bin\bun.exe"
|
|
22
|
+
$BgrunPath = "C:\Code\bgr"
|
|
23
|
+
$LogPath = "$env:USERPROFILE\.bgr\startup.log"
|
|
24
|
+
|
|
25
|
+
function Write-Log {
|
|
26
|
+
param([string]$Message)
|
|
27
|
+
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
|
28
|
+
$line = "[$timestamp] $Message"
|
|
29
|
+
Write-Host $line
|
|
30
|
+
Add-Content -Path $LogPath -Value $line -ErrorAction SilentlyContinue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Ensure .bgr directory exists
|
|
34
|
+
$bgrDir = "$env:USERPROFILE\.bgr"
|
|
35
|
+
if (-not (Test-Path $bgrDir)) {
|
|
36
|
+
New-Item -ItemType Directory -Path $bgrDir -Force | Out-Null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if ($Install) {
|
|
40
|
+
Write-Log "Installing bgrun auto-start task..."
|
|
41
|
+
|
|
42
|
+
$scriptPath = $PSCommandPath
|
|
43
|
+
if (-not $scriptPath) {
|
|
44
|
+
$scriptPath = Join-Path $BgrunPath "scripts\bgr-startup.ps1"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Remove existing task if present
|
|
48
|
+
$existingTask = Get-ScheduledTask -TaskName "bgrun-guard" -ErrorAction SilentlyContinue
|
|
49
|
+
if ($existingTask) {
|
|
50
|
+
Unregister-ScheduledTask -TaskName "bgrun-guard" -Confirm:$false
|
|
51
|
+
Write-Log "Removed existing bgrun-guard task"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Create the scheduled task
|
|
55
|
+
$action = New-ScheduledTaskAction `
|
|
56
|
+
-Execute "powershell.exe" `
|
|
57
|
+
-Argument "-WindowStyle Hidden -ExecutionPolicy Bypass -File `"$scriptPath`""
|
|
58
|
+
|
|
59
|
+
$trigger = New-ScheduledTaskTrigger -AtLogon
|
|
60
|
+
$settings = New-ScheduledTaskSettingsSet `
|
|
61
|
+
-AllowStartIfOnBatteries `
|
|
62
|
+
-DontStopIfGoingOnBatteries `
|
|
63
|
+
-StartWhenAvailable `
|
|
64
|
+
-RestartInterval (New-TimeSpan -Minutes 5) `
|
|
65
|
+
-RestartCount 3
|
|
66
|
+
|
|
67
|
+
Register-ScheduledTask `
|
|
68
|
+
-TaskName "bgrun-guard" `
|
|
69
|
+
-Description "bgrun process manager - auto-starts all guarded processes on login" `
|
|
70
|
+
-Action $action `
|
|
71
|
+
-Trigger $trigger `
|
|
72
|
+
-Settings $settings `
|
|
73
|
+
-RunLevel Highest `
|
|
74
|
+
-Force
|
|
75
|
+
|
|
76
|
+
Write-Log "[OK] Task 'bgrun-guard' registered. Will start on next login."
|
|
77
|
+
Write-Log " Script: $scriptPath"
|
|
78
|
+
exit 0
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# ── Main: Start bgrun guard ─────────────────────────────────
|
|
82
|
+
Write-Log "bgrun startup initiated"
|
|
83
|
+
|
|
84
|
+
# Check bun exists
|
|
85
|
+
if (-not (Test-Path $BunPath)) {
|
|
86
|
+
Write-Log "ERROR: bun not found at $BunPath"
|
|
87
|
+
exit 1
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Check bgrun repo exists
|
|
91
|
+
if (-not (Test-Path "$BgrunPath\src\guard.ts")) {
|
|
92
|
+
Write-Log "ERROR: bgrun not found at $BgrunPath"
|
|
93
|
+
exit 1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Start the dashboard first (guard needs it)
|
|
97
|
+
Write-Log "Starting bgrun dashboard..."
|
|
98
|
+
$dashboardProc = Start-Process -FilePath $BunPath `
|
|
99
|
+
-ArgumentList "run", "$BgrunPath\src\index.ts", "--dashboard", "--port", "3000" `
|
|
100
|
+
-WindowStyle Hidden `
|
|
101
|
+
-PassThru `
|
|
102
|
+
-WorkingDirectory $BgrunPath
|
|
103
|
+
|
|
104
|
+
Write-Log "Dashboard PID: $($dashboardProc.Id)"
|
|
105
|
+
|
|
106
|
+
# Wait for dashboard to be ready
|
|
107
|
+
Start-Sleep -Seconds 5
|
|
108
|
+
|
|
109
|
+
# Start the guard (watches dashboard + all guarded processes)
|
|
110
|
+
Write-Log "Starting bgrun guard..."
|
|
111
|
+
$guardProc = Start-Process -FilePath $BunPath `
|
|
112
|
+
-ArgumentList "run", "$BgrunPath\src\index.ts", "--guard" `
|
|
113
|
+
-WindowStyle Hidden `
|
|
114
|
+
-PassThru `
|
|
115
|
+
-WorkingDirectory $BgrunPath
|
|
116
|
+
|
|
117
|
+
Write-Log "Guard PID: $($guardProc.Id)"
|
|
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
|
+
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bgrun core utility tests
|
|
3
|
+
*
|
|
4
|
+
* Tests pure logic functions: env parsing, config flattening,
|
|
5
|
+
* string truncation, and runtime calculation.
|
|
6
|
+
*
|
|
7
|
+
* Run: bun test src/bgrun.test.ts
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from 'bun:test'
|
|
10
|
+
import { parseEnvString, calculateRuntime } from './utils'
|
|
11
|
+
import { stripAnsi, truncateString, truncatePath } from './table'
|
|
12
|
+
|
|
13
|
+
// ─── parseEnvString ─────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
describe('parseEnvString', () => {
|
|
16
|
+
test('parses comma-separated key=value pairs', () => {
|
|
17
|
+
const result = parseEnvString('PORT=3000,HOST=localhost,DEBUG=true')
|
|
18
|
+
expect(result).toEqual({
|
|
19
|
+
PORT: '3000',
|
|
20
|
+
HOST: 'localhost',
|
|
21
|
+
DEBUG: 'true',
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('handles single pair', () => {
|
|
26
|
+
expect(parseEnvString('KEY=value')).toEqual({ KEY: 'value' })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('handles empty string', () => {
|
|
30
|
+
expect(parseEnvString('')).toEqual({})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('ignores malformed pairs (no =)', () => {
|
|
34
|
+
const result = parseEnvString('GOOD=yes,BAD,ALSO_GOOD=ok')
|
|
35
|
+
expect(result.GOOD).toBe('yes')
|
|
36
|
+
expect(result.ALSO_GOOD).toBe('ok')
|
|
37
|
+
expect(result.BAD).toBeUndefined()
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// ─── calculateRuntime ───────────────────────────────────
|
|
42
|
+
|
|
43
|
+
describe('calculateRuntime', () => {
|
|
44
|
+
test('returns 0 minutes for recent start', () => {
|
|
45
|
+
const now = new Date().toISOString()
|
|
46
|
+
expect(calculateRuntime(now)).toBe('0 minutes')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('returns correct minutes', () => {
|
|
50
|
+
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString()
|
|
51
|
+
expect(calculateRuntime(fiveMinAgo)).toBe('5 minutes')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('returns correct for 1 hour', () => {
|
|
55
|
+
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString()
|
|
56
|
+
expect(calculateRuntime(oneHourAgo)).toBe('60 minutes')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// ─── stripAnsi ──────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
describe('stripAnsi', () => {
|
|
63
|
+
test('strips color codes', () => {
|
|
64
|
+
const colored = '\u001b[31mred text\u001b[0m'
|
|
65
|
+
expect(stripAnsi(colored)).toBe('red text')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('passes through plain text', () => {
|
|
69
|
+
expect(stripAnsi('hello world')).toBe('hello world')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('handles empty string', () => {
|
|
73
|
+
expect(stripAnsi('')).toBe('')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// ─── truncateString ─────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
describe('truncateString', () => {
|
|
80
|
+
test('returns string unchanged if within limit', () => {
|
|
81
|
+
expect(truncateString('hello', 10)).toBe('hello')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('truncates with ellipsis', () => {
|
|
85
|
+
const result = truncateString('a very long string that exceeds limit', 15)
|
|
86
|
+
expect(result.length).toBeLessThanOrEqual(15)
|
|
87
|
+
expect(result).toContain('…')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('handles maxLength smaller than ellipsis', () => {
|
|
91
|
+
const result = truncateString('hello world', 2)
|
|
92
|
+
expect(result.length).toBeLessThanOrEqual(2)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// ─── truncatePath ───────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe('truncatePath', () => {
|
|
99
|
+
test('returns path unchanged if within limit', () => {
|
|
100
|
+
expect(truncatePath('/home/user', 50)).toBe('/home/user')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('truncates middle of long path', () => {
|
|
104
|
+
const longPath = '/home/user/projects/very/deeply/nested/directory/structure'
|
|
105
|
+
const result = truncatePath(longPath, 30)
|
|
106
|
+
expect(result.length).toBeLessThanOrEqual(30)
|
|
107
|
+
expect(result).toContain('…')
|
|
108
|
+
})
|
|
109
|
+
})
|
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
|
// =============================================================================
|