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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bgrun",
3
- "version": "3.12.0",
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",
@@ -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
+
@@ -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) {
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: Bun.file(stdoutPath),
220
- stderr: Bun.file(stderrPath),
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
- // Resolve the actual child PID by traversing the process tree
226
- // (cmd.exe bun.exe), then detect which port it bound
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 actualPid = await findChildPid(newProcess.pid);
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: Bun.file(stdoutPath),
316
- stderr: Bun.file(stderrPath),
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
- const actualPid = await findChildPid(newProcess.pid);
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(err);
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
  });