bgrun 3.3.3 → 3.7.0
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/config/[name]/route.ts +55 -0
- package/dashboard/app/api/debug/route.ts +18 -0
- package/dashboard/app/api/events/route.ts +60 -0
- package/dashboard/app/api/logs/[name]/route.ts +55 -5
- package/dashboard/app/api/processes/[name]/route.ts +5 -2
- package/dashboard/app/api/processes/route.ts +95 -35
- package/dashboard/app/api/restart/[name]/route.ts +4 -3
- package/dashboard/app/api/start/route.ts +4 -3
- package/dashboard/app/api/stop/[name]/route.ts +11 -4
- package/dashboard/app/api/version/route.ts +4 -2
- package/dashboard/app/globals.css +1010 -205
- package/dashboard/app/layout.tsx +2 -23
- package/dashboard/app/page.client.tsx +720 -58
- package/dashboard/app/page.tsx +100 -22
- package/dist/index.js +222 -96
- package/package.json +6 -3
- package/src/api.ts +17 -2
- package/src/commands/list.ts +15 -1
- package/src/commands/run.ts +60 -47
- package/src/db.ts +40 -3
- package/src/index.ts +27 -1
- package/src/platform.ts +225 -75
- package/src/table.ts +2 -0
package/src/commands/run.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CommandOptions } from "../types";
|
|
1
|
+
import type { CommandOptions } from "../types";
|
|
2
2
|
import { getProcess, removeProcessByName, retryDatabaseOperation, insertProcess } from "../db";
|
|
3
3
|
import { isProcessRunning, terminateProcess, getHomeDir, getShellCommand, killProcessOnPort, findChildPid, getProcessPorts, waitForPortFree } from "../platform";
|
|
4
4
|
import { error, announce } from "../logger";
|
|
@@ -7,8 +7,10 @@ import { parseConfigFile } from "../config";
|
|
|
7
7
|
import { $ } from "bun";
|
|
8
8
|
import { sleep } from "bun";
|
|
9
9
|
import { join } from "path";
|
|
10
|
+
import { createMeasure } from "measure-fn";
|
|
10
11
|
|
|
11
12
|
const homePath = getHomeDir();
|
|
13
|
+
const run = createMeasure('run');
|
|
12
14
|
|
|
13
15
|
export async function handleRun(options: CommandOptions) {
|
|
14
16
|
const { command, directory, env, name, configPath, force, fetch, stdout, stderr } = options;
|
|
@@ -24,18 +26,20 @@ export async function handleRun(options: CommandOptions) {
|
|
|
24
26
|
if (!require('fs').existsSync(require('path').join(finalDirectory, '.git'))) {
|
|
25
27
|
error(`Cannot --fetch: '${finalDirectory}' is not a Git repository.`);
|
|
26
28
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
await run.measure(`Git fetch "${name}"`, async () => {
|
|
30
|
+
try {
|
|
31
|
+
await $`git fetch origin`;
|
|
32
|
+
const localHash = (await $`git rev-parse HEAD`.text()).trim();
|
|
33
|
+
const remoteHash = (await $`git rev-parse origin/$(git rev-parse --abbrev-ref HEAD)`.text()).trim();
|
|
34
|
+
|
|
35
|
+
if (localHash !== remoteHash) {
|
|
36
|
+
await $`git pull origin $(git rev-parse --abbrev-ref HEAD)`;
|
|
37
|
+
announce("📥 Pulled latest changes", "Git Update");
|
|
38
|
+
}
|
|
39
|
+
} catch (err) {
|
|
40
|
+
error(`Failed to pull latest changes: ${err}`);
|
|
35
41
|
}
|
|
36
|
-
}
|
|
37
|
-
error(`Failed to pull latest changes: ${err}`);
|
|
38
|
-
}
|
|
42
|
+
});
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
const isRunning = await isProcessRunning(existingProcess.pid);
|
|
@@ -50,23 +54,26 @@ export async function handleRun(options: CommandOptions) {
|
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
if (isRunning) {
|
|
53
|
-
await
|
|
54
|
-
|
|
57
|
+
await run.measure(`Terminate "${name}" (PID ${existingProcess.pid})`, async () => {
|
|
58
|
+
await terminateProcess(existingProcess.pid);
|
|
59
|
+
announce(`🔥 Terminated existing process '${name}'`, "Process Terminated");
|
|
60
|
+
});
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
// Kill anything still on the ports the old process was using
|
|
58
|
-
|
|
59
|
-
await
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
if (detectedPorts.length > 0) {
|
|
65
|
+
await run.measure(`Port cleanup [${detectedPorts.join(', ')}]`, async () => {
|
|
66
|
+
for (const port of detectedPorts) {
|
|
67
|
+
await killProcessOnPort(port);
|
|
68
|
+
}
|
|
69
|
+
for (const port of detectedPorts) {
|
|
70
|
+
const freed = await waitForPortFree(port, 5000);
|
|
71
|
+
if (!freed) {
|
|
72
|
+
await killProcessOnPort(port);
|
|
73
|
+
await waitForPortFree(port, 3000);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
await retryDatabaseOperation(() =>
|
|
@@ -97,12 +104,17 @@ export async function handleRun(options: CommandOptions) {
|
|
|
97
104
|
const fullConfigPath = join(finalDirectory, finalConfigPath);
|
|
98
105
|
|
|
99
106
|
if (await Bun.file(fullConfigPath).exists()) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
107
|
+
const configEnv = await run.measure(`Parse config "${finalConfigPath}"`, async () => {
|
|
108
|
+
try {
|
|
109
|
+
return await parseConfigFile(fullConfigPath);
|
|
110
|
+
} catch (err: any) {
|
|
111
|
+
console.warn(`Warning: Failed to parse config file ${finalConfigPath}: ${err.message}`);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
if (configEnv) {
|
|
116
|
+
finalEnv = { ...finalEnv, ...configEnv };
|
|
103
117
|
console.log(`Loaded config from ${finalConfigPath}`);
|
|
104
|
-
} catch (err: any) {
|
|
105
|
-
console.warn(`Warning: Failed to parse config file ${finalConfigPath}: ${err.message}`);
|
|
106
118
|
}
|
|
107
119
|
} else {
|
|
108
120
|
console.log(`Config file '${finalConfigPath}' not found, continuing without it.`);
|
|
@@ -114,20 +126,23 @@ export async function handleRun(options: CommandOptions) {
|
|
|
114
126
|
const stderrPath = stderr || existingProcess?.stderr_path || join(homePath, ".bgr", `${name}-err.txt`);
|
|
115
127
|
Bun.write(stderrPath, '');
|
|
116
128
|
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
const actualPid = await run.measure(`Spawn "${name}" → ${finalCommand}`, async () => {
|
|
130
|
+
const newProcess = Bun.spawn(getShellCommand(finalCommand!), {
|
|
131
|
+
env: { ...Bun.env, ...finalEnv },
|
|
132
|
+
cwd: finalDirectory,
|
|
133
|
+
stdout: Bun.file(stdoutPath),
|
|
134
|
+
stderr: Bun.file(stderrPath),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
newProcess.unref();
|
|
138
|
+
// Give shell a moment to spawn child, then find PID before shell exits
|
|
139
|
+
await sleep(100);
|
|
140
|
+
// Find the actual child PID (shell wrapper exits immediately after spawning)
|
|
141
|
+
const pid = await findChildPid(newProcess.pid);
|
|
142
|
+
// Wait more for subprocess to initialize
|
|
143
|
+
await sleep(400);
|
|
144
|
+
return pid;
|
|
145
|
+
}) ?? 0;
|
|
131
146
|
|
|
132
147
|
await retryDatabaseOperation(() =>
|
|
133
148
|
insertProcess({
|
|
@@ -147,5 +162,3 @@ export async function handleRun(options: CommandOptions) {
|
|
|
147
162
|
"Process Started"
|
|
148
163
|
);
|
|
149
164
|
}
|
|
150
|
-
|
|
151
|
-
|
package/src/db.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Database, z } from "sqlite-zod-orm";
|
|
|
2
2
|
import { getHomeDir, ensureDir } from "./platform";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { sleep } from "bun";
|
|
5
|
+
import { existsSync, copyFileSync } from "fs";
|
|
5
6
|
|
|
6
7
|
// =============================================================================
|
|
7
8
|
// SCHEMA (inline — single table, no need for a separate file)
|
|
@@ -26,9 +27,24 @@ export type Process = z.infer<typeof ProcessSchema> & { id: number };
|
|
|
26
27
|
// =============================================================================
|
|
27
28
|
|
|
28
29
|
const homePath = getHomeDir();
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
const bgrDir = join(homePath, ".bgr");
|
|
31
|
+
ensureDir(bgrDir);
|
|
32
|
+
|
|
33
|
+
// DB filename: configurable via BGRUN_DB env, default "bgrun.sqlite"
|
|
34
|
+
const dbFilename = process.env.BGRUN_DB ?? "bgrun.sqlite";
|
|
35
|
+
export const dbPath = join(bgrDir, dbFilename);
|
|
36
|
+
export const bgrHome = bgrDir;
|
|
37
|
+
|
|
38
|
+
// Auto-migration: if new DB doesn't exist but old one does, copy it over
|
|
39
|
+
const legacyDbPath = join(bgrDir, "bgr_v2.sqlite");
|
|
40
|
+
if (!existsSync(dbPath) && existsSync(legacyDbPath)) {
|
|
41
|
+
try {
|
|
42
|
+
copyFileSync(legacyDbPath, dbPath);
|
|
43
|
+
console.log(`[bgrun] Migrated database: ${legacyDbPath} → ${dbPath}`);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
// Migration failed — start fresh
|
|
46
|
+
}
|
|
47
|
+
}
|
|
32
48
|
|
|
33
49
|
export const db = new Database(dbPath, {
|
|
34
50
|
process: ProcessSchema,
|
|
@@ -88,6 +104,14 @@ export function removeProcessByName(name: string) {
|
|
|
88
104
|
}
|
|
89
105
|
}
|
|
90
106
|
|
|
107
|
+
/** Update the stored PID for a process (used by PID reconciliation) */
|
|
108
|
+
export function updateProcessPid(name: string, newPid: number) {
|
|
109
|
+
const proc = db.process.select().where({ name }).limit(1).get();
|
|
110
|
+
if (proc) {
|
|
111
|
+
db.process.update(proc.id, { pid: newPid });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
91
115
|
export function removeAllProcesses() {
|
|
92
116
|
const all = db.process.select().all();
|
|
93
117
|
for (const p of all) {
|
|
@@ -95,6 +119,19 @@ export function removeAllProcesses() {
|
|
|
95
119
|
}
|
|
96
120
|
}
|
|
97
121
|
|
|
122
|
+
// =============================================================================
|
|
123
|
+
// DEBUG / INFO
|
|
124
|
+
// =============================================================================
|
|
125
|
+
|
|
126
|
+
export function getDbInfo() {
|
|
127
|
+
return {
|
|
128
|
+
dbPath,
|
|
129
|
+
bgrHome,
|
|
130
|
+
dbFilename,
|
|
131
|
+
exists: existsSync(dbPath),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
98
135
|
// =============================================================================
|
|
99
136
|
// UTILITIES
|
|
100
137
|
// =============================================================================
|
package/src/index.ts
CHANGED
|
@@ -12,11 +12,18 @@ import type { CommandOptions } from "./types";
|
|
|
12
12
|
import { error, announce } from "./logger";
|
|
13
13
|
import { startServer } from "./server";
|
|
14
14
|
import { getHomeDir, getShellCommand, findChildPid, isProcessRunning, terminateProcess, getProcessPorts, killProcessOnPort, waitForPortFree } from "./platform";
|
|
15
|
-
import { insertProcess, removeProcessByName, getProcess, retryDatabaseOperation } from "./db";
|
|
15
|
+
import { insertProcess, removeProcessByName, getProcess, retryDatabaseOperation, getDbInfo } from "./db";
|
|
16
16
|
import dedent from "dedent";
|
|
17
17
|
import chalk from "chalk";
|
|
18
18
|
import { join } from "path";
|
|
19
19
|
import { sleep } from "bun";
|
|
20
|
+
import { configure } from "measure-fn";
|
|
21
|
+
|
|
22
|
+
if (!Bun.argv.includes("--_serve")) {
|
|
23
|
+
if (!Bun.env.MEASURE_SILENT) {
|
|
24
|
+
configure({ silent: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
20
27
|
|
|
21
28
|
async function showHelp() {
|
|
22
29
|
const usage = dedent`
|
|
@@ -51,6 +58,7 @@ async function showHelp() {
|
|
|
51
58
|
--log-stderr Show only stderr logs
|
|
52
59
|
--lines <n> Number of log lines to show (default: all)
|
|
53
60
|
--version Show version
|
|
61
|
+
--debug Show debug info (DB path, BGR home, etc.)
|
|
54
62
|
--dashboard Launch web dashboard as bgrun-managed process
|
|
55
63
|
--port <number> Port for dashboard (default: 3000)
|
|
56
64
|
--help Show this help message
|
|
@@ -92,6 +100,7 @@ async function run() {
|
|
|
92
100
|
stdout: { type: 'string' },
|
|
93
101
|
stderr: { type: 'string' },
|
|
94
102
|
dashboard: { type: 'boolean' },
|
|
103
|
+
debug: { type: 'boolean' },
|
|
95
104
|
"_serve": { type: 'boolean' },
|
|
96
105
|
port: { type: 'string' },
|
|
97
106
|
},
|
|
@@ -233,6 +242,23 @@ async function run() {
|
|
|
233
242
|
return;
|
|
234
243
|
}
|
|
235
244
|
|
|
245
|
+
if (values.debug) {
|
|
246
|
+
const info = getDbInfo();
|
|
247
|
+
const version = await getVersion();
|
|
248
|
+
console.log(dedent`
|
|
249
|
+
${chalk.bold('bgrun debug info')}
|
|
250
|
+
${chalk.gray('─'.repeat(40))}
|
|
251
|
+
Version: ${chalk.cyan(version)}
|
|
252
|
+
BGR Home: ${chalk.yellow(info.bgrHome)}
|
|
253
|
+
DB Path: ${chalk.yellow(info.dbPath)}
|
|
254
|
+
DB File: ${info.dbFilename}
|
|
255
|
+
DB Exists: ${info.exists ? chalk.green('✓') : chalk.red('✗')}
|
|
256
|
+
Platform: ${process.platform}
|
|
257
|
+
Bun: ${Bun.version}
|
|
258
|
+
`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
236
262
|
// Commands flow
|
|
237
263
|
if (values.nuke) {
|
|
238
264
|
await handleDeleteAll();
|
package/src/platform.ts
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
import * as fs from "fs";
|
|
7
7
|
import * as os from "os";
|
|
8
|
+
import { join } from "path";
|
|
8
9
|
import { $ } from "bun";
|
|
10
|
+
import { measure, createMeasure } from "measure-fn";
|
|
11
|
+
|
|
12
|
+
const plat = createMeasure('platform');
|
|
9
13
|
|
|
10
14
|
/** Detect if running on Windows - use function to prevent bundler tree-shaking */
|
|
11
15
|
export function isWindows(): boolean {
|
|
@@ -24,24 +28,24 @@ export function getHomeDir(): string {
|
|
|
24
28
|
* For Docker containers, checks container status instead of PID
|
|
25
29
|
*/
|
|
26
30
|
export async function isProcessRunning(pid: number, command?: string): Promise<boolean> {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
return plat.measure(`PID ${pid} alive?`, async () => {
|
|
32
|
+
try {
|
|
33
|
+
// Docker container detection
|
|
34
|
+
if (command && (command.includes('docker run') || command.includes('docker-compose up') || command.includes('docker compose up'))) {
|
|
35
|
+
return await isDockerContainerRunning(command);
|
|
36
|
+
}
|
|
32
37
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
if (isWindows()) {
|
|
39
|
+
const result = await $`tasklist /FI "PID eq ${pid}" /NH`.nothrow().text();
|
|
40
|
+
return result.includes(`${pid}`);
|
|
41
|
+
} else {
|
|
42
|
+
const result = await $`ps -p ${pid}`.nothrow().text();
|
|
43
|
+
return result.includes(`${pid}`);
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
41
47
|
}
|
|
42
|
-
}
|
|
43
|
-
return false;
|
|
44
|
-
}
|
|
48
|
+
});
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
/**
|
|
@@ -79,8 +83,8 @@ async function isDockerContainerRunning(command: string): Promise<boolean> {
|
|
|
79
83
|
async function getChildPids(pid: number): Promise<number[]> {
|
|
80
84
|
try {
|
|
81
85
|
if (isWindows()) {
|
|
82
|
-
// On Windows, use
|
|
83
|
-
const result = await $`
|
|
86
|
+
// On Windows, use PowerShell to get child processes
|
|
87
|
+
const result = await $`powershell -Command "Get-CimInstance Win32_Process -Filter 'ParentProcessId=${pid}' | Select-Object -ExpandProperty ProcessId"`.nothrow().text();
|
|
84
88
|
return result
|
|
85
89
|
.split('\n')
|
|
86
90
|
.map(line => parseInt(line.trim()))
|
|
@@ -104,46 +108,48 @@ async function getChildPids(pid: number): Promise<number[]> {
|
|
|
104
108
|
* Terminate a process and its children
|
|
105
109
|
*/
|
|
106
110
|
export async function terminateProcess(pid: number, force: boolean = false): Promise<void> {
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
await plat.measure(`Terminate PID ${pid}`, async (m) => {
|
|
112
|
+
// First, kill children
|
|
113
|
+
const children = await m('Get children', () => getChildPids(pid)) ?? [];
|
|
109
114
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
for (const childPid of children) {
|
|
116
|
+
try {
|
|
117
|
+
if (isWindows()) {
|
|
118
|
+
if (force) {
|
|
119
|
+
await $`taskkill /F /PID ${childPid}`.nothrow().quiet();
|
|
120
|
+
} else {
|
|
121
|
+
await $`taskkill /PID ${childPid}`.nothrow().quiet();
|
|
122
|
+
}
|
|
115
123
|
} else {
|
|
116
|
-
|
|
124
|
+
const signal = force ? 'KILL' : 'TERM';
|
|
125
|
+
await $`kill -${signal} ${childPid}`.nothrow();
|
|
117
126
|
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
await $`kill -${signal} ${childPid}`.nothrow();
|
|
127
|
+
} catch {
|
|
128
|
+
// Ignore errors for already-dead processes
|
|
121
129
|
}
|
|
122
|
-
} catch {
|
|
123
|
-
// Ignore errors for already-dead processes
|
|
124
130
|
}
|
|
125
|
-
}
|
|
126
131
|
|
|
127
|
-
|
|
128
|
-
|
|
132
|
+
// Wait a bit for graceful shutdown
|
|
133
|
+
await Bun.sleep(500);
|
|
129
134
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
// Then kill the parent if still running
|
|
136
|
+
if (await isProcessRunning(pid)) {
|
|
137
|
+
try {
|
|
138
|
+
if (isWindows()) {
|
|
139
|
+
if (force) {
|
|
140
|
+
await $`taskkill /F /PID ${pid}`.nothrow().quiet();
|
|
141
|
+
} else {
|
|
142
|
+
await $`taskkill /PID ${pid}`.nothrow().quiet();
|
|
143
|
+
}
|
|
136
144
|
} else {
|
|
137
|
-
|
|
145
|
+
const signal = force ? 'KILL' : 'TERM';
|
|
146
|
+
await $`kill -${signal} ${pid}`.nothrow();
|
|
138
147
|
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
await $`kill -${signal} ${pid}`.nothrow();
|
|
148
|
+
} catch {
|
|
149
|
+
// Ignore errors
|
|
142
150
|
}
|
|
143
|
-
} catch {
|
|
144
|
-
// Ignore errors
|
|
145
151
|
}
|
|
146
|
-
}
|
|
152
|
+
});
|
|
147
153
|
}
|
|
148
154
|
|
|
149
155
|
/**
|
|
@@ -297,6 +303,108 @@ export async function findChildPid(parentPid: number): Promise<number> {
|
|
|
297
303
|
return currentPid;
|
|
298
304
|
}
|
|
299
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Reconcile stale PIDs: when a stored PID is dead, search for a live process
|
|
308
|
+
* matching the same command line and update the DB with the correct PID.
|
|
309
|
+
*
|
|
310
|
+
* This handles the case where cmd.exe wrapper PIDs die after spawning the
|
|
311
|
+
* actual bun.exe child process, or after a system reboot where PIDs change.
|
|
312
|
+
*
|
|
313
|
+
* Returns a map of process name → reconciled PID for all matched processes.
|
|
314
|
+
*/
|
|
315
|
+
export async function reconcileProcessPids(
|
|
316
|
+
processes: Array<{ name: string; pid: number; command: string; workdir: string }>,
|
|
317
|
+
deadPids: Set<number>,
|
|
318
|
+
): Promise<Map<string, number>> {
|
|
319
|
+
return await plat.measure('Reconcile PIDs', async () => {
|
|
320
|
+
const result = new Map<string, number>();
|
|
321
|
+
const needsReconciliation = processes.filter(p => deadPids.has(p.pid));
|
|
322
|
+
if (needsReconciliation.length === 0) return result;
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
// Get all running processes with their command lines
|
|
326
|
+
let runningProcs: Array<{ pid: number; cmdLine: string }> = [];
|
|
327
|
+
|
|
328
|
+
if (isWindows()) {
|
|
329
|
+
// Write a temp PS1 script to avoid quoting issues with $() in Bun's shell
|
|
330
|
+
const tmpScript = join(os.tmpdir(), 'bgr-reconcile.ps1');
|
|
331
|
+
const psCode = `Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'bun.exe' } | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.CommandLine)" }`;
|
|
332
|
+
await Bun.write(tmpScript, psCode);
|
|
333
|
+
|
|
334
|
+
const ps = Bun.spawnSync(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', tmpScript]);
|
|
335
|
+
const output = ps.stdout.toString();
|
|
336
|
+
|
|
337
|
+
for (const line of output.split('\n')) {
|
|
338
|
+
const sepIdx = line.indexOf('|');
|
|
339
|
+
if (sepIdx === -1) continue;
|
|
340
|
+
const pid = parseInt(line.substring(0, sepIdx).trim());
|
|
341
|
+
const cmdLine = line.substring(sepIdx + 1).trim();
|
|
342
|
+
if (!isNaN(pid) && pid > 0 && cmdLine) {
|
|
343
|
+
runningProcs.push({ pid, cmdLine });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
const psOutput = await $`ps -eo pid,args --no-headers`.nothrow().quiet().text();
|
|
348
|
+
for (const line of psOutput.trim().split('\n')) {
|
|
349
|
+
const match = line.trim().match(/^(\d+)\s+(.+)/);
|
|
350
|
+
if (match) {
|
|
351
|
+
runningProcs.push({ pid: parseInt(match[1]), cmdLine: match[2] });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// For each dead process, try to find a matching live process
|
|
357
|
+
// Uses multi-criteria scoring to avoid false matches when multiple
|
|
358
|
+
// processes share similar commands (e.g. "bun run server.ts")
|
|
359
|
+
for (const proc of needsReconciliation) {
|
|
360
|
+
const cmdParts = proc.command.split(/\s+/);
|
|
361
|
+
// Extract meaningful parts: full command and workdir path segments
|
|
362
|
+
const workdirParts = proc.workdir.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
363
|
+
const workdirLast = workdirParts[workdirParts.length - 1]?.toLowerCase() || '';
|
|
364
|
+
|
|
365
|
+
let bestMatch: { pid: number; score: number } | null = null;
|
|
366
|
+
let ambiguous = false;
|
|
367
|
+
|
|
368
|
+
for (const running of runningProcs) {
|
|
369
|
+
const cmdLower = running.cmdLine.toLowerCase();
|
|
370
|
+
let score = 0;
|
|
371
|
+
|
|
372
|
+
// Score 1: command parts match (e.g. "run", "server.ts")
|
|
373
|
+
for (const part of cmdParts) {
|
|
374
|
+
if (part.length > 2 && cmdLower.includes(part.toLowerCase())) score++;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Score 2: workdir folder name appears in command line path
|
|
378
|
+
// This distinguishes "bun run server.ts" in different directories
|
|
379
|
+
if (workdirLast && cmdLower.includes(workdirLast)) score += 3;
|
|
380
|
+
|
|
381
|
+
// Score 3: full workdir path match (strongest signal)
|
|
382
|
+
if (cmdLower.includes(proc.workdir.toLowerCase().replace(/\\/g, '/'))) score += 5;
|
|
383
|
+
if (cmdLower.includes(proc.workdir.toLowerCase())) score += 5;
|
|
384
|
+
|
|
385
|
+
if (score < 4) continue; // Require workdir evidence — generic cmd matches alone aren't enough
|
|
386
|
+
|
|
387
|
+
if (!bestMatch || score > bestMatch.score) {
|
|
388
|
+
ambiguous = false;
|
|
389
|
+
bestMatch = { pid: running.pid, score };
|
|
390
|
+
} else if (score === bestMatch.score) {
|
|
391
|
+
ambiguous = true; // Multiple equally good matches — skip
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (bestMatch && !ambiguous) {
|
|
396
|
+
result.set(proc.name, bestMatch.pid);
|
|
397
|
+
runningProcs = runningProcs.filter(p => p.pid !== bestMatch!.pid);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
} catch {
|
|
401
|
+
// Reconciliation is best-effort — return partial results
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return result;
|
|
405
|
+
}) ?? new Map();
|
|
406
|
+
}
|
|
407
|
+
|
|
300
408
|
/**
|
|
301
409
|
* Wait for a port to become active and return the PID listening on it.
|
|
302
410
|
* More reliable than findChildPid since it waits for the actual server
|
|
@@ -341,19 +449,21 @@ export async function findPidByPort(port: number, maxWaitMs = 8000): Promise<num
|
|
|
341
449
|
}
|
|
342
450
|
|
|
343
451
|
export async function readFileTail(filePath: string, lines?: number): Promise<string> {
|
|
344
|
-
|
|
345
|
-
|
|
452
|
+
return plat.measure(`Read tail ${lines ?? 'all'}L`, async () => {
|
|
453
|
+
try {
|
|
454
|
+
const content = await Bun.file(filePath).text();
|
|
346
455
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
456
|
+
if (!lines) {
|
|
457
|
+
return content;
|
|
458
|
+
}
|
|
350
459
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
460
|
+
const allLines = content.split(/\r?\n/);
|
|
461
|
+
const tailLines = allLines.slice(-lines);
|
|
462
|
+
return tailLines.join('\n');
|
|
463
|
+
} catch (error) {
|
|
464
|
+
throw new Error(`Error reading file: ${error}`);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
357
467
|
}
|
|
358
468
|
|
|
359
469
|
/**
|
|
@@ -367,24 +477,65 @@ export function copyFile(src: string, dest: string): void {
|
|
|
367
477
|
* Get memory usage of a process in bytes
|
|
368
478
|
*/
|
|
369
479
|
export async function getProcessMemory(pid: number): Promise<number> {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
480
|
+
const map = await getProcessBatchMemory([pid]);
|
|
481
|
+
return map.get(pid) || 0;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Get memory usage for a batch of PIDs in bytes.
|
|
486
|
+
* Returns a Map of PID -> Memory (bytes).
|
|
487
|
+
*
|
|
488
|
+
* Optimization: Fetches ALL processes in one go and filters in-memory
|
|
489
|
+
* to avoid spawning N subprocesses.
|
|
490
|
+
*/
|
|
491
|
+
export async function getProcessBatchMemory(pids: number[]): Promise<Map<number, number>> {
|
|
492
|
+
if (pids.length === 0) return new Map();
|
|
493
|
+
|
|
494
|
+
return await plat.measure(`Batch memory (${pids.length} PIDs)`, async () => {
|
|
495
|
+
const memoryMap = new Map<number, number>();
|
|
496
|
+
const pidSet = new Set(pids);
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
if (isWindows()) {
|
|
500
|
+
const result = await $`powershell -Command "Get-Process | Select-Object Id, WorkingSet"`.nothrow().quiet().text();
|
|
501
|
+
const lines = result.trim().split('\n');
|
|
502
|
+
|
|
503
|
+
for (const line of lines) {
|
|
504
|
+
const trimmed = line.trim();
|
|
505
|
+
if (!trimmed || trimmed.startsWith('Id') || trimmed.startsWith('--')) continue;
|
|
506
|
+
|
|
507
|
+
const parts = trimmed.split(/\s+/);
|
|
508
|
+
if (parts.length >= 2) {
|
|
509
|
+
const val1 = parseInt(parts[0]);
|
|
510
|
+
const val2 = parseInt(parts[parts.length - 1]);
|
|
511
|
+
|
|
512
|
+
if (!isNaN(val1) && !isNaN(val2)) {
|
|
513
|
+
if (pidSet.has(val1)) memoryMap.set(val1, val2);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
} else {
|
|
518
|
+
const result = await $`ps -eo pid,rss`.nothrow().quiet().text();
|
|
519
|
+
const lines = result.trim().split('\n');
|
|
520
|
+
|
|
521
|
+
for (let i = 1; i < lines.length; i++) {
|
|
522
|
+
const line = lines[i].trim();
|
|
523
|
+
if (!line) continue;
|
|
524
|
+
const [pidStr, rssStr] = line.split(/\s+/);
|
|
525
|
+
const pid = parseInt(pidStr);
|
|
526
|
+
const rss = parseInt(rssStr);
|
|
527
|
+
|
|
528
|
+
if (pidSet.has(pid)) {
|
|
529
|
+
memoryMap.set(pid, rss * 1024);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
377
532
|
}
|
|
378
|
-
|
|
379
|
-
|
|
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
|
|
533
|
+
} catch (e) {
|
|
534
|
+
// silently fail
|
|
384
535
|
}
|
|
385
|
-
|
|
386
|
-
return
|
|
387
|
-
}
|
|
536
|
+
|
|
537
|
+
return memoryMap;
|
|
538
|
+
}) ?? new Map();
|
|
388
539
|
}
|
|
389
540
|
|
|
390
541
|
/**
|
|
@@ -437,4 +588,3 @@ export async function getProcessPorts(pid: number): Promise<number[]> {
|
|
|
437
588
|
return [];
|
|
438
589
|
}
|
|
439
590
|
}
|
|
440
|
-
|
package/src/table.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface ProcessTableRow {
|
|
|
21
21
|
pid: number;
|
|
22
22
|
name: string;
|
|
23
23
|
port: string;
|
|
24
|
+
memory: string;
|
|
24
25
|
command: string;
|
|
25
26
|
workdir: string;
|
|
26
27
|
status: string;
|
|
@@ -220,6 +221,7 @@ export function renderProcessTable(processes: ProcessTableRow[], options?: Table
|
|
|
220
221
|
{ key: "pid", header: "PID", formatter: (pid) => chalk.yellow(pid) },
|
|
221
222
|
{ key: "name", header: "Name", formatter: (name) => chalk.cyan.bold(name) },
|
|
222
223
|
{ key: "port", header: "Port", formatter: (port) => port === '-' ? chalk.gray(port) : chalk.hex('#FF6B6B')(port) },
|
|
224
|
+
{ key: "memory", header: "Memory", formatter: (mem) => mem === '-' ? chalk.gray(mem) : chalk.hex('#4ECDC4')(mem) },
|
|
223
225
|
{ key: "command", header: "Command" },
|
|
224
226
|
{ key: "workdir", header: "Directory", formatter: (dir) => chalk.gray(dir), truncator: truncatePath },
|
|
225
227
|
{ key: "status", header: "Status" },
|