bgrun 3.12.12 → 3.12.13

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/src/platform.ts DELETED
@@ -1,665 +0,0 @@
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 { join } from "path";
9
- import { $ } from "bun";
10
- import { measure, createMeasure } from "measure-fn";
11
-
12
- const plat = createMeasure('platform');
13
-
14
- /**
15
- * Execute a PowerShell command with -NoProfile synchronously.
16
- * Returns stdout as string, or empty string on error.
17
- *
18
- * Uses Bun.spawnSync to avoid async stream hanging issues on Windows
19
- * where Bun's pipe reader hangs indefinitely when reading killed processes.
20
- */
21
- export function psExec(command: string, _timeoutMs: number = 3000): string {
22
- const tmpFile = join(os.tmpdir(), `bgr-ps-${Date.now()}.ps1`);
23
- try {
24
- fs.writeFileSync(tmpFile, command);
25
- const result = Bun.spawnSync(
26
- ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', tmpFile]
27
- );
28
- try { fs.unlinkSync(tmpFile); } catch { }
29
- return result.stdout?.toString() || '';
30
- } catch {
31
- try { fs.unlinkSync(tmpFile); } catch { }
32
- return '';
33
- }
34
- }
35
-
36
- /** Detect if running on Windows - use function to prevent bundler tree-shaking */
37
- export function isWindows(): boolean {
38
- return process.platform === "win32";
39
- }
40
-
41
- /**
42
- * Get the user's home directory cross-platform
43
- */
44
- export function getHomeDir(): string {
45
- return os.homedir();
46
- }
47
-
48
- /**
49
- * Check if a process with the given PID is running
50
- * For Docker containers, checks container status instead of PID
51
- */
52
- export async function isProcessRunning(pid: number, command?: string): Promise<boolean> {
53
- // PID 0 means intentionally stopped — never alive
54
- if (pid <= 0) return false;
55
-
56
- return (await plat.measure(`PID ${pid} alive?`, async () => {
57
- try {
58
- // Docker container detection
59
- if (command && (command.includes('docker run') || command.includes('docker-compose up') || command.includes('docker compose up'))) {
60
- return await isDockerContainerRunning(command);
61
- }
62
-
63
- if (isWindows()) {
64
- // Fast path: signal 0 works for many native Windows/Bun invocations.
65
- // But under MSYS/Git Bash or detached wrapper scenarios it can return
66
- // false negatives for live Windows PIDs. Fall back to Get-Process so
67
- // CLI, dashboard, and guard all agree on process liveness.
68
- try {
69
- process.kill(pid, 0);
70
- return true;
71
- } catch {
72
- const output = psExec(
73
- `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id`
74
- ).trim();
75
- return output === String(pid);
76
- }
77
- } else {
78
- const result = await $`ps -p ${pid}`.nothrow().text();
79
- return result.includes(`${pid}`);
80
- }
81
- } catch {
82
- return false;
83
- }
84
- })) ?? false;
85
- }
86
-
87
- /**
88
- * Check if a Docker container from a command is running
89
- */
90
- async function isDockerContainerRunning(command: string): Promise<boolean> {
91
- try {
92
- // Extract container name from --name flag
93
- const nameMatch = command.match(/--name\s+["']?(\S+?)["']?(?:\s|$)/);
94
- if (nameMatch) {
95
- const containerName = nameMatch[1];
96
- const result = await $`docker inspect -f "{{.State.Running}}" ${containerName}`.nothrow().text();
97
- return result.trim() === 'true';
98
- }
99
-
100
- // If no --name, try to find running containers that match the image
101
- // Extract image name (last argument before -d or after -d)
102
- const imageMatch = command.match(/docker\s+run\s+.*?(?:-d\s+)?(\S+)\s*$/);
103
- if (imageMatch) {
104
- const imageName = imageMatch[1];
105
- const result = await $`docker ps --filter ancestor=${imageName} --format "{{.ID}}"`.nothrow().text();
106
- return result.trim().length > 0;
107
- }
108
-
109
- return false;
110
- } catch {
111
- return false;
112
- }
113
- }
114
-
115
-
116
- /**
117
- * Get child process PIDs (for termination)
118
- */
119
- async function getChildPids(pid: number): Promise<number[]> {
120
- try {
121
- if (isWindows()) {
122
- const result = await psExec(
123
- `Get-CimInstance Win32_Process -Filter 'ParentProcessId=${pid}' | Select-Object -ExpandProperty ProcessId`,
124
- 3000
125
- );
126
- return result
127
- .split('\n')
128
- .map(line => parseInt(line.trim()))
129
- .filter(n => !isNaN(n) && n > 0);
130
- } else {
131
- // On Unix, use ps --ppid
132
- const result = await $`ps --no-headers -o pid --ppid ${pid}`.nothrow().text();
133
- return result
134
- .trim()
135
- .split('\n')
136
- .filter(p => p.trim())
137
- .map(p => parseInt(p))
138
- .filter(n => !isNaN(n));
139
- }
140
- } catch {
141
- return [];
142
- }
143
- }
144
-
145
- /**
146
- * Terminate a process and its children
147
- */
148
- export async function terminateProcess(pid: number, force: boolean = false): Promise<void> {
149
- await plat.measure(`Terminate PID ${pid}`, async (m) => {
150
- try {
151
- if (isWindows()) {
152
- // Always use /T (tree kill) on Windows to kill the entire process tree
153
- // This prevents grandchild processes from surviving as zombies
154
- await $`taskkill /F /T /PID ${pid}`.nothrow().quiet();
155
- } else {
156
- // On Unix, kill children first, then parent
157
- const children = await m('Get children', () => getChildPids(pid)) ?? [];
158
- const signal = force ? 'KILL' : 'TERM';
159
- for (const childPid of children) {
160
- try {
161
- await $`kill -${signal} ${childPid}`.nothrow();
162
- } catch { /* already dead */ }
163
- }
164
- await Bun.sleep(500);
165
- if (await isProcessRunning(pid)) {
166
- await $`kill -${signal} ${pid}`.nothrow();
167
- }
168
- }
169
- } catch {
170
- // Ignore errors for already-dead processes
171
- }
172
-
173
- // Wait for process to fully exit
174
- await Bun.sleep(300);
175
- });
176
- }
177
-
178
- /**
179
- * Check if a port is free by attempting to bind to it.
180
- * On Windows, also checks whether the process holding the port is actually alive
181
- * (zombie sockets from dead processes don't block new binds on 0.0.0.0).
182
- */
183
- export async function isPortFree(port: number): Promise<boolean> {
184
- try {
185
- if (isWindows()) {
186
- // On Windows, check netstat for anything LISTENING on this port
187
- const result = await $`netstat -ano | findstr :${port}`.nothrow().quiet().text();
188
- for (const line of result.split('\n')) {
189
- // Only match exact port (avoid :35560 matching :3556)
190
- const match = line.match(new RegExp(`:(${port})\\s+.*LISTENING\\s+(\\d+)`));
191
- if (match) {
192
- const pid = parseInt(match[2]);
193
- // If the PID behind the socket is dead, it's a zombie socket
194
- // A new process can still bind to the port on 0.0.0.0
195
- if (pid > 0 && await isProcessRunning(pid)) {
196
- return false; // Real process holding the port
197
- }
198
- // else: zombie socket — consider port free
199
- }
200
- }
201
- return true;
202
- } else {
203
- const result = await $`ss -tln sport = :${port}`.nothrow().quiet().text();
204
- // If output has more than the header line, port is in use
205
- const lines = result.trim().split('\n').filter((l: string) => l.trim());
206
- return lines.length <= 1;
207
- }
208
- } catch {
209
- // If we can't check, assume it's free
210
- return true;
211
- }
212
- }
213
-
214
- /**
215
- * Get info about what's using a port.
216
- * Returns { inUse: boolean, pid?: number, processName?: string }
217
- */
218
- export async function getPortInfo(port: number): Promise<{ inUse: boolean; pid?: number; processName?: string }> {
219
- try {
220
- if (isWindows()) {
221
- const result = await $`netstat -ano | findstr :${port}`.nothrow().quiet().text();
222
- for (const line of result.split('\n')) {
223
- const match = line.match(new RegExp(`:(${port})\\s+.*LISTENING\\s+(\\d+)`));
224
- if (match) {
225
- const pid = parseInt(match[2]);
226
- if (pid > 0 && await isProcessRunning(pid)) {
227
- // Get process name
228
- const nameResult = await $`powershell -NoProfile -Command "(Get-Process -Id ${pid} -ErrorAction SilentlyContinue).ProcessName"`.nothrow().quiet().text();
229
- return { inUse: true, pid, processName: nameResult.trim() || 'unknown' };
230
- }
231
- }
232
- }
233
- return { inUse: false };
234
- } else {
235
- const result = await $`ss -tln sport = :${port}`.nothrow().quiet().text();
236
- const lines = result.trim().split('\n').filter((l: string) => l.trim());
237
- if (lines.length > 1) {
238
- return { inUse: true };
239
- }
240
- return { inUse: false };
241
- }
242
- } catch {
243
- return { inUse: false };
244
- }
245
- }
246
-
247
- /**
248
- * Wait for a port to become free, polling with timeout.
249
- * Returns true if port is free, false if timeout reached.
250
- */
251
- export async function waitForPortFree(port: number, timeoutMs: number = 5000): Promise<boolean> {
252
- const startTime = Date.now();
253
- const pollInterval = 300;
254
-
255
- while (Date.now() - startTime < timeoutMs) {
256
- if (await isPortFree(port)) {
257
- return true;
258
- }
259
- await Bun.sleep(pollInterval);
260
- }
261
- return false;
262
- }
263
-
264
-
265
- /**
266
- * Kill processes using a specific port.
267
- * Force-kills all processes bound to the port and verifies they're gone.
268
- * On Windows, filters out zombie PIDs (sockets orphaned by dead processes)
269
- * since taskkill can't kill those — they require a reboot or TCP stack reset.
270
- */
271
- export async function killProcessOnPort(port: number): Promise<void> {
272
- try {
273
- if (isWindows()) {
274
- // On Windows, use netstat to find processes on port
275
- const result = await $`netstat -ano | findstr :${port}`.nothrow().quiet().text();
276
- const pids = new Set<number>();
277
-
278
- for (const line of result.split('\n')) {
279
- // Match exact port — avoid :35560 matching :3556
280
- // Match any state (LISTENING, ESTABLISHED, TIME_WAIT, etc.)
281
- const match = line.match(new RegExp(`:(${port})\\s+.*?\\s+(\\d+)\\s*$`));
282
- if (match && parseInt(match[1]) === port) {
283
- const pid = parseInt(match[2]);
284
- if (pid > 0) pids.add(pid);
285
- }
286
- }
287
-
288
- for (const pid of pids) {
289
- // Check if the process actually exists before trying to kill it
290
- // This avoids the zombie PID issue where sockets linger after process death
291
- const alive = await isProcessRunning(pid);
292
- if (alive) {
293
- await $`taskkill /F /T /PID ${pid}`.nothrow().quiet();
294
- console.log(`Killed process ${pid} using port ${port}`);
295
- } else {
296
- // Zombie socket — PID no longer exists but socket lingers in kernel
297
- console.warn(`⚠ Port ${port} held by zombie PID ${pid} (process dead, socket stuck in kernel). Will clear on reboot or TCP timeout.`);
298
- }
299
- }
300
- } else {
301
- // On Unix, use lsof
302
- const result = await $`lsof -ti :${port}`.nothrow().text();
303
- if (result.trim()) {
304
- const pids = result.trim().split('\n').filter(pid => pid);
305
- for (const pid of pids) {
306
- await $`kill -9 ${pid}`.nothrow();
307
- console.log(`Killed process ${pid} using port ${port}`);
308
- }
309
- }
310
- }
311
- } catch (error) {
312
- console.warn(`Warning: Could not check or kill process on port ${port}: ${error}`);
313
- }
314
- }
315
-
316
- /**
317
- * Ensure a directory exists, creating it if necessary
318
- */
319
- export function ensureDir(dirPath: string): void {
320
- if (!fs.existsSync(dirPath)) {
321
- fs.mkdirSync(dirPath, { recursive: true });
322
- }
323
- }
324
-
325
- /**
326
- * Get the shell command array for spawning a process
327
- * On Windows uses cmd.exe, on Unix uses sh
328
- */
329
- export function getShellCommand(command: string): string[] {
330
- if (isWindows()) {
331
- return ["cmd", "/c", command];
332
- } else {
333
- return ["sh", "-c", command];
334
- }
335
- }
336
- /**
337
- * Find the actual child process PID spawned by a shell wrapper.
338
- * Traverses the process tree to find the deepest (leaf) child.
339
- * On Windows, bgr spawn creates: cmd.exe → bun.exe (typically 1-2 levels)
340
- *
341
- * Uses PowerShell with -NoProfile and a hard timeout to prevent hangs.
342
- */
343
- export async function findChildPid(parentPid: number): Promise<number> {
344
- let currentPid = parentPid;
345
- const maxDepth = 2; // cmd.exe → bun.exe is the typical chain
346
-
347
- for (let depth = 0; depth < maxDepth; depth++) {
348
- try {
349
- let childPids: number[] = [];
350
-
351
- if (isWindows()) {
352
- const result = await psExec(
353
- `Get-CimInstance Win32_Process -Filter 'ParentProcessId=${currentPid}' | Select-Object -ExpandProperty ProcessId`,
354
- 3000
355
- );
356
- childPids = result
357
- .split('\n')
358
- .map((line: string) => parseInt(line.trim()))
359
- .filter((n: number) => !isNaN(n) && n > 0);
360
- } else {
361
- const result = await $`ps --no-headers -o pid --ppid ${currentPid}`.nothrow().text();
362
- childPids = result
363
- .trim()
364
- .split('\n')
365
- .map(line => parseInt(line.trim()))
366
- .filter(n => !isNaN(n) && n > 0);
367
- }
368
-
369
- if (childPids.length === 0) break;
370
- currentPid = childPids[0];
371
- } catch {
372
- break;
373
- }
374
- }
375
-
376
- return currentPid;
377
- }
378
-
379
- /**
380
- * Reconcile stale PIDs: when a stored PID is dead, search for a live process
381
- * matching the same command line and update the DB with the correct PID.
382
- *
383
- * This handles the case where cmd.exe wrapper PIDs die after spawning the
384
- * actual bun.exe child process, or after a system reboot where PIDs change.
385
- *
386
- * Returns a map of process name → reconciled PID for all matched processes.
387
- */
388
- export async function reconcileProcessPids(
389
- processes: Array<{ name: string; pid: number; command: string; workdir: string }>,
390
- deadPids: Set<number>,
391
- ): Promise<Map<string, number>> {
392
- return await plat.measure('Reconcile PIDs', async () => {
393
- const result = new Map<string, number>();
394
- // Skip processes with PID=0 — these were intentionally stopped
395
- // and should NOT be reconciled to avoid hijacking unrelated processes
396
- const needsReconciliation = processes.filter(p => deadPids.has(p.pid) && p.pid > 0);
397
- if (needsReconciliation.length === 0) return result;
398
-
399
- try {
400
- // Get all running processes with their command lines
401
- let runningProcs: Array<{ pid: number; cmdLine: string }> = [];
402
-
403
- if (isWindows()) {
404
- const output = await psExec(
405
- `Get-CimInstance Win32_Process -Filter "Name='bun.exe'" | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.CommandLine)" }`,
406
- 5000
407
- );
408
- for (const line of output.split('\n')) {
409
- const sepIdx = line.indexOf('|');
410
- if (sepIdx === -1) continue;
411
- const pid = parseInt(line.substring(0, sepIdx).trim());
412
- const cmdLine = line.substring(sepIdx + 1).trim();
413
- if (!isNaN(pid) && pid > 0 && cmdLine) {
414
- runningProcs.push({ pid, cmdLine });
415
- }
416
- }
417
- } else {
418
- const psOutput = await $`ps -eo pid,args --no-headers`.nothrow().quiet().text();
419
- for (const line of psOutput.trim().split('\n')) {
420
- const match = line.trim().match(/^(\d+)\s+(.+)/);
421
- if (match) {
422
- runningProcs.push({ pid: parseInt(match[1]), cmdLine: match[2] });
423
- }
424
- }
425
- }
426
-
427
- // For each dead process, try to find a matching live process
428
- // Uses multi-criteria scoring to avoid false matches when multiple
429
- // processes share similar commands (e.g. "bun run server.ts")
430
- for (const proc of needsReconciliation) {
431
- const cmdParts = proc.command.split(/\s+/);
432
- // Extract meaningful parts: full command and workdir path segments
433
- const workdirParts = proc.workdir.replace(/\\/g, '/').split('/').filter(Boolean);
434
- const workdirLast = workdirParts[workdirParts.length - 1]?.toLowerCase() || '';
435
-
436
- let bestMatch: { pid: number; score: number } | null = null;
437
- let ambiguous = false;
438
-
439
- for (const running of runningProcs) {
440
- const cmdLower = running.cmdLine.toLowerCase();
441
- let score = 0;
442
-
443
- // Score 1: command parts match (e.g. "run", "server.ts")
444
- for (const part of cmdParts) {
445
- if (part.length > 2 && cmdLower.includes(part.toLowerCase())) score++;
446
- }
447
-
448
- // Score 2: workdir folder name appears in command line path
449
- // This distinguishes "bun run server.ts" in different directories
450
- if (workdirLast && cmdLower.includes(workdirLast)) score += 3;
451
-
452
- // Score 3: full workdir path match (strongest signal)
453
- if (cmdLower.includes(proc.workdir.toLowerCase().replace(/\\/g, '/'))) score += 5;
454
- if (cmdLower.includes(proc.workdir.toLowerCase())) score += 5;
455
-
456
- if (score < 4) continue; // Require workdir evidence — generic cmd matches alone aren't enough
457
-
458
- if (!bestMatch || score > bestMatch.score) {
459
- ambiguous = false;
460
- bestMatch = { pid: running.pid, score };
461
- } else if (score === bestMatch.score) {
462
- ambiguous = true; // Multiple equally good matches — skip
463
- }
464
- }
465
-
466
- if (bestMatch && !ambiguous) {
467
- result.set(proc.name, bestMatch.pid);
468
- runningProcs = runningProcs.filter(p => p.pid !== bestMatch!.pid);
469
- }
470
- }
471
- } catch {
472
- // Reconciliation is best-effort — return partial results
473
- }
474
-
475
- return result;
476
- }) ?? new Map();
477
- }
478
-
479
- /**
480
- * Wait for a port to become active and return the PID listening on it.
481
- * More reliable than findChildPid since it waits for the actual server
482
- * to bind the port rather than racing the process tree traversal.
483
- */
484
- export async function findPidByPort(port: number, maxWaitMs = 8000): Promise<number | null> {
485
- const start = Date.now();
486
- const pollMs = 500;
487
-
488
- while (Date.now() - start < maxWaitMs) {
489
- try {
490
- if (isWindows()) {
491
- const result = await $`netstat -ano`.nothrow().quiet().text();
492
- for (const line of result.split('\n')) {
493
- if (line.includes(`:${port}`) && line.includes('LISTENING')) {
494
- const parts = line.trim().split(/\s+/);
495
- const pid = parseInt(parts[parts.length - 1]);
496
- if (!isNaN(pid) && pid > 0) return pid;
497
- }
498
- }
499
- } else {
500
- try {
501
- const result = await $`ss -tlnp`.nothrow().quiet().text();
502
- for (const line of result.split('\n')) {
503
- if (line.includes(`:${port}`)) {
504
- const pidMatch = line.match(/pid=(\d+)/);
505
- if (pidMatch) return parseInt(pidMatch[1]);
506
- }
507
- }
508
- } catch { /* ss not available, try lsof */ }
509
-
510
- const result = await $`lsof -iTCP:${port} -sTCP:LISTEN -t`.nothrow().quiet().text();
511
- const pid = parseInt(result.trim());
512
- if (!isNaN(pid) && pid > 0) return pid;
513
- }
514
- } catch { /* retry */ }
515
-
516
- await new Promise(resolve => setTimeout(resolve, pollMs));
517
- }
518
-
519
- return null;
520
- }
521
-
522
- export async function readFileTail(filePath: string, lines?: number): Promise<string> {
523
- return (await plat.measure(`Read tail ${lines ?? 'all'}L`, async () => {
524
- try {
525
- const content = await Bun.file(filePath).text();
526
-
527
- if (!lines) {
528
- return content;
529
- }
530
-
531
- const allLines = content.split(/\r?\n/);
532
- const tailLines = allLines.slice(-lines);
533
- return tailLines.join('\n');
534
- } catch (error) {
535
- throw new Error(`Error reading file: ${error}`);
536
- }
537
- })) ?? '';
538
- }
539
-
540
- /**
541
- * Copy a file from source to destination
542
- */
543
- export function copyFile(src: string, dest: string): void {
544
- fs.copyFileSync(src, dest);
545
- }
546
-
547
- /**
548
- * Get memory usage of a process in bytes
549
- */
550
- export async function getProcessMemory(pid: number): Promise<number> {
551
- const map = await getProcessBatchResources([pid]);
552
- return map.get(pid)?.memory || 0;
553
- }
554
-
555
- /**
556
- * Get memory and CPU usage for a batch of PIDs.
557
- * Returns a Map of PID -> { memory: bytes, cpu: number }.
558
- * On Windows, CPU is cumulative time in seconds.
559
- * On Unix, CPU is instantaneous percentage.
560
- *
561
- * Optimization: Fetches ALL processes in one go and filters in-memory
562
- * to avoid spawning N subprocesses.
563
- */
564
- export async function getProcessBatchResources(pids: number[]): Promise<Map<number, { memory: number, cpu: number }>> {
565
- if (pids.length === 0) return new Map();
566
-
567
- return await plat.measure(`Batch resources (${pids.length} PIDs)`, async () => {
568
- const resourceMap = new Map<number, { memory: number, cpu: number }>();
569
- const pidSet = new Set(pids);
570
-
571
- try {
572
- if (isWindows()) {
573
- // psExec(Get-Process) is fast (~2ms) vs tasklist which hangs
574
- const output = psExec(
575
- `Get-Process -Id ${pids.join(',')} -ErrorAction SilentlyContinue | Select-Object Id, WorkingSet64 | ForEach-Object { Write-Output "$($_.Id)|$($_.WorkingSet64)" }`
576
- );
577
- for (const line of output.split('\n')) {
578
- const sepIdx = line.indexOf('|');
579
- if (sepIdx === -1) continue;
580
- const pid = parseInt(line.substring(0, sepIdx).trim());
581
- const memory = parseInt(line.substring(sepIdx + 1).trim()) || 0;
582
- if (!isNaN(pid) && pidSet.has(pid)) {
583
- resourceMap.set(pid, { memory, cpu: 0 });
584
- }
585
- }
586
- } else {
587
- const result = await $`ps -eo pid,pcpu,rss`.nothrow().quiet().text();
588
- const lines = result.trim().split('\n');
589
-
590
- for (let i = 1; i < lines.length; i++) {
591
- const line = lines[i].trim();
592
- if (!line) continue;
593
- const [pidStr, cpuStr, rssStr] = line.split(/\s+/);
594
- const pid = parseInt(pidStr);
595
- const cpu = parseFloat(cpuStr) || 0;
596
- const rss = parseInt(rssStr) || 0;
597
-
598
- if (pidSet.has(pid)) {
599
- resourceMap.set(pid, { memory: rss * 1024, cpu });
600
- }
601
- }
602
- }
603
- } catch (e) {
604
- // silently fail
605
- }
606
-
607
- return resourceMap;
608
- }) ?? new Map();
609
- }
610
-
611
- /**
612
- * Parse Unix lsof LISTEN output and return only true listening TCP ports.
613
- */
614
- export function parseUnixListeningPorts(output: string): number[] {
615
- const ports = new Set<number>();
616
- for (const line of output.split('\n')) {
617
- const portMatch = line.match(/:(\d+)\s+\(LISTEN\)/);
618
- if (portMatch) {
619
- ports.add(parseInt(portMatch[1]));
620
- }
621
- }
622
- return Array.from(ports);
623
- }
624
-
625
- /**
626
- * Get the TCP ports a process is currently listening on by querying the OS.
627
- * Returns an array of port numbers (empty if none or process not found).
628
- */
629
- export async function getProcessPorts(pid: number): Promise<number[]> {
630
- try {
631
- if (isWindows()) {
632
- // netstat -ano lists all connections with PIDs
633
- const result = await $`netstat -ano`.nothrow().quiet().text();
634
- const ports = new Set<number>();
635
- for (const line of result.split('\n')) {
636
- // Match lines like: TCP 0.0.0.0:3556 0.0.0.0:0 LISTENING 8608
637
- const match = line.match(/^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/);
638
- if (match && parseInt(match[2]) === pid) {
639
- ports.add(parseInt(match[1]));
640
- }
641
- }
642
- return Array.from(ports);
643
- } else {
644
- // Unix: use ss (modern) with fallback to lsof
645
- try {
646
- const result = await $`ss -tlnp`.nothrow().quiet().text();
647
- const ports = new Set<number>();
648
- for (const line of result.split('\n')) {
649
- if (line.includes(`pid=${pid}`)) {
650
- const portMatch = line.match(/:(\d+)\s/);
651
- if (portMatch) {
652
- ports.add(parseInt(portMatch[1]));
653
- }
654
- }
655
- }
656
- if (ports.size > 0) return Array.from(ports);
657
- } catch { /* ss not available, try lsof */ }
658
-
659
- const result = await $`lsof -Pan -p ${pid} -iTCP -sTCP:LISTEN`.nothrow().quiet().text();
660
- return parseUnixListeningPorts(result);
661
- }
662
- } catch {
663
- return [];
664
- }
665
- }