bgrun 3.10.2 → 3.12.0

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,60 +1,62 @@
1
- {
2
- "name": "bgrun",
3
- "version": "3.10.2",
4
- "description": "bgrun — A lightweight process manager for Bun",
5
- "type": "module",
6
- "main": "./src/api.ts",
7
- "exports": {
8
- ".": "./src/api.ts"
9
- },
10
- "bin": {
11
- "bgrun": "./dist/index.js"
12
- },
13
- "scripts": {
14
- "build": "bun run ./src/build.ts",
15
- "test": "bun test",
16
- "prepublishOnly": "bun run build"
17
- },
18
- "files": [
19
- "dist",
20
- "src",
21
- "dashboard/app",
22
- "README.md",
23
- "image.png",
24
- "examples/bgr-startup.sh"
25
- ],
26
- "keywords": [
27
- "process-manager",
28
- "bun",
29
- "monitoring",
30
- "devops",
31
- "deployment",
32
- "background",
33
- "daemon"
34
- ],
35
- "author": "7flash",
36
- "license": "MIT",
37
- "repository": {
38
- "type": "git",
39
- "url": "https://github.com/7flash/bgrun.git"
40
- },
41
- "devDependencies": {
42
- "bun-types": "latest"
43
- },
44
- "peerDependencies": {
45
- "typescript": "^5.0.0"
46
- },
47
- "dependencies": {
48
- "boxen": "^8.0.1",
49
- "chalk": "^5.4.1",
50
- "dedent": "^1.5.3",
51
- "measure-fn": "^3.2.1",
52
- "melina": "^2.2.1",
53
- "react": "^19.2.4",
54
- "react-dom": "^19.2.4",
55
- "sqlite-zod-orm": "^3.8.0"
56
- },
57
- "engines": {
58
- "bun": ">=1.0.0"
59
- }
60
- }
1
+ {
2
+ "name": "bgrun",
3
+ "version": "3.12.0",
4
+ "description": "bgrun — A lightweight process manager for Bun",
5
+ "type": "module",
6
+ "main": "./src/api.ts",
7
+ "exports": {
8
+ ".": "./src/api.ts"
9
+ },
10
+ "bin": {
11
+ "bgrun": "./dist/index.js"
12
+ },
13
+ "scripts": {
14
+ "build": "bun run ./src/build.ts",
15
+ "test": "bun test",
16
+ "prepublishOnly": "bun run build"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "dashboard/app",
22
+ "scripts",
23
+ "README.md",
24
+ "image.png",
25
+ "examples/bgr-startup.sh"
26
+ ],
27
+ "keywords": [
28
+ "process-manager",
29
+ "bun",
30
+ "monitoring",
31
+ "devops",
32
+ "deployment",
33
+ "background",
34
+ "daemon"
35
+ ],
36
+ "author": "7flash",
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/7flash/bgrun.git"
41
+ },
42
+ "devDependencies": {
43
+ "@types/bun": "^1.3.10",
44
+ "bun-types": "latest"
45
+ },
46
+ "peerDependencies": {
47
+ "typescript": "^5.0.0"
48
+ },
49
+ "dependencies": {
50
+ "boxen": "^8.0.1",
51
+ "chalk": "^5.4.1",
52
+ "dedent": "^1.5.3",
53
+ "measure-fn": "3.10.1",
54
+ "melina": "2.3.6",
55
+ "react": "^19.2.4",
56
+ "react-dom": "^19.2.4",
57
+ "sqlite-zod-orm": "3.26.1"
58
+ },
59
+ "engines": {
60
+ "bun": ">=1.0.0"
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 "✓ 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 "✓ bgrun startup complete. Dashboard: $($dashboardProc.Id), Guard: $($guardProc.Id)"
package/src/api.ts CHANGED
@@ -39,7 +39,7 @@ export {
39
39
  ensureDir,
40
40
  getHomeDir,
41
41
  isWindows,
42
- getProcessBatchMemory,
42
+ getProcessBatchResources,
43
43
  getProcessMemory
44
44
  } from './platform'
45
45
 
@@ -51,13 +51,13 @@ export { getVersion, calculateRuntime, parseEnvString, validateDirectory } from
51
51
 
52
52
  // --- Default Export (namespace style) ---
53
53
  import { getAllProcesses, getProcess, insertProcess, removeProcess, removeProcessByName, removeAllProcesses, retryDatabaseOperation, getDbInfo, dbPath, bgrHome } from './db'
54
- import { isProcessRunning, terminateProcess, readFileTail, getProcessPorts, findChildPid, findPidByPort, getShellCommand, killProcessOnPort, waitForPortFree, ensureDir, getHomeDir, isWindows, getProcessBatchMemory, getProcessMemory } from './platform'
54
+ import { isProcessRunning, terminateProcess, readFileTail, getProcessPorts, findChildPid, findPidByPort, getShellCommand, killProcessOnPort, waitForPortFree, ensureDir, getHomeDir, isWindows, getProcessBatchResources, getProcessMemory } from './platform'
55
55
  import { handleRun } from './commands/run'
56
56
  import { getVersion, calculateRuntime, parseEnvString, validateDirectory } from './utils'
57
57
 
58
58
  export default {
59
59
  getAllProcesses, getProcess, insertProcess, removeProcess, removeProcessByName, removeAllProcesses, retryDatabaseOperation, getDbInfo, dbPath, bgrHome,
60
- isProcessRunning, terminateProcess, readFileTail, getProcessPorts, findChildPid, findPidByPort, getShellCommand, killProcessOnPort, waitForPortFree, ensureDir, getHomeDir, isWindows, getProcessBatchMemory, getProcessMemory,
60
+ isProcessRunning, terminateProcess, readFileTail, getProcessPorts, findChildPid, findPidByPort, getShellCommand, killProcessOnPort, waitForPortFree, ensureDir, getHomeDir, isWindows, getProcessBatchResources, getProcessMemory,
61
61
  handleRun,
62
62
  getVersion, calculateRuntime, parseEnvString, validateDirectory,
63
63
  }
@@ -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
+ })
@@ -4,7 +4,7 @@ import type { ProcessTableRow } from "../table";
4
4
  import { getAllProcesses } from "../db";
