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 +205 -0
- package/debug.log +1 -0
- package/package.json +19 -0
- package/src/index.js +64 -0
- package/src/ui/mainScreen.js +532 -0
- package/src/utils/portScanner.js +111 -0
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
|
+

|
|
6
|
+

|
|
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 };
|