bgrun 3.12.1 → 3.12.3

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/bgrun.test.ts CHANGED
@@ -9,6 +9,13 @@
9
9
  import { describe, expect, test } from 'bun:test'
10
10
  import { parseEnvString, calculateRuntime } from './utils'
11
11
  import { stripAnsi, truncateString, truncatePath } from './table'
12
+ import { detectPackageManager, formatDeployToolError } from './deploy'
13
+ import { isProcessRunning, parseUnixListeningPorts } from './platform'
14
+ import { mkdirSync, rmSync } from 'fs'
15
+
16
+ // Use a test-specific database to avoid polluting real data
17
+ process.env.BGRUN_DB = `bgrun-test-${Date.now()}.sqlite`
18
+ import { addDependency, removeDependency, getDependencyGraph, getDependencies, getDependents, getStartOrder, removeAllDependencies } from './db'
12
19
 
13
20
  // ─── parseEnvString ─────────────────────────────────────
14
21
 
@@ -107,3 +114,200 @@ describe('truncatePath', () => {
107
114
  expect(result).toContain('…')
108
115
  })
109
116
  })
117
+
118
+ // ─── detectPackageManager ───────────────────────────────
119
+
120
+ // ─── isProcessRunning (Windows liveness fallback) ───────
121
+
122
+ describe('isProcessRunning', () => {
123
+ test('returns true for the current process PID', async () => {
124
+ const alive = await isProcessRunning(process.pid)
125
+ expect(alive).toBe(true)
126
+ })
127
+
128
+ test('returns false for PID 0 (intentionally stopped)', async () => {
129
+ const alive = await isProcessRunning(0)
130
+ expect(alive).toBe(false)
131
+ })
132
+
133
+ test('returns false for a very high unlikely PID', async () => {
134
+ const alive = await isProcessRunning(999999)
135
+ expect(alive).toBe(false)
136
+ })
137
+
138
+ test('returns false for negative PID', async () => {
139
+ const alive = await isProcessRunning(-1)
140
+ expect(alive).toBe(false)
141
+ })
142
+ })
143
+
144
+ describe('parseUnixListeningPorts', () => {
145
+ test('extracts only LISTEN ports from lsof output', () => {
146
+ const output = [
147
+ 'COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME',
148
+ 'bun 12345 root 21u IPv4 123456 0t0 TCP *:3400 (LISTEN)',
149
+ 'bun 12345 root 22u IPv4 123457 0t0 TCP 127.0.0.1:9222 (LISTEN)',
150
+ ].join('\n')
151
+
152
+ expect(parseUnixListeningPorts(output)).toEqual([3400, 9222])
153
+ })
154
+
155
+ test('ignores non-LISTEN sockets from broad lsof output', () => {
156
+ const output = [
157
+ 'COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME',
158
+ 'bun 12345 root 18u IPv4 111111 0t0 TCP 127.0.0.1:49440->127.0.0.1:3000 (ESTABLISHED)',
159
+ 'bun 12345 root 19u IPv4 111112 0t0 TCP 127.0.0.1:49441->127.0.0.1:3737 (ESTABLISHED)',
160
+ 'bun 12345 root 20u IPv4 111113 0t0 TCP *:3400 (LISTEN)',
161
+ ].join('\n')
162
+
163
+ expect(parseUnixListeningPorts(output)).toEqual([3400])
164
+ })
165
+
166
+ test('returns empty array for no-port worker output', () => {
167
+ const output = [
168
+ 'COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME',
169
+ 'bun 12345 root 18u unix 0xffff 0t0 /tmp/bun.sock',
170
+ 'bun 12345 root 19u IPv4 111111 0t0 TCP 127.0.0.1:49440->127.0.0.1:3000 (ESTABLISHED)',
171
+ ].join('\n')
172
+
173
+ expect(parseUnixListeningPorts(output)).toEqual([])
174
+ })
175
+ })
176
+
177
+ // ─── detectPackageManager ───────────────────────────────
178
+
179
+ describe('formatDeployToolError', () => {
180
+ test('returns actionable message for missing binary', () => {
181
+ const msg = formatDeployToolError('pnpm', new Error('command not found: pnpm'))
182
+ expect(msg).toContain("requires 'pnpm'")
183
+ expect(msg).toContain('PATH')
184
+ })
185
+
186
+ test('preserves non-missing-binary failures', () => {
187
+ const msg = formatDeployToolError('npm', new Error('npm ci failed with exit code 1'))
188
+ expect(msg).toContain('Dependency install failed with npm')
189
+ expect(msg).toContain('exit code 1')
190
+ })
191
+ })
192
+
193
+ describe('detectPackageManager', () => {
194
+ test('returns null when no package.json exists', async () => {
195
+ const dir = `${process.cwd()}/tmp-no-package-${Date.now()}`
196
+ mkdirSync(dir, { recursive: true })
197
+ try {
198
+ expect(await detectPackageManager(dir)).toBeNull()
199
+ } finally {
200
+ rmSync(dir, { recursive: true, force: true })
201
+ }
202
+ })
203
+
204
+ test('prefers bun lockfiles', async () => {
205
+ const dir = `${process.cwd()}/tmp-bun-${Date.now()}`
206
+ mkdirSync(dir, { recursive: true })
207
+ try {
208
+ await Bun.write(`${dir}/package.json`, '{}')
209
+ await Bun.write(`${dir}/bun.lock`, '')
210
+ expect(await detectPackageManager(dir)).toBe('bun')
211
+ } finally {
212
+ rmSync(dir, { recursive: true, force: true })
213
+ }
214
+ })
215
+
216
+ test('detects pnpm, yarn, and npm lockfiles', async () => {
217
+ const base = `${process.cwd()}/tmp-pm-${Date.now()}`
218
+
219
+ const pnpmDir = `${base}-pnpm`
220
+ mkdirSync(pnpmDir, { recursive: true })
221
+ await Bun.write(`${pnpmDir}/package.json`, '{}')
222
+ await Bun.write(`${pnpmDir}/pnpm-lock.yaml`, '')
223
+ expect(await detectPackageManager(pnpmDir)).toBe('pnpm')
224
+
225
+ const yarnDir = `${base}-yarn`
226
+ mkdirSync(yarnDir, { recursive: true })
227
+ await Bun.write(`${yarnDir}/package.json`, '{}')
228
+ await Bun.write(`${yarnDir}/yarn.lock`, '')
229
+ expect(await detectPackageManager(yarnDir)).toBe('yarn')
230
+
231
+ const npmDir = `${base}-npm`
232
+ mkdirSync(npmDir, { recursive: true })
233
+ await Bun.write(`${npmDir}/package.json`, '{}')
234
+ await Bun.write(`${npmDir}/package-lock.json`, '{}')
235
+ expect(await detectPackageManager(npmDir)).toBe('npm')
236
+
237
+ rmSync(pnpmDir, { recursive: true, force: true })
238
+ rmSync(yarnDir, { recursive: true, force: true })
239
+ rmSync(npmDir, { recursive: true, force: true })
240
+ })
241
+
242
+ test('defaults to bun for package.json projects without a lockfile', async () => {
243
+ const dir = `${process.cwd()}/tmp-default-bun-${Date.now()}`
244
+ mkdirSync(dir, { recursive: true })
245
+ try {
246
+ await Bun.write(`${dir}/package.json`, '{}')
247
+ expect(await detectPackageManager(dir)).toBe('bun')
248
+ } finally {
249
+ rmSync(dir, { recursive: true, force: true })
250
+ }
251
+ })
252
+ })
253
+
254
+ // ─── Dependencies ───────────────────────────────────────
255
+
256
+ describe('addDependency', () => {
257
+ test('adds a valid dependency', () => {
258
+ removeAllDependencies('web-server');
259
+ removeAllDependencies('database');
260
+ const ok = addDependency('web-server', 'database');
261
+ expect(ok).toBe(true);
262
+ expect(getDependencies('web-server')).toContain('database');
263
+ })
264
+
265
+ test('prevents self-dependency', () => {
266
+ expect(addDependency('api', 'api')).toBe(false);
267
+ })
268
+
269
+ test('prevents duplicate dependency', () => {
270
+ removeAllDependencies('app');
271
+ addDependency('app', 'db');
272
+ expect(addDependency('app', 'db')).toBe(false);
273
+ })
274
+
275
+ test('prevents circular dependency', () => {
276
+ removeAllDependencies('a');
277
+ removeAllDependencies('b');
278
+ removeAllDependencies('c');
279
+ addDependency('a', 'b');
280
+ addDependency('b', 'c');
281
+ // c -> a would create a cycle
282
+ expect(addDependency('c', 'a')).toBe(false);
283
+ })
284
+ })
285
+
286
+ describe('getDependencyGraph', () => {
287
+ test('returns full graph', () => {
288
+ removeAllDependencies('svc-a');
289
+ removeAllDependencies('svc-b');
290
+ addDependency('svc-a', 'svc-b');
291
+ const graph = getDependencyGraph();
292
+ expect(graph['svc-a']).toContain('svc-b');
293
+ })
294
+ })
295
+
296
+ describe('getDependents', () => {
297
+ test('finds processes that depend on a target', () => {
298
+ removeAllDependencies('frontend');
299
+ removeAllDependencies('backend');
300
+ addDependency('frontend', 'backend');
301
+ expect(getDependents('backend')).toContain('frontend');
302
+ })
303
+ })
304
+
305
+ describe('removeDependency', () => {
306
+ test('removes an existing dependency', () => {
307
+ removeAllDependencies('x');
308
+ addDependency('x', 'y');
309
+ expect(getDependencies('x')).toContain('y');
310
+ removeDependency('x', 'y');
311
+ expect(getDependencies('x')).not.toContain('y');
312
+ })
313
+ })
package/src/db.ts CHANGED
@@ -44,6 +44,14 @@ export const HistorySchema = z.object({
44
44
 
45
45
  export type History = z.infer<typeof HistorySchema> & { id: number };
46
46
 
47
+ export const DependencySchema = z.object({
48
+ process_name: z.string(), // the process that has the dependency
49
+ depends_on: z.string(), // the process it depends on
50
+ created_at: z.string().default(() => new Date().toISOString()),
51
+ });
52
+
53
+ export type Dependency = z.infer<typeof DependencySchema> & { id: number };
54
+
47
55
  // =============================================================================
48
56
  // DATABASE INITIALIZATION
49
57
  // =============================================================================
@@ -72,11 +80,13 @@ export const db = new Database(dbPath, {
72
80
  process: ProcessSchema,
73
81
  template: TemplateSchema,
74
82
  history: HistorySchema,
83
+ dependency: DependencySchema,
75
84
  }, {
76
85
  indexes: {
77
86
  process: ['name', 'timestamp', 'pid'],
78
87
  template: ['name'],
79
88
  history: ['process_name', 'timestamp'],
89
+ dependency: ['process_name', 'depends_on'],
80
90
  },
81
91
  });
82
92
 
@@ -242,6 +252,138 @@ export function clearOldHistory(daysToKeep = 30) {
242
252
  return oldEntries.length;
243
253
  }
244
254
 
255
+ // =============================================================================
256
+ // DEPENDENCY FUNCTIONS
257
+ // =============================================================================
258
+
259
+ /** Get all dependencies for a process */
260
+ export function getDependencies(processName: string): string[] {
261
+ return db.dependency.select()
262
+ .where({ process_name: processName })
263
+ .all()
264
+ .map(d => d.depends_on);
265
+ }
266
+
267
+ /** Get all processes that depend on a given process */
268
+ export function getDependents(processName: string): string[] {
269
+ return db.dependency.select()
270
+ .where({ depends_on: processName })
271
+ .all()
272
+ .map(d => d.process_name);
273
+ }
274
+
275
+ /** Get the full dependency graph: { processName -> [dependsOn...] } */
276
+ export function getDependencyGraph(): Record<string, string[]> {
277
+ const all = db.dependency.select().all();
278
+ const graph: Record<string, string[]> = {};
279
+ for (const dep of all) {
280
+ if (!graph[dep.process_name]) graph[dep.process_name] = [];
281
+ graph[dep.process_name].push(dep.depends_on);
282
+ }
283
+ return graph;
284
+ }
285
+
286
+ /** Add a dependency (process_name depends on depends_on) */
287
+ export function addDependency(processName: string, dependsOn: string): boolean {
288
+ // Prevent self-dependency
289
+ if (processName === dependsOn) return false;
290
+
291
+ // Prevent duplicates
292
+ const existing = db.dependency.select()
293
+ .where({ process_name: processName, depends_on: dependsOn })
294
+ .limit(1)
295
+ .get();
296
+ if (existing) return false;
297
+
298
+ // Prevent circular dependencies
299
+ if (wouldCreateCycle(processName, dependsOn)) return false;
300
+
301
+ db.dependency.insert({ process_name: processName, depends_on: dependsOn });
302
+ return true;
303
+ }
304
+
305
+ /** Remove a dependency */
306
+ export function removeDependency(processName: string, dependsOn: string) {
307
+ const matches = db.dependency.select()
308
+ .where({ process_name: processName, depends_on: dependsOn })
309
+ .all();
310
+ for (const dep of matches) {
311
+ db.dependency.delete(dep.id);
312
+ }
313
+ }
314
+
315
+ /** Remove all dependencies for a process */
316
+ export function removeAllDependencies(processName: string) {
317
+ const matches = db.dependency.select()
318
+ .where({ process_name: processName })
319
+ .all();
320
+ for (const dep of matches) {
321
+ db.dependency.delete(dep.id);
322
+ }
323
+ }
324
+
325
+ /** Check if adding processName -> dependsOn would create a cycle */
326
+ function wouldCreateCycle(processName: string, dependsOn: string): boolean {
327
+ const graph = getDependencyGraph();
328
+ // Add the proposed edge temporarily
329
+ if (!graph[processName]) graph[processName] = [];
330
+ graph[processName].push(dependsOn);
331
+
332
+ // DFS from dependsOn — if we can reach processName, it's a cycle
333
+ const visited = new Set<string>();
334
+ const stack = [dependsOn];
335
+ while (stack.length > 0) {
336
+ const current = stack.pop()!;
337
+ if (current === processName) return true;
338
+ if (visited.has(current)) continue;
339
+ visited.add(current);
340
+ for (const dep of (graph[current] || [])) {
341
+ stack.push(dep);
342
+ }
343
+ }
344
+ return false;
345
+ }
346
+
347
+ /** Get topological start order (processes with no deps first) */
348
+ export function getStartOrder(): string[] {
349
+ const graph = getDependencyGraph();
350
+ const allProcesses = getAllProcesses().map(p => p.name);
351
+ const allNames = new Set(allProcesses);
352
+
353
+ // Build in-degree map
354
+ const inDegree: Record<string, number> = {};
355
+ for (const name of allNames) inDegree[name] = 0;
356
+ for (const [proc, deps] of Object.entries(graph)) {
357
+ for (const dep of deps) {
358
+ if (allNames.has(dep)) {
359
+ inDegree[proc] = (inDegree[proc] || 0) + 1;
360
+ }
361
+ }
362
+ }
363
+
364
+ // Kahn's algorithm
365
+ const queue: string[] = [];
366
+ for (const name of allNames) {
367
+ if ((inDegree[name] || 0) === 0) queue.push(name);
368
+ }
369
+
370
+ const order: string[] = [];
371
+ while (queue.length > 0) {
372
+ queue.sort(); // stable alphabetical within same level
373
+ const current = queue.shift()!;
374
+ order.push(current);
375
+ // Find processes that depend on current
376
+ for (const [proc, deps] of Object.entries(graph)) {
377
+ if (deps.includes(current) && allNames.has(proc)) {
378
+ inDegree[proc]--;
379
+ if (inDegree[proc] === 0) queue.push(proc);
380
+ }
381
+ }
382
+ }
383
+
384
+ return order;
385
+ }
386
+
245
387
  // =============================================================================
246
388
  // DEBUG / INFO
247
389
  // =============================================================================
package/src/deploy.ts ADDED
@@ -0,0 +1,163 @@
1
+ import { getProcess, getAllProcesses, addHistoryEntry } from './db';
2
+ import { handleRun } from './commands/run';
3
+ import { $ } from 'bun';
4
+
5
+ export interface DeployResult {
6
+ name: string;
7
+ ok: boolean;
8
+ skipped?: boolean;
9
+ reason?: string;
10
+ pullOutput?: string;
11
+ installOutput?: string;
12
+ packageManager?: PackageManager;
13
+ installCommand?: string;
14
+ installAttempted?: boolean;
15
+ }
16
+
17
+ export type PackageManager = 'bun' | 'pnpm' | 'yarn' | 'npm' | null;
18
+
19
+ export function formatDeployToolError(manager: Exclude<PackageManager, null>, error: unknown): string {
20
+ const raw = error instanceof Error ? error.message : String(error ?? 'Unknown error');
21
+ const lower = raw.toLowerCase();
22
+
23
+ if (
24
+ lower.includes('command not found') ||
25
+ lower.includes('not recognized as an internal or external command') ||
26
+ lower.includes('executable not found') ||
27
+ lower.includes('no such file or directory')
28
+ ) {
29
+ return `Deploy requires '${manager}', but it is not installed or not available on PATH.`;
30
+ }
31
+
32
+ return `Dependency install failed with ${manager}: ${raw}`;
33
+ }
34
+
35
+ function isInternalProcess(name: string): boolean {
36
+ return name === 'bgr-dashboard' || name === 'bgr-guard';
37
+ }
38
+
39
+ async function pathExists(path: string): Promise<boolean> {
40
+ return await Bun.file(path).exists();
41
+ }
42
+
43
+ async function isGitRepo(dir: string): Promise<boolean> {
44
+ return await pathExists(`${dir}/.git`) || await pathExists(`${dir}/.git/HEAD`);
45
+ }
46
+
47
+ export async function detectPackageManager(dir: string): Promise<PackageManager> {
48
+ const hasPackageJson = await pathExists(`${dir}/package.json`);
49
+ if (!hasPackageJson) return null;
50
+
51
+ if (await pathExists(`${dir}/bun.lock`) || await pathExists(`${dir}/bun.lockb`)) return 'bun';
52
+ if (await pathExists(`${dir}/pnpm-lock.yaml`)) return 'pnpm';
53
+ if (await pathExists(`${dir}/yarn.lock`)) return 'yarn';
54
+ if (await pathExists(`${dir}/package-lock.json`) || await pathExists(`${dir}/npm-shrinkwrap.json`)) return 'npm';
55
+
56
+ return 'bun';
57
+ }
58
+
59
+ function getInstallCommand(manager: Exclude<PackageManager, null>): string {
60
+ switch (manager) {
61
+ case 'bun': return 'bun install';
62
+ case 'pnpm': return 'pnpm install --frozen-lockfile';
63
+ case 'yarn': return 'yarn install --frozen-lockfile';
64
+ case 'npm': return 'npm ci';
65
+ }
66
+ }
67
+
68
+ async function installDependencies(dir: string): Promise<{ manager: PackageManager; output: string; command: string }> {
69
+ const manager = await detectPackageManager(dir);
70
+ if (!manager) return { manager: null, output: '', command: '' };
71
+
72
+ $.cwd(dir);
73
+ const command = getInstallCommand(manager);
74
+
75
+ try {
76
+ switch (manager) {
77
+ case 'bun':
78
+ return { manager, command, output: (await $`bun install`.text()).trim() };
79
+ case 'pnpm':
80
+ return { manager, command, output: (await $`pnpm install --frozen-lockfile`.text()).trim() };
81
+ case 'yarn':
82
+ return { manager, command, output: (await $`yarn install --frozen-lockfile`.text()).trim() };
83
+ case 'npm':
84
+ return { manager, command, output: (await $`npm ci`.text()).trim() };
85
+ default:
86
+ return { manager: null, output: '', command: '' };
87
+ }
88
+ } catch (error) {
89
+ throw new Error(formatDeployToolError(manager, error));
90
+ }
91
+ }
92
+
93
+ export async function deployProcess(name: string): Promise<DeployResult> {
94
+ const proc = getProcess(name);
95
+ if (!proc) {
96
+ return { name, ok: false, reason: `Process '${name}' not found` };
97
+ }
98
+
99
+ if (isInternalProcess(proc.name)) {
100
+ return { name, ok: false, skipped: true, reason: 'Internal bgrun process skipped' };
101
+ }
102
+
103
+ const dir = proc.workdir;
104
+ if (!(await isGitRepo(dir))) {
105
+ return { name, ok: false, skipped: true, reason: `'${dir}' is not a git repository` };
106
+ }
107
+
108
+ try {
109
+ $.cwd(dir);
110
+
111
+ const pullOutput = (await $`git pull`.text()).trim();
112
+
113
+ const install = await installDependencies(dir);
114
+ const installOutput = install.output;
115
+
116
+ await handleRun({
117
+ action: 'run',
118
+ name,
119
+ force: true,
120
+ remoteName: '',
121
+ });
122
+
123
+ addHistoryEntry(name, 'deploy', proc.pid, {
124
+ directory: dir,
125
+ installed: Boolean(install.manager),
126
+ packageManager: install.manager,
127
+ installCommand: install.command,
128
+ });
129
+
130
+ return {
131
+ name,
132
+ ok: true,
133
+ pullOutput,
134
+ installOutput,
135
+ packageManager: install.manager,
136
+ installCommand: install.command,
137
+ installAttempted: Boolean(install.manager),
138
+ };
139
+ } catch (e: any) {
140
+ return {
141
+ name,
142
+ ok: false,
143
+ reason: e?.message || String(e),
144
+ };
145
+ }
146
+ }
147
+
148
+ export async function deployAllProcesses(group?: string): Promise<DeployResult[]> {
149
+ const processes = getAllProcesses()
150
+ .filter(proc => !isInternalProcess(proc.name))
151
+ .filter(proc => !group || proc.group === group);
152
+
153
+ const seen = new Set<string>();
154
+ const results: DeployResult[] = [];
155
+
156
+ for (const proc of processes) {
157
+ if (seen.has(proc.name)) continue;
158
+ seen.add(proc.name);
159
+ results.push(await deployProcess(proc.name));
160
+ }
161
+
162
+ return results;
163
+ }
package/src/index.ts CHANGED
@@ -574,6 +574,15 @@ async function run() {
574
574
  return;
575
575
  }
576
576
 
577
+ // Explicit "list" command
578
+ if (name === 'list') {
579
+ await showAll({
580
+ json: values.json as boolean | undefined,
581
+ filter: values.filter as string | undefined
582
+ });
583
+ return;
584
+ }
585
+
577
586
  // List or Run or Details
578
587
  if (name) {
579
588
  if (!values.command && !values.directory) {
package/src/platform.ts CHANGED
@@ -61,9 +61,19 @@ export async function isProcessRunning(pid: number, command?: string): Promise<b
61
61
  }
62
62
 
63
63
  if (isWindows()) {
64
- // process.kill(pid, 0) signal 0 checks existence without killing
65
- // 100x faster than tasklist which hangs on some Windows systems
66
- try { process.kill(pid, 0); return true; } catch { return false; }
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
+ }
67
77
  } else {
68
78
  const result = await $`ps -p ${pid}`.nothrow().text();
69
79
  return result.includes(`${pid}`);
@@ -598,6 +608,20 @@ export async function getProcessBatchResources(pids: number[]): Promise<Map<numb
598
608
  }) ?? new Map();
599
609
  }
600
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
+
601
625
  /**
602
626
  * Get the TCP ports a process is currently listening on by querying the OS.
603
627
  * Returns an array of port numbers (empty if none or process not found).
@@ -632,17 +656,8 @@ export async function getProcessPorts(pid: number): Promise<number[]> {
632
656
  if (ports.size > 0) return Array.from(ports);
633
657
  } catch { /* ss not available, try lsof */ }
634
658
 
635
- const result = await $`lsof -i -P -n -p ${pid}`.nothrow().quiet().text();
636
- const ports = new Set<number>();
637
- for (const line of result.split('\n')) {
638
- if (line.includes('LISTEN')) {
639
- const portMatch = line.match(/:(\d+)\s+\(LISTEN\)/);
640
- if (portMatch) {
641
- ports.add(parseInt(portMatch[1]));
642
- }
643
- }
644
- }
645
- return Array.from(ports);
659
+ const result = await $`lsof -Pan -p ${pid} -iTCP -sTCP:LISTEN`.nothrow().quiet().text();
660
+ return parseUnixListeningPorts(result);
646
661
  }
647
662
  } catch {
648
663
  return [];