bgrun 3.11.0 → 3.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bgrun",
3
- "version": "3.11.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",
@@ -19,6 +19,7 @@
19
19
  "dist",
20
20
  "src",
21
21
  "dashboard/app",
22
+ "scripts",
22
23
  "README.md",
23
24
  "image.png",
24
25
  "examples/bgr-startup.sh"
@@ -50,7 +51,7 @@
50
51
  "chalk": "^5.4.1",
51
52
  "dedent": "^1.5.3",
52
53
  "measure-fn": "3.10.1",
53
- "melina": "2.3.3",
54
+ "melina": "2.3.6",
54
55
  "react": "^19.2.4",
55
56
  "react-dom": "^19.2.4",
56
57
  "sqlite-zod-orm": "3.26.1"
@@ -58,4 +59,4 @@
58
59
  "engines": {
59
60
  "bun": ">=1.0.0"
60
61
  }
61
- }
62
+ }
@@ -0,0 +1,118 @@
1
+ # bgr-startup.ps1 — Auto-start bgrun guard on Windows login
2
+ # Ensures all guarded processes (and the dashboard itself) start on boot.
3
+ #
4
+ # Installation:
5
+ # 1. Run this script once with -Install flag:
6
+ # powershell -ExecutionPolicy Bypass -File bgr-startup.ps1 -Install
7
+ #
8
+ # 2. Or manually create a Task Scheduler task:
9
+ # - Trigger: At log on
10
+ # - Action: powershell -WindowStyle Hidden -ExecutionPolicy Bypass -File "C:\Code\bgr\scripts\bgr-startup.ps1"
11
+ # - Run whether user is logged on or not: Yes
12
+ #
13
+ # Usage:
14
+ # bgr-startup.ps1 # Start bgrun guard
15
+ # bgr-startup.ps1 -Install # Register Task Scheduler entry
16
+
17
+ param(
18
+ [switch]$Install
19
+ )
20
+
21
+ $BunPath = "$env:USERPROFILE\.bun\bin\bun.exe"
22
+ $BgrunPath = "C:\Code\bgr"
23
+ $LogPath = "$env:USERPROFILE\.bgr\startup.log"
24
+
25
+ function Write-Log {
26
+ param([string]$Message)
27
+ $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
28
+ $line = "[$timestamp] $Message"
29
+ Write-Host $line
30
+ Add-Content -Path $LogPath -Value $line -ErrorAction SilentlyContinue
31
+ }
32
+
33
+ # Ensure .bgr directory exists
34
+ $bgrDir = "$env:USERPROFILE\.bgr"
35
+ if (-not (Test-Path $bgrDir)) {
36
+ New-Item -ItemType Directory -Path $bgrDir -Force | Out-Null
37
+ }
38
+
39
+ if ($Install) {
40
+ Write-Log "Installing bgrun auto-start task..."
41
+
42
+ $scriptPath = $PSCommandPath
43
+ if (-not $scriptPath) {
44
+ $scriptPath = Join-Path $BgrunPath "scripts\bgr-startup.ps1"
45
+ }
46
+
47
+ # Remove existing task if present
48
+ $existingTask = Get-ScheduledTask -TaskName "bgrun-guard" -ErrorAction SilentlyContinue
49
+ if ($existingTask) {
50
+ Unregister-ScheduledTask -TaskName "bgrun-guard" -Confirm:$false
51
+ Write-Log "Removed existing bgrun-guard task"
52
+ }
53
+
54
+ # Create the scheduled task
55
+ $action = New-ScheduledTaskAction `
56
+ -Execute "powershell.exe" `
57
+ -Argument "-WindowStyle Hidden -ExecutionPolicy Bypass -File `"$scriptPath`""
58
+
59
+ $trigger = New-ScheduledTaskTrigger -AtLogon
60
+ $settings = New-ScheduledTaskSettingsSet `
61
+ -AllowStartIfOnBatteries `
62
+ -DontStopIfGoingOnBatteries `
63
+ -StartWhenAvailable `
64
+ -RestartInterval (New-TimeSpan -Minutes 5) `
65
+ -RestartCount 3
66
+
67
+ Register-ScheduledTask `
68
+ -TaskName "bgrun-guard" `
69
+ -Description "bgrun process manager - auto-starts all guarded processes on login" `
70
+ -Action $action `
71
+ -Trigger $trigger `
72
+ -Settings $settings `
73
+ -RunLevel Highest `
74
+ -Force
75
+
76
+ Write-Log "[OK] Task 'bgrun-guard' registered. Will start on next login."
77
+ Write-Log " Script: $scriptPath"
78
+ exit 0
79
+ }
80
+
81
+ # ── Main: Start bgrun guard ─────────────────────────────────
82
+ Write-Log "bgrun startup initiated"
83
+
84
+ # Check bun exists
85
+ if (-not (Test-Path $BunPath)) {
86
+ Write-Log "ERROR: bun not found at $BunPath"
87
+ exit 1
88
+ }
89
+
90
+ # Check bgrun repo exists
91
+ if (-not (Test-Path "$BgrunPath\src\guard.ts")) {
92
+ Write-Log "ERROR: bgrun not found at $BgrunPath"
93
+ exit 1
94
+ }
95
+
96
+ # Start the dashboard first (guard needs it)
97
+ Write-Log "Starting bgrun dashboard..."
98
+ $dashboardProc = Start-Process -FilePath $BunPath `
99
+ -ArgumentList "run", "$BgrunPath\src\index.ts", "--dashboard", "--port", "3000" `
100
+ -WindowStyle Hidden `
101
+ -PassThru `
102
+ -WorkingDirectory $BgrunPath
103
+
104
+ Write-Log "Dashboard PID: $($dashboardProc.Id)"
105
+
106
+ # Wait for dashboard to be ready
107
+ Start-Sleep -Seconds 5
108
+
109
+ # Start the guard (watches dashboard + all guarded processes)
110
+ Write-Log "Starting bgrun guard..."
111
+ $guardProc = Start-Process -FilePath $BunPath `
112
+ -ArgumentList "run", "$BgrunPath\src\index.ts", "--guard" `
113
+ -WindowStyle Hidden `
114
+ -PassThru `
115
+ -WorkingDirectory $BgrunPath
116
+
117
+ Write-Log "Guard PID: $($guardProc.Id)"
118
+ Write-Log "[OK] bgrun startup complete. Dashboard: $($dashboardProc.Id), Guard: $($guardProc.Id)"
@@ -0,0 +1,91 @@
1
+ # bgrun-startup.ps1 — Persistent bgr-dashboard launcher
2
+ # Ensures the bgrun dashboard survives terminal closures.
3
+ # Individual services (geeksy, mm-dash, galaxy-canvas, etc.) are managed
4
+ # by bgr-guard INSIDE the dashboard — this script only keeps the dashboard alive.
5
+ #
6
+ # Usage:
7
+ # .\bgrun-startup.ps1 # Start bgr-dashboard
8
+ # .\bgrun-startup.ps1 -Install # Register as logon scheduled task
9
+ # .\bgrun-startup.ps1 -Uninstall # Remove scheduled task
10
+ # .\bgrun-startup.ps1 -Guard # Run guard loop (auto-restart dashboard)
11
+
12
+ param(
13
+ [switch]$Install,
14
+ [switch]$Uninstall,
15
+ [switch]$Guard
16
+ )
17
+
18
+ $TaskName = "bgrun-persistence"
19
+ $ScriptPath = $MyInvocation.MyCommand.Path
20
+
21
+ # ─── Install as Scheduled Task ────────────────────────────
22
+ if ($Install) {
23
+ $action = New-ScheduledTaskAction `
24
+ -Execute "pwsh.exe" `
25
+ -Argument "-WindowStyle Hidden -NonInteractive -File `"$ScriptPath`" -Guard"
26
+
27
+ $trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
28
+ $settings = New-ScheduledTaskSettingsSet `
29
+ -AllowStartIfOnBatteries `
30
+ -DontStopIfGoingOnBatteries `
31
+ -StartWhenAvailable `
32
+ -RestartInterval (New-TimeSpan -Minutes 1) `
33
+ -RestartCount 3 `
34
+ -ExecutionTimeLimit (New-TimeSpan -Hours 0) # No time limit
35
+
36
+ Register-ScheduledTask `
37
+ -TaskName $TaskName `
38
+ -Action $action `
39
+ -Trigger $trigger `
40
+ -Settings $settings `
41
+ -Description "Keeps bgr-dashboard alive across terminal closures. Dashboard's own bgr-guard handles individual services." `
42
+ -Force
43
+
44
+ Write-Host "✅ Scheduled task '$TaskName' registered for user $env:USERNAME"
45
+ Write-Host " Runs at logon — keeps bgr-dashboard alive"
46
+ Write-Host " To start immediately: schtasks /run /tn '$TaskName'"
47
+ exit 0
48
+ }
49
+
50
+ # ─── Uninstall ────────────────────────────────────────────
51
+ if ($Uninstall) {
52
+ Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
53
+ Write-Host "🗑️ Scheduled task '$TaskName' removed"
54
+ exit 0
55
+ }
56
+
57
+ # ─── Guard mode: keep bgr-dashboard alive ─────────────────
58
+ if ($Guard) {
59
+ Write-Host "🛡️ bgrun dashboard guard started at $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
60
+
61
+ while ($true) {
62
+ try {
63
+ $dashboard = & bgrun --json 2>$null | ConvertFrom-Json | Where-Object { $_.name -eq 'bgr-dashboard' }
64
+
65
+ if (-not $dashboard -or $dashboard.status -ne "running") {
66
+ Write-Host "[$(Get-Date -Format 'HH:mm:ss')] ⚠️ bgr-dashboard is DOWN — restarting..."
67
+ & bgrun --restart bgr-dashboard --force 2>$null
68
+ Start-Sleep -Seconds 3
69
+ }
70
+ } catch {
71
+ Write-Host "[$(Get-Date -Format 'HH:mm:ss')] ❌ Guard check failed: $_"
72
+ }
73
+
74
+ Start-Sleep -Seconds 60
75
+ }
76
+ exit 0
77
+ }
78
+
79
+ # ─── Default: one-time start ──────────────────────────────
80
+ Write-Host "🚀 Starting bgr-dashboard..."
81
+ $dashboard = & bgrun --json 2>$null | ConvertFrom-Json | Where-Object { $_.name -eq 'bgr-dashboard' }
82
+ if ($dashboard -and $dashboard.status -eq "running") {
83
+ Write-Host " ✓ bgr-dashboard already running (PID $($dashboard.pid))"
84
+ } else {
85
+ & bgrun --restart bgr-dashboard --force 2>$null
86
+ Start-Sleep -Seconds 2
87
+ Write-Host " ✓ bgr-dashboard started"
88
+ }
89
+ Write-Host "`n✅ Done. Dashboard's bgr-guard handles individual services."
90
+ Write-Host " 💡 Run with -Install to persist across terminal closures"
91
+
@@ -0,0 +1,109 @@
1
+ /**
2
+ * bgrun core utility tests
3
+ *
4
+ * Tests pure logic functions: env parsing, config flattening,
5
+ * string truncation, and runtime calculation.
6
+ *
7
+ * Run: bun test src/bgrun.test.ts
8
+ */
9
+ import { describe, expect, test } from 'bun:test'
10
+ import { parseEnvString, calculateRuntime } from './utils'
11
+ import { stripAnsi, truncateString, truncatePath } from './table'
12
+
13
+ // ─── parseEnvString ─────────────────────────────────────
14
+
15
+ describe('parseEnvString', () => {
16
+ test('parses comma-separated key=value pairs', () => {
17
+ const result = parseEnvString('PORT=3000,HOST=localhost,DEBUG=true')
18
+ expect(result).toEqual({
19
+ PORT: '3000',
20
+ HOST: 'localhost',
21
+ DEBUG: 'true',
22
+ })
23
+ })
24
+
25
+ test('handles single pair', () => {
26
+ expect(parseEnvString('KEY=value')).toEqual({ KEY: 'value' })
27
+ })
28
+
29
+ test('handles empty string', () => {
30
+ expect(parseEnvString('')).toEqual({})
31
+ })
32
+
33
+ test('ignores malformed pairs (no =)', () => {
34
+ const result = parseEnvString('GOOD=yes,BAD,ALSO_GOOD=ok')
35
+ expect(result.GOOD).toBe('yes')
36
+ expect(result.ALSO_GOOD).toBe('ok')
37
+ expect(result.BAD).toBeUndefined()
38
+ })
39
+ })
40
+
41
+ // ─── calculateRuntime ───────────────────────────────────
42
+
43
+ describe('calculateRuntime', () => {
44
+ test('returns 0 minutes for recent start', () => {
45
+ const now = new Date().toISOString()
46
+ expect(calculateRuntime(now)).toBe('0 minutes')
47
+ })
48
+
49
+ test('returns correct minutes', () => {
50
+ const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString()
51
+ expect(calculateRuntime(fiveMinAgo)).toBe('5 minutes')
52
+ })
53
+
54
+ test('returns correct for 1 hour', () => {
55
+ const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString()
56
+ expect(calculateRuntime(oneHourAgo)).toBe('60 minutes')
57
+ })
58
+ })
59
+
60
+ // ─── stripAnsi ──────────────────────────────────────────
61
+
62
+ describe('stripAnsi', () => {
63
+ test('strips color codes', () => {
64
+ const colored = '\u001b[31mred text\u001b[0m'
65
+ expect(stripAnsi(colored)).toBe('red text')
66
+ })
67
+
68
+ test('passes through plain text', () => {
69
+ expect(stripAnsi('hello world')).toBe('hello world')
70
+ })
71
+
72
+ test('handles empty string', () => {
73
+ expect(stripAnsi('')).toBe('')
74
+ })
75
+ })
76
+
77
+ // ─── truncateString ─────────────────────────────────────
78
+
79
+ describe('truncateString', () => {
80
+ test('returns string unchanged if within limit', () => {
81
+ expect(truncateString('hello', 10)).toBe('hello')
82
+ })
83
+
84
+ test('truncates with ellipsis', () => {
85
+ const result = truncateString('a very long string that exceeds limit', 15)
86
+ expect(result.length).toBeLessThanOrEqual(15)
87
+ expect(result).toContain('…')
88
+ })
89
+
90
+ test('handles maxLength smaller than ellipsis', () => {
91
+ const result = truncateString('hello world', 2)
92
+ expect(result.length).toBeLessThanOrEqual(2)
93
+ })
94
+ })
95
+
96
+ // ─── truncatePath ───────────────────────────────────────
97
+
98
+ describe('truncatePath', () => {
99
+ test('returns path unchanged if within limit', () => {
100
+ expect(truncatePath('/home/user', 50)).toBe('/home/user')
101
+ })
102
+
103
+ test('truncates middle of long path', () => {
104
+ const longPath = '/home/user/projects/very/deeply/nested/directory/structure'
105
+ const result = truncatePath(longPath, 30)
106
+ expect(result.length).toBeLessThanOrEqual(30)
107
+ expect(result).toContain('…')
108
+ })
109
+ })
@@ -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
  // =============================================================================