portly-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/README.md ADDED
@@ -0,0 +1,205 @@
1
+ # 🔍 portly-cli
2
+
3
+ > A pretty CLI tool to explore and manage open ports. Like `lsof`, but with style.
4
+
5
+ ![Terminal](https://img.shields.io/badge/Terminal-macOS-green)
6
+ ![Node](https://img.shields.io/badge/Node-16+-yellow)
7
+
8
+ ## 🚀 Quick Start
9
+
10
+ ### Install via npm
11
+ ```bash
12
+ npm install -g portly-cli
13
+ ```
14
+
15
+ ### Run
16
+ ```bash
17
+ portly
18
+ ```
19
+
20
+ Or run locally:
21
+ ```bash
22
+ # Navigate to the portly directory
23
+ cd portly
24
+
25
+ # Run the app
26
+ node src/index.js
27
+ # or
28
+ npm start
29
+ ```
30
+
31
+ ---
32
+
33
+ ## ⌨️ Keyboard Shortcuts
34
+
35
+ ### Navigation
36
+ | Key | Action |
37
+ |-----|--------|
38
+ | `↑` / `↓` | Navigate up/down through the port list |
39
+ | `Page Up` / `Page Down` | Jump 10 entries at a time |
40
+ | `Home` / `End` | Jump to first/last entry |
41
+
42
+ ### Views
43
+ | Key | Action |
44
+ |-----|--------|
45
+ | `Enter` / `→` | Open detail view for selected port |
46
+ | `Escape` / `←` | Go back / close detail view |
47
+
48
+ ### Actions
49
+ | Key | Action |
50
+ |-----|--------|
51
+ | `F` | Open filter / search |
52
+ | `F` (when filter active) | Clear filter |
53
+ | `A` | Toggle auto-refresh (2 second interval) |
54
+ | `R` | Manual refresh of port list |
55
+ | `K` | Kill the selected process (confirmation required) |
56
+ | `Q` | Quit portly |
57
+
58
+ ---
59
+
60
+ ## 🔍 Filter Syntax
61
+
62
+ The filter supports multiple search modes:
63
+
64
+ | Example | Result |
65
+ |---------|--------|
66
+ | `3000` | Show only port 3000 |
67
+ | `node` | Show all processes containing "node" |
68
+ | `Google` | Show all Google processes |
69
+ | `10-4000` | Show all ports between 10 and 4000 |
70
+
71
+ Press `Enter` to apply the filter, `Escape` to cancel, or `F` again to clear.
72
+
73
+ ---
74
+
75
+ ## 📋 Views
76
+
77
+ ### List View (Default)
78
+ ```
79
+ PORTLY - Port Explorer
80
+ -------------------------------------------
81
+ 74 ports found | [AUTO-OFF]
82
+
83
+ PORT PROTO STATE COMMAND PID USER
84
+ --------------------------------------------------------
85
+ > 9 TCP [L] identitys 643 promptnpray
86
+ 2738 TCP [L] identitys 643 promptnpray
87
+ 5000 TCP [L] ControlCe 602 promptnpray
88
+ ...
89
+ --------------------------------------------------------
90
+ [UP/DOWN] Navigate [ENTER] Details [F] Filter [A] Auto-Refresh [R] Refresh [Q] Quit
91
+
92
+ [1/74] 9 -> identitys | [K] Kill
93
+ ```
94
+
95
+ ### Detail View
96
+ ```
97
+ PORTLY - Port Explorer
98
+ -------------------------------------------
99
+ 74 ports found | [DETAIL] | [AUTO-OFF]
100
+
101
+ ========================================
102
+ PORT DETAILS
103
+ ========================================
104
+ Port: 5000
105
+ Protocol: TCP6
106
+ State: LISTEN
107
+ Category: Registered (1024-49151)
108
+ ----------------------------------------
109
+ Command: ControlCe
110
+ PID: 602
111
+ User: promptnpray
112
+ ----------------------------------------
113
+ Local: *:5000
114
+ Remote: -
115
+ ========================================
116
+
117
+ [K] Kill [C] Copy Port [P] Copy PID [ESC] Back
118
+
119
+ [5/74] 5000 -> ControlCe (PID: 602)
120
+ ```
121
+
122
+ ---
123
+
124
+ ## 🔴 Killing Processes
125
+
126
+ When you press `K` on a port, a confirmation dialog appears:
127
+
128
+ ```
129
+ +----------------------------------+
130
+ | WARNING: KILL PROCESS |
131
+ +----------------------------------+
132
+ | |
133
+ | Process: ControlCe |
134
+ | PID: 602 |
135
+ | Port: 5000 |
136
+ | |
137
+ | Are you sure you want to kill |
138
+ | this process? |
139
+ | |
140
+ | [Y] KILL [N] CANCEL |
141
+ | |
142
+ +----------------------------------+
143
+ ```
144
+
145
+ Press `Y` to kill, `N` or `Escape` to cancel.
146
+
147
+ ---
148
+
149
+ ## 🔄 Auto-Refresh
150
+
151
+ When auto-refresh is enabled (`[AUTO-ON]`), portly automatically scans every 2 seconds. Useful for monitoring ports that come and go.
152
+
153
+ ---
154
+
155
+ ## 🛠️ Requirements
156
+
157
+ - macOS or Linux
158
+ - Node.js 16 or higher
159
+ - `lsof` command (standard on macOS/Linux)
160
+
161
+ ---
162
+
163
+ ## 📦 Installation
164
+
165
+ ```bash
166
+ # Install globally via npm
167
+ npm install -g portly-cli
168
+
169
+ # Run
170
+ portly
171
+ ```
172
+
173
+ ### For Development
174
+
175
+ ```bash
176
+ # Clone the repo
177
+ git clone <your-repo-url>
178
+ cd portly
179
+
180
+ # Install dependencies
181
+ npm install
182
+
183
+ # Run
184
+ npm start
185
+
186
+ # Or with file watching (auto-reload on changes)
187
+ npm run dev
188
+ ```
189
+
190
+ ---
191
+
192
+ ## 🎨 Port States
193
+
194
+ | State | Icon | Meaning |
195
+ |-------|------|---------|
196
+ | LISTEN | `[L]` | Port is listening for connections |
197
+ | ESTABLISHED | `[E]` | Active connection |
198
+ | TIME_WAIT | `[W]` | Connection closing |
199
+ | Other | `[-]` | Unknown state |
200
+
201
+ ---
202
+
203
+ ## 📝 License
204
+
205
+ MIT
package/debug.log ADDED
@@ -0,0 +1 @@
1
+ This file is just a marker.
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "portly-cli",
3
+ "version": "1.0.0",
4
+ "description": "A pretty CLI tool to explore and manage open ports. Like lsof, but with style.",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "portly-cli": "./src/index.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node src/index.js",
11
+ "dev": "node --watch src/index.js"
12
+ },
13
+ "keywords": ["cli", "ports", "lsof", "network", "monitoring", "port-scanner"],
14
+ "author": "promptnpray",
15
+ "license": "MIT",
16
+ "dependencies": {
17
+ "blessed": "^0.1.81"
18
+ }
19
+ }
package/src/index.js ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+
3
+ const blessed = require('blessed');
4
+ const MainScreen = require('./ui/mainScreen');
5
+ const { scanPorts } = require('./utils/portScanner');
6
+
7
+ class Portly {
8
+ constructor() {
9
+ this.screen = null;
10
+ this.mainScreen = null;
11
+ this.ports = [];
12
+ this.refreshInterval = null;
13
+ }
14
+
15
+ init() {
16
+ // Create the screen
17
+ this.screen = blessed.screen({
18
+ smartCSR: true,
19
+ title: 'portly - port explorer',
20
+ warnings: true,
21
+ });
22
+
23
+ // Create main screen (binds keys internally)
24
+ this.mainScreen = new MainScreen(this.screen, this);
25
+
26
+ // Initial scan
27
+ this.refreshPorts();
28
+
29
+ // Render
30
+ this.screen.render();
31
+ }
32
+
33
+ async refreshPorts() {
34
+ try {
35
+ this.ports = await scanPorts();
36
+ if (this.mainScreen) {
37
+ this.mainScreen.update(this.ports);
38
+ }
39
+ } catch (err) {
40
+ console.error('Error scanning ports:', err);
41
+ }
42
+ }
43
+
44
+ startAutoRefresh(interval = 2000) {
45
+ this.stopAutoRefresh();
46
+ this.refreshInterval = setInterval(() => this.refreshPorts(), interval);
47
+ }
48
+
49
+ stopAutoRefresh() {
50
+ if (this.refreshInterval) {
51
+ clearInterval(this.refreshInterval);
52
+ this.refreshInterval = null;
53
+ }
54
+ }
55
+
56
+ quit() {
57
+ this.stopAutoRefresh();
58
+ process.exit(0);
59
+ }
60
+ }
61
+
62
+ // Start
63
+ const app = new Portly();
64
+ app.init();
@@ -0,0 +1,532 @@
1
+ const blessed = require('blessed');
2
+ const { CATEGORY_LABELS } = require('../utils/portScanner');
3
+
4
+ const STATUS_ICONS = {
5
+ LISTEN: '[L]',
6
+ ESTABLISHED: '[E]',
7
+ TIME_WAIT: '[W]',
8
+ CLOSE_WAIT: '[W]',
9
+ SYN_SENT: '[S]',
10
+ SYN_RECV: '[S]',
11
+ UNKNOWN: '[-]',
12
+ };
13
+
14
+ class MainScreen {
15
+ constructor(screen, app) {
16
+ this.screen = screen;
17
+ this.app = app;
18
+ this.ports = [];
19
+ this.selectedIndex = 0;
20
+ this.view = 'table';
21
+ this.selectedPort = null;
22
+ this.filterText = '';
23
+ this.autoRefresh = false;
24
+ this.scrollTop = 0;
25
+ this.visibleRows = 20;
26
+ this.confirmingKill = false;
27
+ this.filterMode = false;
28
+
29
+ this.createUI();
30
+ this.bindKeys();
31
+ }
32
+
33
+ createUI() {
34
+ // Main container
35
+ this.mainBox = blessed.box({
36
+ top: 0,
37
+ left: 0,
38
+ width: '100%',
39
+ height: '100%',
40
+ style: { bg: 'black' },
41
+ });
42
+ this.screen.append(this.mainBox);
43
+
44
+ // Header
45
+ this.header = blessed.box({
46
+ top: 0,
47
+ left: 0,
48
+ width: '100%',
49
+ height: 3,
50
+ style: { fg: 'cyan' },
51
+ });
52
+ this.mainBox.append(this.header);
53
+
54
+ // Fixed column headers (not scrollable)
55
+ this.columnHeader = blessed.box({
56
+ top: 3,
57
+ left: 0,
58
+ width: '100%',
59
+ height: 2,
60
+ style: { fg: 'cyan', bold: true },
61
+ });
62
+ this.mainBox.append(this.columnHeader);
63
+
64
+ // Table content (scrollable)
65
+ this.tableContent = blessed.box({
66
+ top: 5,
67
+ left: 0,
68
+ width: '100%',
69
+ height: '100%-10',
70
+ style: { fg: 'white' },
71
+ scrollable: true,
72
+ focusable: true,
73
+ keys: true,
74
+ vi: true,
75
+ alwaysScroll: true,
76
+ });
77
+ this.mainBox.append(this.tableContent);
78
+
79
+ // Help bar
80
+ this.helpBar = blessed.box({
81
+ bottom: 0,
82
+ left: 0,
83
+ width: '100%',
84
+ height: 3,
85
+ content: '[UP/DOWN] Navigate [ENTER] Details [/] Filter [A] Auto [K] Kill [R] Refresh [Q] Quit',
86
+ style: { border: { fg: 'magenta' }, fg: 'white' },
87
+ });
88
+ this.mainBox.append(this.helpBar);
89
+
90
+ // Status bar
91
+ this.statusBar = blessed.box({
92
+ bottom: 3,
93
+ left: 0,
94
+ width: '100%',
95
+ height: 1,
96
+ style: { fg: 'blue' },
97
+ });
98
+ this.mainBox.append(this.statusBar);
99
+
100
+ // Initial focus
101
+ this.tableContent.focus();
102
+ }
103
+
104
+ bindKeys() {
105
+ // Navigation with scrolling - works in both table AND detail view
106
+ this.tableContent.key('up', () => this.navigate(-1));
107
+ this.tableContent.key('down', () => this.navigate(1));
108
+ this.tableContent.key('page up', () => {
109
+ if (this.confirmingKill || this.filterMode) return;
110
+ this.selectedIndex = Math.max(0, this.selectedIndex - 10);
111
+ this.scrollTop = Math.max(0, this.scrollTop - 10);
112
+ if (this.view === 'detail' && this.selectedPort !== this.ports[this.selectedIndex]) {
113
+ this.selectedPort = this.ports[this.selectedIndex];
114
+ }
115
+ this.render();
116
+ });
117
+ this.tableContent.key('page down', () => {
118
+ if (this.confirmingKill || this.filterMode) return;
119
+ this.selectedIndex = Math.min(this.ports.length - 1, this.selectedIndex + 10);
120
+ this.scrollTop = Math.min(this.scrollTop, Math.max(0, this.selectedIndex - this.visibleRows + 1));
121
+ if (this.view === 'detail' && this.selectedPort !== this.ports[this.selectedIndex]) {
122
+ this.selectedPort = this.ports[this.selectedIndex];
123
+ }
124
+ this.render();
125
+ });
126
+ this.tableContent.key('home', () => {
127
+ if (this.confirmingKill || this.filterMode) return;
128
+ this.selectedIndex = 0;
129
+ this.scrollTop = 0;
130
+ if (this.view === 'detail') {
131
+ this.selectedPort = this.ports[0];
132
+ }
133
+ this.render();
134
+ });
135
+ this.tableContent.key('end', () => {
136
+ if (this.confirmingKill || this.filterMode) return;
137
+ this.selectedIndex = Math.max(0, this.ports.length - 1);
138
+ this.scrollTop = Math.max(0, this.ports.length - this.visibleRows);
139
+ if (this.view === 'detail') {
140
+ this.selectedPort = this.ports[this.selectedIndex];
141
+ }
142
+ this.render();
143
+ });
144
+
145
+ // Enter to open detail (table view only)
146
+ this.tableContent.key('enter', () => { if (!this.filterMode) this.openDetail(); });
147
+ this.tableContent.key('right', () => { if (!this.filterMode) this.openDetail(); });
148
+
149
+ // Escape/Left to go back or cancel dialog
150
+ this.tableContent.key('escape', () => this.handleEscape());
151
+ this.tableContent.key('left', () => this.handleEscape());
152
+
153
+ // Single character keys
154
+ this.tableContent.key('r', () => { if (!this.confirmingKill && !this.filterMode) this.app.refreshPorts(); });
155
+ this.tableContent.key('a', () => { if (!this.confirmingKill && !this.filterMode) this.toggleAutoRefresh(); });
156
+ this.tableContent.key('k', () => { if (!this.confirmingKill && !this.filterMode && this.ports[this.selectedIndex]) this.showKillConfirm(); });
157
+ this.tableContent.key('c', () => { if (!this.confirmingKill && !this.filterMode && this.view === 'detail') this.copyPort(); });
158
+ this.tableContent.key('p', () => { if (!this.confirmingKill && !this.filterMode && this.view === 'detail') this.copyPid(); });
159
+ this.tableContent.key('f', () => { if (!this.confirmingKill && !this.filterMode) this.startFilter(); });
160
+ this.tableContent.key('q', () => { if (!this.confirmingKill && !this.filterMode) this.app.quit(); });
161
+ }
162
+
163
+ navigate(direction) {
164
+ if (this.confirmingKill || this.filterMode) return;
165
+
166
+ const newIndex = this.selectedIndex + direction;
167
+ if (newIndex < 0 || newIndex >= this.ports.length) return;
168
+
169
+ this.selectedIndex = newIndex;
170
+
171
+ // Auto-scroll logic
172
+ if (direction < 0 && this.selectedIndex < this.scrollTop) {
173
+ this.scrollTop = this.selectedIndex;
174
+ } else if (direction > 0) {
175
+ const visibleBottom = this.scrollTop + this.visibleRows - 1;
176
+ if (this.selectedIndex > visibleBottom) {
177
+ this.scrollTop = this.selectedIndex - this.visibleRows + 1;
178
+ }
179
+ }
180
+
181
+ // In detail view, update the selected port to match
182
+ if (this.view === 'detail') {
183
+ this.selectedPort = this.ports[this.selectedIndex];
184
+ }
185
+
186
+ this.render();
187
+ }
188
+
189
+ handleEscape() {
190
+ if (this.confirmingKill || this.filterMode) return;
191
+ this.closeDetail();
192
+ }
193
+
194
+ update(ports) {
195
+ this.ports = this.filterText ? this.getFilteredPorts(ports) : ports;
196
+
197
+ if (this.ports.length === 0) {
198
+ this.selectedIndex = 0;
199
+ } else if (this.selectedIndex >= this.ports.length) {
200
+ this.selectedIndex = this.ports.length - 1;
201
+ }
202
+
203
+ // Ensure scroll position is valid
204
+ if (this.scrollTop > Math.max(0, this.ports.length - this.visibleRows)) {
205
+ this.scrollTop = Math.max(0, this.ports.length - this.visibleRows);
206
+ }
207
+
208
+ this.render();
209
+ }
210
+
211
+ getFilteredPorts(ports) {
212
+ const filter = this.filterText.toLowerCase().trim();
213
+
214
+ // Check for port range (e.g., "10-4000")
215
+ const rangeMatch = filter.match(/^(\d+)-(\d+)$/);
216
+ if (rangeMatch) {
217
+ const minPort = parseInt(rangeMatch[1], 10);
218
+ const maxPort = parseInt(rangeMatch[2], 10);
219
+ return ports.filter(p => p.port >= minPort && p.port <= maxPort);
220
+ }
221
+
222
+ // Regular text filter
223
+ return ports.filter(p =>
224
+ p.port.toString().includes(filter) ||
225
+ (p.command && p.command.toLowerCase().includes(filter)) ||
226
+ (p.user && p.user.toLowerCase().includes(filter))
227
+ );
228
+ }
229
+
230
+ render() {
231
+ this.renderHeader();
232
+
233
+ if (this.view === 'detail') {
234
+ this.renderDetail();
235
+ } else {
236
+ this.renderTable();
237
+ }
238
+
239
+ this.renderStatusBar();
240
+ this.renderHelpBar();
241
+ this.screen.render();
242
+ }
243
+
244
+ renderHeader() {
245
+ const count = this.ports.length;
246
+ const total = this.app.ports.length;
247
+ const refreshStatus = this.autoRefresh ? '[AUTO-ON]' : '[AUTO-OFF]';
248
+ const filterInfo = this.filterText ? ` | Filter: "${this.filterText}"` : '';
249
+ const showingInfo = count !== total ? ` (showing ${count} of ${total})` : '';
250
+
251
+ let viewInfo = '';
252
+ if (this.confirmingKill) viewInfo = ' | [KILL CONFIRM]';
253
+ else if (this.view === 'detail') viewInfo = ' | [DETAIL]';
254
+
255
+ this.header.setContent(
256
+ 'PORTLY - Port Explorer\n' +
257
+ '-------------------------------------------\n' +
258
+ `${count} ports found${showingInfo}${filterInfo}${viewInfo} | ${refreshStatus}`
259
+ );
260
+ }
261
+
262
+ renderTable() {
263
+ // Fixed column headers
264
+ this.columnHeader.setContent(
265
+ 'PORT PROTO STATE COMMAND PID USER\n' +
266
+ '--------------------------------------------------------'
267
+ );
268
+
269
+ // Calculate visible rows based on available space
270
+ this.visibleRows = Math.max(5, (this.tableContent.height || 15));
271
+
272
+ // Build content - only data rows
273
+ const lines = [];
274
+
275
+ // Render only visible portion
276
+ const endIndex = Math.min(this.scrollTop + this.visibleRows, this.ports.length);
277
+
278
+ for (let i = this.scrollTop; i < endIndex; i++) {
279
+ const port = this.ports[i];
280
+ const icon = STATUS_ICONS[port.state] || '[-]';
281
+ const prefix = i === this.selectedIndex ? '> ' : ' ';
282
+
283
+ const row =
284
+ prefix +
285
+ String(port.port).padEnd(6) + ' ' +
286
+ String(port.protocol).padEnd(5) + ' ' +
287
+ icon.padEnd(7) + ' ' +
288
+ String(port.command || '').substring(0, 15).padEnd(16) + ' ' +
289
+ String(port.pid || '').padEnd(7) + ' ' +
290
+ String(port.user || '').substring(0, 10);
291
+
292
+ lines.push(row);
293
+ }
294
+
295
+ // Add empty lines to fill visible area if needed
296
+ while (lines.length < this.visibleRows) {
297
+ lines.push('');
298
+ }
299
+
300
+ // Add scroll indicator
301
+ if (this.ports.length > this.visibleRows) {
302
+ const start = this.scrollTop + 1;
303
+ const end = Math.min(this.scrollTop + this.visibleRows, this.ports.length);
304
+ lines.push('');
305
+ lines.push(`--- [${start}-${end}] of ${this.ports.length} ports ---`);
306
+ }
307
+
308
+ this.tableContent.setContent(lines.join('\n'));
309
+ }
310
+
311
+ renderDetail() {
312
+ // Hide column headers when in detail view
313
+ this.columnHeader.setContent('');
314
+
315
+ const port = this.selectedPort;
316
+ if (!port) return;
317
+
318
+ const content = [
319
+ '========================================',
320
+ ' PORT DETAILS',
321
+ '========================================',
322
+ ` Port: ${port.port}`,
323
+ ` Protocol: ${port.protocolFull || port.protocol}`,
324
+ ` State: ${port.state}`,
325
+ ` Category: ${CATEGORY_LABELS[port.category] || ''}`,
326
+ '----------------------------------------',
327
+ ` Command: ${port.command}`,
328
+ ` PID: ${port.pid}`,
329
+ ` User: ${port.user}`,
330
+ '----------------------------------------',
331
+ ` Local: *:${port.port}`,
332
+ ` Remote: ${port.remoteAddress || '-'}`,
333
+ '========================================',
334
+ '',
335
+ '[K] Kill [C] Copy Port [P] Copy PID [ESC] Back',
336
+ ].join('\n');
337
+
338
+ this.tableContent.setContent(content);
339
+ }
340
+
341
+ renderHelpBar() {
342
+ if (this.confirmingKill) {
343
+ this.helpBar.setContent('[Y] KILL [N/ESC] Cancel');
344
+ } else if (this.view === 'detail') {
345
+ this.helpBar.setContent('[UP/DOWN] Switch [K] Kill [C] Copy [P] PID [ESC] Back');
346
+ } else {
347
+ this.helpBar.setContent('[UP/DOWN] Navigate [ENTER] Details [F] Filter [A] Auto-Refresh [R] Refresh [Q] Quit');
348
+ }
349
+ }
350
+
351
+ renderStatusBar() {
352
+ const port = this.ports[this.selectedIndex];
353
+ if (port) {
354
+ const pos = `[${this.selectedIndex + 1}/${this.ports.length}]`;
355
+ if (this.confirmingKill) {
356
+ this.statusBar.setContent(`${pos} ${port.port} -> ${port.command} | [Y] Confirm [N/ESC] Cancel`);
357
+ } else if (this.view === 'detail') {
358
+ this.statusBar.setContent(`${pos} ${port.port} -> ${port.command} (PID: ${port.pid})`);
359
+ } else {
360
+ this.statusBar.setContent(`${pos} ${port.port} -> ${port.command} | [K] Kill`);
361
+ }
362
+ } else {
363
+ this.statusBar.setContent('');
364
+ }
365
+ }
366
+
367
+ openDetail() {
368
+ if (this.ports[this.selectedIndex]) {
369
+ this.selectedPort = this.ports[this.selectedIndex];
370
+ this.view = 'detail';
371
+ this.render();
372
+ }
373
+ }
374
+
375
+ closeDetail() {
376
+ if (this.view === 'detail') {
377
+ this.view = 'table';
378
+ this.selectedPort = null;
379
+ this.render();
380
+ }
381
+ }
382
+
383
+ toggleAutoRefresh() {
384
+ this.autoRefresh = !this.autoRefresh;
385
+ if (this.autoRefresh) {
386
+ this.app.startAutoRefresh();
387
+ } else {
388
+ this.app.stopAutoRefresh();
389
+ }
390
+ this.render();
391
+ }
392
+
393
+ startFilter() {
394
+ // If filter is already active, clear it
395
+ if (this.filterText) {
396
+ this.filterText = '';
397
+ this.scrollTop = 0;
398
+ this.selectedIndex = 0;
399
+ this.update(this.app.ports);
400
+ return;
401
+ }
402
+
403
+ this.filterMode = true;
404
+
405
+ // Create a simple input prompt
406
+ const input = blessed.textbox({
407
+ parent: this.screen,
408
+ top: 'center',
409
+ left: 'center',
410
+ width: '40%',
411
+ height: 3,
412
+ border: { type: 'line' },
413
+ style: { border: { fg: 'yellow' }, fg: 'white', bg: 'black' },
414
+ inputOnFocus: true,
415
+ });
416
+
417
+ this.screen.append(input);
418
+ this.screen.render();
419
+ input.focus();
420
+
421
+ // Listen for input completion
422
+ input.key('enter', () => {
423
+ const value = input.getValue();
424
+ this.screen.remove(input);
425
+ input.destroy();
426
+ this.filterMode = false;
427
+
428
+ if (value && value.trim()) {
429
+ this.filterText = value.trim();
430
+ this.scrollTop = 0;
431
+ this.selectedIndex = 0;
432
+ this.update(this.app.ports);
433
+ } else {
434
+ this.render();
435
+ }
436
+ });
437
+
438
+ // Listen for escape to cancel
439
+ input.key('escape', () => {
440
+ this.screen.remove(input);
441
+ input.destroy();
442
+ this.filterMode = false;
443
+ this.render();
444
+ });
445
+ }
446
+
447
+ showKillConfirm() {
448
+ // Get port from detail view or current selection
449
+ const port = this.selectedPort || this.ports[this.selectedIndex];
450
+ if (!port) return;
451
+
452
+ this.confirmingKill = true;
453
+
454
+ const dialog = blessed.box({
455
+ parent: this.screen,
456
+ top: 'center',
457
+ left: 'center',
458
+ width: 49,
459
+ height: 13,
460
+ border: { type: 'line' },
461
+ style: { border: { fg: 'red' }, bg: 'black', fg: 'white' },
462
+ content: [
463
+ '',
464
+ ' WARNING: KILL PROCESS',
465
+ '',
466
+ ' Process: ' + String(port.command || '?'),
467
+ ' PID: ' + String(port.pid || ''),
468
+ ' Port: ' + String(port.port || ''),
469
+ '',
470
+ ' Are you sure you want to kill this process?',
471
+ '',
472
+ ' [Y] KILL [N] CANCEL [ESC]',
473
+ '',
474
+ ].join('\n'),
475
+ });
476
+
477
+ this.screen.append(dialog);
478
+ this.render();
479
+
480
+ const handler = (ch, key) => {
481
+ this.screen.off('keypress', handler);
482
+ dialog.destroy();
483
+ this.confirmingKill = false;
484
+
485
+ const char = (ch || '').toLowerCase();
486
+ if (char === 'y') {
487
+ this.killProcess(port.pid);
488
+ } else {
489
+ this.render();
490
+ }
491
+ };
492
+
493
+ this.screen.on('keypress', handler);
494
+ }
495
+
496
+ killProcess(pid) {
497
+ const exec = require('child_process').exec;
498
+ exec(`kill ${pid}`, (err) => {
499
+ if (err) {
500
+ this.statusBar.setContent(`Failed to kill PID ${pid}: ${err.message}`);
501
+ } else {
502
+ this.statusBar.setContent(`Killed PID ${pid}`);
503
+ setTimeout(() => {
504
+ this.app.refreshPorts();
505
+ this.view = 'table';
506
+ this.selectedPort = null;
507
+ }, 500);
508
+ }
509
+ this.renderStatusBar();
510
+ });
511
+ }
512
+
513
+ copyPort() {
514
+ const exec = require('child_process').exec;
515
+ const port = this.selectedPort.port;
516
+ exec(`echo ${port} | pbcopy`, () => {
517
+ this.statusBar.setContent(`Copied port ${port} to clipboard!`);
518
+ this.renderStatusBar();
519
+ });
520
+ }
521
+
522
+ copyPid() {
523
+ const exec = require('child_process').exec;
524
+ const pid = this.selectedPort.pid;
525
+ exec(`echo ${pid} | pbcopy`, () => {
526
+ this.statusBar.setContent(`Copied PID ${pid} to clipboard!`);
527
+ this.renderStatusBar();
528
+ });
529
+ }
530
+ }
531
+
532
+ module.exports = MainScreen;
@@ -0,0 +1,111 @@
1
+ const exec = require('child_process').exec;
2
+ const promisify = require('util').promisify;
3
+
4
+ const execAsync = promisify(exec);
5
+
6
+ const CATEGORY_LABELS = {
7
+ system: 'System (0-1023)',
8
+ registered: 'Registered (1024-49151)',
9
+ dynamic: 'Dynamic (49152+)',
10
+ };
11
+
12
+ function getCategory(port) {
13
+ if (port >= 0 && port <= 1023) return 'system';
14
+ if (port >= 1024 && port <= 49151) return 'registered';
15
+ return 'dynamic';
16
+ }
17
+
18
+ function getProtocolLabel(protocol) {
19
+ return protocol.toUpperCase();
20
+ }
21
+
22
+ function getStateLabel(state) {
23
+ const states = {
24
+ LISTEN: '🟢',
25
+ ESTABLISHED: '🟡',
26
+ TIME_WAIT: '⏳',
27
+ CLOSE_WAIT: '⏳',
28
+ SYN_SENT: '📡',
29
+ SYN_RECV: '📡',
30
+ LAST_ACK: '⏳',
31
+ CLOSING: '🔄',
32
+ CLOSED: '⚫',
33
+ };
34
+ return states[state] || '⚪';
35
+ }
36
+
37
+ async function scanPorts() {
38
+ const ports = [];
39
+
40
+ try {
41
+ // Use lsof to get all listening ports with process info
42
+ const { stdout } = await execAsync(
43
+ 'lsof -i -P -n 2>/dev/null || lsof -i -n 2>/dev/null',
44
+ { maxBuffer: 1024 * 1024 * 10 }
45
+ );
46
+
47
+ const lines = stdout.split('\n').slice(1); // Skip header
48
+
49
+ for (const line of lines) {
50
+ if (!line.trim()) continue;
51
+
52
+ // lsof output: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
53
+ // NAME column contains things like "*:7000 (LISTEN)" or "*:5000 (LISTEN)"
54
+ const nameMatch = line.match(/(\*|[\d.:a-f]+):(\d+)(?:\s*\((\w+)\))?/);
55
+ if (!nameMatch) continue;
56
+
57
+ const localAddr = nameMatch[1];
58
+ const port = parseInt(nameMatch[2], 10);
59
+ const state = nameMatch[3] || 'LISTEN';
60
+
61
+ if (isNaN(port)) continue;
62
+
63
+ // Extract fields from beginning of line
64
+ const parts = line.split(/\s+/).filter(p => p.length > 0);
65
+ if (parts.length < 4) continue;
66
+
67
+ const command = parts[0];
68
+ const pid = parseInt(parts[1], 10);
69
+ const user = parts[2];
70
+ const type = parts[4]; // IPv4, IPv6, etc.
71
+
72
+ // Parse protocol from the line
73
+ const isIPv6 = type === 'IPv6' || line.includes('IPv6');
74
+ const protocol = isIPv6 ? 'TCP6' : 'TCP';
75
+
76
+ // Parse remote address if present (format: *:7000->192.168.1.1:1234)
77
+ const remoteMatch = line.match(/->([^\s]+)/);
78
+ const remoteAddress = remoteMatch ? remoteMatch[1] : '';
79
+
80
+ ports.push({
81
+ command,
82
+ pid,
83
+ user,
84
+ port,
85
+ protocol: protocol.replace('6', ''),
86
+ protocolFull: protocol,
87
+ state: state || 'UNKNOWN',
88
+ localAddress: localAddr.replace(/.*:/, '').replace(/^\*/, '0.0.0.0'),
89
+ remoteAddress: remoteAddress,
90
+ category: getCategory(port),
91
+ stateIcon: getStateLabel(state),
92
+ });
93
+ }
94
+ } catch (err) {
95
+ console.error('Error running lsof:', err.message);
96
+ }
97
+
98
+ // Deduplicate by port+protocol
99
+ const seen = new Map();
100
+ for (const p of ports) {
101
+ const key = `${p.port}-${p.protocol}`;
102
+ if (!seen.has(key)) {
103
+ seen.set(key, p);
104
+ }
105
+ }
106
+
107
+ // Sort by port
108
+ return Array.from(seen.values()).sort((a, b) => a.port - b.port);
109
+ }
110
+
111
+ module.exports = { scanPorts, getCategory, getProtocolLabel, CATEGORY_LABELS };