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.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/ports.js +585 -0
- 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
|
+
}
|