bgrun 3.12.0 → 3.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/db.ts CHANGED
@@ -18,10 +18,40 @@ 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
+
47
+ export const DependencySchema = z.object({
48
+ process_name: z.string(), // the process that has the dependency
49
+ depends_on: z.string(), // the process it depends on
50
+ created_at: z.string().default(() => new Date().toISOString()),
51
+ });
52
+
53
+ export type Dependency = z.infer<typeof DependencySchema> & { id: number };
54
+
25
55
  // =============================================================================
26
56
  // DATABASE INITIALIZATION
27
57
  // =============================================================================
@@ -48,9 +78,15 @@ if (!existsSync(dbPath) && existsSync(legacyDbPath)) {
48
78
 
49
79
  export const db = new Database(dbPath, {
50
80
  process: ProcessSchema,
81
+ template: TemplateSchema,
82
+ history: HistorySchema,
83
+ dependency: DependencySchema,
51
84
  }, {
52
85
  indexes: {
53
86
  process: ['name', 'timestamp', 'pid'],
87
+ template: ['name'],
88
+ history: ['process_name', 'timestamp'],
89
+ dependency: ['process_name', 'depends_on'],
54
90
  },
55
91
  });
56
92
 
@@ -127,6 +163,227 @@ export function updateProcessEnv(name: string, envJson: string) {
127
163
  }
128
164
  }
129
165
 
