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 +14 -0
- package/README.md +0 -9
- package/package.json +1 -1
- package/src/index.ts +94 -8
- package/src/platform/types.ts +6 -0
- package/src/platform/unix.ts +171 -0
- package/src/platform/windows.ts +75 -0
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
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 {
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
);
|
|
461
|
+
const meta = metadataMap.get(p.pid)!;
|
|
462
|
+
console.log(` ${formatProcessRow(p, meta.cwd)}`);
|
|
377
463
|
}
|
|
378
464
|
console.log();
|
|
379
465
|
|
package/src/platform/types.ts
CHANGED
|
@@ -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
|
*/
|
package/src/platform/unix.ts
CHANGED
|
@@ -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.
|
package/src/platform/windows.ts
CHANGED
|
@@ -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
|
*/
|