offmyport 1.3.0 → 1.4.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/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.4.0] - 2026-01-02
6
+
7
+ ### Added
8
+
9
+ - Process metadata display in interactive mode with table headers
10
+ - CWD column showing current working directory for each process
11
+ - Extended details panel below list showing path, cwd, cpu, memory, start time
12
+ - Batch metadata fetching for ~50x faster loading on long process lists
13
+ - Loading indicator ("Loading process details...") for lists with >5 processes
14
+
15
+ ### Changed
16
+
17
+ - Metadata is now fetched with batch system calls (2 calls instead of 2N)
18
+
5
19
  ## [1.3.0] - 2025-12-31
6
20
 
7
21
  ### Added
package/README.md CHANGED
@@ -6,15 +6,6 @@
6
6
 
7
7
  Interactive CLI tool to find and kill processes by port. No more memorizing `lsof -i :8080` or `netstat` flags.
8
8
 
9
- ## Table of Contents
10
-
11
- - [Installation](#installation)
12
- - [Usage](#usage)
13
- - [Features](#features)
14
- - [CLI Reference](#cli-reference)
15
- - [Requirements](#requirements)
16
- - [License](#license)
17
-
18
9
  ## Installation
19
10
 
20
11
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offmyport",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Interactive CLI tool to find and kill processes by port.",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/index.ts CHANGED
@@ -4,7 +4,11 @@ import { select, confirm } from "@inquirer/prompts";
4
4
  import { ExitPromptError } from "@inquirer/core";
5
5
  import * as readline from "readline";
6
6
  import meow from "meow";
7
- import { getAdapter, type ProcessInfo } from "./platform/index.js";
7
+ import {
8
+ getAdapter,
9
+ type ProcessInfo,
10
+ type ProcessMetadata,
11
+ } from "./platform/index.js";
8
12
 
9
13
  export interface CliFlags {
10
14
  ports: string | null;
@@ -208,6 +212,77 @@ export function parsePorts(input: string): number[] {
208
212
  return [...new Set(ports)].sort((a, b) => a - b);
209
213
  }
210
214
 
215
+ /**
216
+ * Format bytes to human-readable string (e.g., "52.4 MB").
217
+ */
218
+ function formatBytes(bytes: number | null): string {
219
+ if (bytes === null) return "n/a";
220
+ const units = ["B", "KB", "MB", "GB"];
221
+ let value = bytes;
222
+ let unitIndex = 0;
223
+ while (value >= 1024 && unitIndex < units.length - 1) {
224
+ value /= 1024;
225
+ unitIndex++;
226
+ }
227
+ return `${value.toFixed(1)} ${units[unitIndex]}`;
228
+ }
229
+
230
+ /**
231
+ * Format process metadata as description text for @inquirer/select.
232
+ */
233
+ function formatProcessDescription(meta: ProcessMetadata): string {
234
+ const lines: string[] = [];
235
+
236
+ if (meta.path) lines.push(`Path: ${meta.path}`);
237
+ if (meta.cwd) lines.push(`CWD: ${meta.cwd}`);
238
+ if (meta.cpuPercent !== null) lines.push(`CPU: ${meta.cpuPercent.toFixed(1)}%`);
239
+ if (meta.memoryBytes !== null) lines.push(`Mem: ${formatBytes(meta.memoryBytes)}`);
240
+ if (meta.startTime) {
241
+ const date = new Date(meta.startTime);
242
+ const formatted = isNaN(date.getTime()) ? meta.startTime : date.toLocaleString();
243
+ lines.push(`Started: ${formatted}`);
244
+ }
245
+
246
+ return lines.length > 0 ? lines.join("\n") : "No additional info available";
247
+ }
248
+
249
+ /**
250
+ * Generate table header for process list.
251
+ */
252
+ function getTableHeader(): string {
253
+ return ` ${"Port".padEnd(7)}│ ${"Command".padEnd(16)}│ ${"PID".padEnd(8)}│ ${"User".padEnd(11)}│ CWD`;
254
+ }
255
+
256
+ /**
257
+ * Format a single process row for display.
258
+ */
259
+ function formatProcessRow(p: ProcessInfo, cwd: string | null): string {
260
+ const cwdDisplay = cwd ?? "(n/a)";
261
+ return `Port ${p.port.toString().padStart(5)} │ ${p.command.padEnd(15)} │ PID ${p.pid.toString().padEnd(6)} │ ${p.user.padEnd(10)} │ ${cwdDisplay}`;
262
+ }
263
+
264
+ /**
265
+ * Fetch metadata for all processes using batch call for performance.
266
+ */
267
+ function fetchAllMetadata(processes: ProcessInfo[]): Map<number, ProcessMetadata> {
268
+ const platform = getPlatformAdapter();
269
+ const pids = processes.map((p) => p.pid);
270
+
271
+ // Show loading indicator for long lists
272
+ if (pids.length > 5) {
273
+ process.stdout.write(`\x1b[2mLoading process details...\x1b[0m`);
274
+ }
275
+
276
+ const metadataMap = platform.getProcessMetadataBatch(pids);
277
+
278
+ // Clear loading indicator
279
+ if (pids.length > 5) {
280
+ process.stdout.write(`\r\x1b[K`);
281
+ }
282
+
283
+ return metadataMap;
284
+ }
285
+
211
286
  // Platform adapter instance (lazy initialized)
212
287
  let adapter: ReturnType<typeof getAdapter> | null = null;
213
288
 
@@ -280,9 +355,14 @@ async function main() {
280
355
  }
281
356
 
282
357
  // Interactive mode
358
+
359
+ // Fetch metadata for all processes upfront
360
+ const metadataMap = fetchAllMetadata(processes);
361
+
283
362
  console.log(
284
363
  `\nFound ${processes.length} listening process${processes.length > 1 ? "es" : ""} \x1b[2m(q to quit)\x1b[0m\n`,
285
364
  );
365
+ console.log(`\x1b[2m${getTableHeader()}\x1b[0m`);
286
366
 
287
367
  const pageSize = getPageSize();
288
368
  const { cleanup, controller } = setupQuitHandler();
@@ -296,10 +376,14 @@ async function main() {
296
376
  {
297
377
  message: "Select a process to kill:",
298
378
  pageSize,
299
- choices: processes.map((p) => ({
300
- name: `Port ${p.port.toString().padStart(5)} │ ${p.command.padEnd(15)} │ PID ${p.pid} │ ${p.user}`,
301
- value: p.pid,
302
- })),
379
+ choices: processes.map((p) => {
380
+ const meta = metadataMap.get(p.pid)!;
381
+ return {
382
+ name: formatProcessRow(p, meta.cwd),
383
+ value: p.pid,
384
+ description: formatProcessDescription(meta),
385
+ };
386
+ }),
303
387
  },
304
388
  { signal: controller.signal },
305
389
  );
@@ -369,11 +453,13 @@ export async function handleKillMode(
369
453
  const platform = getPlatformAdapter();
370
454
 
371
455
  // Display what will be killed
456
+ const metadataMap = fetchAllMetadata(processes);
457
+
372
458
  console.log(`\nProcesses to kill (${processes.length}):\n`);
459
+ console.log(`\x1b[2m${getTableHeader()}\x1b[0m`);
373
460
  for (const p of processes) {
374
- console.log(
375
- ` Port ${p.port.toString().padStart(5)} │ ${p.command.padEnd(15)} │ PID ${p.pid} │ ${p.user}`,
376
- );
461
+ const meta = metadataMap.get(p.pid)!;
462
+ console.log(` ${formatProcessRow(p, meta.cwd)}`);
377
463
  }
378
464
  console.log();
379
465
 
@@ -35,6 +35,12 @@ export interface PlatformAdapter {
35
35
  */
36
36
  getProcessMetadata(pid: number): ProcessMetadata;
37
37
 
38
+ /**
39
+ * Get extended metadata for multiple processes in a single batch call.
40
+ * More efficient than calling getProcessMetadata for each PID.
41
+ */
42
+ getProcessMetadataBatch(pids: number[]): Map<number, ProcessMetadata>;
43
+
38
44
  /**
39
45
  * Kill a process with the specified signal.
40
46
  */
@@ -290,6 +290,177 @@ export class UnixAdapter implements PlatformAdapter {
290
290
  }
291
291
  }
292
292
 
293
+ /**
294
+ * Get extended metadata for multiple processes in a batch.
295
+ * Much faster than calling getProcessMetadata for each PID individually.
296
+ */
297
+ getProcessMetadataBatch(pids: number[]): Map<number, ProcessMetadata> {
298
+ const metadataMap = new Map<number, ProcessMetadata>();
299
+
300
+ if (pids.length === 0) return metadataMap;
301
+
302
+ // Initialize with defaults
303
+ for (const pid of pids) {
304
+ metadataMap.set(pid, {
305
+ cpuPercent: null,
306
+ memoryBytes: null,
307
+ startTime: null,
308
+ path: null,
309
+ cwd: null,
310
+ });
311
+ }
312
+
313
+ // Batch ps call: ps -p PID1,PID2,... -o pid=,%cpu=,rss=,lstart=,args=
314
+ try {
315
+ const proc = Bun.spawnSync([
316
+ "ps",
317
+ "-p",
318
+ pids.join(","),
319
+ "-o",
320
+ "pid=,%cpu=,rss=,lstart=,args=",
321
+ ]);
322
+
323
+ if (proc.exitCode === 0) {
324
+ const output = proc.stdout.toString().trim();
325
+ for (const line of output.split("\n")) {
326
+ if (!line.trim()) continue;
327
+
328
+ const parsed = this.parsePsOutputLine(line);
329
+ if (parsed) {
330
+ const existing = metadataMap.get(parsed.pid);
331
+ if (existing) {
332
+ existing.cpuPercent = parsed.cpuPercent;
333
+ existing.memoryBytes = parsed.memoryBytes;
334
+ existing.startTime = parsed.startTime;
335
+ existing.path = parsed.path;
336
+ }
337
+ }
338
+ }
339
+ }
340
+ } catch {
341
+ // Ignore ps errors
342
+ }
343
+
344
+ // Batch CWD lookup using lsof
345
+ this.batchGetProcessCwd(pids, metadataMap);
346
+
347
+ return metadataMap;
348
+ }
349
+
350
+ /**
351
+ * Parse a single line of ps output.
352
+ * Format: "PID %CPU RSS lstart args"
353
+ */
354
+ private parsePsOutputLine(line: string): {
355
+ pid: number;
356
+ cpuPercent: number | null;
357
+ memoryBytes: number | null;
358
+ startTime: string | null;
359
+ path: string | null;
360
+ } | null {
361
+ const parts = line.trim().split(/\s+/);
362
+ if (parts.length < 8) return null;
363
+
364
+ const pid = parseInt(parts[0] ?? "", 10);
365
+ if (isNaN(pid)) return null;
366
+
367
+ const cpuStr = (parts[1] ?? "").replace(",", ".");
368
+ const rssStr = parts[2] ?? "";
369
+
370
+ const cpuParsed = parseFloat(cpuStr);
371
+ const cpuPercent = isNaN(cpuParsed) ? null : cpuParsed;
372
+ const rssKb = parseInt(rssStr, 10) || null;
373
+
374
+ // Find the path - it's after the year (4 digits) and multiple spaces
375
+ const yearMatch = line.match(/\d{4}\s{2,}(.+)$/);
376
+ const path = yearMatch?.[1]?.trim() ?? null;
377
+
378
+ // Extract start time - parts[3..7] is typically: Day Mon DD HH:MM:SS YYYY
379
+ let startTime: string | null = null;
380
+ if (parts.length >= 8) {
381
+ const dateStr = parts.slice(3, 8).join(" ");
382
+ try {
383
+ const date = new Date(dateStr);
384
+ if (!isNaN(date.getTime())) {
385
+ startTime = date.toISOString();
386
+ } else {
387
+ startTime = dateStr;
388
+ }
389
+ } catch {
390
+ startTime = dateStr;
391
+ }
392
+ }
393
+
394
+ return {
395
+ pid,
396
+ cpuPercent,
397
+ memoryBytes: rssKb ? rssKb * 1024 : null,
398
+ startTime,
399
+ path,
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Batch fetch CWD for multiple processes and update the metadata map.
405
+ */
406
+ private batchGetProcessCwd(
407
+ pids: number[],
408
+ metadataMap: Map<number, ProcessMetadata>,
409
+ ): void {
410
+ // Try lsof first with all PIDs
411
+ try {
412
+ // Build args: lsof -d cwd -a -p PID1 -p PID2 ... -Fn
413
+ const args = ["lsof", "-d", "cwd", "-Fn"];
414
+ for (const pid of pids) {
415
+ args.push("-a", "-p", String(pid));
416
+ }
417
+
418
+ // Actually, lsof doesn't work well with multiple -p flags combined with -a
419
+ // Use a different approach: lsof -d cwd -Fn and filter by known PIDs
420
+ const proc = Bun.spawnSync(["lsof", "-d", "cwd", "-Fn"]);
421
+
422
+ if (proc.exitCode === 0) {
423
+ const output = proc.stdout.toString();
424
+ const pidSet = new Set(pids);
425
+ let currentPid: number | null = null;
426
+
427
+ for (const line of output.split("\n")) {
428
+ if (line.startsWith("p")) {
429
+ currentPid = parseInt(line.slice(1), 10);
430
+ // Only track PIDs we care about
431
+ if (!pidSet.has(currentPid)) {
432
+ currentPid = null;
433
+ }
434
+ } else if (line.startsWith("n") && currentPid !== null) {
435
+ const existing = metadataMap.get(currentPid);
436
+ if (existing) {
437
+ existing.cwd = line.slice(1);
438
+ }
439
+ currentPid = null; // Reset after getting cwd
440
+ }
441
+ }
442
+ }
443
+ } catch {
444
+ // Ignore lsof errors
445
+ }
446
+
447
+ // Fallback for any missing CWDs on Linux: try /proc
448
+ for (const pid of pids) {
449
+ const existing = metadataMap.get(pid);
450
+ if (existing && existing.cwd === null) {
451
+ try {
452
+ const proc = Bun.spawnSync(["readlink", `/proc/${pid}/cwd`]);
453
+ if (proc.exitCode === 0) {
454
+ const cwd = proc.stdout.toString().trim();
455
+ if (cwd) existing.cwd = cwd;
456
+ }
457
+ } catch {
458
+ // Ignore
459
+ }
460
+ }
461
+ }
462
+ }
463
+
293
464
  /**
294
465
  * Get the current working directory of a process.
295
466
  * Tries lsof first, falls back to /proc on Linux.
@@ -111,6 +111,81 @@ export class WindowsAdapter implements PlatformAdapter {
111
111
  }
112
112
  }
113
113
 
114
+ /**
115
+ * Get extended metadata for multiple processes in a batch.
116
+ * Much faster than calling getProcessMetadata for each PID individually.
117
+ */
118
+ getProcessMetadataBatch(pids: number[]): Map<number, ProcessMetadata> {
119
+ const metadataMap = new Map<number, ProcessMetadata>();
120
+
121
+ if (pids.length === 0) return metadataMap;
122
+
123
+ // Initialize with defaults
124
+ for (const pid of pids) {
125
+ metadataMap.set(pid, this.emptyMetadata());
126
+ }
127
+
128
+ // PowerShell script to get metadata for all PIDs at once
129
+ const pidsArray = pids.join(",");
130
+ const script = `
131
+ $pids = @(${pidsArray})
132
+ $results = @()
133
+ foreach ($pid in $pids) {
134
+ $p = Get-Process -Id $pid -ErrorAction SilentlyContinue
135
+ if ($p) {
136
+ $wmi = Get-WmiObject Win32_Process -Filter "ProcessId = $pid" -ErrorAction SilentlyContinue
137
+ $results += [PSCustomObject]@{
138
+ PID = $pid
139
+ CPU = $p.CPU
140
+ Memory = $p.WorkingSet64
141
+ StartTime = if ($p.StartTime) { $p.StartTime.ToString("o") } else { $null }
142
+ Path = $p.Path
143
+ Cwd = if ($wmi) { Split-Path -Parent $wmi.ExecutablePath -ErrorAction SilentlyContinue } else { $null }
144
+ }
145
+ }
146
+ }
147
+ $results | ConvertTo-Json -Compress
148
+ `;
149
+
150
+ try {
151
+ const proc = Bun.spawnSync([
152
+ "powershell",
153
+ "-NoProfile",
154
+ "-Command",
155
+ script,
156
+ ]);
157
+
158
+ if (proc.exitCode !== 0) {
159
+ return metadataMap;
160
+ }
161
+
162
+ const output = proc.stdout.toString().trim();
163
+ if (!output || output === "null" || output === "[]") {
164
+ return metadataMap;
165
+ }
166
+
167
+ const data = JSON.parse(output);
168
+ // PowerShell returns single object (not array) when only one result
169
+ const items = Array.isArray(data) ? data : [data];
170
+
171
+ for (const item of items) {
172
+ const existing = metadataMap.get(item.PID);
173
+ if (existing) {
174
+ existing.cpuPercent = typeof item.CPU === "number" ? item.CPU : null;
175
+ existing.memoryBytes =
176
+ typeof item.Memory === "number" ? item.Memory : null;
177
+ existing.startTime = item.StartTime ?? null;
178
+ existing.path = item.Path ?? null;
179
+ existing.cwd = item.Cwd ?? null;
180
+ }
181
+ }
182
+ } catch {
183
+ // Return what we have
184
+ }
185
+
186
+ return metadataMap;
187
+ }
188
+
114
189
  /**
115
190
  * Return empty metadata object.
116
191
  */