ports-cli 1.0.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/dist/ports.js +585 -0
  4. package/package.json +68 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Pate Bryant
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,108 @@
1
+ <h1 align="center">ports-cli</h1>
2
+
3
+ <p align="center">
4
+ Interactive TUI for viewing and killing listening TCP ports on macOS and Linux.
5
+ </p>
6
+
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/ports-cli"><img src="https://img.shields.io/npm/v/ports-cli.svg" alt="npm version"></a>
9
+ <a href="https://www.npmjs.com/package/ports-cli"><img src="https://img.shields.io/npm/dm/ports-cli.svg" alt="monthly downloads"></a>
10
+ <a href="https://github.com/patebryant/ports-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="license"></a>
11
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg" alt="node version"></a>
12
+ <img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="platform">
13
+ <img src="https://img.shields.io/badge/coverage-100%25-brightgreen.svg" alt="coverage">
14
+ </p>
15
+
16
+ ---
17
+
18
+ ## Demo
19
+
20
+ <p align="center">
21
+ <img src="https://raw.githubusercontent.com/patebryant/ports-cli/main/demo.gif" alt="ports-cli demo" width="800">
22
+ </p>
23
+
24
+ ## Quick Start
25
+
26
+ Run it directly with npx -- no install required:
27
+
28
+ ```sh
29
+ npx ports-cli
30
+ ```
31
+
32
+ Or install globally:
33
+
34
+ ```sh
35
+ npm install -g ports-cli
36
+ ```
37
+
38
+ Then run:
39
+
40
+ ```sh
41
+ ports
42
+ ```
43
+
44
+ ## Features
45
+
46
+ - **Real-time monitoring** -- port list auto-refreshes every 2 seconds
47
+ - **Interactive search** -- filter by port number, address, PID, or process name
48
+ - **Kill processes** -- terminate processes with a confirmation prompt, or skip it with `ctrl+k`
49
+ - **Vim-style navigation** -- `j`/`k` to move, `g`/`G` to jump to first/last
50
+ - **IPv6 normalization** -- `[::1]` maps to `127.0.0.1`, `[::]` maps to `0.0.0.0`, deduplicating entries
51
+ - **Viewport scrolling** -- adapts to terminal height, keeps selection visible
52
+ - **Help overlay** -- press `?` for a full keybinding reference
53
+ - **Zero config** -- no flags, no setup, just run it
54
+
55
+ ## Keybindings
56
+
57
+ | Key | Action |
58
+ | -------------- | ----------------------------------------- |
59
+ | `↑` / `k` | Move selection up |
60
+ | `↓` / `j` | Move selection down |
61
+ | `g` / `G` | Jump to first / last |
62
+ | `enter` / `x` | Kill selected process (with confirmation) |
63
+ | `ctrl+k` | Kill selected process (skip confirmation) |
64
+ | `/` | Enter search mode |
65
+ | `ESC` | Clear search / cancel |
66
+ | `r` / `R` | Refresh port list |
67
+ | `?` | Toggle help overlay |
68
+ | `q` / `ctrl+c` | Quit |
69
+
70
+ ## Options
71
+
72
+ ```
73
+ ports --help, -h Show help
74
+ ports --version, -v Show version
75
+ ```
76
+
77
+ ## How It Works
78
+
79
+ `ports-cli` runs `lsof -iTCP -sTCP:LISTEN -nP` to discover all processes listening on TCP ports. The raw output is parsed, deduplicated, and normalized (converting IPv6 loopback and wildcard addresses to their IPv4 equivalents). The result is rendered as a full-screen terminal UI using [Ink](https://github.com/vadimdemedes/ink), a React renderer for the terminal. The list refreshes automatically every 2 seconds. Killing a process sends `SIGKILL` to the target PID.
80
+
81
+ ## Requirements
82
+
83
+ - **macOS or Linux** -- requires `lsof` (pre-installed on macOS; install via `apt install lsof` or `dnf install lsof` on Linux)
84
+ - **Node.js >= 18**
85
+
86
+ ## Contributing
87
+
88
+ ```sh
89
+ git clone https://github.com/patebryant/ports-cli.git
90
+ cd ports-cli
91
+ npm install
92
+ ```
93
+
94
+ Development commands:
95
+
96
+ ```sh
97
+ npm run build # Compile to dist/ports.js
98
+ npm run typecheck # TypeScript type checking
99
+ npm run lint # ESLint
100
+ npm test # Run test suite (189 tests)
101
+ npm run coverage # Run tests with coverage report
102
+ ```
103
+
104
+ Built with TypeScript, React 18, Ink 4, esbuild, and Vitest.
105
+
106
+ ## License
107
+
108
+ [MIT](LICENSE)
package/dist/ports.js ADDED
@@ -0,0 +1,585 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/components/SearchBar.tsx
13
+ import { Box, Text } from "ink";
14
+ import { jsx, jsxs } from "react/jsx-runtime";
15
+ function SearchBar({ value, isActive }) {
16
+ return /* @__PURE__ */ jsxs(Box, { borderStyle: "round", borderColor: isActive ? "cyan" : value ? "yellow" : "gray", paddingX: 1, children: [
17
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "ports" }),
18
+ /* @__PURE__ */ jsx(Text, { color: isActive ? "cyan" : value ? "yellow" : "gray", children: " / " }),
19
+ isActive ? value ? /* @__PURE__ */ jsxs(Text, { children: [
20
+ value,
21
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: CURSOR_CHAR })
22
+ ] }) : /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
23
+ "type to filter...",
24
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: CURSOR_CHAR })
25
+ ] }) : value ? /* @__PURE__ */ jsx(Text, { color: "yellow", children: value }) : /* @__PURE__ */ jsx(Text, { dimColor: true, children: "to search" })
26
+ ] });
27
+ }
28
+ var CURSOR_CHAR;
29
+ var init_SearchBar = __esm({
30
+ "src/components/SearchBar.tsx"() {
31
+ "use strict";
32
+ CURSOR_CHAR = "\u2588";
33
+ }
34
+ });
35
+
36
+ // src/components/PortRow.tsx
37
+ import { Box as Box2, Text as Text2 } from "ink";
38
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
39
+ function PortRow({ port, isSelected, colProcess }) {
40
+ const portStr = String(port.port).padEnd(COL_PORT);
41
+ const processStr = port.process.slice(0, colProcess).padEnd(colProcess);
42
+ const userStr = port.user.slice(0, COL_USER).padEnd(COL_USER);
43
+ const pidStr = String(port.pid).padEnd(COL_PID);
44
+ const addressStr = port.address;
45
+ if (isSelected) {
46
+ return /* @__PURE__ */ jsxs2(HighlightBox, { backgroundColor: "blue", children: [
47
+ /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: SELECTION_ARROW }),
48
+ /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: portStr }),
49
+ /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: processStr }),
50
+ /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: userStr }),
51
+ /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: pidStr }),
52
+ /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: addressStr })
53
+ ] });
54
+ }
55
+ return /* @__PURE__ */ jsxs2(Box2, { children: [
56
+ /* @__PURE__ */ jsx2(Text2, { children: UNSELECTED_PREFIX }),
57
+ /* @__PURE__ */ jsx2(Text2, { children: portStr }),
58
+ /* @__PURE__ */ jsx2(Text2, { children: processStr }),
59
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: userStr }),
60
+ /* @__PURE__ */ jsx2(Text2, { children: pidStr }),
61
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: addressStr })
62
+ ] });
63
+ }
64
+ var COL_PORT, COL_PID, COL_USER, SELECTION_ARROW, UNSELECTED_PREFIX, HighlightBox;
65
+ var init_PortRow = __esm({
66
+ "src/components/PortRow.tsx"() {
67
+ "use strict";
68
+ COL_PORT = 8;
69
+ COL_PID = 8;
70
+ COL_USER = 14;
71
+ SELECTION_ARROW = "\u25B6 ";
72
+ UNSELECTED_PREFIX = " ";
73
+ HighlightBox = Box2;
74
+ }
75
+ });
76
+
77
+ // src/components/PortList.tsx
78
+ import { Box as Box3, Text as Text3, useStdout } from "ink";
79
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
80
+ function calculateProcessColWidth(terminalWidth) {
81
+ const reserved = ROW_PREFIX_WIDTH + COL_PORT + COL_USER + COL_PID + ADDRESS_COL_MIN_WIDTH;
82
+ const available = terminalWidth - reserved;
83
+ return Math.min(MAX_PROCESS_COL_WIDTH, Math.max(MIN_PROCESS_COL_WIDTH, available));
84
+ }
85
+ function PortList({ ports, selectedIndex }) {
86
+ const { stdout } = useStdout();
87
+ const colProcess = calculateProcessColWidth(stdout?.columns ?? DEFAULT_TERMINAL_WIDTH);
88
+ const terminalRows = stdout?.rows ?? 24;
89
+ const maxVisiblePorts = Math.max(1, terminalRows - TOTAL_UI_OVERHEAD);
90
+ let startIndex = 0;
91
+ let endIndex = ports.length;
92
+ if (ports.length > maxVisiblePorts) {
93
+ const halfWindow = Math.floor(maxVisiblePorts / 2);
94
+ startIndex = Math.max(0, selectedIndex - halfWindow);
95
+ endIndex = startIndex + maxVisiblePorts;
96
+ if (endIndex > ports.length) {
97
+ endIndex = ports.length;
98
+ startIndex = Math.max(0, endIndex - maxVisiblePorts);
99
+ }
100
+ }
101
+ const visiblePorts = ports.slice(startIndex, endIndex);
102
+ const portHeader = "PORT".padEnd(COL_PORT);
103
+ const processHeader = "PROCESS".padEnd(colProcess);
104
+ const userHeader = "USER".padEnd(COL_USER);
105
+ const pidHeader = "PID".padEnd(COL_PID);
106
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
107
+ /* @__PURE__ */ jsxs3(Box3, { paddingX: 1, children: [
108
+ /* @__PURE__ */ jsx3(Text3, { children: UNSELECTED_PREFIX }),
109
+ /* @__PURE__ */ jsx3(Text3, { bold: true, color: "gray", children: portHeader }),
110
+ /* @__PURE__ */ jsx3(Text3, { bold: true, color: "gray", children: processHeader }),
111
+ /* @__PURE__ */ jsx3(Text3, { bold: true, color: "gray", children: userHeader }),
112
+ /* @__PURE__ */ jsx3(Text3, { bold: true, color: "gray", children: pidHeader }),
113
+ /* @__PURE__ */ jsx3(Text3, { bold: true, color: "gray", children: "ADDRESS" })
114
+ ] }),
115
+ ports.length === 0 ? (
116
+ // Empty state: shown when the filtered or unfiltered port list is empty.
117
+ // Zero ports could mean no servers are currently listening, OR that
118
+ // lsof exited without output (e.g. insufficient permissions). Keeping
119
+ // the message generic avoids a false "no servers running" claim.
120
+ /* @__PURE__ */ jsx3(Box3, { paddingX: 1, children: /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "No listening ports found." }) })
121
+ ) : visiblePorts.map((port, i) => {
122
+ const actualIndex = startIndex + i;
123
+ return /* @__PURE__ */ jsx3(
124
+ PortRow,
125
+ {
126
+ port,
127
+ isSelected: actualIndex === selectedIndex,
128
+ colProcess
129
+ },
130
+ `${port.pid}-${port.port}-${port.address}`
131
+ );
132
+ })
133
+ ] });
134
+ }
135
+ var DEFAULT_TERMINAL_WIDTH, ROW_PREFIX_WIDTH, ADDRESS_COL_MIN_WIDTH, MIN_PROCESS_COL_WIDTH, MAX_PROCESS_COL_WIDTH, SEARCH_BAR_HEIGHT, HEADER_ROW_HEIGHT, STATUS_BAR_HEIGHT, BUFFER_HEIGHT, TOTAL_UI_OVERHEAD;
136
+ var init_PortList = __esm({
137
+ "src/components/PortList.tsx"() {
138
+ "use strict";
139
+ init_PortRow();
140
+ DEFAULT_TERMINAL_WIDTH = 80;
141
+ ROW_PREFIX_WIDTH = 2;
142
+ ADDRESS_COL_MIN_WIDTH = 20;
143
+ MIN_PROCESS_COL_WIDTH = 16;
144
+ MAX_PROCESS_COL_WIDTH = 40;
145
+ SEARCH_BAR_HEIGHT = 3;
146
+ HEADER_ROW_HEIGHT = 1;
147
+ STATUS_BAR_HEIGHT = 1;
148
+ BUFFER_HEIGHT = 1;
149
+ TOTAL_UI_OVERHEAD = SEARCH_BAR_HEIGHT + HEADER_ROW_HEIGHT + STATUS_BAR_HEIGHT + BUFFER_HEIGHT;
150
+ }
151
+ });
152
+
153
+ // src/components/StatusBar.tsx
154
+ import { Box as Box4, Text as Text4 } from "ink";
155
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
156
+ function StatusBar({ mode, confirmKill, killMessage, selectedPort }) {
157
+ if (confirmKill && selectedPort) {
158
+ return /* @__PURE__ */ jsxs4(Box4, { paddingX: 1, children: [
159
+ /* @__PURE__ */ jsx4(Text4, { color: "red", children: "Kill " }),
160
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: selectedPort.process }),
161
+ /* @__PURE__ */ jsxs4(Text4, { color: "red", children: [
162
+ ":",
163
+ selectedPort.port,
164
+ "? "
165
+ ] }),
166
+ /* @__PURE__ */ jsx4(Text4, { color: "green", children: "y " }),
167
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "confirm " }),
168
+ /* @__PURE__ */ jsx4(Text4, { color: "gray", children: "ESC " }),
169
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "cancel" })
170
+ ] });
171
+ }
172
+ const rightContent = killMessage ? /* @__PURE__ */ jsx4(Text4, { color: killMessage.type === "success" ? "green" : "red", children: killMessage.text }) : selectedPort ? /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
173
+ selectedPort.process,
174
+ ":",
175
+ selectedPort.port
176
+ ] }) : null;
177
+ const hints = mode === "search" ? /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
178
+ "type to filter ",
179
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "\u2191\u2193/j k" }),
180
+ " navigate ",
181
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "enter" }),
182
+ " done ",
183
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "ESC" }),
184
+ " clear"
185
+ ] }) : /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
186
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "\u2191\u2193/j k" }),
187
+ " navigate ",
188
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "/" }),
189
+ " search ",
190
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "enter" }),
191
+ " kill ",
192
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "r" }),
193
+ " refresh ",
194
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "?" }),
195
+ " help ",
196
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "q" }),
197
+ " quit"
198
+ ] });
199
+ return (
200
+ // justifyContent="space-between" pins hints to the left edge and status
201
+ // content to the right edge, making both scannable without crowding.
202
+ /* @__PURE__ */ jsxs4(Box4, { justifyContent: "space-between", paddingX: 1, children: [
203
+ /* @__PURE__ */ jsx4(Box4, { children: hints }),
204
+ /* @__PURE__ */ jsx4(Box4, { children: rightContent })
205
+ ] })
206
+ );
207
+ }
208
+ var init_StatusBar = __esm({
209
+ "src/components/StatusBar.tsx"() {
210
+ "use strict";
211
+ }
212
+ });
213
+
214
+ // src/components/HelpOverlay.tsx
215
+ import { Box as Box5, Text as Text5 } from "ink";
216
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
217
+ function HelpOverlay() {
218
+ return /* @__PURE__ */ jsxs5(Box5, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 2, paddingY: 1, children: [
219
+ /* @__PURE__ */ jsx5(Text5, { bold: true, color: "cyan", children: " Keybindings" }),
220
+ /* @__PURE__ */ jsx5(Text5, { children: " " }),
221
+ KEYBINDINGS.map(({ key, desc }) => /* @__PURE__ */ jsxs5(Text5, { children: [
222
+ /* @__PURE__ */ jsx5(Text5, { color: "cyan", bold: true, children: key.padEnd(maxKeyLen) }),
223
+ " ",
224
+ desc
225
+ ] }, key)),
226
+ /* @__PURE__ */ jsx5(Text5, { children: " " }),
227
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Press any key to close" })
228
+ ] });
229
+ }
230
+ var KEYBINDINGS, maxKeyLen;
231
+ var init_HelpOverlay = __esm({
232
+ "src/components/HelpOverlay.tsx"() {
233
+ "use strict";
234
+ KEYBINDINGS = [
235
+ { key: "\u2191 / k", desc: "Move up" },
236
+ { key: "\u2193 / j", desc: "Move down" },
237
+ { key: "enter", desc: "Kill selected port (with confirm)" },
238
+ { key: "ctrl+k", desc: "Kill selected port (no confirm)" },
239
+ { key: "/ + type", desc: "Filter by name, port, or address" },
240
+ { key: "ESC", desc: "Clear filter / exit search" },
241
+ { key: "r / R", desc: "Refresh port list" },
242
+ { key: "?", desc: "Toggle this help" },
243
+ { key: "q", desc: "Quit" },
244
+ { key: "ctrl+c", desc: "Quit" }
245
+ ];
246
+ maxKeyLen = Math.max(...KEYBINDINGS.map((b) => b.key.length));
247
+ }
248
+ });
249
+
250
+ // src/utils/getPorts.ts
251
+ import { execSync } from "child_process";
252
+ function getPorts() {
253
+ try {
254
+ const output = execSync("lsof -nP -iTCP -sTCP:LISTEN +c 0 2>/dev/null", {
255
+ encoding: "utf8",
256
+ timeout: LSOF_TIMEOUT_MS
257
+ });
258
+ const lines = output.trim().split("\n");
259
+ const dataLines = lines.slice(1);
260
+ const seen = /* @__PURE__ */ new Set();
261
+ const ports = [];
262
+ for (const line of dataLines) {
263
+ if (!line.trim()) continue;
264
+ const parts = line.trim().split(/\s+/);
265
+ if (parts.length < 9) continue;
266
+ const processName = parts[0];
267
+ const pid = parts[1];
268
+ const user = parts[2];
269
+ const addrPort = parts[8];
270
+ const lastColon = addrPort.lastIndexOf(":");
271
+ if (lastColon === -1) continue;
272
+ const rawAddr = addrPort.slice(0, lastColon);
273
+ const port = parseInt(addrPort.slice(lastColon + 1), 10);
274
+ if (isNaN(port)) continue;
275
+ let address = rawAddr;
276
+ if (address === "*" || address === "0.0.0.0" || address === "[::]" || address === "::") {
277
+ address = "0.0.0.0";
278
+ }
279
+ if (address === "[::1]" || address === "::1") {
280
+ address = "127.0.0.1";
281
+ }
282
+ const key = `${address}:${port}:${pid}`;
283
+ if (seen.has(key)) continue;
284
+ seen.add(key);
285
+ ports.push({
286
+ port,
287
+ process: processName,
288
+ pid,
289
+ user,
290
+ address
291
+ });
292
+ }
293
+ ports.sort((a, b) => a.port - b.port);
294
+ return ports;
295
+ } catch {
296
+ return [];
297
+ }
298
+ }
299
+ var LSOF_TIMEOUT_MS;
300
+ var init_getPorts = __esm({
301
+ "src/utils/getPorts.ts"() {
302
+ "use strict";
303
+ LSOF_TIMEOUT_MS = 5e3;
304
+ }
305
+ });
306
+
307
+ // src/utils/killPort.ts
308
+ import { execSync as execSync2 } from "child_process";
309
+ function killPort(pid) {
310
+ try {
311
+ const safePid = parseInt(pid, 10);
312
+ if (isNaN(safePid) || safePid <= 0) {
313
+ return { success: false, error: "Invalid PID" };
314
+ }
315
+ execSync2(`kill -9 ${safePid}`);
316
+ return { success: true };
317
+ } catch (err) {
318
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
319
+ }
320
+ }
321
+ var init_killPort = __esm({
322
+ "src/utils/killPort.ts"() {
323
+ "use strict";
324
+ }
325
+ });
326
+
327
+ // src/utils/clampIndex.ts
328
+ function clampIndex(index, maxIndex) {
329
+ return Math.min(Math.max(0, index), Math.max(0, maxIndex));
330
+ }
331
+ var init_clampIndex = __esm({
332
+ "src/utils/clampIndex.ts"() {
333
+ "use strict";
334
+ }
335
+ });
336
+
337
+ // src/hooks/useKeyboardInput.ts
338
+ import { useInput } from "ink";
339
+ function useKeyboardInput(props) {
340
+ const {
341
+ mode,
342
+ showHelp,
343
+ confirmKill,
344
+ searchQuery,
345
+ selectedPort,
346
+ exit,
347
+ executeKill,
348
+ toggleHelp,
349
+ closeHelp,
350
+ setConfirmKill,
351
+ setMode,
352
+ setSearchQuery,
353
+ moveUp,
354
+ moveDown,
355
+ refresh
356
+ } = props;
357
+ useInput((input, key) => {
358
+ if (key.ctrl && input === "c") {
359
+ exit();
360
+ return;
361
+ }
362
+ if (key.ctrl && input === "k") {
363
+ executeKill();
364
+ return;
365
+ }
366
+ if (input === "?" && mode === "navigate" && !confirmKill) {
367
+ toggleHelp();
368
+ return;
369
+ }
370
+ if (showHelp) {
371
+ closeHelp();
372
+ return;
373
+ }
374
+ if (confirmKill) {
375
+ if (input === "y") {
376
+ executeKill();
377
+ setConfirmKill(false);
378
+ } else if (key.escape || input === "n") {
379
+ setConfirmKill(false);
380
+ }
381
+ return;
382
+ }
383
+ if (mode === "navigate") {
384
+ if (key.upArrow || input === "k") {
385
+ moveUp();
386
+ return;
387
+ }
388
+ if (key.downArrow || input === "j") {
389
+ moveDown();
390
+ return;
391
+ }
392
+ if (input === "/") {
393
+ setMode("search");
394
+ return;
395
+ }
396
+ if (key.return) {
397
+ if (selectedPort) setConfirmKill(true);
398
+ return;
399
+ }
400
+ if (input === "r" || input === "R") {
401
+ refresh();
402
+ return;
403
+ }
404
+ if (key.escape) {
405
+ if (searchQuery) setSearchQuery("");
406
+ return;
407
+ }
408
+ if (input === "q") {
409
+ exit();
410
+ return;
411
+ }
412
+ return;
413
+ }
414
+ if (mode === "search") {
415
+ if (key.escape) {
416
+ setSearchQuery("");
417
+ setMode("navigate");
418
+ return;
419
+ }
420
+ if (key.backspace || key.delete) {
421
+ setSearchQuery((q) => q.slice(0, -1));
422
+ return;
423
+ }
424
+ if (key.upArrow) {
425
+ moveUp();
426
+ return;
427
+ }
428
+ if (key.downArrow) {
429
+ moveDown();
430
+ return;
431
+ }
432
+ if (key.return) {
433
+ setMode("navigate");
434
+ return;
435
+ }
436
+ if (key.ctrl || key.meta) return;
437
+ if (input) {
438
+ setSearchQuery((q) => q + input);
439
+ }
440
+ }
441
+ });
442
+ }
443
+ var init_useKeyboardInput = __esm({
444
+ "src/hooks/useKeyboardInput.ts"() {
445
+ "use strict";
446
+ }
447
+ });
448
+
449
+ // src/app.tsx
450
+ var app_exports = {};
451
+ __export(app_exports, {
452
+ App: () => App
453
+ });
454
+ import { useState, useEffect, useRef } from "react";
455
+ import { Box as Box6, useApp } from "ink";
456
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
457
+ function App({ _killMessageTimeoutMs = KILL_MESSAGE_TIMEOUT_MS }) {
458
+ const { exit } = useApp();
459
+ const [ports, setPorts] = useState(() => getPorts());
460
+ const [selectedIndex, setSelectedIndex] = useState(0);
461
+ const [searchQuery, setSearchQuery] = useState("");
462
+ const [killMessage, setKillMessage] = useState(null);
463
+ const [mode, setMode] = useState("navigate");
464
+ const [showHelp, setShowHelp] = useState(false);
465
+ const killRefreshTimerRef = useRef(null);
466
+ const [confirmKill, setConfirmKill] = useState(false);
467
+ const query = searchQuery.toLowerCase();
468
+ const filteredPorts = ports.filter(
469
+ (p) => !searchQuery || [p.process, String(p.port), p.address].some(
470
+ (v) => v.toLowerCase().includes(query)
471
+ )
472
+ );
473
+ const clampedIndex = clampIndex(selectedIndex, filteredPorts.length - 1);
474
+ const selectedPort = filteredPorts[clampedIndex] ?? null;
475
+ useEffect(() => {
476
+ if (selectedIndex !== clampedIndex) {
477
+ setSelectedIndex(clampedIndex);
478
+ }
479
+ }, [clampedIndex, selectedIndex]);
480
+ useEffect(() => {
481
+ if (!killMessage) return;
482
+ const timer = setTimeout(() => setKillMessage(null), _killMessageTimeoutMs);
483
+ return () => clearTimeout(timer);
484
+ }, [killMessage, _killMessageTimeoutMs]);
485
+ const refresh = () => setPorts(getPorts());
486
+ useEffect(() => {
487
+ const id = setInterval(refresh, AUTO_REFRESH_INTERVAL_MS);
488
+ return () => clearInterval(id);
489
+ }, []);
490
+ const executeKill = () => {
491
+ if (!selectedPort) return;
492
+ const result = killPort(selectedPort.pid);
493
+ setKillMessage(
494
+ result.success ? { type: "success", text: `Killed ${selectedPort.process} (${selectedPort.pid})` } : { type: "error", text: `Failed: ${result.error}` }
495
+ );
496
+ if (killRefreshTimerRef.current !== null) clearTimeout(killRefreshTimerRef.current);
497
+ killRefreshTimerRef.current = setTimeout(refresh, POST_KILL_REFRESH_DELAY_MS);
498
+ };
499
+ useEffect(() => () => {
500
+ if (killRefreshTimerRef.current !== null) clearTimeout(killRefreshTimerRef.current);
501
+ }, []);
502
+ const moveUp = () => {
503
+ setSelectedIndex((i) => clampIndex(i - 1, filteredPorts.length - 1));
504
+ };
505
+ const moveDown = () => {
506
+ setSelectedIndex((i) => clampIndex(i + 1, filteredPorts.length - 1));
507
+ };
508
+ useKeyboardInput({
509
+ mode,
510
+ showHelp,
511
+ confirmKill,
512
+ searchQuery,
513
+ selectedPort,
514
+ exit,
515
+ executeKill,
516
+ toggleHelp: () => setShowHelp((s) => !s),
517
+ closeHelp: () => setShowHelp(false),
518
+ setConfirmKill,
519
+ setMode,
520
+ setSearchQuery,
521
+ moveUp,
522
+ moveDown,
523
+ refresh
524
+ });
525
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
526
+ /* @__PURE__ */ jsx6(SearchBar, { value: searchQuery, isActive: mode === "search" }),
527
+ /* @__PURE__ */ jsx6(PortList, { ports: filteredPorts, selectedIndex: clampedIndex }),
528
+ /* @__PURE__ */ jsx6(StatusBar, { mode, confirmKill, killMessage, selectedPort }),
529
+ showHelp && /* @__PURE__ */ jsx6(HelpOverlay, {})
530
+ ] });
531
+ }
532
+ var AUTO_REFRESH_INTERVAL_MS, KILL_MESSAGE_TIMEOUT_MS, POST_KILL_REFRESH_DELAY_MS;
533
+ var init_app = __esm({
534
+ "src/app.tsx"() {
535
+ "use strict";
536
+ init_SearchBar();
537
+ init_PortList();
538
+ init_StatusBar();
539
+ init_HelpOverlay();
540
+ init_getPorts();
541
+ init_killPort();
542
+ init_clampIndex();
543
+ init_useKeyboardInput();
544
+ AUTO_REFRESH_INTERVAL_MS = 2e3;
545
+ KILL_MESSAGE_TIMEOUT_MS = 2e3;
546
+ POST_KILL_REFRESH_DELAY_MS = 300;
547
+ }
548
+ });
549
+
550
+ // bin/ports.tsx
551
+ import { readFileSync } from "fs";
552
+ import { dirname, join } from "path";
553
+ import { fileURLToPath } from "url";
554
+ import { jsx as jsx7 } from "react/jsx-runtime";
555
+ var __dirname = dirname(fileURLToPath(import.meta.url));
556
+ var pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
557
+ var version = pkg.version;
558
+ var args = process.argv.slice(2);
559
+ if (args.includes("--help") || args.includes("-h")) {
560
+ console.log(`ports-cli v${version}
561
+
562
+ Interactive TUI for viewing and killing listening TCP ports on macOS and Linux.
563
+
564
+ Usage:
565
+ ports [options]
566
+
567
+ Options:
568
+ -h, --help Show this help message
569
+ -v, --version Show version number
570
+
571
+ Keybindings:
572
+ j/k, Up/Down Navigate ports
573
+ / Search/filter
574
+ x Kill selected port
575
+ ? Toggle help overlay
576
+ q Quit`);
577
+ process.exit(0);
578
+ }
579
+ if (args.includes("--version") || args.includes("-v")) {
580
+ console.log(version);
581
+ process.exit(0);
582
+ }
583
+ var { render } = await import("ink");
584
+ var { App: App2 } = await Promise.resolve().then(() => (init_app(), app_exports));
585
+ render(/* @__PURE__ */ jsx7(App2, {}));
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "ports-cli",
3
+ "version": "1.0.0",
4
+ "description": "Interactive TUI for viewing and killing listening TCP ports",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/patebryant/ports-cli.git"
9
+ },
10
+ "author": "patebryant",
11
+ "license": "MIT",
12
+ "bugs": {
13
+ "url": "https://github.com/patebryant/ports-cli/issues"
14
+ },
15
+ "homepage": "https://github.com/patebryant/ports-cli#readme",
16
+ "os": ["darwin", "linux"],
17
+ "bin": {
18
+ "ports": "./dist/ports.js",
19
+ "ports-cli": "./dist/ports.js"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "scripts": {
27
+ "build": "esbuild bin/ports.tsx --bundle --platform=node --target=node18 --format=esm --jsx=automatic --loader:.ts=ts --loader:.tsx=tsx --loader:.js=jsx --loader:.jsx=jsx --outfile=dist/ports.js --banner:js='#!/usr/bin/env node' --external:react --external:ink && chmod +x dist/ports.js",
28
+ "typecheck": "tsc --noEmit",
29
+ "lint": "eslint src/ bin/",
30
+ "prepublishOnly": "npm run build",
31
+ "test": "vitest run",
32
+ "coverage": "vitest run --coverage"
33
+ },
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "keywords": [
38
+ "ports",
39
+ "lsof",
40
+ "cli",
41
+ "tui",
42
+ "network",
43
+ "kill",
44
+ "process",
45
+ "tcp",
46
+ "macos",
47
+ "linux",
48
+ "terminal",
49
+ "interactive",
50
+ "ink"
51
+ ],
52
+ "dependencies": {
53
+ "ink": "^4",
54
+ "react": "^18"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^25.3.0",
58
+ "@types/react": "^19.2.14",
59
+ "@vitest/coverage-v8": "^4.0.18",
60
+ "esbuild": "^0.27.3",
61
+ "eslint": "^9.39.2",
62
+ "eslint-plugin-react-hooks": "^7.0.1",
63
+ "ink-testing-library": "^4.0.0",
64
+ "typescript": "^5.9.3",
65
+ "typescript-eslint": "^8.56.0",
66
+ "vitest": "^4.0.18"
67
+ }
68
+ }