166
+ // =============================================================================
167
+ // TEMPLATE FUNCTIONS
168
+ // =============================================================================
169
+
170
+ export function getAllTemplates() {
171
+ return db.template.select().all();
172
+ }
173
+
174
+ export function getTemplate(name: string) {
175
+ return db.template.select().where({ name }).limit(1).get() || null;
176
+ }
177
+
178
+ export function saveTemplate(data: {
179
+ name: string;
180
+ command: string;
181
+ workdir?: string;
182
+ env?: string;
183
+ group?: string;
184
+ }) {
185
+ const existing = db.template.select().where({ name: data.name }).limit(1).get();
186
+ if (existing) {
187
+ db.template.update(existing.id, {
188
+ command: data.command,
189
+ workdir: data.workdir || '',
190
+ env: data.env || '',
191
+ group: data.group || '',
192
+ });
193
+ } else {
194
+ db.template.insert({
195
+ name: data.name,
196
+ command: data.command,
197
+ workdir: data.workdir || '',
198
+ env: data.env || '',
199
+ group: data.group || '',
200
+ });
201
+ }
202
+ }
203
+
204
+ export function deleteTemplate(name: string) {
205
+ const tmpl = db.template.select().where({ name }).limit(1).get();
206
+ if (tmpl) {
207
+ db.template.delete(tmpl.id);
208
+ }
209
+ }
210
+
211
+ // =============================================================================
212
+ // HISTORY FUNCTIONS
213
+ // =============================================================================
214
+
215
+ export function getProcessHistory(name: string, limit = 50) {
216
+ return db.history.select()
217
+ .where({ process_name: name })
218
+ .orderBy('timestamp', 'desc')
219
+ .limit(limit)
220
+ .all();
221
+ }
222
+
223
+ export function addHistoryEntry(processName: string, event: string, pid?: number, metadata = {}) {
224
+ return db.history.insert({
225
+ process_name: processName,
226
+ event,
227
+ pid,
228
+ metadata: JSON.stringify(metadata),
229
+ });
230
+ }
231
+
232
+ export function getRecentHistory(limit = 100) {
233
+ return db.history.select()
234
+ .orderBy('timestamp', 'desc')
235
+ .limit(limit)
236
+ .all();
237
+ }
238
+
239
+ export function clearOldHistory(daysToKeep = 30) {
240
+ const cutoff = new Date();
241
+ cutoff.setDate(cutoff.getDate() - daysToKeep);
242
+ const cutoffStr = cutoff.toISOString();
243
+
244
+ const oldEntries = db.history.select()
245
+ .where('timestamp', '<', cutoffStr)
246
+ .all();
247
+
248
+ for (const entry of oldEntries) {
249
+ db.history.delete(entry.id);
250
+ }
251
+
252
+ return oldEntries.length;
253
+ }
254
+
255
+ // =============================================================================
256
+ // DEPENDENCY FUNCTIONS
257
+ // =============================================================================
258
+
259
+ /** Get all dependencies for a process */
260
+ export function getDependencies(processName: string): string[] {
261
+ return db.dependency.select()
262
+ .where({ process_name: processName })
263
+ .all()
264
+ .map(d => d.depends_on);
265
+ }
266
+
267
+ /** Get all processes that depend on a given process */
268
+ export function getDependents(processName: string): string[] {
269
+ return db.dependency.select()
270
+ .where({ depends_on: processName })
271
+ .all()
272
+ .map(d => d.process_name);
273
+ }
274
+
275
+ /** Get the full dependency graph: { processName -> [dependsOn...] } */
276
+ export function getDependencyGraph(): Record<string, string[]> {
277
+ const all = db.dependency.select().all();
278
+ const graph: Record<string, string[]> = {};
279
+ for (const dep of all) {
280
+ if (!graph[dep.process_name]) graph[dep.process_name] = [];
281
+ graph[dep.process_name].push(dep.depends_on);
282
+ }
283
+ return graph;
284
+ }
285
+
286
+ /** Add a dependency (process_name depends on depends_on) */
287
+ export function addDependency(processName: string, dependsOn: string): boolean {
288
+ // Prevent self-dependency
289
+ if (processName === dependsOn) return false;
290
+
291
+ // Prevent duplicates
292
+ const existing = db.dependency.select()
293
+ .where({ process_name: processName, depends_on: dependsOn })
294
+ .limit(1)
295
+ .get();
296
+ if (existing) return false;
297
+
298
+ // Prevent circular dependencies
299
+ if (wouldCreateCycle(processName, dependsOn)) return false;
300
+
301
+ db.dependency.insert({ process_name: processName, depends_on: dependsOn });
302
+ return true;
303
+ }
304
+
305
+ /** Remove a dependency */
306
+ export function removeDependency(processName: string, dependsOn: string) {
307
+ const matches = db.dependency.select()
308
+ .where({ process_name: processName, depends_on: dependsOn })
309
+ .all();
310
+ for (const dep of matches) {
311
+ db.dependency.delete(dep.id);
312
+ }
313
+ }
314
+
315
+ /** Remove all dependencies for a process */
316
+ export function removeAllDependencies(processName: string) {
317
+ const matches = db.dependency.select()
318
+ .where({ process_name: processName })
319
+ .all();
320
+ for (const dep of matches) {
321
+ db.dependency.delete(dep.id);
322
+ }
323
+ }
324
+
325
+ /** Check if adding processName -> dependsOn would create a cycle */
326
+ function wouldCreateCycle(processName: string, dependsOn: string): boolean {
327
+ const graph = getDependencyGraph();
328
+ // Add the proposed edge temporarily
329
+ if (!graph[processName]) graph[processName] = [];
330
+ graph[processName].push(dependsOn);
331
+
332
+ // DFS from dependsOn — if we can reach processName, it's a cycle
333
+ const visited = new Set<string>();
334
+ const stack = [dependsOn];
335
+ while (stack.length > 0) {
336
+ const current = stack.pop()!;
337
+ if (current === processName) return true;
338
+ if (visited.has(current)) continue;
339
+ visited.add(current);
340
+ for (const dep of (graph[current] || [])) {
341
+ stack.push(dep);
342
+ }
343
+ }
344
+ return false;
345
+ }
346
+
347
+ /** Get topological start order (processes with no deps first) */
348
+ export function getStartOrder(): string[] {
349
+ const graph = getDependencyGraph();
350
+ const allProcesses = getAllProcesses().map(p => p.name);
351
+ const allNames = new Set(allProcesses);
352
+
353
+ // Build in-degree map
354
+ const inDegree: Record<string, number> = {};
355
+ for (const name of allNames) inDegree[name] = 0;
356
+ for (const [proc, deps] of Object.entries(graph)) {
357
+ for (const dep of deps) {
358
+ if (allNames.has(dep)) {
359
+ inDegree[proc] = (inDegree[proc] || 0) + 1;
360
+ }
361
+ }
362
+ }
363
+
364
+ // Kahn's algorithm
365
+ const queue: string[] = [];
366
+ for (const name of allNames) {
367
+ if ((inDegree[name] || 0) === 0) queue.push(name);
368
+ }
369
+
370
+ const order: string[] = [];
371
+ while (queue.length > 0) {
372
+ queue.sort(); // stable alphabetical within same level
373
+ const current = queue.shift()!;
374
+ order.push(current);
375
+ // Find processes that depend on current
376
+ for (const [proc, deps] of Object.entries(graph)) {
377
+ if (deps.includes(current) && allNames.has(proc)) {
378
+ inDegree[proc]--;
379
+ if (inDegree[proc] === 0) queue.push(proc);
380
+ }
381
+ }
382
+ }
383
+
384
+ return order;
385
+ }
386
+
130
387
  // =============================================================================