5
5
  import { announce } from "../logger";
6
6
  import { isProcessRunning, calculateRuntime, parseEnvString } from "../utils";
7
- import { getProcessPorts, getProcessBatchMemory } from "../platform";
7
+ import { getProcessPorts, getProcessBatchResources } from "../platform";
8
8
  import { measure } from "measure-fn";
9
9
 
10
10
  function formatMemory(bytes: number): string {
@@ -51,12 +51,12 @@ export async function showAll(opts?: { json?: boolean; filter?: string }) {
51
51
 
52
52
  // Batch fetch memory for all PIDs
53
53
  const allPids = filtered.map(p => p.pid);
54
- const memoryMap = await getProcessBatchMemory(allPids);
54
+ const resourceMap = await getProcessBatchResources(allPids);
55
55
 
56
56
  for (const proc of filtered) {
57
57
  const isRunning = await isProcessRunning(proc.pid, proc.command);
58
58
  const runtime = calculateRuntime(proc.timestamp);
59
- const mem = isRunning ? (memoryMap.get(proc.pid) || 0) : 0;
59
+ const mem = isRunning ? (resourceMap.get(proc.pid)?.memory || 0) : 0;
60
60
 
61
61
  const ports = isRunning ? await getProcessPorts(proc.pid) : [];
62
62
  tableData.push({
@@ -17,6 +17,23 @@ export async function handleRun(options: CommandOptions) {
17
17
 
18
18
  const existingProcess = name ? getProcess(name) : null;
19
19
 
20
+ // Auto-start unmet dependencies before starting this process
21
+ if (name && existingProcess) {
22
+ const { getUnmetDeps } = await import('../deps');
23
+ const unmet = await getUnmetDeps(name);
24
+ if (unmet.length > 0) {
25
+ await run.measure(`Start ${unmet.length} dependencies for "${name}"`, async () => {
26
+ for (const depName of unmet) {
27
+ const depProc = getProcess(depName);
28
+ if (depProc) {
29
+ announce(`📦 Starting dependency "${depName}" for "${name}"`, 'Dependency');
30
+ await handleRun({ action: 'run', name: depName, force: true, remoteName: '' });
31
+ }
32
+ }
33
+ });
34
+ }
35
+ }
36
+
20
37
  if (existingProcess) {
21
38
  const finalDirectory = directory || existingProcess.workdir;
22
39
  validateDirectory(finalDirectory);
package/src/deps.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Process Dependency Graph
3
+ *
4
+ * Defines and resolves process startup dependencies.
5
+ * Dependencies are stored in process env as BGR_DEPENDS_ON=name1,name2
6
+ *
7
+ * Features:
8
+ * - Topological sort for startup order
9
+ * - Cycle detection
10
+ * - Dependency graph as adjacency list
11
+ * - Auto-start dependencies on process run
12
+ */
13
+
14
+ import { getAllProcesses, getProcess } from './db'
15
+ import { isProcessRunning } from './platform'
16
+ import { parseEnvString } from './utils'
17
+
18
+ export interface DepNode {
19
+ name: string
20
+ dependsOn: string[] // processes this depends ON (must start first)
21
+ dependedBy: string[] // processes that depend on THIS
22
+ running: boolean
23
+ pid: number
24
+ }
25
+
26
+ export interface DepGraph {
27
+ nodes: DepNode[]
28
+ order: string[] // topological sort (startup order)
29
+ hasCycle: boolean
30
+ cycleNodes?: string[] // nodes involved in cycle
31
+ }
32
+
33
+ /** Parse BGR_DEPENDS_ON from a process env string */
34
+ export function getDependencies(envStr: string): string[] {
35
+ const env = parseEnvString(envStr)
36
+ const raw = env.BGR_DEPENDS_ON || ''
37
+ return raw.split(',').map(s => s.trim()).filter(Boolean)
38
+ }
39
+
40
+ /** Build the full dependency graph from all registered processes */
41
+ export async function buildDepGraph(): Promise<DepGraph> {
42
+ const processes = getAllProcesses()
43
+ const nodeMap = new Map<string, DepNode>()
44
+
45
+ // Phase 1: Create all nodes
46
+ for (const proc of processes) {
47
+ const deps = getDependencies(proc.env)
48
+ const alive = await isProcessRunning(proc.pid, proc.command)
49
+ nodeMap.set(proc.name, {
50
+ name: proc.name,
51
+ dependsOn: deps,
52
+ dependedBy: [],
53
+ running: alive,
54
+ pid: proc.pid,
55
+ })
56
+ }
57
+
58
+ // Phase 2: Build reverse edges (dependedBy)
59
+ for (const node of nodeMap.values()) {
60
+ for (const dep of node.dependsOn) {
61
+ const depNode = nodeMap.get(dep)
62
+ if (depNode) {
63
+ depNode.dependedBy.push(node.name)
64
+ }
65
+ }
66
+ }
67
+
68
+ // Phase 3: Topological sort (Kahn's algorithm)
69
+ const inDegree = new Map<string, number>()
70
+ for (const node of nodeMap.values()) {
71
+ inDegree.set(node.name, node.dependsOn.filter(d => nodeMap.has(d)).length)
72
+ }
73
+
74
+ const queue: string[] = []
75
+ for (const [name, degree] of inDegree) {
76
+ if (degree === 0) queue.push(name)
77
+ }
78
+
79
+ const order: string[] = []
80
+ while (queue.length > 0) {
81
+ const current = queue.shift()!
82
+ order.push(current)
83
+
84
+ const node = nodeMap.get(current)!
85
+ for (const dependent of node.dependedBy) {
86
+ const newDegree = (inDegree.get(dependent) || 0) - 1
87
+ inDegree.set(dependent, newDegree)
88
+ if (newDegree === 0) queue.push(dependent)
89
+ }
90
+ }
91
+
92
+ const hasCycle = order.length < nodeMap.size
93
+ const cycleNodes = hasCycle
94
+ ? [...nodeMap.keys()].filter(n => !order.includes(n))
95
+ : undefined
96
+
97
+ return {
98
+ nodes: [...nodeMap.values()],
99
+ order,
100
+ hasCycle,
101
+ cycleNodes,
102
+ }
103
+ }
104
+
105
+ /** Get unmet dependencies for a specific process */
106
+ export async function getUnmetDeps(name: string): Promise<string[]> {
107
+ const proc = getProcess(name)
108
+ if (!proc) return []
109
+
110
+ const deps = getDependencies(proc.env)
111
+ const unmet: string[] = []
112
+
113
+ for (const depName of deps) {
114
+ const depProc = getProcess(depName)
115
+ if (!depProc) {
116
+ unmet.push(depName) // dependency not even registered
117
+ continue
118
+ }
119
+ const alive = await isProcessRunning(depProc.pid, depProc.command)
120
+ if (!alive) {
121
+ unmet.push(depName)
122
+ }
123
+ }
124
+
125
+ return unmet
126
+ }