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.
@@ -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
@@ -0,0 +1,2 @@
1
+ // Schema is now defined inline in db.ts — re-export for backwards compatibility
2
+ export { ProcessSchema, type Process } from "./db";
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
+ }