bgrun 3.3.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/README.md +720 -0
- package/dashboard/app/api/logs/[name]/route.ts +17 -0
- package/dashboard/app/api/processes/[name]/route.ts +19 -0
- package/dashboard/app/api/processes/route.ts +150 -0
- package/dashboard/app/api/restart/[name]/route.ts +20 -0
- package/dashboard/app/api/start/route.ts +22 -0
- package/dashboard/app/api/stop/[name]/route.ts +16 -0
- package/dashboard/app/api/version/route.ts +8 -0
- package/dashboard/app/globals.css +1135 -0
- package/dashboard/app/layout.tsx +47 -0
- package/dashboard/app/page.client.tsx +554 -0
- package/dashboard/app/page.tsx +130 -0
- package/dist/index.js +1580 -0
- package/examples/bgr-startup.sh +40 -0
- package/package.json +60 -0
- package/src/api.ts +31 -0
- package/src/build.ts +26 -0
- package/src/commands/cleanup.ts +142 -0
- package/src/commands/details.ts +46 -0
- package/src/commands/list.ts +86 -0
- package/src/commands/logs.ts +49 -0
- package/src/commands/run.ts +151 -0
- package/src/commands/watch.ts +223 -0
- package/src/config.ts +37 -0
- package/src/db.ts +115 -0
- package/src/index.ts +349 -0
- package/src/logger.ts +29 -0
- package/src/platform.ts +440 -0
- package/src/schema.ts +2 -0
- package/src/server.ts +24 -0
- package/src/table.ts +230 -0
- package/src/types.ts +27 -0
- package/src/utils.ts +99 -0
- package/src/version.macro.ts +17 -0
package/src/platform.ts
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform utility functions for BGR
|
|
3
|
+
* Provides Windows and Unix compatible process management
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import * as os from "os";
|
|
8
|
+
import { $ } from "bun";
|
|
9
|
+
|
|
10
|
+
/** Detect if running on Windows - use function to prevent bundler tree-shaking */
|
|
11
|
+
export function isWindows(): boolean {
|
|
12
|
+
return process.platform === "win32";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get the user's home directory cross-platform
|
|
17
|
+
*/
|
|
18
|
+
export function getHomeDir(): string {
|
|
19
|
+
return os.homedir();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check if a process with the given PID is running
|
|
24
|
+
* For Docker containers, checks container status instead of PID
|
|
25
|
+
*/
|
|
26
|
+
export async function isProcessRunning(pid: number, command?: string): Promise<boolean> {
|
|
27
|
+
try {
|
|
28
|
+
// Docker container detection
|
|
29
|
+
if (command && (command.includes('docker run') || command.includes('docker-compose up') || command.includes('docker compose up'))) {
|
|
30
|
+
return await isDockerContainerRunning(command);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (isWindows()) {
|
|
34
|
+
// On Windows, use tasklist to check for PID
|
|
35
|
+
const result = await $`tasklist /FI "PID eq ${pid}" /NH`.nothrow().text();
|
|
36
|
+
return result.includes(`${pid}`);
|
|
37
|
+
} else {
|
|
38
|
+
// On Unix, use ps -p
|
|
39
|
+
const result = await $`ps -p ${pid}`.nothrow().text();
|
|
40
|
+
return result.includes(`${pid}`);
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if a Docker container from a command is running
|
|
49
|
+
*/
|
|
50
|
+
async function isDockerContainerRunning(command: string): Promise<boolean> {
|
|
51
|
+
try {
|
|
52
|
+
// Extract container name from --name flag
|
|
53
|
+
const nameMatch = command.match(/--name\s+["']?(\S+?)["']?(?:\s|$)/);
|
|
54
|
+
if (nameMatch) {
|
|
55
|
+
const containerName = nameMatch[1];
|
|
56
|
+
const result = await $`docker inspect -f "{{.State.Running}}" ${containerName}`.nothrow().text();
|
|
57
|
+
return result.trim() === 'true';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// If no --name, try to find running containers that match the image
|
|
61
|
+
// Extract image name (last argument before -d or after -d)
|
|
62
|
+
const imageMatch = command.match(/docker\s+run\s+.*?(?:-d\s+)?(\S+)\s*$/);
|
|
63
|
+
if (imageMatch) {
|
|
64
|
+
const imageName = imageMatch[1];
|
|
65
|
+
const result = await $`docker ps --filter ancestor=${imageName} --format "{{.ID}}"`.nothrow().text();
|
|
66
|
+
return result.trim().length > 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return false;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get child process PIDs (for termination)
|
|
78
|
+
*/
|
|
79
|
+
async function getChildPids(pid: number): Promise<number[]> {
|
|
80
|
+
try {
|
|
81
|
+
if (isWindows()) {
|
|
82
|
+
// On Windows, use wmic to get child processes
|
|
83
|
+
const result = await $`wmic process where (ParentProcessId=${pid}) get ProcessId`.nothrow().text();
|
|
84
|
+
return result
|
|
85
|
+
.split('\n')
|
|
86
|
+
.map(line => parseInt(line.trim()))
|
|
87
|
+
.filter(n => !isNaN(n) && n > 0);
|
|
88
|
+
} else {
|
|
89
|
+
// On Unix, use ps --ppid
|
|
90
|
+
const result = await $`ps --no-headers -o pid --ppid ${pid}`.nothrow().text();
|
|
91
|
+
return result
|
|
92
|
+
.trim()
|
|
93
|
+
.split('\n')
|
|
94
|
+
.filter(p => p.trim())
|
|
95
|
+
.map(p => parseInt(p))
|
|
96
|
+
.filter(n => !isNaN(n));
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Terminate a process and its children
|
|
105
|
+
*/
|
|
106
|
+
export async function terminateProcess(pid: number, force: boolean = false): Promise<void> {
|
|
107
|
+
// First, kill children
|
|
108
|
+
const children = await getChildPids(pid);
|
|
109
|
+
|
|
110
|
+
for (const childPid of children) {
|
|
111
|
+
try {
|
|
112
|
+
if (isWindows()) {
|
|
113
|
+
if (force) {
|
|
114
|
+
await $`taskkill /F /PID ${childPid}`.nothrow().quiet();
|
|
115
|
+
} else {
|
|
116
|
+
await $`taskkill /PID ${childPid}`.nothrow().quiet();
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
const signal = force ? 'KILL' : 'TERM';
|
|
120
|
+
await $`kill -${signal} ${childPid}`.nothrow();
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// Ignore errors for already-dead processes
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Wait a bit for graceful shutdown
|
|
128
|
+
await Bun.sleep(500);
|
|
129
|
+
|
|
130
|
+
// Then kill the parent if still running
|
|
131
|
+
if (await isProcessRunning(pid)) {
|
|
132
|
+
try {
|
|
133
|
+
if (isWindows()) {
|
|
134
|
+
if (force) {
|
|
135
|
+
await $`taskkill /F /PID ${pid}`.nothrow().quiet();
|
|
136
|
+
} else {
|
|
137
|
+
await $`taskkill /PID ${pid}`.nothrow().quiet();
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
const signal = force ? 'KILL' : 'TERM';
|
|
141
|
+
await $`kill -${signal} ${pid}`.nothrow();
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// Ignore errors
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Check if a port is free by attempting to bind to it.
|
|
151
|
+
*/
|
|
152
|
+
export async function isPortFree(port: number): Promise<boolean> {
|
|
153
|
+
try {
|
|
154
|
+
if (isWindows()) {
|
|
155
|
+
// On Windows, check netstat for anything LISTENING on this port
|
|
156
|
+
const result = await $`netstat -ano | findstr :${port}`.nothrow().quiet().text();
|
|
157
|
+
for (const line of result.split('\n')) {
|
|
158
|
+
// Only match exact port (avoid :35560 matching :3556)
|
|
159
|
+
const match = line.match(new RegExp(`:(${port})\\s+.*LISTENING`));
|
|
160
|
+
if (match) return false;
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
} else {
|
|
164
|
+
const result = await $`ss -tln sport = :${port}`.nothrow().quiet().text();
|
|
165
|
+
// If output has more than the header line, port is in use
|
|
166
|
+
const lines = result.trim().split('\n').filter(l => l.trim());
|
|
167
|
+
return lines.length <= 1;
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// If we can't check, assume it's free
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Wait for a port to become free, polling with timeout.
|
|
177
|
+
* Returns true if port is free, false if timeout reached.
|
|
178
|
+
*/
|
|
179
|
+
export async function waitForPortFree(port: number, timeoutMs: number = 5000): Promise<boolean> {
|
|
180
|
+
const startTime = Date.now();
|
|
181
|
+
const pollInterval = 300;
|
|
182
|
+
|
|
183
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
184
|
+
if (await isPortFree(port)) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
await Bun.sleep(pollInterval);
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Kill processes using a specific port.
|
|
195
|
+
* Force-kills all processes bound to the port and verifies they're gone.
|
|
196
|
+
*/
|
|
197
|
+
export async function killProcessOnPort(port: number): Promise<void> {
|
|
198
|
+
try {
|
|
199
|
+
if (isWindows()) {
|
|
200
|
+
// On Windows, use netstat to find processes on port
|
|
201
|
+
const result = await $`netstat -ano | findstr :${port}`.nothrow().quiet().text();
|
|
202
|
+
const pids = new Set<number>();
|
|
203
|
+
|
|
204
|
+
for (const line of result.split('\n')) {
|
|
205
|
+
// Match exact port — avoid :35560 matching :3556
|
|
206
|
+
// Match any state (LISTENING, ESTABLISHED, TIME_WAIT, etc.)
|
|
207
|
+
const match = line.match(new RegExp(`:(${port})\\s+.*?\\s+(\\d+)\\s*$`));
|
|
208
|
+
if (match && parseInt(match[1]) === port) {
|
|
209
|
+
const pid = parseInt(match[2]);
|
|
210
|
+
if (pid > 0) pids.add(pid);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (const pid of pids) {
|
|
215
|
+
// Force kill with /F /T (tree kill to get children too)
|
|
216
|
+
await $`taskkill /F /T /PID ${pid}`.nothrow().quiet();
|
|
217
|
+
console.log(`Killed process ${pid} using port ${port}`);
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
// On Unix, use lsof
|
|
221
|
+
const result = await $`lsof -ti :${port}`.nothrow().text();
|
|
222
|
+
if (result.trim()) {
|
|
223
|
+
const pids = result.trim().split('\n').filter(pid => pid);
|
|
224
|
+
for (const pid of pids) {
|
|
225
|
+
await $`kill -9 ${pid}`.nothrow();
|
|
226
|
+
console.log(`Killed process ${pid} using port ${port}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
console.warn(`Warning: Could not check or kill process on port ${port}: ${error}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Ensure a directory exists, creating it if necessary
|
|
237
|
+
*/
|
|
238
|
+
export function ensureDir(dirPath: string): void {
|
|
239
|
+
if (!fs.existsSync(dirPath)) {
|
|
240
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Get the shell command array for spawning a process
|
|
246
|
+
* On Windows uses cmd.exe, on Unix uses sh
|
|
247
|
+
*/
|
|
248
|
+
export function getShellCommand(command: string): string[] {
|
|
249
|
+
if (isWindows()) {
|
|
250
|
+
return ["cmd", "/c", command];
|
|
251
|
+
} else {
|
|
252
|
+
return ["sh", "-c", command];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Find the actual child process PID spawned by a shell wrapper.
|
|
258
|
+
* Traverses the process tree recursively to find the deepest (leaf) child.
|
|
259
|
+
* On Windows, bgr spawn creates: cmd.exe → bgr.exe → bun.exe
|
|
260
|
+
* We need the bun.exe PID, not the intermediate bgr.exe.
|
|
261
|
+
*/
|
|
262
|
+
export async function findChildPid(parentPid: number): Promise<number> {
|
|
263
|
+
let currentPid = parentPid;
|
|
264
|
+
const maxDepth = 5; // Safety limit to avoid infinite loops
|
|
265
|
+
|
|
266
|
+
for (let depth = 0; depth < maxDepth; depth++) {
|
|
267
|
+
try {
|
|
268
|
+
let childPids: number[] = [];
|
|
269
|
+
|
|
270
|
+
if (isWindows()) {
|
|
271
|
+
const result = await $`powershell -Command "Get-CimInstance Win32_Process -Filter 'ParentProcessId=${currentPid}' | Select-Object -ExpandProperty ProcessId"`.nothrow().text();
|
|
272
|
+
childPids = result
|
|
273
|
+
.split('\n')
|
|
274
|
+
.map((line: string) => parseInt(line.trim()))
|
|
275
|
+
.filter((n: number) => !isNaN(n) && n > 0);
|
|
276
|
+
} else {
|
|
277
|
+
const result = await $`ps --no-headers -o pid --ppid ${currentPid}`.nothrow().text();
|
|
278
|
+
childPids = result
|
|
279
|
+
.trim()
|
|
280
|
+
.split('\n')
|
|
281
|
+
.map(line => parseInt(line.trim()))
|
|
282
|
+
.filter(n => !isNaN(n) && n > 0);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (childPids.length === 0) {
|
|
286
|
+
// No children — currentPid is the leaf process
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Follow the first child deeper
|
|
291
|
+
currentPid = childPids[0];
|
|
292
|
+
} catch {
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return currentPid;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Wait for a port to become active and return the PID listening on it.
|
|
302
|
+
* More reliable than findChildPid since it waits for the actual server
|
|
303
|
+
* to bind the port rather than racing the process tree traversal.
|
|
304
|
+
*/
|
|
305
|
+
export async function findPidByPort(port: number, maxWaitMs = 8000): Promise<number | null> {
|
|
306
|
+
const start = Date.now();
|
|
307
|
+
const pollMs = 500;
|
|
308
|
+
|
|
309
|
+
while (Date.now() - start < maxWaitMs) {
|
|
310
|
+
try {
|
|
311
|
+
if (isWindows()) {
|
|
312
|
+
const result = await $`netstat -ano`.nothrow().quiet().text();
|
|
313
|
+
for (const line of result.split('\n')) {
|
|
314
|
+
if (line.includes(`:${port}`) && line.includes('LISTENING')) {
|
|
315
|
+
const parts = line.trim().split(/\s+/);
|
|
316
|
+
const pid = parseInt(parts[parts.length - 1]);
|
|
317
|
+
if (!isNaN(pid) && pid > 0) return pid;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} else {
|
|
321
|
+
try {
|
|
322
|
+
const result = await $`ss -tlnp`.nothrow().quiet().text();
|
|
323
|
+
for (const line of result.split('\n')) {
|
|
324
|
+
if (line.includes(`:${port}`)) {
|
|
325
|
+
const pidMatch = line.match(/pid=(\d+)/);
|
|
326
|
+
if (pidMatch) return parseInt(pidMatch[1]);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} catch { /* ss not available, try lsof */ }
|
|
330
|
+
|
|
331
|
+
const result = await $`lsof -iTCP:${port} -sTCP:LISTEN -t`.nothrow().quiet().text();
|
|
332
|
+
const pid = parseInt(result.trim());
|
|
333
|
+
if (!isNaN(pid) && pid > 0) return pid;
|
|
334
|
+
}
|
|
335
|
+
} catch { /* retry */ }
|
|
336
|
+
|
|
337
|
+
await new Promise(resolve => setTimeout(resolve, pollMs));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export async function readFileTail(filePath: string, lines?: number): Promise<string> {
|
|
344
|
+
try {
|
|
345
|
+
const content = await Bun.file(filePath).text();
|
|
346
|
+
|
|
347
|
+
if (!lines) {
|
|
348
|
+
return content;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const allLines = content.split(/\r?\n/);
|
|
352
|
+
const tailLines = allLines.slice(-lines);
|
|
353
|
+
return tailLines.join('\n');
|
|
354
|
+
} catch (error) {
|
|
355
|
+
throw new Error(`Error reading file: ${error}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Copy a file from source to destination
|
|
361
|
+
*/
|
|
362
|
+
export function copyFile(src: string, dest: string): void {
|
|
363
|
+
fs.copyFileSync(src, dest);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Get memory usage of a process in bytes
|
|
368
|
+
*/
|
|
369
|
+
export async function getProcessMemory(pid: number): Promise<number> {
|
|
370
|
+
try {
|
|
371
|
+
if (isWindows()) {
|
|
372
|
+
// On Windows, use wmic to get memory
|
|
373
|
+
const result = await $`wmic process where ProcessId=${pid} get WorkingSetSize`.nothrow().text();
|
|
374
|
+
const lines = result.split('\n').filter(line => line.trim() && !line.includes('WorkingSetSize'));
|
|
375
|
+
if (lines.length > 0) {
|
|
376
|
+
return parseInt(lines[0].trim()) || 0;
|
|
377
|
+
}
|
|
378
|
+
return 0;
|
|
379
|
+
} else {
|
|
380
|
+
// On Unix, use ps to get RSS in KB
|
|
381
|
+
const result = await $`ps -o rss= -p ${pid}`.text();
|
|
382
|
+
const memoryKB = parseInt(result.trim());
|
|
383
|
+
return memoryKB * 1024; // Convert to bytes
|
|
384
|
+
}
|
|
385
|
+
} catch {
|
|
386
|
+
return 0;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Get the TCP ports a process is currently listening on by querying the OS.
|
|
392
|
+
* Returns an array of port numbers (empty if none or process not found).
|
|
393
|
+
*/
|
|
394
|
+
export async function getProcessPorts(pid: number): Promise<number[]> {
|
|
395
|
+
try {
|
|
396
|
+
if (isWindows()) {
|
|
397
|
+
// netstat -ano lists all connections with PIDs
|
|
398
|
+
const result = await $`netstat -ano`.nothrow().quiet().text();
|
|
399
|
+
const ports = new Set<number>();
|
|
400
|
+
for (const line of result.split('\n')) {
|
|
401
|
+
// Match lines like: TCP 0.0.0.0:3556 0.0.0.0:0 LISTENING 8608
|
|
402
|
+
const match = line.match(/^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/);
|
|
403
|
+
if (match && parseInt(match[2]) === pid) {
|
|
404
|
+
ports.add(parseInt(match[1]));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return Array.from(ports);
|
|
408
|
+
} else {
|
|
409
|
+
// Unix: use ss (modern) with fallback to lsof
|
|
410
|
+
try {
|
|
411
|
+
const result = await $`ss -tlnp`.nothrow().quiet().text();
|
|
412
|
+
const ports = new Set<number>();
|
|
413
|
+
for (const line of result.split('\n')) {
|
|
414
|
+
if (line.includes(`pid=${pid}`)) {
|
|
415
|
+
const portMatch = line.match(/:(\d+)\s/);
|
|
416
|
+
if (portMatch) {
|
|
417
|
+
ports.add(parseInt(portMatch[1]));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (ports.size > 0) return Array.from(ports);
|
|
422
|
+
} catch { /* ss not available, try lsof */ }
|
|
423
|
+
|
|
424
|
+
const result = await $`lsof -i -P -n -p ${pid}`.nothrow().quiet().text();
|
|
425
|
+
const ports = new Set<number>();
|
|
426
|
+
for (const line of result.split('\n')) {
|
|
427
|
+
if (line.includes('LISTEN')) {
|
|
428
|
+
const portMatch = line.match(/:(\d+)\s+\(LISTEN\)/);
|
|
429
|
+
if (portMatch) {
|
|
430
|
+
ports.add(parseInt(portMatch[1]));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return Array.from(ports);
|
|
435
|
+
}
|
|
436
|
+
} catch {
|
|
437
|
+
return [];
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
package/src/schema.ts
ADDED
package/src/server.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BGR Dashboard Server
|
|
3
|
+
*
|
|
4
|
+
* Uses Melina.js to serve the dashboard app with file-based routing.
|
|
5
|
+
* All API endpoints and page rendering are handled by the dashboard/app/ directory.
|
|
6
|
+
*
|
|
7
|
+
* Port selection is handled entirely by Melina:
|
|
8
|
+
* - If BUN_PORT env var is set → uses that (explicit, will fail if busy)
|
|
9
|
+
* - Otherwise → defaults to 3000, falls back to next available if busy
|
|
10
|
+
*/
|
|
11
|
+
import { start } from 'melina';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
|
|
14
|
+
export async function startServer() {
|
|
15
|
+
const appDir = path.join(import.meta.dir, '../dashboard/app');
|
|
16
|
+
|
|
17
|
+
const port = process.env.BUN_PORT ? parseInt(process.env.BUN_PORT, 10) : 3000;
|
|
18
|
+
await start({
|
|
19
|
+
appDir,
|
|
20
|
+
defaultTitle: 'bgrun Dashboard - Process Manager',
|
|
21
|
+
globalCss: path.join(appDir, 'globals.css'),
|
|
22
|
+
port,
|
|
23
|
+
});
|
|
24
|
+
}
|
package/src/table.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
// ./Documents/bgr/src/table.ts
|
|
4
|
+
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
|
|
7
|
+
export interface TableColumn {
|
|
8
|
+
key: string;
|
|
9
|
+
header: string;
|
|
10
|
+
formatter?: (value: any) => string;
|
|
11
|
+
truncator?: (value: string, maxLength: number) => string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TableOptions {
|
|
15
|
+
maxWidth?: number;
|
|
16
|
+
padding?: number;
|
|
17
|
+
borderStyle?: "single" | "double" | "rounded" | "none";
|
|
18
|
+
showHeaders?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ProcessTableRow {
|
|
22
|
+
id: number;
|
|
23
|
+
pid: number;
|
|
24
|
+
name: string;
|
|
25
|
+
port: string;
|
|
26
|
+
command: string;
|
|
27
|
+
workdir: string;
|
|
28
|
+
status: string;
|
|
29
|
+
runtime: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Get terminal width or use default
|
|
33
|
+
export function getTerminalWidth(): number {
|
|
34
|
+
return process.stdout.columns || 120;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Strip ANSI color codes for accurate length calculation
|
|
38
|
+
function stripAnsi(str: string): string {
|
|
39
|
+
return str.replace(/\u001b\[[0-9;]*m/g, "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Default truncator: trims the end of a string
|
|
43
|
+
function truncateString(str: string, maxLength: number): string {
|
|
44
|
+
const stripped = stripAnsi(str);
|
|
45
|
+
if (stripped.length <= maxLength) return str;
|
|
46
|
+
const ellipsis = "…";
|
|
47
|
+
// Ensure maxLength is at least 1 for the ellipsis
|
|
48
|
+
if (maxLength < 1) return "";
|
|
49
|
+
if (maxLength === 1) return ellipsis;
|
|
50
|
+
|
|
51
|
+
const targetLength = maxLength - ellipsis.length;
|
|
52
|
+
return str.substring(0, targetLength > 0 ? targetLength : 0) + ellipsis;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Path truncator: trims the middle of a string
|
|
56
|
+
function truncatePath(str: string, maxLength: number): string {
|
|
57
|
+
const stripped = stripAnsi(str);
|
|
58
|
+
if (stripped.length <= maxLength) return str;
|
|
59
|
+
const ellipsis = "…";
|
|
60
|
+
// Ensure maxLength is at least 3 for a start, middle, and end character
|
|
61
|
+
if (maxLength < 3) return truncateString(str, maxLength);
|
|
62
|
+
|
|
63
|
+
const targetLength = maxLength - ellipsis.length;
|
|
64
|
+
const startLen = Math.ceil(targetLength / 2);
|
|
65
|
+
const endLen = Math.floor(targetLength / 2);
|
|
66
|
+
return str.substring(0, startLen) + ellipsis + str.substring(str.length - endLen);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Calculate column widths by proportionally shrinking the widest columns
|
|
70
|
+
export function calculateColumnWidths(
|
|
71
|
+
rows: any[],
|
|
72
|
+
columns: TableColumn[],
|
|
73
|
+
maxWidth: number,
|
|
74
|
+
padding: number = 2
|
|
75
|
+
): Map<string, number> {
|
|
76
|
+
const separatorsWidth = columns.length + 1;
|
|
77
|
+
const paddingWidth = padding * columns.length;
|
|
78
|
+
const availableWidth = maxWidth - separatorsWidth - paddingWidth;
|
|
79
|
+
|
|
80
|
+
const naturalWidths = new Map<string, number>();
|
|
81
|
+
|
|
82
|
+
// 1. Calculate the natural width (max content length) for each column
|
|
83
|
+
for (const col of columns) {
|
|
84
|
+
let maxNatural = stripAnsi(col.header).length;
|
|
85
|
+
for (const row of rows) {
|
|
86
|
+
const value = col.formatter ? col.formatter(row[col.key]) : String(row[col.key] || "");
|
|
87
|
+
maxNatural = Math.max(maxNatural, stripAnsi(value).length);
|
|
88
|
+
}
|
|
89
|
+
naturalWidths.set(col.key, maxNatural);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const totalNaturalWidth = Array.from(naturalWidths.values()).reduce((sum, w) => sum + w, 0);
|
|
93
|
+
|
|
94
|
+
// 2. If it fits, we're done
|
|
95
|
+
if (totalNaturalWidth <= availableWidth) {
|
|
96
|
+
return naturalWidths;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 3. If not, calculate the overage and shrink the widest columns iteratively
|
|
100
|
+
let overage = totalNaturalWidth - availableWidth;
|
|
101
|
+
const currentWidths = new Map(naturalWidths);
|
|
102
|
+
|
|
103
|
+
while (overage > 0) {
|
|
104
|
+
// Find the column that is currently the widest
|
|
105
|
+
let widestColKey: string | null = null;
|
|
106
|
+
let maxW = -1;
|
|
107
|
+
for (const [key, width] of currentWidths.entries()) {
|
|
108
|
+
if (width > maxW) {
|
|
109
|
+
maxW = width;
|
|
110
|
+
widestColKey = key;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// If no column can be shrunk (e.g., all are width 0), break
|
|
115
|
+
if (widestColKey === null || maxW <= 1) {
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Shrink the widest column by 1
|
|
120
|
+
currentWidths.set(widestColKey, maxW - 1);
|
|
121
|
+
overage--;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return currentWidths;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function renderBorder(widths: number[], padding: number, style: string[]): string {
|
|
128
|
+
const [left, mid, right, line] = style;
|
|
129
|
+
let lineStr = left;
|
|
130
|
+
for (let i = 0; i < widths.length; i++) {
|
|
131
|
+
lineStr += line.repeat(widths[i] + padding);
|
|
132
|
+
if (i < widths.length - 1) {
|
|
133
|
+
lineStr += mid;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
lineStr += right;
|
|
137
|
+
return lineStr;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function renderHorizontalTable(
|
|
141
|
+
rows: any[],
|
|
142
|
+
columns: TableColumn[],
|
|
143
|
+
options: TableOptions = {}
|
|
144
|
+
): { table: string; truncatedIndices: number[] } {
|
|
145
|
+
const { maxWidth = getTerminalWidth(), padding = 2, borderStyle = "rounded", showHeaders = true } = options;
|
|
146
|
+
if (rows.length === 0) return { table: chalk.gray("No data to display"), truncatedIndices: [] };
|
|
147
|
+
|
|
148
|
+
const borderChars = {
|
|
149
|
+
rounded: ["╭", "┬", "╮", "─", "│", "├", "┼", "┤", "╰", "┴", "╯"],
|
|
150
|
+
none: [" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "],
|
|
151
|
+
}[borderStyle]!;
|
|
152
|
+
const [tl, tc, tr, h, v, ml, mc, mr, bl, bc, br] = borderChars;
|
|
153
|
+
const columnWidths = calculateColumnWidths(rows, columns, maxWidth, padding);
|
|
154
|
+
const widthArray = columns.map((col) => columnWidths.get(col.key)!);
|
|
155
|
+
const truncatedIndices = new Set<number>();
|
|
156
|
+
const lines: string[] = [];
|
|
157
|
+
const cellPadding = " ".repeat(padding / 2);
|
|
158
|
+
|
|
159
|
+
if (borderStyle !== "none") lines.push(renderBorder(widthArray, padding, [tl, tc, tr, h]));
|
|
160
|
+
|
|
161
|
+
if (showHeaders) {
|
|
162
|
+
const headerCells = columns.map((col, i) => chalk.bold(truncateString(col.header, widthArray[i]).padEnd(widthArray[i])));
|
|
163
|
+
lines.push(`${v}${cellPadding}${headerCells.join(`${cellPadding}${v}${cellPadding}`)}${cellPadding}${v}`);
|
|
164
|
+
if (borderStyle !== "none") lines.push(renderBorder(widthArray, padding, [ml, mc, mr, h]));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
rows.forEach((row, rowIndex) => {
|
|
168
|
+
const cells = columns.map((col, i) => {
|
|
169
|
+
const width = widthArray[i];
|
|
170
|
+
const originalValue = col.formatter ? col.formatter(row[col.key]) : String(row[col.key] || "");
|
|
171
|
+
if (stripAnsi(originalValue).length > width) {
|
|
172
|
+
truncatedIndices.add(rowIndex);
|
|
173
|
+
}
|
|
174
|
+
const truncator = col.truncator || truncateString;
|
|
175
|
+
const truncated = truncator(originalValue, width);
|
|
176
|
+
return truncated + " ".repeat(Math.max(0, width - stripAnsi(truncated).length));
|
|
177
|
+
});
|
|
178
|
+
lines.push(`${v}${cellPadding}${cells.join(`${cellPadding}${v}${cellPadding}`)}${cellPadding}${v}`);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (borderStyle !== "none") lines.push(renderBorder(widthArray, padding, [bl, bc, br, h]));
|
|
182
|
+
|
|
183
|
+
return { table: lines.join("\n"), truncatedIndices: Array.from(truncatedIndices) };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function renderVerticalTree(rows: any[], columns: TableColumn[]): string {
|
|
187
|
+
const lines: string[] = [];
|
|
188
|
+
rows.forEach((row, index) => {
|
|
189
|
+
if (index > 0) lines.push("");
|
|
190
|
+
const name = row.name ? `'${row.name}'` : `(ID: ${row.id})`;
|
|
191
|
+
lines.push(chalk.cyan(`▶ ${name}`));
|
|
192
|
+
|
|
193
|
+
columns.forEach((col) => {
|
|
194
|
+
const value = col.formatter ? col.formatter(row[col.key]) : String(row[col.key] || "");
|
|
195
|
+
lines.push(` ├─ ${chalk.gray(`${col.header}:`)} ${value}`);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
return lines.join("\n");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function renderHybridTable(
|
|
202
|
+
rows: any[],
|
|
203
|
+
columns: TableColumn[],
|
|
204
|
+
options: TableOptions = {}
|
|
205
|
+
): string {
|
|
206
|
+
const { table, truncatedIndices } = renderHorizontalTable(rows, columns, options);
|
|
207
|
+
const output = [table];
|
|
208
|
+
|
|
209
|
+
if (truncatedIndices.length > 0) {
|
|
210
|
+
const truncatedRows = truncatedIndices.map((i) => rows[i]);
|
|
211
|
+
output.push("\n" + renderVerticalTree(truncatedRows, columns));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return output.join("\n");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function renderProcessTable(processes: ProcessTableRow[], options?: TableOptions): string {
|
|
218
|
+
const columns: TableColumn[] = [
|
|
219
|
+
{ key: "id", header: "ID", formatter: (id) => chalk.blue(id) },
|
|
220
|
+
{ key: "pid", header: "PID", formatter: (pid) => chalk.yellow(pid) },
|
|
221
|
+
{ key: "name", header: "Name", formatter: (name) => chalk.cyan.bold(name) },
|
|
222
|
+
{ key: "port", header: "Port", formatter: (port) => port === '-' ? chalk.gray(port) : chalk.hex('#FF6B6B')(port) },
|
|
223
|
+
{ key: "command", header: "Command" },
|
|
224
|
+
{ key: "workdir", header: "Directory", formatter: (dir) => chalk.gray(dir), truncator: truncatePath },
|
|
225
|
+
{ key: "status", header: "Status" },
|
|
226
|
+
{ key: "runtime", header: "Runtime", formatter: (runtime) => chalk.magenta(runtime) },
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
return renderHybridTable(processes, columns, options);
|
|
230
|
+
}
|