131
388
  // DEBUG / INFO
132
389
  // =============================================================================
package/src/deploy.ts ADDED
@@ -0,0 +1,163 @@
1
+ import { getProcess, getAllProcesses, addHistoryEntry } from './db';
2
+ import { handleRun } from './commands/run';
3
+ import { $ } from 'bun';
4
+
5
+ export interface DeployResult {
6
+ name: string;
7
+ ok: boolean;
8
+ skipped?: boolean;
9
+ reason?: string;
10
+ pullOutput?: string;
11
+ installOutput?: string;
12
+ packageManager?: PackageManager;
13
+ installCommand?: string;
14
+ installAttempted?: boolean;
15
+ }
16
+
17
+ export type PackageManager = 'bun' | 'pnpm' | 'yarn' | 'npm' | null;
18
+
19
+ export function formatDeployToolError(manager: Exclude<PackageManager, null>, error: unknown): string {
20
+ const raw = error instanceof Error ? error.message : String(error ?? 'Unknown error');
21
+ const lower = raw.toLowerCase();
22
+
23
+ if (
24
+ lower.includes('command not found') ||
25
+ lower.includes('not recognized as an internal or external command') ||
26
+ lower.includes('executable not found') ||
27
+ lower.includes('no such file or directory')
28
+ ) {
29
+ return `Deploy requires '${manager}', but it is not installed or not available on PATH.`;
30
+ }
31
+
32
+ return `Dependency install failed with ${manager}: ${raw}`;
33
+ }
34
+
35
+ function isInternalProcess(name: string): boolean {
36
+ return name === 'bgr-dashboard' || name === 'bgr-guard';
37
+ }
38
+
39
+ async function pathExists(path: string): Promise<boolean> {
40
+ return await Bun.file(path).exists();
41
+ }
42
+
43
+ async function isGitRepo(dir: string): Promise<boolean> {
44
+ return await pathExists(`${dir}/.git`) || await pathExists(`${dir}/.git/HEAD`);
45
+ }
46
+
47
+ export async function detectPackageManager(dir: string): Promise<PackageManager> {
48
+ const hasPackageJson = await pathExists(`${dir}/package.json`);
49
+ if (!hasPackageJson) return null;
50
+
51
+ if (await pathExists(`${dir}/bun.lock`) || await pathExists(`${dir}/bun.lockb`)) return 'bun';
52
+ if (await pathExists(`${dir}/pnpm-lock.yaml`)) return 'pnpm';
53
+ if (await pathExists(`${dir}/yarn.lock`)) return 'yarn';
54
+ if (await pathExists(`${dir}/package-lock.json`) || await pathExists(`${dir}/npm-shrinkwrap.json`)) return 'npm';
55
+
56
+ return 'bun';
57
+ }
58
+
59
+ function getInstallCommand(manager: Exclude<PackageManager, null>): string {
60
+ switch (manager) {
61
+ case 'bun': return 'bun install';
62
+ case 'pnpm': return 'pnpm install --frozen-lockfile';
63
+ case 'yarn': return 'yarn install --frozen-lockfile';
64
+ case 'npm': return 'npm ci';
65
+ }
66
+ }
67
+
68
+ async function installDependencies(dir: string): Promise<{ manager: PackageManager; output: string; command: string }> {
69
+ const manager = await detectPackageManager(dir);
70
+ if (!manager) return { manager: null, output: '', command: '' };
71
+
72
+ $.cwd(dir);
73
+ const command = getInstallCommand(manager);
74
+
75
+ try {
76
+ switch (manager) {
77
+ case 'bun':
78
+ return { manager, command, output: (await $`bun install`.text()).trim() };
79
+ case 'pnpm':
80
+ return { manager, command, output: (await $`pnpm install --frozen-lockfile`.text()).trim() };
81
+ case 'yarn':
82
+ return { manager, command, output: (await $`yarn install --frozen-lockfile`.text()).trim() };
83
+ case 'npm':
84
+ return { manager, command, output: (await $`npm ci`.text()).trim() };
85
+ default:
86
+ return { manager: null, output: '', command: '' };
87
+ }
88
+ } catch (error) {
89
+ throw new Error(formatDeployToolError(manager, error));
90
+ }
91
+ }
92
+
93
+ export async function deployProcess(name: string): Promise<DeployResult> {
94
+ const proc = getProcess(name);
95
+ if (!proc) {
96
+ return { name, ok: false, reason: `Process '${name}' not found` };
97
+ }
98
+
99
+ if (isInternalProcess(proc.name)) {
100
+ return { name, ok: false, skipped: true, reason: 'Internal bgrun process skipped' };
101
+ }
102
+
103
+ const dir = proc.workdir;
104
+ if (!(await isGitRepo(dir))) {
105
+ return { name, ok: false, skipped: true, reason: `'${dir}' is not a git repository` };
106
+ }
107
+
108
+ try {
109
+ $.cwd(dir);
110
+
111
+ const pullOutput = (await $`git pull`.text()).trim();
112
+
113
+ const install = await installDependencies(dir);
114
+ const installOutput = install.output;
115
+
116
+ await handleRun({
117
+ action: 'run',
118
+ name,
119
+ force: true,
120
+ remoteName: '',
121
+ });
122
+
123
+ addHistoryEntry(name, 'deploy', proc.pid, {
124
+ directory: dir,
125
+ installed: Boolean(install.manager),
126
+ packageManager: install.manager,
127
+ installCommand: install.command,
128
+ });
129
+
130
+ return {
131
+ name,
132
+ ok: true,
133
+ pullOutput,
134
+ installOutput,
135
+ packageManager: install.manager,
136
+ installCommand: install.command,
137
+ installAttempted: Boolean(install.manager),
138
+ };
139
+ } catch (e: any) {
140
+ return {
141
+ name,
142
+ ok: false,
143
+ reason: e?.message || String(e),
144
+ };
145
+ }
146
+ }
147
+
148
+ export async function deployAllProcesses(group?: string): Promise<DeployResult[]> {
149
+ const processes = getAllProcesses()
150
+ .filter(proc => !isInternalProcess(proc.name))
151
+ .filter(proc => !group || proc.group === group);
152
+
153
+ const seen = new Set<string>();
154
+ const results: DeployResult[] = [];
155
+
156
+ for (const proc of processes) {
157
+ if (seen.has(proc.name)) continue;
158
+ seen.add(proc.name);
159
+ results.push(await deployProcess(proc.name));
160
+ }
161
+
162
+ return results;
163
+ }
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