bgrun 3.12.0 → 3.12.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bgrun",
3
- "version": "3.12.0",
3
+ "version": "3.12.2",
4
4
  "description": "bgrun — A lightweight process manager for Bun",
5
5
  "type": "module",
6
6
  "main": "./src/api.ts",
@@ -59,4 +59,4 @@
59
59
  "engines": {
60
60
  "bun": ">=1.0.0"
61
61
  }
62
- }
62
+ }
@@ -66,14 +66,14 @@ if ($Install) {
66
66
 
67
67
  Register-ScheduledTask `
68
68
  -TaskName "bgrun-guard" `
69
- -Description "bgrun process manager auto-starts all guarded processes on login" `
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 " Task 'bgrun-guard' registered. Will start on next login."
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 " bgrun startup complete. Dashboard: $($dashboardProc.Id), Guard: $($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
+
package/src/bgrun.test.ts CHANGED
@@ -9,6 +9,13 @@
9
9
  import { describe, expect, test } from 'bun:test'
10
10
  import { parseEnvString, calculateRuntime } from './utils'
11
11
  import { stripAnsi, truncateString, truncatePath } from './table'
12
+ import { detectPackageManager, formatDeployToolError } from './deploy'
13
+ import { isProcessRunning } from './platform'
14
+ import { mkdirSync, rmSync } from 'fs'
15
+
16
+ // Use a test-specific database to avoid polluting real data
17
+ process.env.BGRUN_DB = `bgrun-test-${Date.now()}.sqlite`
18
+ import { addDependency, removeDependency, getDependencyGraph, getDependencies, getDependents, getStartOrder, removeAllDependencies } from './db'
12
19
 
13
20
  // ─── parseEnvString ─────────────────────────────────────
14
21
 
@@ -107,3 +114,167 @@ describe('truncatePath', () => {
107
114
  expect(result).toContain('…')
108
115
  })
109
116
  })
117
+
118
+ // ─── detectPackageManager ───────────────────────────────
119
+
120
+ // ─── isProcessRunning (Windows liveness fallback) ───────
121
+
122
+ describe('isProcessRunning', () => {
123
+ test('returns true for the current process PID', async () => {
124
+ const alive = await isProcessRunning(process.pid)
125
+ expect(alive).toBe(true)
126
+ })
127
+
128
+ test('returns false for PID 0 (intentionally stopped)', async () => {
129
+ const alive = await isProcessRunning(0)
130
+ expect(alive).toBe(false)
131
+ })
132
+
133
+ test('returns false for a very high unlikely PID', async () => {
134
+ const alive = await isProcessRunning(999999)
135
+ expect(alive).toBe(false)
136
+ })
137
+
138
+ test('returns false for negative PID', async () => {
139
+ const alive = await isProcessRunning(-1)
140
+ expect(alive).toBe(false)
141
+ })
142
+ })
143
+
144
+ // ─── detectPackageManager ───────────────────────────────
145
+
146
+ describe('formatDeployToolError', () => {
147
+ test('returns actionable message for missing binary', () => {
148
+ const msg = formatDeployToolError('pnpm', new Error('command not found: pnpm'))
149
+ expect(msg).toContain("requires 'pnpm'")
150
+ expect(msg).toContain('PATH')
151
+ })
152
+
153
+ test('preserves non-missing-binary failures', () => {
154
+ const msg = formatDeployToolError('npm', new Error('npm ci failed with exit code 1'))
155
+ expect(msg).toContain('Dependency install failed with npm')
156
+ expect(msg).toContain('exit code 1')
157
+ })
158
+ })
159
+
160
+ describe('detectPackageManager', () => {
161
+ test('returns null when no package.json exists', async () => {
162
+ const dir = `${process.cwd()}/tmp-no-package-${Date.now()}`
163
+ mkdirSync(dir, { recursive: true })
164
+ try {
165
+ expect(await detectPackageManager(dir)).toBeNull()
166
+ } finally {
167
+ rmSync(dir, { recursive: true, force: true })
168
+ }
169
+ })
170
+
171
+ test('prefers bun lockfiles', async () => {
172
+ const dir = `${process.cwd()}/tmp-bun-${Date.now()}`
173
+ mkdirSync(dir, { recursive: true })
174
+ try {
175
+ await Bun.write(`${dir}/package.json`, '{}')
176
+ await Bun.write(`${dir}/bun.lock`, '')
177
+ expect(await detectPackageManager(dir)).toBe('bun')
178
+ } finally {
179
+ rmSync(dir, { recursive: true, force: true })
180
+ }
181
+ })
182
+
183
+ test('detects pnpm, yarn, and npm lockfiles', async () => {
184
+ const base = `${process.cwd()}/tmp-pm-${Date.now()}`
185
+
186
+ const pnpmDir = `${base}-pnpm`
187
+ mkdirSync(pnpmDir, { recursive: true })
188
+ await Bun.write(`${pnpmDir}/package.json`, '{}')
189
+ await Bun.write(`${pnpmDir}/pnpm-lock.yaml`, '')
190
+ expect(await detectPackageManager(pnpmDir)).toBe('pnpm')
191
+
192
+ const yarnDir = `${base}-yarn`
193
+ mkdirSync(yarnDir, { recursive: true })
194
+ await Bun.write(`${yarnDir}/package.json`, '{}')
195
+ await Bun.write(`${yarnDir}/yarn.lock`, '')
196
+ expect(await detectPackageManager(yarnDir)).toBe('yarn')
197
+
198
+ const npmDir = `${base}-npm`
199
+ mkdirSync(npmDir, { recursive: true })
200
+ await Bun.write(`${npmDir}/package.json`, '{}')
201
+ await Bun.write(`${npmDir}/package-lock.json`, '{}')
202
+ expect(await detectPackageManager(npmDir)).toBe('npm')
203
+
204
+ rmSync(pnpmDir, { recursive: true, force: true })
205
+ rmSync(yarnDir, { recursive: true, force: true })
206
+ rmSync(npmDir, { recursive: true, force: true })
207
+ })
208
+
209
+ test('defaults to bun for package.json projects without a lockfile', async () => {
210
+ const dir = `${process.cwd()}/tmp-default-bun-${Date.now()}`
211
+ mkdirSync(dir, { recursive: true })
212
+ try {
213
+ await Bun.write(`${dir}/package.json`, '{}')
214
+ expect(await detectPackageManager(dir)).toBe('bun')
215
+ } finally {
216
+ rmSync(dir, { recursive: true, force: true })
217
+ }
218
+ })
219
+ })
220
+
221
+ // ─── Dependencies ───────────────────────────────────────
222
+
223
+ describe('addDependency', () => {
224
+ test('adds a valid dependency', () => {
225
+ removeAllDependencies('web-server');
226
+ removeAllDependencies('database');
227
+ const ok = addDependency('web-server', 'database');
228
+ expect(ok).toBe(true);
229
+ expect(getDependencies('web-server')).toContain('database');
230
+ })
231
+
232
+ test('prevents self-dependency', () => {
233
+ expect(addDependency('api', 'api')).toBe(false);
234
+ })
235
+
236
+ test('prevents duplicate dependency', () => {
237
+ removeAllDependencies('app');
238
+ addDependency('app', 'db');
239
+ expect(addDependency('app', 'db')).toBe(false);
240
+ })
241
+
242
+ test('prevents circular dependency', () => {
243
+ removeAllDependencies('a');
244
+ removeAllDependencies('b');
245
+ removeAllDependencies('c');
246
+ addDependency('a', 'b');
247
+ addDependency('b', 'c');
248
+ // c -> a would create a cycle
249
+ expect(addDependency('c', 'a')).toBe(false);
250
+ })
251
+ })
252
+
253
+ describe('getDependencyGraph', () => {
254
+ test('returns full graph', () => {
255
+ removeAllDependencies('svc-a');
256
+ removeAllDependencies('svc-b');
257
+ addDependency('svc-a', 'svc-b');
258
+ const graph = getDependencyGraph();
259
+ expect(graph['svc-a']).toContain('svc-b');
260
+ })
261
+ })
262
+
263
+ describe('getDependents', () => {
264
+ test('finds processes that depend on a target', () => {
265
+ removeAllDependencies('frontend');
266
+ removeAllDependencies('backend');
267
+ addDependency('frontend', 'backend');
268
+ expect(getDependents('backend')).toContain('frontend');
269
+ })
270
+ })
271
+
272
+ describe('removeDependency', () => {
273
+ test('removes an existing dependency', () => {
274
+ removeAllDependencies('x');
275
+ addDependency('x', 'y');
276
+ expect(getDependencies('x')).toContain('y');
277
+ removeDependency('x', 'y');
278
+ expect(getDependencies('x')).not.toContain('y');
279
+ })
280
+ })
@@ -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
- const isRunning = await isProcessRunning(proc.pid, proc.command);
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
 
@@ -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
 
@@ -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 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();
103
- const zombiePids = result.split('\n').map((l: string) => parseInt(l.trim())).filter((n: number) => !isNaN(n) && n > 0);
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) {