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/dashboard/app/api/check-port/route.ts +35 -0
- package/dashboard/app/api/dependencies/route.ts +40 -0
- package/dashboard/app/api/deploy/[name]/route.ts +6 -41
- package/dashboard/app/api/deploy-all/route.ts +25 -0
- package/dashboard/app/api/guard/route.ts +4 -1
- package/dashboard/app/api/guard-events/route.ts +5 -0
- package/dashboard/app/api/history/route.ts +39 -0
- package/dashboard/app/api/next-port/route.ts +32 -0
- package/dashboard/app/api/processes/route.ts +11 -3
- package/dashboard/app/api/restart/[name]/route.ts +7 -0
- package/dashboard/app/api/start/route.ts +11 -0
- package/dashboard/app/api/stop/[name]/route.ts +4 -1
- package/dashboard/app/api/templates/route.ts +47 -0
- package/dashboard/app/globals.css +1565 -5
- package/dashboard/app/page.client.tsx +1907 -2
- package/dashboard/app/page.tsx +292 -5
- package/dist/index.js +787 -194
- package/package.json +2 -2
- package/scripts/bgr-startup.ps1 +3 -3
- package/scripts/bgrun-startup.ps1 +91 -0
- package/src/bgrun.test.ts +171 -0
- package/src/commands/details.ts +17 -3
- package/src/commands/list.ts +37 -4
- package/src/commands/run.ts +21 -3
- package/src/db.ts +257 -0
- package/src/deploy.ts +163 -0
- package/src/guard.ts +51 -0
- package/src/index.ts +92 -14
- package/src/index_copy.ts +614 -0
- package/src/logger.ts +12 -2
- package/src/platform.ts +101 -56
- package/src/server.ts +87 -3
- package/src/utils.ts +2 -2
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
|
|