bgrun 3.4.0 → 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 +3 -2
- 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 +88 -44
- 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 +652 -79
- package/dashboard/app/layout.tsx +2 -23
- package/dashboard/app/page.client.tsx +683 -107
- package/dashboard/app/page.tsx +97 -33
- package/dist/index.js +170 -128
- package/package.json +5 -2
- package/src/commands/list.ts +1 -0
- package/src/commands/run.ts +60 -47
- package/src/db.ts +27 -5
- package/src/index.ts +8 -1
- package/src/platform.ts +202 -96
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)
|
|
@@ -27,11 +28,24 @@ export type Process = z.infer<typeof ProcessSchema> & { id: number };
|
|
|
27
28
|
|
|
28
29
|
const homePath = getHomeDir();
|
|
29
30
|
const bgrDir = join(homePath, ".bgr");
|
|
30
|
-
const dbName = process.env.DB_NAME ?? "bgr";
|
|
31
|
-
export const dbPath = join(bgrDir, `${dbName}_v2.sqlite`);
|
|
32
|
-
export const bgrHome = bgrDir;
|
|
33
31
|
ensureDir(bgrDir);
|
|
34
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
|
+
}
|
|
48
|
+
|
|
35
49
|
export const db = new Database(dbPath, {
|
|
36
50
|
process: ProcessSchema,
|
|
37
51
|
}, {
|
|
@@ -90,6 +104,14 @@ export function removeProcessByName(name: string) {
|
|
|
90
104
|
}
|
|
91
105
|
}
|
|
92
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
|
+
|
|
93
115
|
export function removeAllProcesses() {
|
|
94
116
|
const all = db.process.select().all();
|
|
95
117
|
for (const p of all) {
|
|
@@ -105,8 +127,8 @@ export function getDbInfo() {
|
|
|
105
127
|
return {
|
|
106
128
|
dbPath,
|
|
107
129
|
bgrHome,
|
|
108
|
-
|
|
109
|
-
exists:
|
|
130
|
+
dbFilename,
|
|
131
|
+
exists: existsSync(dbPath),
|
|
110
132
|
};
|
|
111
133
|
}
|
|
112
134
|
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,13 @@ 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`
|
|
@@ -244,7 +251,7 @@ async function run() {
|
|
|
244
251
|
Version: ${chalk.cyan(version)}
|
|
245
252
|
BGR Home: ${chalk.yellow(info.bgrHome)}
|
|
246
253
|
DB Path: ${chalk.yellow(info.dbPath)}
|
|
247
|
-
DB
|
|
254
|
+
DB File: ${info.dbFilename}
|
|
248
255
|
DB Exists: ${info.exists ? chalk.green('✓') : chalk.red('✗')}
|
|
249
256
|
Platform: ${process.platform}
|
|
250
257
|
Bun: ${Bun.version}
|
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
|
/**
|
|
@@ -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
|
/**
|
|
@@ -379,57 +489,53 @@ export async function getProcessMemory(pid: number): Promise<number> {
|
|
|
379
489
|
* to avoid spawning N subprocesses.
|
|
380
490
|
*/
|
|
381
491
|
export async function getProcessBatchMemory(pids: number[]): Promise<Map<number, number>> {
|
|
382
|
-
|
|
383
|
-
if (pids.length === 0) return memoryMap;
|
|
492
|
+
if (pids.length === 0) return new Map();
|
|
384
493
|
|
|
385
|
-
|
|
386
|
-
|
|
494
|
+
return await plat.measure(`Batch memory (${pids.length} PIDs)`, async () => {
|
|
495
|
+
const memoryMap = new Map<number, number>();
|
|
496
|
+
const pidSet = new Set(pids);
|
|
387
497
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const lines = result.trim().split('\n');
|
|
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');
|
|
393
502
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
503
|
+
for (const line of lines) {
|
|
504
|
+
const trimmed = line.trim();
|
|
505
|
+
if (!trimmed || trimmed.startsWith('Id') || trimmed.startsWith('--')) continue;
|
|
397
506
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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]);
|
|
402
511
|
|
|
403
|
-
|
|
404
|
-
|
|
512
|
+
if (!isNaN(val1) && !isNaN(val2)) {
|
|
513
|
+
if (pidSet.has(val1)) memoryMap.set(val1, val2);
|
|
514
|
+
}
|
|
405
515
|
}
|
|
406
516
|
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const rss = parseInt(rssStr); // KB
|
|
422
|
-
|
|
423
|
-
if (pidSet.has(pid)) {
|
|
424
|
-
memoryMap.set(pid, rss * 1024); // Convert KB to Bytes
|
|
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
|
+
}
|
|
425
531
|
}
|
|
426
532
|
}
|
|
533
|
+
} catch (e) {
|
|
534
|
+
// silently fail
|
|
427
535
|
}
|
|
428
|
-
} catch (e) {
|
|
429
|
-
// console.error("Error fetching batch memory:", e);
|
|
430
|
-
}
|
|
431
536
|
|
|
432
|
-
|
|
537
|
+
return memoryMap;
|
|
538
|
+
}) ?? new Map();
|
|
433
539
|
}
|
|
434
540
|
|
|
435
541
|
/**
|