bgrun 3.12.2 → 3.12.4

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/dist/index.js CHANGED
@@ -25,6 +25,7 @@ __export(exports_platform, {
25
25
  reconcileProcessPids: () => reconcileProcessPids,
26
26
  readFileTail: () => readFileTail,
27
27
  psExec: () => psExec,
28
+ parseUnixListeningPorts: () => parseUnixListeningPorts,
28
29
  killProcessOnPort: () => killProcessOnPort,
29
30
  isWindows: () => isWindows,
30
31
  isProcessRunning: () => isProcessRunning,
@@ -457,6 +458,17 @@ async function getProcessBatchResources(pids) {
457
458
  return resourceMap;
458
459
  }) ?? new Map;
459
460
  }
461
+ function parseUnixListeningPorts(output) {
462
+ const ports = new Set;
463
+ for (const line of output.split(`
464
+ `)) {
465
+ const portMatch = line.match(/:(\d+)\s+\(LISTEN\)/);
466
+ if (portMatch) {
467
+ ports.add(parseInt(portMatch[1]));
468
+ }
469
+ }
470
+ return Array.from(ports);
471
+ }
460
472
  async function getProcessPorts(pid) {
461
473
  try {
462
474
  if (isWindows()) {
@@ -473,29 +485,21 @@ async function getProcessPorts(pid) {
473
485
  } else {
474
486
  try {
475
487
  const result2 = await $`ss -tlnp`.nothrow().quiet().text();
476
- const ports2 = new Set;
488
+ const ports = new Set;
477
489
  for (const line of result2.split(`
478
490
  `)) {
479
491
  if (line.includes(`pid=${pid}`)) {
480
492
  const portMatch = line.match(/:(\d+)\s/);
481
493
  if (portMatch) {
482
- ports2.add(parseInt(portMatch[1]));
494
+ ports.add(parseInt(portMatch[1]));
483
495
  }
484
496
  }
485
497
  }
486
- if (ports2.size > 0)
487
- return Array.from(ports2);
498
+ if (ports.size > 0)
499
+ return Array.from(ports);
488
500
  } catch {}
489
501
  const result = await $`lsof -Pan -p ${pid} -iTCP -sTCP:LISTEN`.nothrow().quiet().text();
490
- const ports = new Set;
491
- for (const line of result.split(`
492
- `)) {
493
- const portMatch = line.match(/:(\d+)\s+\(LISTEN\)/);
494
- if (portMatch) {
495
- ports.add(parseInt(portMatch[1]));
496
- }
497
- }
498
- return Array.from(ports);
502
+ return parseUnixListeningPorts(result);
499
503
  }
500
504
  } catch {
501
505
  return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bgrun",
3
- "version": "3.12.2",
3
+ "version": "3.12.4",
4
4
  "description": "bgrun — A lightweight process manager for Bun",
5
5
  "type": "module",
6
6
  "main": "./src/api.ts",
@@ -8,7 +8,7 @@
8
8
  ".": "./src/api.ts"
9
9
  },
10
10
  "bin": {
11
- "bgrun": "./dist/index.js"
11
+ "bgrun": "dist/index.js"
12
12
  },
13
13
  "scripts": {
14
14
  "build": "bun run ./src/build.ts",
@@ -17,7 +17,22 @@
17
17
  },
18
18
  "files": [
19
19
  "dist",
20
- "src",
20
+ "src/api.ts",
21
+ "src/build.ts",
22
+ "src/config.ts",
23
+ "src/db.ts",
24
+ "src/deploy.ts",
25
+ "src/deps.ts",
26
+ "src/guard.ts",
27
+ "src/index.ts",
28
+ "src/log-rotation.ts",
29
+ "src/logger.ts",
30
+ "src/platform.ts",
31
+ "src/server.ts",
32
+ "src/table.ts",
33
+ "src/types.ts",
34
+ "src/utils.ts",
35
+ "src/commands",
21
36
  "dashboard/app",
22
37
  "scripts",
23
38
  "README.md",
@@ -37,7 +52,7 @@
37
52
  "license": "MIT",
38
53
  "repository": {
39
54
  "type": "git",
40
- "url": "https://github.com/7flash/bgrun.git"
55
+ "url": "git+https://github.com/7flash/bgrun.git"
41
56
  },
42
57
  "devDependencies": {
43
58
  "@types/bun": "^1.3.10",
package/src/platform.ts CHANGED
@@ -608,6 +608,20 @@ export async function getProcessBatchResources(pids: number[]): Promise<Map<numb
608
608
  }) ?? new Map();
609
609
  }
610
610
 
611
+ /**
612
+ * Parse Unix lsof LISTEN output and return only true listening TCP ports.
613
+ */
614
+ export function parseUnixListeningPorts(output: string): number[] {
615
+ const ports = new Set<number>();
616
+ for (const line of output.split('\n')) {
617
+ const portMatch = line.match(/:(\d+)\s+\(LISTEN\)/);
618
+ if (portMatch) {
619
+ ports.add(parseInt(portMatch[1]));
620
+ }
621
+ }
622
+ return Array.from(ports);
623
+ }
624
+
611
625
  /**
612
626
  * Get the TCP ports a process is currently listening on by querying the OS.
613
627
  * Returns an array of port numbers (empty if none or process not found).
@@ -643,14 +657,7 @@ export async function getProcessPorts(pid: number): Promise<number[]> {
643
657
  } catch { /* ss not available, try lsof */ }
