offmyport 1.2.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 ADDED
@@ -0,0 +1,38 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [1.2.0] - 2025-12-31
6
+
7
+ ### Added
8
+
9
+ - GitHub Actions workflows for CI and automated releases
10
+ - Comprehensive unit tests for CLI argument and port parsing
11
+ - Development tooling with vitest test runner
12
+ - Claude Code release command for streamlined releases
13
+
14
+ ### Changed
15
+
16
+ - Refactored CLI to use meow for argument parsing
17
+ - Improved cross-platform support with dedicated Unix and Windows adapters
18
+
19
+ ## [1.1.0] - 2025-12-31
20
+
21
+ ### Changed
22
+
23
+ - Internal refactoring and code organization
24
+
25
+ ## [1.0.0] - 2025-12-31
26
+
27
+ ### Added
28
+
29
+ - Interactive TUI for selecting and killing processes by port
30
+ - Filter by single port, multiple ports (comma-separated), or port ranges
31
+ - Choose between SIGTERM (gentle) or SIGKILL (force) termination
32
+ - `--kill` flag for non-interactive batch killing
33
+ - `--force` flag to skip confirmation prompts
34
+ - `--json` flag for JSON output (scripting/piping)
35
+ - Press `q` to quit during selection
36
+ - Graceful cancellation with Ctrl+C or ESC
37
+ - Cross-platform support (macOS, Linux, Windows)
38
+ - Extended process metadata in JSON output (CPU, memory, path, cwd)
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Helge Sverre
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,168 @@
1
+ # offmyport 🔪
2
+
3
+ [![npm version](https://img.shields.io/npm/v/offmyport.svg)](https://www.npmjs.com/package/offmyport)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Bun](https://img.shields.io/badge/Bun-%23000000.svg?style=flat&logo=bun&logoColor=white)](https://bun.sh)
6
+
7
+ Interactive CLI tool to find and kill processes by port. No more memorizing `lsof -i :8080` or `netstat` flags.
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
+ ## Installation
19
+
20
+ ```bash
21
+ # Using bun (recommended)
22
+ bun install -g offmyport
23
+
24
+ # Using npm
25
+ npm install -g offmyport
26
+
27
+ # Run without installing
28
+ npx offmyport
29
+ ```
30
+
31
+ ### Install from Source
32
+
33
+ ```shell
34
+ git clone https://github.com/HelgeSverre/offmyport.git
35
+ cd offmyport
36
+
37
+ # Install dependencies and make it accessible globally
38
+ bun install
39
+ bun link
40
+
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ```shell
46
+ # List all listening ports, pick one to kill
47
+ offmyport
48
+
49
+ # Filter to a specific port
50
+ offmyport 8080
51
+
52
+ # Filter multiple ports (comma-separated)
53
+ offmyport 80,443,8080
54
+
55
+ # Filter port ranges (inclusive)
56
+ offmyport 3000-3005
57
+
58
+ # Mix and match
59
+ offmyport 80,443,3000-3005
60
+ ```
61
+
62
+ ### Non-Interactive Mode
63
+
64
+ ```shell
65
+ # Kill all processes on port(s) with confirmation prompt
66
+ offmyport 3000 --kill
67
+
68
+ # Skip confirmation (use with caution)
69
+ offmyport 3000 --kill --force
70
+
71
+ # Shorthand flags
72
+ offmyport 3000 -k -f
73
+ ```
74
+
75
+ ### JSON Output
76
+
77
+ ```shell
78
+ # Output process info as JSON (no TUI)
79
+ offmyport --json
80
+
81
+ # Filter and output as JSON
82
+ offmyport 3000-3005 --json
83
+ ```
84
+
85
+ ## Features
86
+
87
+ - Lists all TCP listening ports with process info
88
+ - Interactive selection with arrow keys
89
+ - Choose between SIGTERM (gentle) or SIGKILL (force)
90
+ - Filter by single port, multiple ports, or port ranges
91
+ - Non-interactive mode with `--kill` for scripting
92
+ - JSON output with `--json` for scripting and piping
93
+ - Press `q` to quit during selection
94
+ - Graceful cancellation with Ctrl+C or ESC
95
+
96
+ ## Example
97
+
98
+ ### Interactive Mode
99
+
100
+ ```shell
101
+ $ offmyport 3000
102
+
103
+ Found 1 listening process (q to quit)
104
+
105
+ ? Select a process to kill:
106
+ ❯ Port 3000 │ node │ PID 12345 │ user
107
+
108
+ ? Kill node (PID 12345) with:
109
+ ❯ SIGTERM (gentle - allows cleanup)
110
+ SIGKILL (force - immediate)
111
+
112
+ Sent SIGTERM to PID 12345 (node on port 3000)
113
+ ```
114
+
115
+ ### Kill Mode
116
+
117
+ ```shell
118
+ $ offmyport 3000-3005 --kill
119
+
120
+ Processes to kill (3):
121
+
122
+ Port 3000 │ node │ PID 12345 │ user
123
+ Port 3001 │ python │ PID 12346 │ user
124
+ Port 3002 │ ruby │ PID 12347 │ user
125
+
126
+ ? Kill 3 processes? (y/N)
127
+ ```
128
+
129
+ ### JSON Output
130
+
131
+ ```
132
+ $ offmyport 3000 --json
133
+ [
134
+ {
135
+ "pid": 12345,
136
+ "name": "node",
137
+ "port": 3000,
138
+ "protocol": "TCP",
139
+ "user": "helge",
140
+ "cpuPercent": 0.5,
141
+ "memoryBytes": 46137344,
142
+ "startTime": "2025-12-31T10:30:15.000Z",
143
+ "path": "/usr/local/bin/node server.js",
144
+ "cwd": "/Users/helge/projects/myapp"
145
+ }
146
+ ]
147
+ ```
148
+
149
+ ## CLI Reference
150
+
151
+ | Flag | Shorthand | Description |
152
+ | ----------- | --------- | ---------------------------------------------- |
153
+ | `--json` | | Output as JSON (no TUI, for scripting) |
154
+ | `--kill` | `-k` | Non-interactive mode, kills matching processes |
155
+ | `--force` | `-f` | Skip confirmation prompt (use with `--kill`) |
156
+ | `--version` | `-v` | Show version number |
157
+ | `--help` | `-h` | Show help |
158
+
159
+ ## Requirements
160
+
161
+ - **macOS**: Works out of the box (uses `lsof`)
162
+ - **Linux**: Uses `lsof` or falls back to `ss` (from iproute2)
163
+ - **Windows**: Requires PowerShell 5.0+ (uses `Get-NetTCPConnection`)
164
+ - Node.js 18+ or Bun
165
+
166
+ ## License
167
+
168
+ This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details.
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "offmyport",
3
+ "version": "1.2.0",
4
+ "description": "Interactive CLI tool to find and kill processes by port.",
5
+ "type": "module",
6
+ "scripts": {
7
+ "setup": "bun link offmyport",
8
+ "test": "vitest run",
9
+ "test:watch": "vitest",
10
+ "format": "bunx prettier --write ."
11
+ },
12
+ "bin": {
13
+ "offmyport": "src/index.ts"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "!src/**/*.test.ts",
18
+ "README.md",
19
+ "LICENSE.md",
20
+ "CHANGELOG.md"
21
+ ],
22
+ "keywords": [
23
+ "cli",
24
+ "port",
25
+ "process",
26
+ "cross-platform",
27
+ "tui"
28
+ ],
29
+ "author": "Helge Sverre <helge.sverre@gmail.com>",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/HelgeSverre/offmyport.git"
34
+ },
35
+ "homepage": "https://github.com/HelgeSverre/offmyport#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/HelgeSverre/offmyport/issues"
38
+ },
39
+ "engines": {
40
+ "bun": ">=1.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/bun": "^1.3.5",
44
+ "@types/node": "^24.10.4",
45
+ "vitest": "^4.0.16"
46
+ },
47
+ "dependencies": {
48
+ "@inquirer/prompts": "^8.1.0",
49
+ "meow": "^14.0.0"
50
+ }
51
+ }
package/src/index.ts ADDED
@@ -0,0 +1,452 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { select, confirm } from "@inquirer/prompts";
4
+ import { ExitPromptError } from "@inquirer/core";
5
+ import * as readline from "readline";
6
+ import meow from "meow";
7
+ import { getAdapter, type ProcessInfo } from "./platform/index.js";
8
+
9
+ export interface CliFlags {
10
+ ports: string | null;
11
+ kill: boolean;
12
+ force: boolean;
13
+ murder: boolean; // 🔪 undocumented: uses SIGKILL instead of SIGTERM
14
+ json: boolean;
15
+ }
16
+
17
+ const helpText = `
18
+ Usage
19
+ $ offmyport [ports] [options]
20
+
21
+ Options
22
+ --kill, -k Kill matching processes (with confirmation)
23
+ --force, -f Skip confirmation prompt (use with --kill)
24
+ --json Output process info as JSON (no TUI)
25
+ --version Show version number
26
+ --help Show this help
27
+
28
+ Examples
29
+ $ offmyport List all listening ports
30
+ $ offmyport 3000 Filter to port 3000
31
+ $ offmyport 80,443 Filter multiple ports
32
+ $ offmyport 3000-3005 Filter port range
33
+ $ offmyport 3000 --kill Kill process on port 3000
34
+ $ offmyport 3000 -k -f Kill without confirmation
35
+ $ offmyport --json Output all ports as JSON
36
+ `;
37
+
38
+ /**
39
+ * Parse CLI arguments using meow.
40
+ * Exported for testing compatibility.
41
+ */
42
+ export function parseArgs(argv: string[]): CliFlags {
43
+ const cli = meow(helpText, {
44
+ importMeta: import.meta,
45
+ argv: argv.slice(2), // skip 'bun' and script path
46
+ flags: {
47
+ kill: {
48
+ type: "boolean",
49
+ shortFlag: "k",
50
+ default: false,
51
+ },
52
+ force: {
53
+ type: "boolean",
54
+ shortFlag: "f",
55
+ default: false,
56
+ },
57
+ murder: {
58
+ type: "boolean",
59
+ default: false,
60
+ },
61
+ json: {
62
+ type: "boolean",
63
+ default: false,
64
+ },
65
+ },
66
+ autoHelp: false, // Handle manually to avoid auto-exit during tests
67
+ autoVersion: false, // Handle manually to avoid auto-exit during tests
68
+ });
69
+
70
+ // Handle --help and --version manually when running as CLI
71
+ if (import.meta.main) {
72
+ if (argv.includes("--help") || argv.includes("-h")) {
73
+ cli.showHelp(0);
74
+ }
75
+ if (argv.includes("--version") || argv.includes("-v")) {
76
+ cli.showVersion();
77
+ }
78
+ }
79
+
80
+ const murder = cli.flags.murder;
81
+
82
+ return {
83
+ ports: cli.input[0] ?? null,
84
+ kill: cli.flags.kill || murder, // --murder implies --kill
85
+ force: cli.flags.force || murder, // --murder implies --force
86
+ murder,
87
+ json: cli.flags.json,
88
+ };
89
+ }
90
+
91
+ // Calculate page size as half the terminal height (min 5, max 20)
92
+ function getPageSize(): number {
93
+ const rows = process.stdout.rows || 24;
94
+ return Math.min(20, Math.max(5, Math.floor(rows / 2)));
95
+ }
96
+
97
+ /**
98
+ * Setup keyboard listener for 'q' to quit.
99
+ * Returns cleanup function and AbortController for cancelling prompts.
100
+ */
101
+ function setupQuitHandler(): {
102
+ cleanup: () => void;
103
+ controller: AbortController;
104
+ } {
105
+ const controller = new AbortController();
106
+
107
+ // Save original raw mode state
108
+ const wasRaw = process.stdin.isRaw;
109
+
110
+ if (process.stdin.isTTY) {
111
+ readline.emitKeypressEvents(process.stdin);
112
+
113
+ const onKeypress = (char: string, key: readline.Key) => {
114
+ if (char === "q" || char === "Q") {
115
+ controller.abort();
116
+ }
117
+ };
118
+
119
+ process.stdin.on("keypress", onKeypress);
120
+
121
+ const cleanup = () => {
122
+ process.stdin.removeListener("keypress", onKeypress);
123
+ };
124
+
125
+ return { cleanup, controller };
126
+ }
127
+
128
+ return { cleanup: () => {}, controller };
129
+ }
130
+
131
+ // JSON output format (--json flag)
132
+ // Uses common denominator fields across macOS, Linux, and Windows
133
+ export interface ProcessJsonOutput {
134
+ pid: number;
135
+ name: string;
136
+ port: number;
137
+ protocol: string;
138
+ user: string;
139
+ cpuPercent: number | null; // Can be null if unavailable (Windows edge cases)
140
+ memoryBytes: number | null; // Working set (Win) or RSS (Unix), null if unavailable
141
+ startTime: string | null; // ISO 8601 format, null if unavailable
142
+ path: string | null; // Full executable path, null if unavailable (Win 32-bit accessing 64-bit)
143
+ cwd: string | null; // Current working directory of the process
144
+ }
145
+
146
+ /**
147
+ * Parse port specification supporting:
148
+ * - Single port: "80" → [80]
149
+ * - Comma-separated: "80,8080,3000" → [80, 8080, 3000]
150
+ * - Ranges (inclusive): "3000-3005" → [3000, 3001, 3002, 3003, 3004, 3005]
151
+ * - Mixed: "80,8080,3000-3005" → [80, 8080, 3000, 3001, 3002, 3003, 3004, 3005]
152
+ */
153
+ export function parsePorts(input: string): number[] {
154
+ const ports: number[] = [];
155
+ const segments = input
156
+ .split(",")
157
+ .map((s) => s.trim())
158
+ .filter(Boolean);
159
+
160
+ for (const segment of segments) {
161
+ if (segment.includes("-")) {
162
+ const rangeParts = segment.split("-").map((s) => s.trim());
163
+ const startStr = rangeParts[0] ?? "";
164
+ const endStr = rangeParts[1] ?? "";
165
+ const start = parseInt(startStr, 10);
166
+ const end = parseInt(endStr, 10);
167
+
168
+ if (isNaN(start) || isNaN(end)) {
169
+ console.error(`Invalid port range: ${segment}`);
170
+ process.exit(1);
171
+ }
172
+
173
+ if (start > end) {
174
+ console.error(`Invalid port range (start > end): ${segment}`);
175
+ process.exit(1);
176
+ }
177
+
178
+ if (start < 1 || end > 65535) {
179
+ console.error(`Port out of range (1-65535): ${segment}`);
180
+ process.exit(1);
181
+ }
182
+
183
+ for (let p = start; p <= end; p++) {
184
+ ports.push(p);
185
+ }
186
+ } else {
187
+ const port = parseInt(segment, 10);
188
+
189
+ if (isNaN(port)) {
190
+ console.error(`Invalid port number: ${segment}`);
191
+ process.exit(1);
192
+ }
193
+
194
+ if (port < 1 || port > 65535) {
195
+ console.error(`Port out of range (1-65535): ${port}`);
196
+ process.exit(1);
197
+ }
198
+
199
+ ports.push(port);
200
+ }
201
+ }
202
+
203
+ // Deduplicate and sort
204
+ return [...new Set(ports)].sort((a, b) => a - b);
205
+ }
206
+
207
+ // Platform adapter instance (lazy initialized)
208
+ let adapter: ReturnType<typeof getAdapter> | null = null;
209
+
210
+ function getPlatformAdapter() {
211
+ if (!adapter) {
212
+ adapter = getAdapter();
213
+ }
214
+ return adapter;
215
+ }
216
+
217
+ /**
218
+ * Convert ProcessInfo to ProcessJsonOutput with extended metadata.
219
+ */
220
+ function toJsonOutput(p: ProcessInfo): ProcessJsonOutput {
221
+ const platform = getPlatformAdapter();
222
+ const meta = platform.getProcessMetadata(p.pid);
223
+ return {
224
+ pid: p.pid,
225
+ name: p.command,
226
+ port: p.port,
227
+ protocol: p.protocol,
228
+ user: p.user,
229
+ cpuPercent: meta.cpuPercent,
230
+ memoryBytes: meta.memoryBytes,
231
+ startTime: meta.startTime,
232
+ path: meta.path,
233
+ cwd: meta.cwd,
234
+ };
235
+ }
236
+
237
+ async function main() {
238
+ const flags = parseArgs(Bun.argv);
239
+ const filterPorts = flags.ports ? parsePorts(flags.ports) : null;
240
+ const platform = getPlatformAdapter();
241
+
242
+ let processes = platform.getListeningProcesses();
243
+
244
+ if (filterPorts && filterPorts.length > 0) {
245
+ const portSet = new Set(filterPorts);
246
+ processes = processes.filter((p) => portSet.has(p.port));
247
+ }
248
+
249
+ // Sort by port
250
+ processes.sort((a, b) => a.port - b.port);
251
+
252
+ // --json mode: output JSON and exit (no TUI)
253
+ if (flags.json) {
254
+ const output = processes.map(toJsonOutput);
255
+ console.log(JSON.stringify(output, null, 2));
256
+ process.exit(0);
257
+ }
258
+
259
+ if (processes.length === 0) {
260
+ if (filterPorts && filterPorts.length > 0) {
261
+ const portDisplay =
262
+ filterPorts.length === 1
263
+ ? `port ${filterPorts[0]}`
264
+ : `ports ${filterPorts.join(", ")}`;
265
+ console.log(`No process found listening on ${portDisplay}`);
266
+ } else {
267
+ console.log("No listening TCP processes found");
268
+ }
269
+ process.exit(0);
270
+ }
271
+
272
+ // --kill mode: kill all matching processes without interactive selection
273
+ if (flags.kill) {
274
+ await handleKillMode(processes, flags.force, flags.murder, flags.ports);
275
+ return;
276
+ }
277
+
278
+ // Interactive mode
279
+ console.log(
280
+ `\nFound ${processes.length} listening process${processes.length > 1 ? "es" : ""} \x1b[2m(q to quit)\x1b[0m\n`,
281
+ );
282
+
283
+ const pageSize = getPageSize();
284
+ const { cleanup, controller } = setupQuitHandler();
285
+
286
+ let selectedPid: number;
287
+ let selectedProcess: ProcessInfo;
288
+
289
+ try {
290
+ // Show the list and let user pick
291
+ selectedPid = await select(
292
+ {
293
+ message: "Select a process to kill:",
294
+ pageSize,
295
+ choices: processes.map((p) => ({
296
+ name: `Port ${p.port.toString().padStart(5)} │ ${p.command.padEnd(15)} │ PID ${p.pid} │ ${p.user}`,
297
+ value: p.pid,
298
+ })),
299
+ },
300
+ { signal: controller.signal },
301
+ );
302
+
303
+ selectedProcess = processes.find((p) => p.pid === selectedPid)!;
304
+
305
+ // Ask for kill method
306
+ const signal = await select(
307
+ {
308
+ message: `Kill ${selectedProcess.command} (PID ${selectedPid}) with:`,
309
+ pageSize: 2,
310
+ choices: [
311
+ {
312
+ name: "SIGTERM (gentle - allows cleanup)",
313
+ value: "SIGTERM" as const,
314
+ },
315
+ { name: "SIGKILL (force - immediate)", value: "SIGKILL" as const },
316
+ ],
317
+ },
318
+ { signal: controller.signal },
319
+ );
320
+
321
+ cleanup();
322
+
323
+ platform.killProcess(selectedPid, signal);
324
+ console.log(
325
+ `\nSent ${signal} to PID ${selectedPid} (${selectedProcess.command} on port ${selectedProcess.port})`,
326
+ );
327
+ } catch (err: any) {
328
+ cleanup();
329
+
330
+ const isAbortError =
331
+ err instanceof ExitPromptError ||
332
+ err.name === "AbortError" ||
333
+ err.code === "ABORT_ERR" ||
334
+ (typeof err.message === "string" &&
335
+ err.message.toLowerCase().includes("abort"));
336
+
337
+ if (isAbortError) {
338
+ // User pressed Ctrl+C, ESC, or 'q'
339
+ console.log("\nCancelled");
340
+ process.exit(0);
341
+ }
342
+ if (err.code === "EPERM") {
343
+ console.error(
344
+ `\nPermission denied. Try: sudo offmyport ${flags.ports || ""}`,
345
+ );
346
+ } else if (err.code === "ESRCH") {
347
+ console.error(`\nProcess no longer exists`);
348
+ } else {
349
+ console.error(`\nFailed to kill process: ${err.message}`);
350
+ }
351
+ process.exit(1);
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Handle --kill mode: kill all matching processes with optional confirmation.
357
+ */
358
+ async function handleKillMode(
359
+ processes: ProcessInfo[],
360
+ force: boolean,
361
+ murder: boolean,
362
+ portArg: string | null,
363
+ ): Promise<void> {
364
+ const signal: "SIGTERM" | "SIGKILL" = murder ? "SIGKILL" : "SIGTERM";
365
+ const platform = getPlatformAdapter();
366
+
367
+ // Display what will be killed
368
+ console.log(`\nProcesses to kill (${processes.length}):\n`);
369
+ for (const p of processes) {
370
+ console.log(
371
+ ` Port ${p.port.toString().padStart(5)} │ ${p.command.padEnd(15)} │ PID ${p.pid} │ ${p.user}`,
372
+ );
373
+ }
374
+ console.log();
375
+
376
+ // Require confirmation unless --force is set
377
+ if (!force) {
378
+ try {
379
+ const confirmed = await confirm({
380
+ message: `Kill ${processes.length} process${processes.length > 1 ? "es" : ""}?`,
381
+ default: false,
382
+ });
383
+
384
+ if (!confirmed) {
385
+ console.log("Cancelled");
386
+ process.exit(0);
387
+ }
388
+ } catch (err: any) {
389
+ const isAbortError =
390
+ err instanceof ExitPromptError ||
391
+ err.name === "AbortError" ||
392
+ err.code === "ABORT_ERR" ||
393
+ (typeof err.message === "string" &&
394
+ err.message.toLowerCase().includes("abort"));
395
+
396
+ if (isAbortError) {
397
+ console.log("\nCancelled");
398
+ process.exit(0);
399
+ }
400
+ throw err;
401
+ }
402
+ }
403
+
404
+ // Kill all processes
405
+ let killed = 0;
406
+ let failed = 0;
407
+
408
+ for (const p of processes) {
409
+ try {
410
+ platform.killProcess(p.pid, signal);
411
+ if (murder) {
412
+ console.log(
413
+ `Process ${p.command} \x1b[91mELIMINATED\x1b[0m! 👁️👅👁️ 🔪`,
414
+ );
415
+ } else {
416
+ console.log(`Killed PID ${p.pid} (${p.command} on port ${p.port})`);
417
+ }
418
+ killed++;
419
+ } catch (err: any) {
420
+ if (err.code === "EPERM") {
421
+ console.error(
422
+ `Permission denied for PID ${p.pid}. Try: sudo offmyport ${portArg || ""} --kill`,
423
+ );
424
+ } else if (err.code === "ESRCH") {
425
+ console.error(`PID ${p.pid} no longer exists`);
426
+ } else {
427
+ console.error(`Failed to kill PID ${p.pid}: ${err.message}`);
428
+ }
429
+ failed++;
430
+ }
431
+ }
432
+
433
+ console.log(
434
+ `\nKilled ${killed} process${killed !== 1 ? "es" : ""}${failed > 0 ? `, ${failed} failed` : ""}`,
435
+ );
436
+
437
+ if (failed > 0) {
438
+ process.exit(1);
439
+ }
440
+ }
441
+
442
+ // Only run when executed directly (not when imported for testing)
443
+ if (import.meta.main) {
444
+ main().catch((err) => {
445
+ if (err instanceof ExitPromptError) {
446
+ console.log("\nCancelled");
447
+ process.exit(0);
448
+ }
449
+ console.error(err);
450
+ process.exit(1);
451
+ });
452
+ }
@@ -0,0 +1,16 @@
1
+ import type { PlatformAdapter } from "./types.js";
2
+ import { UnixAdapter } from "./unix.js";
3
+ import { WindowsAdapter } from "./windows.js";
4
+
5
+ export type { PlatformAdapter, ProcessInfo, ProcessMetadata } from "./types.js";
6
+
7
+ /**
8
+ * Get the platform-specific adapter for the current OS.
9
+ */
10
+ export function getAdapter(): PlatformAdapter {
11
+ if (process.platform === "win32") {
12
+ return new WindowsAdapter();
13
+ }
14
+ // macOS (darwin) and Linux both use Unix adapter
15
+ return new UnixAdapter();
16
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Process information from port listing.
3
+ */
4
+ export interface ProcessInfo {
5
+ command: string;
6
+ pid: number;
7
+ user: string;
8
+ port: number;
9
+ protocol: string;
10
+ }
11
+
12
+ /**
13
+ * Extended process metadata for JSON output.
14
+ */
15
+ export interface ProcessMetadata {
16
+ cpuPercent: number | null;
17
+ memoryBytes: number | null;
18
+ startTime: string | null;
19
+ path: string | null;
20
+ cwd: string | null;
21
+ }
22
+
23
+ /**
24
+ * Platform-specific adapter interface.
25
+ * Implementations exist for Unix (macOS/Linux) and Windows.
26
+ */
27
+ export interface PlatformAdapter {
28
+ /**
29
+ * Get all processes listening on TCP ports.
30
+ */
31
+ getListeningProcesses(): ProcessInfo[];
32
+
33
+ /**
34
+ * Get extended metadata for a process.
35
+ */
36
+ getProcessMetadata(pid: number): ProcessMetadata;
37
+
38
+ /**
39
+ * Kill a process with the specified signal.
40
+ */
41
+ killProcess(pid: number, signal: "SIGTERM" | "SIGKILL"): void;
42
+ }
@@ -0,0 +1,341 @@
1
+ import type { PlatformAdapter, ProcessInfo, ProcessMetadata } from "./types.js";
2
+
3
+ /**
4
+ * Unix (macOS/Linux) platform adapter.
5
+ * Uses lsof with ss fallback for port discovery.
6
+ */
7
+ export class UnixAdapter implements PlatformAdapter {
8
+ /**
9
+ * Get all processes listening on TCP ports.
10
+ * Tries lsof first, falls back to ss on Linux.
11
+ */
12
+ getListeningProcesses(): ProcessInfo[] {
13
+ // Try lsof first (available on macOS, usually on Linux)
14
+ const lsofResult = this.tryLsof();
15
+ if (lsofResult !== null) {
16
+ return lsofResult;
17
+ }
18
+
19
+ // Fallback to ss (Linux when lsof unavailable)
20
+ const ssResult = this.trySs();
21
+ if (ssResult !== null) {
22
+ return ssResult;
23
+ }
24
+
25
+ console.error("Neither lsof nor ss available. Install lsof or iproute2.");
26
+ process.exit(1);
27
+ }
28
+
29
+ /**
30
+ * Try to get listening processes using lsof.
31
+ * Returns null if lsof is not available.
32
+ */
33
+ private tryLsof(): ProcessInfo[] | null {
34
+ try {
35
+ const proc = Bun.spawnSync(["lsof", "-iTCP", "-sTCP:LISTEN", "-P", "-n"]);
36
+
37
+ if (proc.exitCode !== 0) {
38
+ // Check if lsof is not found vs other errors
39
+ const stderr = proc.stderr.toString();
40
+ if (stderr.includes("not found") || proc.exitCode === 127) {
41
+ return null; // lsof not available, try fallback
42
+ }
43
+ // lsof exists but failed (e.g., no listening processes)
44
+ return [];
45
+ }
46
+
47
+ return this.parseLsofOutput(proc.stdout.toString());
48
+ } catch (err: unknown) {
49
+ // Bun throws ENOENT when executable not found
50
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
51
+ return null;
52
+ }
53
+ throw err;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Parse lsof output into ProcessInfo array.
59
+ */
60
+ private parseLsofOutput(output: string): ProcessInfo[] {
61
+ const lines = output.split("\n").slice(1); // skip header
62
+ const processes: ProcessInfo[] = [];
63
+
64
+ for (const line of lines) {
65
+ if (!line.trim()) continue;
66
+
67
+ const parts = line.split(/\s+/);
68
+ // COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
69
+ // node 123 user 45u IPv4 0x123 0t0 TCP 127.0.0.1:3000 (LISTEN)
70
+
71
+ const command = parts[0];
72
+ const pidStr = parts[1];
73
+ const user = parts[2];
74
+ const name = parts[8] ?? "";
75
+
76
+ if (!command || !pidStr || !user) continue;
77
+
78
+ const pid = parseInt(pidStr, 10);
79
+ if (isNaN(pid)) continue;
80
+
81
+ // Extract port from name like "127.0.0.1:3000" or "*:8080"
82
+ const portMatch = name.match(/:(\d+)$/);
83
+ if (!portMatch?.[1]) continue;
84
+
85
+ const port = parseInt(portMatch[1], 10);
86
+
87
+ processes.push({
88
+ command,
89
+ pid,
90
+ user,
91
+ port,
92
+ protocol: "TCP",
93
+ });
94
+ }
95
+
96
+ // Deduplicate by pid+port (same process may have multiple file descriptors)
97
+ const seen = new Set<string>();
98
+ return processes.filter((p) => {
99
+ const key = `${p.pid}:${p.port}`;
100
+ if (seen.has(key)) return false;
101
+ seen.add(key);
102
+ return true;
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Try to get listening processes using ss (Linux fallback).
108
+ * Returns null if ss is not available.
109
+ */
110
+ private trySs(): ProcessInfo[] | null {
111
+ try {
112
+ // ss -tulnp shows TCP/UDP listening with process info
113
+ const proc = Bun.spawnSync(["ss", "-tulnp"]);
114
+
115
+ if (proc.exitCode !== 0) {
116
+ const stderr = proc.stderr.toString();
117
+ if (stderr.includes("not found") || proc.exitCode === 127) {
118
+ return null; // ss not available
119
+ }
120
+ return [];
121
+ }
122
+
123
+ return this.parseSsOutput(proc.stdout.toString());
124
+ } catch (err: unknown) {
125
+ // Bun throws ENOENT when executable not found
126
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
127
+ return null;
128
+ }
129
+ throw err;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Parse ss output into ProcessInfo array.
135
+ * Example line: LISTEN 0 128 *:3000 *:* users:(("node",pid=1234,fd=20))
136
+ */
137
+ private parseSsOutput(output: string): ProcessInfo[] {
138
+ const lines = output.split("\n").slice(1); // skip header
139
+ const processes: ProcessInfo[] = [];
140
+
141
+ for (const line of lines) {
142
+ if (!line.trim()) continue;
143
+
144
+ // Only process LISTEN state
145
+ if (!line.startsWith("LISTEN")) continue;
146
+
147
+ // Extract port from local address (4th column)
148
+ // Format: *:3000 or 0.0.0.0:3000 or [::]:3000
149
+ const parts = line.split(/\s+/);
150
+ const localAddr = parts[3] ?? "";
151
+ const portMatch = localAddr.match(/:(\d+)$/);
152
+ if (!portMatch?.[1]) continue;
153
+ const port = parseInt(portMatch[1], 10);
154
+
155
+ // Extract process info from users:((... ))
156
+ // Format: users:(("node",pid=1234,fd=20))
157
+ const usersMatch = line.match(/users:\(\("([^"]+)",pid=(\d+)/);
158
+ if (!usersMatch) continue;
159
+
160
+ const command = usersMatch[1] ?? "unknown";
161
+ const pid = parseInt(usersMatch[2] ?? "0", 10);
162
+ if (isNaN(pid) || pid === 0) continue;
163
+
164
+ // ss doesn't show user, get it from /proc
165
+ const user = this.getProcessUser(pid) ?? "unknown";
166
+
167
+ processes.push({
168
+ command,
169
+ pid,
170
+ user,
171
+ port,
172
+ protocol: "TCP",
173
+ });
174
+ }
175
+
176
+ // Deduplicate
177
+ const seen = new Set<string>();
178
+ return processes.filter((p) => {
179
+ const key = `${p.pid}:${p.port}`;
180
+ if (seen.has(key)) return false;
181
+ seen.add(key);
182
+ return true;
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Get the user owning a process from /proc on Linux.
188
+ */
189
+ private getProcessUser(pid: number): string | null {
190
+ try {
191
+ const proc = Bun.spawnSync(["stat", "-c", "%U", `/proc/${pid}`]);
192
+ if (proc.exitCode === 0) {
193
+ return proc.stdout.toString().trim();
194
+ }
195
+ } catch {
196
+ // Ignore errors
197
+ }
198
+ return null;
199
+ }
200
+
201
+ /**
202
+ * Get extended metadata for a process.
203
+ */
204
+ getProcessMetadata(pid: number): ProcessMetadata {
205
+ const cwd = this.getProcessCwd(pid);
206
+
207
+ try {
208
+ // ps -p PID -o %cpu=,%mem=,rss=,lstart=,args=
209
+ const proc = Bun.spawnSync([
210
+ "ps",
211
+ "-p",
212
+ String(pid),
213
+ "-o",
214
+ "%cpu=,%mem=,rss=,lstart=,args=",
215
+ ]);
216
+
217
+ if (proc.exitCode !== 0) {
218
+ return {
219
+ cpuPercent: null,
220
+ memoryBytes: null,
221
+ startTime: null,
222
+ path: null,
223
+ cwd,
224
+ };
225
+ }
226
+
227
+ const output = proc.stdout.toString().trim();
228
+ if (!output) {
229
+ return {
230
+ cpuPercent: null,
231
+ memoryBytes: null,
232
+ startTime: null,
233
+ path: null,
234
+ cwd,
235
+ };
236
+ }
237
+
238
+ // Output format varies by locale:
239
+ // "0.0 0.1 12345 Mon Jan 1 12:00:00 2025 /usr/bin/node server.js"
240
+ // "0,0 0,1 12345 ons 31 des 06:04:50 2025 /usr/bin/node server.js"
241
+ // Note: lstart uses multiple spaces before args
242
+
243
+ // Parse numbers first (handle both . and , as decimal separator)
244
+ const parts = output.trim().split(/\s+/);
245
+ const cpuStr = (parts[0] ?? "").replace(",", ".");
246
+ const rssStr = parts[2] ?? "";
247
+
248
+ const cpuParsed = parseFloat(cpuStr);
249
+ const cpuPercent = isNaN(cpuParsed) ? null : cpuParsed;
250
+ const rssKb = parseInt(rssStr, 10) || null;
251
+
252
+ // Find the path - it's after the year (4 digits) and multiple spaces
253
+ // lstart format: "Day Mon DD HH:MM:SS YYYY" then spaces then args
254
+ const yearMatch = output.match(/\d{4}\s{2,}(.+)$/);
255
+ const path = yearMatch?.[1]?.trim() ?? null;
256
+
257
+ // Extract start time - between RSS and path
258
+ // parts[3..7] is typically: Day Mon DD HH:MM:SS YYYY
259
+ let startTime: string | null = null;
260
+ if (parts.length >= 8) {
261
+ const dateStr = parts.slice(3, 8).join(" ");
262
+ try {
263
+ const date = new Date(dateStr);
264
+ if (!isNaN(date.getTime())) {
265
+ startTime = date.toISOString();
266
+ } else {
267
+ // Keep raw string for non-English locales
268
+ startTime = dateStr;
269
+ }
270
+ } catch {
271
+ startTime = dateStr;
272
+ }
273
+ }
274
+
275
+ return {
276
+ cpuPercent,
277
+ memoryBytes: rssKb ? rssKb * 1024 : null,
278
+ startTime,
279
+ path,
280
+ cwd,
281
+ };
282
+ } catch {
283
+ return {
284
+ cpuPercent: null,
285
+ memoryBytes: null,
286
+ startTime: null,
287
+ path: null,
288
+ cwd,
289
+ };
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Get the current working directory of a process.
295
+ * Tries lsof first, falls back to /proc on Linux.
296
+ */
297
+ private getProcessCwd(pid: number): string | null {
298
+ // Try lsof first
299
+ try {
300
+ const proc = Bun.spawnSync([
301
+ "lsof",
302
+ "-a",
303
+ "-p",
304
+ String(pid),
305
+ "-d",
306
+ "cwd",
307
+ "-Fn",
308
+ ]);
309
+ if (proc.exitCode === 0) {
310
+ const output = proc.stdout.toString();
311
+ // Output format: "p12345\nn/path/to/cwd\n"
312
+ const match = output.match(/^n(.+)$/m);
313
+ if (match?.[1]) {
314
+ return match[1];
315
+ }
316
+ }
317
+ } catch {
318
+ // Ignore, try fallback
319
+ }
320
+
321
+ // Fallback: read /proc/PID/cwd symlink (Linux only)
322
+ try {
323
+ const proc = Bun.spawnSync(["readlink", `/proc/${pid}/cwd`]);
324
+ if (proc.exitCode === 0) {
325
+ const cwd = proc.stdout.toString().trim();
326
+ if (cwd) return cwd;
327
+ }
328
+ } catch {
329
+ // Ignore
330
+ }
331
+
332
+ return null;
333
+ }
334
+
335
+ /**
336
+ * Kill a process with the specified signal.
337
+ */
338
+ killProcess(pid: number, signal: "SIGTERM" | "SIGKILL"): void {
339
+ process.kill(pid, signal);
340
+ }
341
+ }
@@ -0,0 +1,146 @@
1
+ import type { PlatformAdapter, ProcessInfo, ProcessMetadata } from "./types.js";
2
+
3
+ /**
4
+ * Windows platform adapter.
5
+ * Uses PowerShell for port discovery and process metadata.
6
+ */
7
+ export class WindowsAdapter implements PlatformAdapter {
8
+ /**
9
+ * Get all processes listening on TCP ports using PowerShell.
10
+ */
11
+ getListeningProcesses(): ProcessInfo[] {
12
+ // PowerShell script to get listening ports with process info
13
+ const script = `
14
+ Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue |
15
+ ForEach-Object {
16
+ $proc = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue
17
+ [PSCustomObject]@{
18
+ Port = $_.LocalPort
19
+ PID = $_.OwningProcess
20
+ Name = if ($proc) { $proc.ProcessName } else { "unknown" }
21
+ User = try { (Get-Process -Id $_.OwningProcess -IncludeUserName -ErrorAction SilentlyContinue).UserName } catch { "unknown" }
22
+ }
23
+ } | ConvertTo-Json -Compress
24
+ `;
25
+
26
+ const proc = Bun.spawnSync([
27
+ "powershell",
28
+ "-NoProfile",
29
+ "-Command",
30
+ script,
31
+ ]);
32
+
33
+ if (proc.exitCode !== 0) {
34
+ console.error("Failed to query listening ports via PowerShell.");
35
+ process.exit(1);
36
+ }
37
+
38
+ const output = proc.stdout.toString().trim();
39
+ if (!output || output === "null") {
40
+ return [];
41
+ }
42
+
43
+ try {
44
+ const data = JSON.parse(output);
45
+ // PowerShell returns single object (not array) when only one result
46
+ const items = Array.isArray(data) ? data : [data];
47
+
48
+ return items.map(
49
+ (item: { Port: number; PID: number; Name: string; User: string }) => ({
50
+ port: item.Port,
51
+ pid: item.PID,
52
+ command: item.Name,
53
+ user: item.User ?? "unknown",
54
+ protocol: "TCP",
55
+ }),
56
+ );
57
+ } catch {
58
+ console.error("Failed to parse PowerShell output.");
59
+ return [];
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Get extended metadata for a process using PowerShell.
65
+ */
66
+ getProcessMetadata(pid: number): ProcessMetadata {
67
+ const script = `
68
+ $p = Get-Process -Id ${pid} -ErrorAction SilentlyContinue
69
+ if ($p) {
70
+ $wmi = Get-WmiObject Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction SilentlyContinue
71
+ [PSCustomObject]@{
72
+ CPU = $p.CPU
73
+ Memory = $p.WorkingSet64
74
+ StartTime = if ($p.StartTime) { $p.StartTime.ToString("o") } else { $null }
75
+ Path = $p.Path
76
+ Cwd = if ($wmi) { Split-Path -Parent $wmi.ExecutablePath -ErrorAction SilentlyContinue } else { $null }
77
+ } | ConvertTo-Json -Compress
78
+ } else {
79
+ "{}"
80
+ }
81
+ `;
82
+
83
+ try {
84
+ const proc = Bun.spawnSync([
85
+ "powershell",
86
+ "-NoProfile",
87
+ "-Command",
88
+ script,
89
+ ]);
90
+
91
+ if (proc.exitCode !== 0) {
92
+ return this.emptyMetadata();
93
+ }
94
+
95
+ const output = proc.stdout.toString().trim();
96
+ if (!output || output === "{}" || output === "null") {
97
+ return this.emptyMetadata();
98
+ }
99
+
100
+ const data = JSON.parse(output);
101
+
102
+ return {
103
+ cpuPercent: typeof data.CPU === "number" ? data.CPU : null,
104
+ memoryBytes: typeof data.Memory === "number" ? data.Memory : null,
105
+ startTime: data.StartTime ?? null,
106
+ path: data.Path ?? null,
107
+ cwd: data.Cwd ?? null,
108
+ };
109
+ } catch {
110
+ return this.emptyMetadata();
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Return empty metadata object.
116
+ */
117
+ private emptyMetadata(): ProcessMetadata {
118
+ return {
119
+ cpuPercent: null,
120
+ memoryBytes: null,
121
+ startTime: null,
122
+ path: null,
123
+ cwd: null,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Kill a process with the specified signal.
129
+ * Uses process.kill which works on Windows for SIGTERM/SIGKILL.
130
+ */
131
+ killProcess(pid: number, signal: "SIGTERM" | "SIGKILL"): void {
132
+ try {
133
+ process.kill(pid, signal);
134
+ } catch (err: unknown) {
135
+ // Fallback to taskkill if process.kill fails
136
+ const force = signal === "SIGKILL" ? "/F" : "";
137
+ const args = ["/PID", String(pid)];
138
+ if (force) args.push(force);
139
+
140
+ const proc = Bun.spawnSync(["taskkill", ...args]);
141
+ if (proc.exitCode !== 0) {
142
+ throw err; // Re-throw original error
143
+ }
144
+ }
145
+ }
146
+ }