port-hunter-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 +52 -0
- package/package.json +48 -0
- package/src/index.js +561 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rebebuca contributors
|
|
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,52 @@
|
|
|
1
|
+
# port-hunter-cli
|
|
2
|
+
|
|
3
|
+
Interactive terminal UI to list TCP listening ports (with project / command hints on macOS) and kill selected processes. Works on macOS, Linux, and Windows.
|
|
4
|
+
|
|
5
|
+
## Install (global)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g port-hunter-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
With pnpm:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add -g port-hunter-cli
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
port-hunter # mouse + keyboard TUI
|
|
21
|
+
port-hunter --once # print table once, then exit
|
|
22
|
+
port-hunter --help
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### TUI shortcuts
|
|
26
|
+
|
|
27
|
+
- **Mouse**: click a row to toggle selection; bottom buttons for refresh / kill / quit.
|
|
28
|
+
- **Space**: toggle the focused row.
|
|
29
|
+
- **Enter** or **click the same row again**: toggle selection (blessed `action`).
|
|
30
|
+
- **g** / **Kill(g)**: graceful kill (SIGTERM / `taskkill`).
|
|
31
|
+
- **f** / **Force(f)**: force kill (SIGKILL / `taskkill /F`).
|
|
32
|
+
- **r** / **Refresh**: rescan ports.
|
|
33
|
+
- **q** / **Quit**: exit.
|
|
34
|
+
|
|
35
|
+
## Requirements
|
|
36
|
+
|
|
37
|
+
- Node.js **18+**
|
|
38
|
+
- Port discovery uses `lsof` / `ss` (Unix) or `netstat` (Windows). Process details on non-macOS Unix are best-effort compared to macOS.
|
|
39
|
+
|
|
40
|
+
## Publish (maintainers)
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
cd port-hunter-cli
|
|
44
|
+
npm login
|
|
45
|
+
npm publish --access public
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Use `npm version patch|minor|major` before publishing when bumping versions.
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT — see [LICENSE](./LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "port-hunter-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Interactive terminal UI to list and kill processes listening on TCP ports (macOS, Linux, Windows)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"port-hunter": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"LICENSE",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "node ./src/index.js",
|
|
16
|
+
"prepublishOnly": "node --check ./src/index.js"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"port",
|
|
20
|
+
"kill-port",
|
|
21
|
+
"lsof",
|
|
22
|
+
"netstat",
|
|
23
|
+
"cli",
|
|
24
|
+
"tui",
|
|
25
|
+
"blessed",
|
|
26
|
+
"process",
|
|
27
|
+
"devtools"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"author": "Rebebuca contributors",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/langhuihui/rebebuca.git",
|
|
34
|
+
"directory": "port-hunter-cli"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/langhuihui/rebebuca/issues"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/langhuihui/rebebuca/tree/main/port-hunter-cli#readme",
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"blessed": "^0.1.81",
|
|
45
|
+
"chalk": "^5.4.1",
|
|
46
|
+
"cli-table3": "^0.6.5"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import Table from 'cli-table3';
|
|
9
|
+
import blessed from 'blessed';
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
const EXEC_TIMEOUT_MS = 15000;
|
|
14
|
+
const argv = process.argv.slice(2);
|
|
15
|
+
|
|
16
|
+
function runCommand(command, args, timeout = EXEC_TIMEOUT_MS) {
|
|
17
|
+
return execFileAsync(command, args, {
|
|
18
|
+
timeout,
|
|
19
|
+
windowsHide: true,
|
|
20
|
+
maxBuffer: 1024 * 1024 * 8,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatMemory(rssKB) {
|
|
25
|
+
if (rssKB > 1048576) return `${(rssKB / 1048576).toFixed(1)}G`;
|
|
26
|
+
if (rssKB > 1024) return `${(rssKB / 1024).toFixed(1)}M`;
|
|
27
|
+
return `${rssKB}K`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function findProjectRoot(dir) {
|
|
31
|
+
const markers = ['package.json', 'Cargo.toml', 'go.mod', 'pyproject.toml', 'Gemfile'];
|
|
32
|
+
let current = dir;
|
|
33
|
+
let depth = 0;
|
|
34
|
+
while (current !== '/' && current !== path.dirname(current) && depth < 15) {
|
|
35
|
+
for (const marker of markers) {
|
|
36
|
+
if (fs.existsSync(path.join(current, marker))) return current;
|
|
37
|
+
}
|
|
38
|
+
current = path.dirname(current);
|
|
39
|
+
depth += 1;
|
|
40
|
+
}
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function detectFramework(projectRoot, command = '', processName = '') {
|
|
45
|
+
const cmd = String(command || '').toLowerCase();
|
|
46
|
+
if (cmd.includes('next')) return 'Next.js';
|
|
47
|
+
if (cmd.includes('vite')) return 'Vite';
|
|
48
|
+
if (cmd.includes('nuxt')) return 'Nuxt';
|
|
49
|
+
if (cmd.includes('webpack')) return 'Webpack';
|
|
50
|
+
if (cmd.includes('flask')) return 'Flask';
|
|
51
|
+
if (cmd.includes('django')) return 'Django';
|
|
52
|
+
if (cmd.includes('rails')) return 'Rails';
|
|
53
|
+
const pkgPath = path.join(projectRoot || '', 'package.json');
|
|
54
|
+
if (projectRoot && fs.existsSync(pkgPath)) {
|
|
55
|
+
try {
|
|
56
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
57
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
58
|
+
if (deps.next) return 'Next.js';
|
|
59
|
+
if (deps.vite) return 'Vite';
|
|
60
|
+
if (deps.nuxt || deps.nuxt3) return 'Nuxt';
|
|
61
|
+
if (deps.react) return 'React';
|
|
62
|
+
if (deps.vue) return 'Vue';
|
|
63
|
+
if (deps.express) return 'Express';
|
|
64
|
+
} catch {
|
|
65
|
+
// Ignore parse errors.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const lowerName = String(processName || '').toLowerCase();
|
|
69
|
+
if (lowerName === 'node') return 'Node.js';
|
|
70
|
+
if (lowerName.startsWith('python')) return 'Python';
|
|
71
|
+
if (lowerName === 'java') return 'Java';
|
|
72
|
+
return '-';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function listPortsUnixRaw() {
|
|
76
|
+
const platform = os.platform();
|
|
77
|
+
const portMap = new Map();
|
|
78
|
+
if (platform !== 'darwin') {
|
|
79
|
+
try {
|
|
80
|
+
const { stdout } = await runCommand('ss', ['-tlnp']);
|
|
81
|
+
for (const line of stdout.split('\n').slice(1)) {
|
|
82
|
+
const parts = line.trim().split(/\s+/);
|
|
83
|
+
if (parts.length < 4) continue;
|
|
84
|
+
const localAddr = parts[3];
|
|
85
|
+
const portMatch = localAddr.match(/:(\d+)$/);
|
|
86
|
+
if (!portMatch) continue;
|
|
87
|
+
const port = parseInt(portMatch[1], 10);
|
|
88
|
+
if (portMap.has(port)) continue;
|
|
89
|
+
const pidMatch = line.match(/pid=(\d+)/);
|
|
90
|
+
const procMatch = line.match(/users:\(\("([^"]+)"/);
|
|
91
|
+
if (port > 0) {
|
|
92
|
+
portMap.set(port, {
|
|
93
|
+
port,
|
|
94
|
+
pid: pidMatch ? parseInt(pidMatch[1], 10) : 0,
|
|
95
|
+
process: procMatch ? procMatch[1] : '',
|
|
96
|
+
protocol: 'tcp',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (portMap.size > 0) return [...portMap.values()];
|
|
101
|
+
} catch {
|
|
102
|
+
// Fallback to lsof.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const { stdout } = await runCommand('lsof', ['-iTCP', '-sTCP:LISTEN', '-n', '-P']);
|
|
106
|
+
for (const line of stdout.split('\n').slice(1)) {
|
|
107
|
+
const parts = line.trim().split(/\s+/);
|
|
108
|
+
if (parts.length < 9) continue;
|
|
109
|
+
const processName = parts[0];
|
|
110
|
+
const pid = parseInt(parts[1], 10);
|
|
111
|
+
const addrPort = parts[8];
|
|
112
|
+
const portMatch = addrPort.match(/:(\d+)$/);
|
|
113
|
+
if (!portMatch) continue;
|
|
114
|
+
const port = parseInt(portMatch[1], 10);
|
|
115
|
+
if (port > 0 && !portMap.has(port)) {
|
|
116
|
+
portMap.set(port, { port, pid, process: processName, protocol: 'tcp' });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return [...portMap.values()];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function listPortsWindowsRaw() {
|
|
123
|
+
const portMap = new Map();
|
|
124
|
+
const { stdout } = await runCommand('netstat', ['-ano']);
|
|
125
|
+
for (const line of stdout.split('\n')) {
|
|
126
|
+
const parts = line.trim().split(/\s+/);
|
|
127
|
+
if (parts[0] !== 'TCP' || parts[3] !== 'LISTENING') continue;
|
|
128
|
+
const portMatch = parts[1].match(/:(\d+)$/);
|
|
129
|
+
if (!portMatch) continue;
|
|
130
|
+
const port = parseInt(portMatch[1], 10);
|
|
131
|
+
const pid = parseInt(parts[4], 10);
|
|
132
|
+
if (port > 0 && !portMap.has(port)) {
|
|
133
|
+
portMap.set(port, { port, pid, process: 'unknown', protocol: 'tcp' });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return [...portMap.values()];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const PS_LINE = /^(\d+)\s+(\d+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(.*)$/;
|
|
140
|
+
|
|
141
|
+
async function batchProcessInfoDarwin(pids) {
|
|
142
|
+
const map = new Map();
|
|
143
|
+
if (pids.length === 0) return map;
|
|
144
|
+
const { stdout } = await runCommand(
|
|
145
|
+
'ps',
|
|
146
|
+
['-ww', '-p', pids.join(','), '-o', 'pid=,ppid=,stat=,rss=,etime=,command='],
|
|
147
|
+
10000,
|
|
148
|
+
);
|
|
149
|
+
for (const line of stdout.trim().split('\n')) {
|
|
150
|
+
const m = line.trim().match(PS_LINE);
|
|
151
|
+
if (!m) continue;
|
|
152
|
+
map.set(parseInt(m[1], 10), {
|
|
153
|
+
ppid: parseInt(m[2], 10),
|
|
154
|
+
stat: m[3],
|
|
155
|
+
rss: parseInt(m[4], 10),
|
|
156
|
+
etime: m[5],
|
|
157
|
+
command: m[6],
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return map;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function batchCwdDarwin(pids) {
|
|
164
|
+
const map = new Map();
|
|
165
|
+
if (pids.length === 0) return map;
|
|
166
|
+
try {
|
|
167
|
+
const { stdout } = await runCommand('lsof', ['-a', '-d', 'cwd', '-p', pids.join(',')], 10000);
|
|
168
|
+
for (const line of stdout.trim().split('\n').slice(1)) {
|
|
169
|
+
const parts = line.trim().split(/\s+/);
|
|
170
|
+
if (parts.length < 9) continue;
|
|
171
|
+
const pid = parseInt(parts[1], 10);
|
|
172
|
+
const cwdPath = parts.slice(8).join(' ');
|
|
173
|
+
if (cwdPath && cwdPath.startsWith('/')) map.set(pid, cwdPath);
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// Best effort.
|
|
177
|
+
}
|
|
178
|
+
return map;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function enrichRows(rawRows) {
|
|
182
|
+
const pids = [...new Set(rawRows.map((r) => r.pid).filter((p) => p > 0))];
|
|
183
|
+
let psMap = new Map();
|
|
184
|
+
let cwdMap = new Map();
|
|
185
|
+
if (os.platform() === 'darwin') {
|
|
186
|
+
psMap = await batchProcessInfoDarwin(pids);
|
|
187
|
+
cwdMap = await batchCwdDarwin(pids);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return rawRows.map((row) => {
|
|
191
|
+
const ps = psMap.get(row.pid);
|
|
192
|
+
const cwdRaw = cwdMap.get(row.pid);
|
|
193
|
+
const command = ps?.command || '';
|
|
194
|
+
const root = cwdRaw ? findProjectRoot(cwdRaw) : '';
|
|
195
|
+
const project = root ? path.basename(root) : '-';
|
|
196
|
+
const status = ps?.stat?.includes('Z') ? 'zombie' : 'healthy';
|
|
197
|
+
return {
|
|
198
|
+
...row,
|
|
199
|
+
command,
|
|
200
|
+
cwd: root || '-',
|
|
201
|
+
project,
|
|
202
|
+
framework: detectFramework(root, command, row.process),
|
|
203
|
+
uptime: ps?.etime || '-',
|
|
204
|
+
memory: ps?.rss ? formatMemory(ps.rss) : '-',
|
|
205
|
+
status,
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function listPortsDetailed() {
|
|
211
|
+
const raw = os.platform() === 'win32' ? await listPortsWindowsRaw() : await listPortsUnixRaw();
|
|
212
|
+
const rows = await enrichRows(raw);
|
|
213
|
+
return rows.sort((a, b) => a.port - b.port);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function killPid(pid, force) {
|
|
217
|
+
if (pid === process.pid) throw new Error(`Refusing to kill current process ${pid}`);
|
|
218
|
+
if (os.platform() === 'win32') {
|
|
219
|
+
await runCommand('taskkill', force ? ['/F', '/PID', String(pid)] : ['/PID', String(pid)]);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
process.kill(pid, force ? 'SIGKILL' : 'SIGTERM');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Blessed treats `{` / `}` as inline tags; strip to avoid parseContent blowing the stack. */
|
|
226
|
+
function blessedPlain(text) {
|
|
227
|
+
return String(text).replace(/\{/g, '\uFF5B').replace(/\}/g, '\uFF5D');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* blessed uses its own EventEmitter (no `prependListener`). Run `listener` before existing ones.
|
|
232
|
+
*/
|
|
233
|
+
function prependBlessedListener(emitter, type, listener) {
|
|
234
|
+
if (typeof emitter.prependListener === 'function') {
|
|
235
|
+
emitter.prependListener(type, listener);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (!emitter._events) emitter._events = {};
|
|
239
|
+
const existing = emitter._events[type];
|
|
240
|
+
if (!existing) {
|
|
241
|
+
emitter.on(type, listener);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (typeof existing === 'function') {
|
|
245
|
+
emitter._events[type] = [listener, existing];
|
|
246
|
+
} else {
|
|
247
|
+
existing.unshift(listener);
|
|
248
|
+
}
|
|
249
|
+
if (typeof emitter._emit === 'function') {
|
|
250
|
+
emitter._emit('newListener', [type, listener]);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function formatRowLine(row, selected) {
|
|
255
|
+
const mark = selected ? '[x]' : '[ ]';
|
|
256
|
+
const command = blessedPlain((row.command || '-').slice(0, 28));
|
|
257
|
+
const processName = blessedPlain((row.process || '-').slice(0, 14));
|
|
258
|
+
const project = blessedPlain((row.project || '-').slice(0, 14));
|
|
259
|
+
const framework = blessedPlain((row.framework || '-').slice(0, 10));
|
|
260
|
+
return `${mark} ${String(row.port).padEnd(5)} ${String(row.pid).padEnd(7)} ${processName.padEnd(14)} ${project.padEnd(14)} ${framework.padEnd(10)} ${String(row.uptime || '-').padEnd(10)} ${String(row.memory || '-').padEnd(8)} ${blessedPlain(row.status || '-').padEnd(8)} ${command}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function tableHeaderLine() {
|
|
264
|
+
return '[ ] Port PID Process Project Framework Uptime Memory Status Command';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function printTableOnce(rows) {
|
|
268
|
+
const table = new Table({
|
|
269
|
+
head: [
|
|
270
|
+
chalk.cyan('Port'),
|
|
271
|
+
chalk.cyan('PID'),
|
|
272
|
+
chalk.cyan('Process'),
|
|
273
|
+
chalk.cyan('Project'),
|
|
274
|
+
chalk.cyan('Framework'),
|
|
275
|
+
chalk.cyan('Uptime'),
|
|
276
|
+
chalk.cyan('Memory'),
|
|
277
|
+
chalk.cyan('Status'),
|
|
278
|
+
chalk.cyan('Command'),
|
|
279
|
+
],
|
|
280
|
+
style: { head: [], border: [] },
|
|
281
|
+
wordWrap: true,
|
|
282
|
+
});
|
|
283
|
+
for (const row of rows) {
|
|
284
|
+
table.push([
|
|
285
|
+
row.port,
|
|
286
|
+
row.pid || '-',
|
|
287
|
+
row.process || '-',
|
|
288
|
+
row.project || '-',
|
|
289
|
+
row.framework || '-',
|
|
290
|
+
row.uptime || '-',
|
|
291
|
+
row.memory || '-',
|
|
292
|
+
row.status || '-',
|
|
293
|
+
(row.command || '-').slice(0, 40),
|
|
294
|
+
]);
|
|
295
|
+
}
|
|
296
|
+
console.log(table.toString());
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function runInteractiveTui() {
|
|
300
|
+
const screen = blessed.screen({
|
|
301
|
+
smartCSR: true,
|
|
302
|
+
title: 'Port Hunter CLI',
|
|
303
|
+
fullUnicode: true,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const title = blessed.box({
|
|
307
|
+
parent: screen,
|
|
308
|
+
top: 0,
|
|
309
|
+
left: 0,
|
|
310
|
+
width: '100%',
|
|
311
|
+
height: 1,
|
|
312
|
+
content: ' Port Hunter CLI | Mouse: click row toggle | Space/Enter toggle | g: graceful kill | f: force kill | r: refresh | q: quit ',
|
|
313
|
+
style: { fg: 'black', bg: 'cyan' },
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const info = blessed.box({
|
|
317
|
+
parent: screen,
|
|
318
|
+
top: 1,
|
|
319
|
+
left: 0,
|
|
320
|
+
width: '100%',
|
|
321
|
+
height: 1,
|
|
322
|
+
content: ' Loading...',
|
|
323
|
+
style: { fg: 'yellow' },
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const list = blessed.list({
|
|
327
|
+
parent: screen,
|
|
328
|
+
top: 2,
|
|
329
|
+
left: 0,
|
|
330
|
+
width: '100%',
|
|
331
|
+
height: '100%-4',
|
|
332
|
+
keys: true,
|
|
333
|
+
mouse: true,
|
|
334
|
+
vi: true,
|
|
335
|
+
tags: false,
|
|
336
|
+
border: 'line',
|
|
337
|
+
style: {
|
|
338
|
+
selected: { bg: 'blue' },
|
|
339
|
+
item: { fg: 'white' },
|
|
340
|
+
border: { fg: 'gray' },
|
|
341
|
+
},
|
|
342
|
+
scrollbar: { ch: ' ', inverse: true },
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const status = blessed.box({
|
|
346
|
+
parent: screen,
|
|
347
|
+
bottom: 0,
|
|
348
|
+
left: 0,
|
|
349
|
+
width: '100%',
|
|
350
|
+
height: 1,
|
|
351
|
+
content: ' Ready',
|
|
352
|
+
style: { fg: 'green' },
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const btnRefresh = blessed.button({
|
|
356
|
+
parent: screen,
|
|
357
|
+
bottom: 0,
|
|
358
|
+
right: 30,
|
|
359
|
+
mouse: true,
|
|
360
|
+
keys: true,
|
|
361
|
+
shrink: true,
|
|
362
|
+
padding: { left: 1, right: 1 },
|
|
363
|
+
content: 'Refresh',
|
|
364
|
+
style: { bg: 'blue', focus: { bg: 'cyan' }, hover: { bg: 'cyan' } },
|
|
365
|
+
});
|
|
366
|
+
const btnKill = blessed.button({
|
|
367
|
+
parent: screen,
|
|
368
|
+
bottom: 0,
|
|
369
|
+
right: 19,
|
|
370
|
+
mouse: true,
|
|
371
|
+
keys: true,
|
|
372
|
+
shrink: true,
|
|
373
|
+
padding: { left: 1, right: 1 },
|
|
374
|
+
content: 'Kill(g)',
|
|
375
|
+
style: { bg: 'yellow', fg: 'black', focus: { bg: 'green' }, hover: { bg: 'green' } },
|
|
376
|
+
});
|
|
377
|
+
const btnForce = blessed.button({
|
|
378
|
+
parent: screen,
|
|
379
|
+
bottom: 0,
|
|
380
|
+
right: 8,
|
|
381
|
+
mouse: true,
|
|
382
|
+
keys: true,
|
|
383
|
+
shrink: true,
|
|
384
|
+
padding: { left: 1, right: 1 },
|
|
385
|
+
content: 'Force(f)',
|
|
386
|
+
style: { bg: 'red', focus: { bg: 'magenta' }, hover: { bg: 'magenta' } },
|
|
387
|
+
});
|
|
388
|
+
const btnQuit = blessed.button({
|
|
389
|
+
parent: screen,
|
|
390
|
+
bottom: 0,
|
|
391
|
+
right: 0,
|
|
392
|
+
mouse: true,
|
|
393
|
+
keys: true,
|
|
394
|
+
shrink: true,
|
|
395
|
+
padding: { left: 1, right: 1 },
|
|
396
|
+
content: 'Quit',
|
|
397
|
+
style: { bg: 'gray', focus: { bg: 'white', fg: 'black' }, hover: { bg: 'white', fg: 'black' } },
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
let rows = [];
|
|
401
|
+
const selectedPorts = new Set();
|
|
402
|
+
/** When true, ignore `select item` from blessed (setItems/select re-emit synchronously). */
|
|
403
|
+
let ignoreSelectItem = false;
|
|
404
|
+
/** After arrow / wheel navigation, blessed calls `select()` which emits `select item` — do not treat that as a checkbox toggle. */
|
|
405
|
+
let skipToggleOnNextSelectItem = false;
|
|
406
|
+
|
|
407
|
+
function selectedRows() {
|
|
408
|
+
const byPort = new Map(rows.map((r) => [r.port, r]));
|
|
409
|
+
return [...selectedPorts].map((port) => byPort.get(port)).filter(Boolean);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function updateList() {
|
|
413
|
+
ignoreSelectItem = true;
|
|
414
|
+
try {
|
|
415
|
+
const items = [tableHeaderLine(), ...rows.map((r) => formatRowLine(r, selectedPorts.has(r.port)))];
|
|
416
|
+
const prevIdx = list.selected;
|
|
417
|
+
list.setItems(items);
|
|
418
|
+
if (items.length > 1) {
|
|
419
|
+
const nextIdx = prevIdx >= 1 && prevIdx < items.length ? prevIdx : 1;
|
|
420
|
+
list.select(nextIdx);
|
|
421
|
+
}
|
|
422
|
+
info.setContent(
|
|
423
|
+
blessedPlain(` ${rows.length} ports | selected: ${selectedPorts.size}`),
|
|
424
|
+
);
|
|
425
|
+
screen.render();
|
|
426
|
+
} finally {
|
|
427
|
+
ignoreSelectItem = false;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function refresh() {
|
|
432
|
+
status.setContent(' Scanning ports...');
|
|
433
|
+
status.style.fg = 'yellow';
|
|
434
|
+
screen.render();
|
|
435
|
+
try {
|
|
436
|
+
rows = await listPortsDetailed();
|
|
437
|
+
for (const port of [...selectedPorts]) {
|
|
438
|
+
if (!rows.some((r) => r.port === port)) selectedPorts.delete(port);
|
|
439
|
+
}
|
|
440
|
+
status.setContent(` Refreshed: ${rows.length} listening ports`);
|
|
441
|
+
status.style.fg = 'green';
|
|
442
|
+
updateList();
|
|
443
|
+
} catch (error) {
|
|
444
|
+
status.setContent(` Scan failed: ${error.message || error}`);
|
|
445
|
+
status.style.fg = 'red';
|
|
446
|
+
screen.render();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function killSelected(force) {
|
|
451
|
+
const targets = selectedRows();
|
|
452
|
+
if (targets.length === 0) {
|
|
453
|
+
status.setContent(' No ports selected');
|
|
454
|
+
status.style.fg = 'yellow';
|
|
455
|
+
screen.render();
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
let fail = 0;
|
|
459
|
+
for (const row of targets) {
|
|
460
|
+
try {
|
|
461
|
+
await killPid(row.pid, force);
|
|
462
|
+
} catch {
|
|
463
|
+
fail += 1;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
status.setContent(
|
|
467
|
+
` ${force ? 'Force kill' : 'Graceful kill'} finished: ok=${targets.length - fail}, failed=${fail}`,
|
|
468
|
+
);
|
|
469
|
+
status.style.fg = fail > 0 ? 'yellow' : 'green';
|
|
470
|
+
selectedPorts.clear();
|
|
471
|
+
await refresh();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function toggleCurrent() {
|
|
475
|
+
if (ignoreSelectItem) return;
|
|
476
|
+
const idx = list.selected;
|
|
477
|
+
if (idx <= 0) return;
|
|
478
|
+
const row = rows[idx - 1];
|
|
479
|
+
if (!row) return;
|
|
480
|
+
if (selectedPorts.has(row.port)) selectedPorts.delete(row.port);
|
|
481
|
+
else selectedPorts.add(row.port);
|
|
482
|
+
updateList();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function markNavigationSelectItem() {
|
|
486
|
+
skipToggleOnNextSelectItem = true;
|
|
487
|
+
queueMicrotask(() => {
|
|
488
|
+
skipToggleOnNextSelectItem = false;
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
prependBlessedListener(list, 'keypress', (ch, key) => {
|
|
493
|
+
if (key.name === 'up' || key.name === 'down') markNavigationSelectItem();
|
|
494
|
+
if (list.options.vi && (key.name === 'k' || key.name === 'j')) markNavigationSelectItem();
|
|
495
|
+
});
|
|
496
|
+
prependBlessedListener(list, 'element wheeldown', () => markNavigationSelectItem());
|
|
497
|
+
prependBlessedListener(list, 'element wheelup', () => markNavigationSelectItem());
|
|
498
|
+
|
|
499
|
+
list.on('select item', () => {
|
|
500
|
+
if (ignoreSelectItem) return;
|
|
501
|
+
if (skipToggleOnNextSelectItem) return;
|
|
502
|
+
toggleCurrent();
|
|
503
|
+
});
|
|
504
|
+
list.on('action', (_el, index) => {
|
|
505
|
+
if (ignoreSelectItem) return;
|
|
506
|
+
if (typeof index !== 'number' || index <= 0) return;
|
|
507
|
+
toggleCurrent();
|
|
508
|
+
});
|
|
509
|
+
list.on('keypress', (_, key) => {
|
|
510
|
+
if (key.full === 'space') toggleCurrent();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
screen.key(['q', 'C-c'], () => process.exit(0));
|
|
514
|
+
screen.key(['r'], () => void refresh());
|
|
515
|
+
screen.key(['g'], () => void killSelected(false));
|
|
516
|
+
screen.key(['f'], () => void killSelected(true));
|
|
517
|
+
|
|
518
|
+
btnRefresh.on('press', () => void refresh());
|
|
519
|
+
btnKill.on('press', () => void killSelected(false));
|
|
520
|
+
btnForce.on('press', () => void killSelected(true));
|
|
521
|
+
btnQuit.on('press', () => process.exit(0));
|
|
522
|
+
|
|
523
|
+
screen.append(title);
|
|
524
|
+
screen.append(info);
|
|
525
|
+
screen.append(list);
|
|
526
|
+
screen.append(status);
|
|
527
|
+
screen.render();
|
|
528
|
+
await refresh();
|
|
529
|
+
list.focus();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function printHelp() {
|
|
533
|
+
console.log(`
|
|
534
|
+
Port Hunter CLI
|
|
535
|
+
|
|
536
|
+
Usage:
|
|
537
|
+
port-hunter Start mouse-enabled TUI
|
|
538
|
+
port-hunter --once Scan and print detailed listening ports once
|
|
539
|
+
port-hunter --help Show this help
|
|
540
|
+
`);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function runOnce() {
|
|
544
|
+
const rows = await listPortsDetailed();
|
|
545
|
+
if (rows.length === 0) {
|
|
546
|
+
console.log('No listening ports found.');
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
printTableOnce(rows);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function main() {
|
|
553
|
+
if (argv.includes('--help') || argv.includes('-h')) return printHelp();
|
|
554
|
+
if (argv.includes('--once')) return runOnce();
|
|
555
|
+
return runInteractiveTui();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
main().catch((error) => {
|
|
559
|
+
console.error(error?.message || error);
|
|
560
|
+
process.exit(1);
|
|
561
|
+
});
|