644
658
 
645
659
  const result = await $`lsof -Pan -p ${pid} -iTCP -sTCP:LISTEN`.nothrow().quiet().text();
646
- const ports = new Set<number>();
647
- for (const line of result.split('\n')) {
648
- const portMatch = line.match(/:(\d+)\s+\(LISTEN\)/);
649
- if (portMatch) {
650
- ports.add(parseInt(portMatch[1]));
651
- }
652
- }
653
- return Array.from(ports);
660
+ return parseUnixListeningPorts(result);
654
661
  }
655
662
  } catch {
656
663
  return [];
package/src/bgrun.test.ts DELETED
@@ -1,280 +0,0 @@
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
- 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'
19
-
20
- // ─── parseEnvString ─────────────────────────────────────
21
-
22
- describe('parseEnvString', () => {
23
- test('parses comma-separated key=value pairs', () => {
24
- const result = parseEnvString('PORT=3000,HOST=localhost,DEBUG=true')
25
- expect(result).toEqual({
26
- PORT: '3000',
27
- HOST: 'localhost',
28
- DEBUG: 'true',
29
- })
30
- })
31
-
32
- test('handles single pair', () => {
33
- expect(parseEnvString('KEY=value')).toEqual({ KEY: 'value' })
34
- })
35
-
36
- test('handles empty string', () => {
37
- expect(parseEnvString('')).toEqual({})
38
- })
39
-
40
- test('ignores malformed pairs (no =)', () => {
41
- const result = parseEnvString('GOOD=yes,BAD,ALSO_GOOD=ok')
42
- expect(result.GOOD).toBe('yes')
43
- expect(result.ALSO_GOOD).toBe('ok')
44
- expect(result.BAD).toBeUndefined()
45
- })
46
- })
47
-
48
- // ─── calculateRuntime ───────────────────────────────────
49
-
50
- describe('calculateRuntime', () => {
51
- test('returns 0 minutes for recent start', () => {
52
- const now = new Date().toISOString()
53
- expect(calculateRuntime(now)).toBe('0 minutes')
54
- })
55
-
56
- test('returns correct minutes', () => {
57
- const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString()
58
- expect(calculateRuntime(fiveMinAgo)).toBe('5 minutes')
59
- })
60
-
61
- test('returns correct for 1 hour', () => {
62
- const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString()
63
- expect(calculateRuntime(oneHourAgo)).toBe('60 minutes')
64
- })
65
- })
66
-
67
- // ─── stripAnsi ──────────────────────────────────────────
68
-
69
- describe('stripAnsi', () => {
70
- test('strips color codes', () => {
71
- const colored = '\u001b[31mred text\u001b[0m'
72
- expect(stripAnsi(colored)).toBe('red text')
73
- })
74
-
75
- test('passes through plain text', () => {
76
- expect(stripAnsi('hello world')).toBe('hello world')
77
- })
78
-
79
- test('handles empty string', () => {
80
- expect(stripAnsi('')).toBe('')
81
- })
82
- })
83
-
84
- // ─── truncateString ─────────────────────────────────────
85
-
86
- describe('truncateString', () => {
87
- test('returns string unchanged if within limit', () => {
88
- expect(truncateString('hello', 10)).toBe('hello')
89
- })
90
-
91
- test('truncates with ellipsis', () => {
92
- const result = truncateString('a very long string that exceeds limit', 15)
93
- expect(result.length).toBeLessThanOrEqual(15)
94
- expect(result).toContain('…')
95
- })
96
-
97
- test('handles maxLength smaller than ellipsis', () => {
98
- const result = truncateString('hello world', 2)
99
- expect(result.length).toBeLessThanOrEqual(2)
100
- })
101
- })
102
-
103
- // ─── truncatePath ───────────────────────────────────────
104
-
105
- describe('truncatePath', () => {
106
- test('returns path unchanged if within limit', () => {
107
- expect(truncatePath('/home/user', 50)).toBe('/home/user')
108
- })
109
-
110
- test('truncates middle of long path', () => {
111
- const longPath = '/home/user/projects/very/deeply/nested/directory/structure'
112
- const result = truncatePath(longPath, 30)
113
- expect(result.length).toBeLessThanOrEqual(30)
114
- expect(result).toContain('…')
115
- })
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
- })
package/src/index_copy.ts DELETED
@@ -1,614 +0,0 @@
1
- #!/usr/bin/env bun
2
-
3
- import { parseArgs } from "util";
4
- import { getVersion } from "./utils";
5
- import { handleRun } from "./commands/run";
6
- import { showAll } from "./commands/list";
7
- import { handleDelete, handleClean, handleDeleteAll, handleStop } from "./commands/cleanup";
8
- import { handleWatch } from "./commands/watch";
9
- import { showLogs } from "./commands/logs";
10
- import { showDetails } from "./commands/details";
11
- import type { CommandOptions } from "./types";
12
- import { error, announce } from "./logger";
13
- // startServer is dynamically imported only when --_serve is used
14
- // to avoid loading melina (which has side-effects) on every bgrun command
15
- import { getHomeDir, getShellCommand, findChildPid, isProcessRunning, terminateProcess, getProcessPorts, killProcessOnPort, waitForPortFree, isPortFree, findPidByPort } from "./platform";
16
- import { insertProcess, removeProcessByName, getProcess, retryDatabaseOperation, getDbInfo } from "./db";
17
- import dedent from "dedent";
18
- import chalk from "chalk";
19
- import { join } from "path";
20
- import { sleep } from "bun";
21
- import { configure } from "measure-fn";
22
-
23
- if (!Bun.argv.includes("--_serve")) {
24
- if (!Bun.env.MEASURE_SILENT) {
25
- configure({ silent: true });
26
- }
27
- }
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
-
71
- async function showHelp() {
72
- const usage = dedent`
73
- ${chalk.bold('bgrun — Bun Background Runner')}
74
- ${chalk.gray('═'.repeat(50))}
75
-
76
- ${chalk.yellow('Usage:')}
77
- bgrun [name] [options]
78
-
79
- ${chalk.yellow('Commands:')}
80
- bgrun List all processes
81
- bgrun [name] Show details for a process
82
- bgrun --dashboard Launch web dashboard (managed by bgrun)
83
- bgrun --guard Launch standalone process guard
84
- bgrun --restart [name] Restart a process
85
- bgrun --restart-all Restart ALL registered processes
86
- bgrun --stop [name] Stop a process (keep in registry)
87
- bgrun --stop-all Stop ALL running processes
88
- bgrun --delete [name] Delete a process
89
- bgrun --clean Remove all stopped processes
90
- bgrun --nuke Delete ALL processes
91
-
92
- ${chalk.yellow('Options:')}
93
- --name <string> Process name (required for new)
94
- --command <string> Process command (required for new)
95
- --directory <path> Working directory (required for new)
96
- --config <path> Config file (default: .config.toml)
97
- --watch Watch for file changes and auto-restart
98
- --force Force restart existing process
99
- --fetch Fetch latest git changes before running
100
- --json Output in JSON format
101
- --filter <group> Filter list by BGR_GROUP
102
- --logs Show logs
103
- --log-stdout Show only stdout logs
104
- --log-stderr Show only stderr logs
105
- --lines <n> Number of log lines to show (default: all)
106
- --version Show version
107
- --debug Show debug info (DB path, BGR home, etc.)
108
- --dashboard Launch web dashboard as bgrun-managed process
109
- --port <number> Port for dashboard (default: 3000)
110
- --help Show this help message
111
-
112
- ${chalk.yellow('Examples:')}
113
- bgrun --dashboard
114
- bgrun --name myapp --command "bun run dev" --directory . --watch
115
- bgrun myapp --logs --lines 50
116
- `;
117
- console.log(usage);
118
- }
119
-
120
- // Re-running parseArgs logic properly
121
- async function run() {
122
- const { values, positionals } = parseArgs({
123
- args: Bun.argv.slice(2),
124
- options: {
125
- name: { type: 'string' },
126
- command: { type: 'string' },
127
- directory: { type: 'string' },
128
- config: { type: 'string' },
129
- watch: { type: 'boolean' },
130
- force: { type: 'boolean' },
131
- fetch: { type: 'boolean' },
132
- delete: { type: 'boolean' },
133
- nuke: { type: 'boolean' },
134
- restart: { type: 'boolean' },
135
- "restart-all": { type: 'boolean' },
136
- stop: { type: 'boolean' },
137
- "stop-all": { type: 'boolean' },
138
- clean: { type: 'boolean' },
139
- json: { type: 'boolean' },
140
- logs: { type: 'boolean' },
141
- "log-stdout": { type: 'boolean' },
142
- "log-stderr": { type: 'boolean' },
143
- lines: { type: 'string' },
144
- filter: { type: 'string' },
145
- version: { type: 'boolean' },
146
- help: { type: 'boolean' },
147
- db: { type: 'string' },
148
- stdout: { type: 'string' },
149
- stderr: { type: 'string' },
150
- dashboard: { type: 'boolean' },
151
- guard: { type: 'boolean' },
152
- debug: { type: 'boolean' },
153
- "_serve": { type: 'boolean' },
154
- "_guard-loop": { type: 'boolean' },
155
- port: { type: 'string' },
156
- },
157
- strict: false,
158
- allowPositionals: true,
159
- });
160
-
161
- // Internal: actually run the HTTP server (spawned by --dashboard)
162
- // Port is NOT passed explicitly — Melina auto-detects from BUN_PORT env
163
- // or defaults to 3000 with fallback to next available port.
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();
168
- const { startServer } = await import("./server");
169
- await startServer();
170
- return;
171
- }
172
-
173
- // Internal: actually run the guard loop (spawned by --guard)
174
- if (values['_guard-loop']) {
175
- // Redirect console output to log files when running detached
176
- redirectConsoleToFiles();
177
- const { startGuardLoop } = await import("./guard");
178
- const intervalStr = positionals[0];
179
- const intervalMs = intervalStr ? parseInt(intervalStr) * 1000 : undefined;
180
- await startGuardLoop(intervalMs);
181
- return;
182
- }
183
-
184
- // Dashboard: spawn the dashboard server as a bgr-managed process
185
- if (values.dashboard) {
186
- const dashboardName = 'bgr-dashboard';
187
- const homePath = getHomeDir();
188
- const bgrDir = join(homePath, '.bgr');
189
- // User can request a specific port via BUN_PORT=XXXX bgrun --dashboard
190
- // Otherwise Melina picks automatically (3000 → fallback)
191
- const requestedPort = values.port as string | undefined;
192
-
193
- // Check if dashboard is already running
194
- const existing = getProcess(dashboardName);
195
- if (existing && await isProcessRunning(existing.pid)) {
196
- // The stored PID may be the shell wrapper (cmd.exe), not the actual bun process
197
- // Try the stored PID first, then traverse the process tree to find the real one
198
- let existingPorts = await getProcessPorts(existing.pid);
199
- if (existingPorts.length === 0) {
200
- const childPid = await findChildPid(existing.pid);
201
- if (childPid !== existing.pid) {
202
- existingPorts = await getProcessPorts(childPid);
203
- }
204
- }
205
- const portStr = existingPorts.length > 0 ? `:${existingPorts[0]}` : '(detecting...)';
206
- announce(
207
- `Dashboard is already running (PID ${existing.pid})\n\n` +
208
- ` 🌐 ${chalk.cyan(`http://localhost${portStr}`)}\n\n` +
209
- ` Use ${chalk.yellow(`bgrun --stop ${dashboardName}`)} to stop it\n` +
210
- ` Use ${chalk.yellow(`bgrun --dashboard --force`)} to restart`,
211
- 'BGR Dashboard'
212
- );
213
- return;
214
- }
215
-
216
- // Kill existing if force
217
- if (existing) {
218
- if (await isProcessRunning(existing.pid)) {
219
- const detectedPorts = await getProcessPorts(existing.pid);
220
- await terminateProcess(existing.pid);
221
- for (const p of detectedPorts) {
222
- await killProcessOnPort(p);
223
- await waitForPortFree(p, 5000);
224
- }
225
- }
226
- await retryDatabaseOperation(() => removeProcessByName(dashboardName));
227
- }
228
-
229
- // Spawn the dashboard server as a managed process
230
- // Port is NOT passed as CLI arg — Melina will auto-detect.
231
- // If user wants a specific port, we pass it via BUN_PORT env var.
232
- const { resolve } = require('path');
233
- const scriptPath = resolve(process.argv[1]);
234
- const spawnCommand = `bun run ${scriptPath} --_serve`;
235
- const command = `bgrun --_serve`;
236
- const stdoutPath = join(bgrDir, `${dashboardName}-out.txt`);
237
- const stderrPath = join(bgrDir, `${dashboardName}-err.txt`);
238
-
239
- await Bun.write(stdoutPath, '');
240
- await Bun.write(stderrPath, '');
241
-
242
- // Pass BUN_PORT env var only if user explicitly requested a port
243
- const spawnEnv: Record<string, string> = { ...Bun.env } as any;
244
- if (requestedPort) {
245
- spawnEnv.BUN_PORT = requestedPort;
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;
250
-
251
- // Resolve the target port: --port flag > BUN_PORT env > default 3000
252
- const targetPort = parseInt(requestedPort || Bun.env.BUN_PORT || '3000');
253
- if (!isNaN(targetPort) && targetPort > 0) {
254
- // Auto-kill whatever occupies the target port so dashboard always reclaims it
255
- const portFree = await isPortFree(targetPort);
256
- if (!portFree) {
257
- console.log(chalk.yellow(` ⚡ Port ${targetPort} is occupied — reclaiming...`));
258
- await killProcessOnPort(targetPort);
259
- const freed = await waitForPortFree(targetPort, 5000);
260
- if (!freed) {
261
- console.log(chalk.red(` ⚠ Could not free port ${targetPort} — dashboard may pick a fallback port`));
262
- }
263
- }
264
- }
265
-
266
- const newProcess = Bun.spawn(getShellCommand(spawnCommand), {
267
- env: spawnEnv,
268
- cwd: bgrDir,
269
- stdout: "ignore",
270
- stderr: "ignore",
271
- detached: true, // Windows: new process group outside parent's Job Object — survives terminal close
272
- } as any);
273
-
274
- newProcess.unref();
275
-
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.
278
- await sleep(2000); // Give the server time to start and bind a port
279
- const resolvedPort = parseInt(requestedPort || Bun.env.BUN_PORT || '3000');
280
- const actualPid = await findPidByPort(resolvedPort, 10000) ?? await findChildPid(newProcess.pid);
281
-
282
- // Detect the port the server actually bound to
283
- let actualPort: number | null = null;
284
- for (let attempt = 0; attempt < 10; attempt++) {
285
- const ports = await getProcessPorts(actualPid);
286
- if (ports.length > 0) {
287
- actualPort = ports[0];
288
- break;
289
- }
290
- await sleep(1000);
291
- }
292
-
293
- await retryDatabaseOperation(() =>
294
- insertProcess({
295
- pid: actualPid,
296
- workdir: bgrDir,
297
- command,
298
- name: dashboardName,
299
- env: '',
300
- configPath: '',
301
- stdout_path: stdoutPath,
302
- stderr_path: stderrPath,
303
- })
304
- );
305
-
306
- const portDisplay = actualPort ? String(actualPort) : '(detecting...)';
307
- const urlDisplay = actualPort ? `http://localhost:${actualPort}` : 'http://localhost (port auto-assigned)';
308
-
309
- const msg = dedent`
310
- ${chalk.bold('⚡ BGR Dashboard launched')}
311
- ${chalk.gray('─'.repeat(40))}
312
-
313
- 🌐 Open in browser: ${chalk.cyan.underline(urlDisplay)}
314
- 📊 Manage all your processes from the web UI
315
- 🔄 Auto-refreshes every 3 seconds
316
-
317
- ${chalk.gray('─'.repeat(40))}
318
- Process: ${chalk.white(dashboardName)} | PID: ${chalk.white(String(actualPid))} | Port: ${chalk.white(portDisplay)}
319
-
320
- ${chalk.yellow('bgrun bgr-dashboard --logs')} View dashboard logs
321
- ${chalk.yellow('bgrun --stop bgr-dashboard')} Stop the dashboard
322
- ${chalk.yellow('bgrun --restart bgr-dashboard')} Restart the dashboard
323
- `;
324
- announce(msg, 'BGR Dashboard');
325
- return;
326
- }
327
-
328
- // Guard: spawn the standalone guard as a bgr-managed process
329
- if (values.guard) {
330
- const guardName = 'bgr-guard';
331
- const homePath = getHomeDir();
332
- const bgrDir = join(homePath, '.bgr');
333
-
334
- // Check if guard is already running
335
- const existing = getProcess(guardName);
336
- if (existing && await isProcessRunning(existing.pid)) {
337
- announce(
338
- `Guard is already running (PID ${existing.pid})\n\n` +
339
- ` Use ${chalk.yellow(`bgrun --stop ${guardName}`)} to stop it\n` +
340
- ` Use ${chalk.yellow(`bgrun --guard --force`)} to restart`,
341
- 'BGR Guard'
342
- );
343
- return;
344
- }
345
-
346
- // Kill existing if force
347
- if (existing) {
348
- if (await isProcessRunning(existing.pid)) {
349
- await terminateProcess(existing.pid);
350
- }
351
- await retryDatabaseOperation(() => removeProcessByName(guardName));
352
- }
353
-
354
- const { resolve } = require('path');
355
- const scriptPath = resolve(process.argv[1]);
356
- const spawnCommand = `bun run ${scriptPath} --_guard-loop`;
357
- const command = `bgrun --_guard-loop`;
358
- const stdoutPath = join(bgrDir, `${guardName}-out.txt`);
359
- const stderrPath = join(bgrDir, `${guardName}-err.txt`);
360
-
361
- await Bun.write(stdoutPath, '');
362
- await Bun.write(stderrPath, '');
363
-
364
- const newProcess = Bun.spawn(getShellCommand(spawnCommand), {
365
- env: { ...Bun.env, BGR_STDOUT: stdoutPath, BGR_STDERR: stderrPath },
366
- cwd: bgrDir,
367
- stdout: "ignore",
368
- stderr: "ignore",
369
- detached: true, // Windows: new process group outside parent's Job Object — survives terminal close
370
- } as any);
371
-
372
- newProcess.unref();
373
- await sleep(1000);
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
- }
386
-
387
- await retryDatabaseOperation(() =>
388
- insertProcess({
389
- pid: actualPid,
390
- workdir: bgrDir,
391
- command,
392
- name: guardName,
393
- env: 'BGR_KEEP_ALIVE=false', // Guard doesn't guard itself
394
- configPath: '',
395
- stdout_path: stdoutPath,
396
- stderr_path: stderrPath,
397
- })
398
- );
399
-
400
- const msg = dedent`
401
- ${chalk.bold('🛡️ BGR Standalone Guard launched')}
402
- ${chalk.gray('─'.repeat(40))}
403
-
404
- Monitors: All processes with BGR_KEEP_ALIVE=true
405
- Also watches: bgr-dashboard (auto-restart if it dies)
406
- Check interval: 30 seconds
407
- Backoff: Exponential after 5 rapid crashes
408
-
409
- ${chalk.gray('─'.repeat(40))}
410
- Process: ${chalk.white(guardName)} | PID: ${chalk.white(String(actualPid))}
411
-
412
- ${chalk.yellow(`bgrun ${guardName} --logs`)} View guard logs
413
- ${chalk.yellow(`bgrun --stop ${guardName}`)} Stop the guard
414
- `;
415
- announce(msg, 'BGR Guard');
416
- return;
417
- }
418
-
419
- if (values.version) {
420
- console.log(`bgrun version: ${await getVersion()}`);
421
- return;
422
- }
423
-
424
- if (values.help) {
425
- await showHelp();
426
- return;
427
- }
428
-
429
- if (values.debug) {
430
- const info = getDbInfo();
431
- const version = await getVersion();
432
- console.log(dedent`
433
- ${chalk.bold('bgrun debug info')}
434
- ${chalk.gray('─'.repeat(40))}
435
- Version: ${chalk.cyan(version)}
436
- BGR Home: ${chalk.yellow(info.bgrHome)}
437
- DB Path: ${chalk.yellow(info.dbPath)}
438
- DB File: ${info.dbFilename}
439
- DB Exists: ${info.exists ? chalk.green('✓') : chalk.red('✗')}
440
- Platform: ${process.platform}
441
- Bun: ${Bun.version}
442
- `);
443
- return;
444
- }
445
-
446
- // Commands flow
447
- if (values.nuke) {
448
- await handleDeleteAll();
449
- return;
450
- }
451
-
452
- if (values.clean) {
453
- await handleClean();
454
- return;
455
- }
456
-
457
- // Restart all registered processes
458
- if (values['restart-all']) {
459
- const { getAllProcesses } = await import('./db');
460
- const all = getAllProcesses();
461
- if (all.length === 0) {
462
- error('No processes registered.');
463
- return;
464
- }
465
- console.log(chalk.bold(`\n Restarting ${all.length} processes...\n`));
466
- for (const proc of all) {
467
- try {
468
- console.log(chalk.yellow(` ↻ Restarting ${proc.name}...`));
469
- await handleRun({
470
- action: 'run',
471
- name: proc.name,
472
- force: true,
473
- remoteName: '',
474
- });
475
- } catch (err: any) {
476
- console.error(chalk.red(` ✗ Failed to restart ${proc.name}: ${err.message}`));
477
- }
478
- }
479
- console.log(chalk.green(`\n ✓ All processes restarted.\n`));
480
- return;
481
- }
482
-
483
- // Stop all running processes
484
- if (values['stop-all']) {
485
- const { getAllProcesses } = await import('./db');
486
- const all = getAllProcesses();
487
- if (all.length === 0) {
488
- error('No processes registered.');
489
- return;
490
- }
491
- console.log(chalk.bold(`\n Stopping ${all.length} processes...\n`));
492
- for (const proc of all) {
493
- try {
494
- if (await isProcessRunning(proc.pid)) {
495
- console.log(chalk.yellow(` ■ Stopping ${proc.name} (PID ${proc.pid})...`));
496
- await handleStop(proc.name);
497
- } else {
498
- console.log(chalk.gray(` ○ ${proc.name} already stopped`));
499
- }
500
- } catch (err: any) {
501
- console.error(chalk.red(` ✗ Failed to stop ${proc.name}: ${err.message}`));
502
- }
503
- }
504
- console.log(chalk.green(`\n ✓ All processes stopped.\n`));
505
- return;
506
- }
507
-
508
- const name = (values.name as string) || positionals[0];
509
-
510
- // Delete
511
- if (values.delete) {
512
- // bgr --delete (bool)
513
- if (name) {
514
- await handleDelete(name);
515
- } else {
516
- error("Please specify a process name to delete.");
517
- }
518
- return;
519
- }
520
-
521
- // Restart
522
- if (values.restart) {
523
- if (!name) {
524
- error("Please specify a process name to restart.");
525
- }
526
- await handleRun({
527
- action: 'run',
528
- name: name,
529
- force: true,
530
- // other options undefined, handleRun will look up process
531
- remoteName: '',
532
- });
533
- return;
534
- }
535
-
536
- // Stop
537
- if (values.stop) {
538
- if (!name) {
539
- error("Please specify a process name to stop.");
540
- }
541
- await handleStop(name);
542
- return;
543
- }
544
-
545
- // Logs
546
- if (values.logs || values["log-stdout"] || values["log-stderr"]) {
547
- if (!name) {
548
- error("Please specify a process name to show logs for.");
549
- }
550
- const logType = values["log-stdout"] ? 'stdout' : (values["log-stderr"] ? 'stderr' : 'both');
551
- const lines = values.lines ? parseInt(values.lines as string) : undefined;
552
- await showLogs(name, logType, lines);
553
- return;
554
- }
555
-
556
- // Watch
557
- if (values.watch) {
558
- await handleWatch({
559
- action: 'watch',
560
- name: name,
561
- command: values.command as string | undefined,
562
- directory: values.directory as string | undefined,
563
- configPath: values.config as string | undefined,
564
- force: values.force as boolean | undefined,
565
- remoteName: '',
566
- dbPath: values.db as string | undefined,
567
- stdout: values.stdout as string | undefined,
568
- stderr: values.stderr as string | undefined
569
- }, {
570
- showLogs: (values.logs as boolean) || false,
571
- logType: 'both',
572
- lines: values.lines ? parseInt(values.lines as string) : undefined
573
- });
574
- return;
575
- }
576
-
577
- // List or Run or Details
578
- if (name) {
579
- if (!values.command && !values.directory) {
580
- await showDetails(name);
581
- } else {
582
- await handleRun({
583
- action: 'run',
584
- name: name,
585
- command: values.command as string | undefined,
586
- directory: values.directory as string | undefined,
587
- configPath: values.config as string | undefined,
588
- force: values.force as boolean | undefined,
589
- fetch: values.fetch as boolean | undefined,
590
- remoteName: '',
591
- dbPath: values.db as string | undefined,
592
- stdout: values.stdout as string | undefined,
593
- stderr: values.stderr as string | undefined
594
- });
595
- }
596
- } else {
597
- if (values.command) {
598
- error("Process name is required.");
599
- }
600
- await showAll({
601
- json: values.json as boolean | undefined,
602
- filter: values.filter as string | undefined
603
- });
604
- }
605
- }
606
-
607
- run().catch(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);
614
- });