port-patrol 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 mifwar
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,77 @@
1
+ # Port Patrol
2
+
3
+ [![npm](https://img.shields.io/npm/v/port-patrol)](https://www.npmjs.com/package/port-patrol)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js](https://img.shields.io/badge/Node.js-18+-339933?logo=node.js&logoColor=white)](https://nodejs.org)
6
+
7
+ A terminal UI to inspect listening ports and terminate the owning process.
8
+
9
+ Built with [Ink](https://github.com/vadimdemedes/ink) + [React](https://react.dev).
10
+
11
+ ## Features
12
+
13
+ - **List all listening ports** with process info
14
+ - **Search/filter** by port number or process name
15
+ - **Kill processes** with optional force kill (SIGKILL)
16
+ - **Sort** by port, PID, or process name
17
+ - **Color-coded ports** (system < 1024, registered < 10000, dynamic)
18
+ - **Cross-platform** (macOS, Linux, Windows)
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ # Run directly
24
+ npx port-patrol
25
+
26
+ # Or install globally
27
+ npm install -g port-patrol
28
+ pp
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```bash
34
+ # Open TUI
35
+ pp
36
+
37
+ # Quick search for port 3000
38
+ pp 3000
39
+ ```
40
+
41
+ ### Keyboard Shortcuts
42
+
43
+ | Key | Action |
44
+ | --------- | ------------------------- |
45
+ | `j / ↓` | Move down |
46
+ | `k / ↑` | Move up |
47
+ | `g` | Jump to top |
48
+ | `G` | Jump to bottom |
49
+ | `/ or f` | Search/filter |
50
+ | `Enter` | Kill selected process |
51
+ | `K` | Force kill (SIGKILL) |
52
+ | `r` | Refresh list |
53
+ | `s` | Toggle sort field |
54
+ | `o` | Toggle sort order |
55
+ | `Esc` | Clear search / Close |
56
+ | `?` | Show help |
57
+ | `q` | Quit |
58
+
59
+ ### Port Colors
60
+
61
+ - **Red** - System ports (< 1024) - usually need sudo to bind
62
+ - **Yellow** - Registered ports (< 10000)
63
+ - **Green** - Dynamic/private ports (>= 10000)
64
+
65
+ ## Development
66
+
67
+ ```bash
68
+ npm install
69
+ npm run dev
70
+
71
+ # Build
72
+ npm run build
73
+ ```
74
+
75
+ ## License
76
+
77
+ MIT
package/dist/App.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ interface Props {
2
+ onExit: () => void;
3
+ initialPort?: number;
4
+ }
5
+ export declare function App({ onExit, initialPort }: Props): import("react/jsx-runtime").JSX.Element;
6
+ export {};
package/dist/App.js ADDED
@@ -0,0 +1,276 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect, useCallback, useMemo, useReducer } from "react";
3
+ import { Box, Text, useInput, useStdout } from "ink";
4
+ import Fuse from "fuse.js";
5
+ import { Header } from "./components/Header.js";
6
+ import { ProcessList } from "./components/ProcessList.js";
7
+ import { KillConfirm } from "./components/KillConfirm.js";
8
+ import { SearchInput } from "./components/SearchInput.js";
9
+ import { HelpModal } from "./components/HelpModal.js";
10
+ import { ProcessDetails } from "./components/ProcessDetails.js";
11
+ import { StatusBar } from "./components/StatusBar.js";
12
+ import { getListeningPorts, getProcessDetails, killProcess } from "./utils/process.js";
13
+ function uiReducer(state, action) {
14
+ switch (action.type) {
15
+ case "navigate": {
16
+ const { direction, listLength, maxVisible } = action;
17
+ if (listLength === 0)
18
+ return state;
19
+ const nextIndex = direction === "down"
20
+ ? Math.min(state.selectedIndex + 1, listLength - 1)
21
+ : Math.max(state.selectedIndex - 1, 0);
22
+ // Keep selected item visible with some padding
23
+ let nextScroll = state.scrollOffset;
24
+ if (nextIndex < nextScroll) {
25
+ nextScroll = nextIndex;
26
+ }
27
+ else if (nextIndex >= nextScroll + maxVisible) {
28
+ nextScroll = nextIndex - maxVisible + 1;
29
+ }
30
+ nextScroll = Math.max(0, Math.min(nextScroll, listLength - maxVisible));
31
+ return { ...state, selectedIndex: nextIndex, scrollOffset: nextScroll };
32
+ }
33
+ case "jumpTop":
34
+ return { ...state, selectedIndex: 0, scrollOffset: 0 };
35
+ case "jumpBottom": {
36
+ const { listLength, maxVisible } = action;
37
+ const lastIndex = Math.max(0, listLength - 1);
38
+ return {
39
+ ...state,
40
+ selectedIndex: lastIndex,
41
+ scrollOffset: Math.max(0, listLength - maxVisible)
42
+ };
43
+ }
44
+ case "setViewMode":
45
+ return { ...state, viewMode: action.mode };
46
+ case "setSearch":
47
+ return { ...state, searchQuery: action.query, selectedIndex: 0, scrollOffset: 0 };
48
+ case "appendSearch":
49
+ return {
50
+ ...state,
51
+ searchQuery: state.searchQuery + action.char,
52
+ selectedIndex: 0,
53
+ scrollOffset: 0
54
+ };
55
+ case "backspaceSearch":
56
+ return {
57
+ ...state,
58
+ searchQuery: state.searchQuery.slice(0, -1),
59
+ selectedIndex: 0,
60
+ scrollOffset: 0
61
+ };
62
+ case "clearSearch":
63
+ return { ...state, searchQuery: "", viewMode: "list", selectedIndex: 0, scrollOffset: 0 };
64
+ case "toggleSort": {
65
+ const fields = ["port", "pid", "name"];
66
+ const currentIdx = fields.indexOf(state.sortField);
67
+ const nextField = fields[(currentIdx + 1) % fields.length];
68
+ return { ...state, sortField: nextField, selectedIndex: 0, scrollOffset: 0 };
69
+ }
70
+ case "toggleSortOrder":
71
+ return {
72
+ ...state,
73
+ sortOrder: state.sortOrder === "asc" ? "desc" : "asc",
74
+ selectedIndex: 0,
75
+ scrollOffset: 0
76
+ };
77
+ case "reset":
78
+ return { ...state, selectedIndex: 0, scrollOffset: 0, viewMode: "list" };
79
+ default:
80
+ return state;
81
+ }
82
+ }
83
+ export function App({ onExit, initialPort }) {
84
+ const { stdout } = useStdout();
85
+ const width = stdout?.columns || 100;
86
+ const height = stdout?.rows || 24;
87
+ const [processes, setProcesses] = useState([]);
88
+ const [isLoading, setIsLoading] = useState(true);
89
+ const [message, setMessage] = useState(null);
90
+ const [ui, dispatch] = useReducer(uiReducer, {
91
+ selectedIndex: 0,
92
+ scrollOffset: 0,
93
+ viewMode: "list",
94
+ searchQuery: initialPort ? String(initialPort) : "",
95
+ sortField: "port",
96
+ sortOrder: "asc"
97
+ });
98
+ const headerHeight = ui.searchQuery || ui.viewMode === "search" ? 4 : 3;
99
+ const footerHeight = 2;
100
+ const listHeaderHeight = 2;
101
+ const modalHeight = ui.viewMode !== "list" && ui.viewMode !== "search" ? 12 : 0;
102
+ const refresh = useCallback(() => {
103
+ setIsLoading(true);
104
+ const ports = getListeningPorts();
105
+ setProcesses(ports);
106
+ setIsLoading(false);
107
+ }, []);
108
+ useEffect(() => {
109
+ refresh();
110
+ }, [refresh]);
111
+ const showMessage = useCallback((text, type = "info") => {
112
+ setMessage({ text, type });
113
+ setTimeout(() => setMessage(null), 3000);
114
+ }, []);
115
+ const fuse = useMemo(() => new Fuse(processes, {
116
+ keys: ["port", "processName", "command", "user", "cwd"],
117
+ threshold: 0.4,
118
+ ignoreLocation: true,
119
+ findAllMatches: true
120
+ }), [processes]);
121
+ const filteredProcesses = useMemo(() => {
122
+ let result = processes;
123
+ if (ui.searchQuery) {
124
+ // Check if it's a port number search
125
+ const portNum = parseInt(ui.searchQuery, 10);
126
+ if (!isNaN(portNum)) {
127
+ result = processes.filter((p) => String(p.port).includes(ui.searchQuery));
128
+ }
129
+ else {
130
+ result = fuse.search(ui.searchQuery).map((r) => r.item);
131
+ }
132
+ }
133
+ // Sort
134
+ result = [...result].sort((a, b) => {
135
+ let cmp = 0;
136
+ switch (ui.sortField) {
137
+ case "port":
138
+ cmp = a.port - b.port;
139
+ break;
140
+ case "pid":
141
+ cmp = a.pid - b.pid;
142
+ break;
143
+ case "name":
144
+ cmp = a.processName.localeCompare(b.processName);
145
+ break;
146
+ }
147
+ return ui.sortOrder === "asc" ? cmp : -cmp;
148
+ });
149
+ return result;
150
+ }, [processes, ui.searchQuery, ui.sortField, ui.sortOrder, fuse]);
151
+ const baseMaxVisible = Math.max(1, height - headerHeight - footerHeight - listHeaderHeight - modalHeight);
152
+ const showScrollIndicators = filteredProcesses.length > baseMaxVisible;
153
+ const maxVisible = Math.max(1, baseMaxVisible - (showScrollIndicators ? 2 : 0));
154
+ const selectedProcess = filteredProcesses[ui.selectedIndex];
155
+ const selectedDetails = useMemo(() => {
156
+ if (ui.viewMode !== "details" || !selectedProcess)
157
+ return "";
158
+ return getProcessDetails(selectedProcess.pid);
159
+ }, [ui.viewMode, selectedProcess?.pid]);
160
+ const handleKill = useCallback((force) => {
161
+ if (!selectedProcess)
162
+ return;
163
+ dispatch({ type: "setViewMode", mode: "list" });
164
+ const result = killProcess(selectedProcess.pid, force);
165
+ if (result.success) {
166
+ showMessage(`Killed process ${selectedProcess.pid} (port ${selectedProcess.port})`, "success");
167
+ setTimeout(refresh, 500);
168
+ }
169
+ else {
170
+ showMessage(result.error || "Failed to kill process", "error");
171
+ }
172
+ }, [selectedProcess, refresh, showMessage]);
173
+ useInput((input, key) => {
174
+ // Search mode input handling
175
+ if (ui.viewMode === "search") {
176
+ if (key.escape) {
177
+ if (ui.searchQuery) {
178
+ dispatch({ type: "clearSearch" });
179
+ }
180
+ else {
181
+ dispatch({ type: "setViewMode", mode: "list" });
182
+ }
183
+ }
184
+ else if (key.return) {
185
+ dispatch({ type: "setViewMode", mode: "list" });
186
+ }
187
+ else if (key.backspace || key.delete) {
188
+ dispatch({ type: "backspaceSearch" });
189
+ }
190
+ else if (input && input.length === 1 && !key.ctrl && !key.meta) {
191
+ dispatch({ type: "appendSearch", char: input });
192
+ }
193
+ return;
194
+ }
195
+ // Modal open - ignore other inputs
196
+ if (ui.viewMode === "kill" || ui.viewMode === "help" || ui.viewMode === "details")
197
+ return;
198
+ // Normal mode
199
+ if (input === "q" || (input === "c" && key.ctrl)) {
200
+ onExit();
201
+ return;
202
+ }
203
+ if (key.escape) {
204
+ if (ui.searchQuery) {
205
+ dispatch({ type: "clearSearch" });
206
+ }
207
+ return;
208
+ }
209
+ if (input === "/" || input === "f") {
210
+ dispatch({ type: "setViewMode", mode: "search" });
211
+ return;
212
+ }
213
+ if (input === "j" || key.downArrow) {
214
+ dispatch({
215
+ type: "navigate",
216
+ direction: "down",
217
+ listLength: filteredProcesses.length,
218
+ maxVisible
219
+ });
220
+ }
221
+ else if (input === "k" || key.upArrow) {
222
+ dispatch({
223
+ type: "navigate",
224
+ direction: "up",
225
+ listLength: filteredProcesses.length,
226
+ maxVisible
227
+ });
228
+ }
229
+ else if (input === "g") {
230
+ dispatch({ type: "jumpTop" });
231
+ }
232
+ else if (input === "G") {
233
+ dispatch({ type: "jumpBottom", listLength: filteredProcesses.length, maxVisible });
234
+ }
235
+ else if (key.return) {
236
+ if (selectedProcess) {
237
+ dispatch({ type: "setViewMode", mode: "kill" });
238
+ }
239
+ }
240
+ else if (input === "K") {
241
+ // Quick force kill
242
+ if (selectedProcess) {
243
+ const result = killProcess(selectedProcess.pid, true);
244
+ if (result.success) {
245
+ showMessage(`Force killed ${selectedProcess.pid} (port ${selectedProcess.port})`, "success");
246
+ setTimeout(refresh, 500);
247
+ }
248
+ else {
249
+ showMessage(result.error || "Failed to kill", "error");
250
+ }
251
+ }
252
+ }
253
+ else if (input === "r") {
254
+ refresh();
255
+ showMessage("Refreshed", "success");
256
+ }
257
+ else if (input === "s") {
258
+ dispatch({ type: "toggleSort" });
259
+ }
260
+ else if (input === "o") {
261
+ dispatch({ type: "toggleSortOrder" });
262
+ }
263
+ else if (input === "?") {
264
+ dispatch({ type: "setViewMode", mode: "help" });
265
+ }
266
+ else if (input === "i") {
267
+ if (selectedProcess) {
268
+ dispatch({ type: "setViewMode", mode: "details" });
269
+ }
270
+ }
271
+ });
272
+ if (isLoading && processes.length === 0) {
273
+ return (_jsx(Box, { padding: 2, children: _jsx(Text, { color: "#60a5fa", children: "Scanning ports..." }) }));
274
+ }
275
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Header, { totalPorts: filteredProcesses.length, filterQuery: ui.searchQuery }), ui.viewMode === "search" && _jsx(SearchInput, { value: ui.searchQuery }), _jsxs(Box, { flexGrow: 1, flexDirection: "column", marginTop: 1, children: [(ui.viewMode === "list" || ui.viewMode === "search") && (_jsx(ProcessList, { processes: filteredProcesses, selectedIndex: ui.selectedIndex, scrollOffset: ui.scrollOffset, maxVisible: maxVisible, showScrollIndicators: showScrollIndicators })), ui.viewMode === "kill" && selectedProcess && (_jsx(Box, { paddingX: 1, children: _jsx(KillConfirm, { process: selectedProcess, onConfirm: handleKill, onCancel: () => dispatch({ type: "setViewMode", mode: "list" }) }) })), ui.viewMode === "help" && (_jsx(Box, { paddingX: 1, children: _jsx(HelpModal, { onClose: () => dispatch({ type: "setViewMode", mode: "list" }) }) })), ui.viewMode === "details" && selectedProcess && (_jsx(Box, { paddingX: 1, children: _jsx(ProcessDetails, { process: selectedProcess, details: selectedDetails, onClose: () => dispatch({ type: "setViewMode", mode: "list" }) }) }))] }), _jsx(StatusBar, { message: message?.text, messageType: message?.type, sortField: ui.sortField, sortOrder: ui.sortOrder })] }));
276
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "./index.js";
3
+ // Parse CLI args for quick port lookup
4
+ const args = process.argv.slice(2);
5
+ let initialPort;
6
+ if (args.length > 0) {
7
+ const portArg = parseInt(args[0], 10);
8
+ if (!isNaN(portArg)) {
9
+ initialPort = portArg;
10
+ }
11
+ }
12
+ run(initialPort);
@@ -0,0 +1,6 @@
1
+ interface Props {
2
+ totalPorts: number;
3
+ filterQuery: string;
4
+ }
5
+ export declare function Header({ totalPorts, filterQuery }: Props): import("react/jsx-runtime").JSX.Element;
6
+ export {};
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ export function Header({ totalPorts, filterQuery }) {
4
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderBottom: true, borderTop: false, borderLeft: false, borderRight: false, borderColor: "#3b3b3b", paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { color: "#f87171", bold: true, children: "Port Patrol" }), _jsx(Text, { color: "#6b7280", children: " - Who's using my ports?" })] }), _jsxs(Text, { color: "#6b7280", children: [totalPorts, " listening ", totalPorts === 1 ? "port" : "ports"] })] }), filterQuery && (_jsxs(Box, { children: [_jsx(Text, { color: "#6b7280", children: "Filter: " }), _jsxs(Text, { color: "#fbbf24", children: ["\"", filterQuery, "\""] })] }))] }));
5
+ }
@@ -0,0 +1,5 @@
1
+ interface Props {
2
+ onClose: () => void;
3
+ }
4
+ export declare function HelpModal({ onClose }: Props): import("react/jsx-runtime").JSX.Element;
5
+ export {};
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from "ink";
3
+ const KEYBINDINGS = [
4
+ { key: "j / ↓", action: "Move down" },
5
+ { key: "k / ↑", action: "Move up" },
6
+ { key: "g", action: "Jump to top" },
7
+ { key: "G", action: "Jump to bottom" },
8
+ { key: "/ or f", action: "Search/filter" },
9
+ { key: "Enter", action: "Kill selected process" },
10
+ { key: "i", action: "Show process details" },
11
+ { key: "K", action: "Force kill (SIGKILL)" },
12
+ { key: "r", action: "Refresh list" },
13
+ { key: "s", action: "Toggle sort (port/pid/name)" },
14
+ { key: "o", action: "Toggle sort order" },
15
+ { key: "Esc", action: "Clear search / Close modal" },
16
+ { key: "?", action: "Show this help" },
17
+ { key: "q", action: "Quit" }
18
+ ];
19
+ export function HelpModal({ onClose }) {
20
+ useInput((input, key) => {
21
+ if (key.escape || input === "?" || input === "q") {
22
+ onClose();
23
+ }
24
+ });
25
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "#60a5fa", paddingX: 2, paddingY: 1, children: [_jsx(Text, { color: "#60a5fa", bold: true, children: "Keyboard Shortcuts" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: KEYBINDINGS.map(({ key, action }) => (_jsxs(Box, { children: [_jsx(Box, { width: 14, children: _jsx(Text, { color: "#fbbf24", children: key }) }), _jsx(Text, { color: "#9ca3af", children: action })] }, key))) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "#6b7280", dimColor: true, children: ["Port colors: ", _jsx(Text, { color: "#f87171", children: "system (<1024)" }), " ", _jsx(Text, { color: "#fbbf24", children: "registered (<10000)" }), " ", _jsx(Text, { color: "#22c55e", children: "dynamic (10000+)" })] }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "#6b7280", dimColor: true, children: "Press ? or Esc to close" }) })] }));
26
+ }
@@ -0,0 +1,8 @@
1
+ import type { PortProcess } from "../types/index.js";
2
+ interface Props {
3
+ process: PortProcess;
4
+ onConfirm: (force: boolean) => void;
5
+ onCancel: () => void;
6
+ }
7
+ export declare function KillConfirm({ process, onConfirm, onCancel }: Props): import("react/jsx-runtime").JSX.Element;
8
+ export {};
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ export function KillConfirm({ process, onConfirm, onCancel }) {
5
+ const [forceMode, setForceMode] = useState(false);
6
+ useInput((input, key) => {
7
+ if (key.escape || input === "n") {
8
+ onCancel();
9
+ return;
10
+ }
11
+ if (input === "y" || key.return) {
12
+ onConfirm(forceMode);
13
+ }
14
+ if (input === "f") {
15
+ setForceMode((v) => !v);
16
+ }
17
+ });
18
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "#f87171", paddingX: 2, paddingY: 1, children: [_jsx(Text, { color: "#f87171", bold: true, children: "Kill Process" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", paddingLeft: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: "#6b7280", children: "Port: " }), _jsx(Text, { color: "#fbbf24", children: process.port })] }), _jsxs(Text, { children: [_jsx(Text, { color: "#6b7280", children: "PID: " }), _jsx(Text, { color: "#60a5fa", children: process.pid })] }), _jsxs(Text, { children: [_jsx(Text, { color: "#6b7280", children: "Process: " }), _jsx(Text, { children: process.processName })] }), process.command && (_jsxs(Text, { children: [_jsx(Text, { color: "#6b7280", children: "Command: " }), _jsx(Text, { dimColor: true, children: process.command.slice(0, 60) })] }))] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "#6b7280", children: "Force kill (SIGKILL): " }), _jsx(Text, { color: forceMode ? "#f87171" : "#22c55e", children: forceMode ? "YES" : "no" }), _jsx(Text, { color: "#6b7280", children: " (press f to toggle)" })] }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "#6b7280", dimColor: true, children: "y/Enter confirm | n/Esc cancel | f toggle force" }) })] }));
19
+ }
@@ -0,0 +1,8 @@
1
+ import type { PortProcess } from "../types/index.js";
2
+ interface Props {
3
+ process: PortProcess;
4
+ details: string;
5
+ onClose: () => void;
6
+ }
7
+ export declare function ProcessDetails({ process, details, onClose }: Props): import("react/jsx-runtime").JSX.Element;
8
+ export {};
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from "ink";
3
+ export function ProcessDetails({ process, details, onClose }) {
4
+ useInput((input, key) => {
5
+ if (key.escape || input === "i" || input === "q") {
6
+ onClose();
7
+ }
8
+ });
9
+ const detailLines = details ? details.split("\n") : [];
10
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "#22c55e", paddingX: 2, paddingY: 1, children: [_jsx(Text, { color: "#22c55e", bold: true, children: "Process Details" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", paddingLeft: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: "#6b7280", children: "Port: " }), _jsx(Text, { color: "#fbbf24", children: process.port }), _jsxs(Text, { color: "#6b7280", children: [" (", process.protocol, ")"] })] }), _jsxs(Text, { children: [_jsx(Text, { color: "#6b7280", children: "PID: " }), _jsx(Text, { color: "#60a5fa", children: process.pid })] }), _jsxs(Text, { children: [_jsx(Text, { color: "#6b7280", children: "Process: " }), _jsx(Text, { children: process.processName })] }), process.user && (_jsxs(Text, { children: [_jsx(Text, { color: "#6b7280", children: "User: " }), _jsx(Text, { children: process.user })] })), _jsxs(Text, { children: [_jsx(Text, { color: "#6b7280", children: "Address: " }), _jsx(Text, { children: process.address })] }), process.command && (_jsxs(Text, { children: [_jsx(Text, { color: "#6b7280", children: "Command: " }), _jsx(Text, { dimColor: true, children: process.command })] })), process.cwd && (_jsxs(Text, { children: [_jsx(Text, { color: "#6b7280", children: "CWD: " }), _jsx(Text, { dimColor: true, children: process.cwd })] }))] }), detailLines.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "#6b7280", children: "ps output:" }), _jsx(Box, { marginTop: 1, flexDirection: "column", paddingLeft: 1, children: detailLines.map((line, idx) => (_jsx(Text, { dimColor: true, children: line }, `${process.pid}:${idx}`))) })] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "#6b7280", dimColor: true, children: "Press i or Esc to close" }) })] }));
11
+ }
@@ -0,0 +1,10 @@
1
+ import type { PortProcess } from "../types/index.js";
2
+ interface Props {
3
+ processes: PortProcess[];
4
+ selectedIndex: number;
5
+ scrollOffset: number;
6
+ maxVisible: number;
7
+ showScrollIndicators: boolean;
8
+ }
9
+ export declare function ProcessList({ processes, selectedIndex, scrollOffset, maxVisible, showScrollIndicators }: Props): import("react/jsx-runtime").JSX.Element;
10
+ export {};
@@ -0,0 +1,37 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ function truncate(str, len) {
4
+ if (str.length <= len)
5
+ return str;
6
+ return str.slice(0, len - 1) + "…";
7
+ }
8
+ function formatCwd(cwd) {
9
+ const home = process.env.HOME || "";
10
+ if (cwd.startsWith(home)) {
11
+ return "~" + cwd.slice(home.length);
12
+ }
13
+ return cwd;
14
+ }
15
+ function getDisplayCommand(proc) {
16
+ // For node/interpreters, show cwd (project path) if available
17
+ if (proc.cwd) {
18
+ return formatCwd(proc.cwd);
19
+ }
20
+ // Otherwise show the command
21
+ return proc.command || proc.processName;
22
+ }
23
+ function ProcessRow({ proc, isSelected }) {
24
+ const portColor = proc.port < 1024 ? "#f87171" : proc.port < 10000 ? "#fbbf24" : "#22c55e";
25
+ const displayCmd = getDisplayCommand(proc);
26
+ const colGap = 1;
27
+ return (_jsxs(Box, { paddingX: 1, children: [_jsx(Box, { width: 2, marginRight: colGap, children: _jsx(Text, { color: isSelected ? "#fbbf24" : "#6b7280", children: isSelected ? "▸" : " " }) }), _jsx(Box, { width: 7, marginRight: colGap, children: _jsx(Text, { color: portColor, bold: isSelected, children: proc.port }) }), _jsx(Box, { width: 5, marginRight: colGap, children: _jsx(Text, { color: "#6b7280", children: proc.protocol }) }), _jsx(Box, { width: 8, marginRight: colGap, children: _jsx(Text, { color: isSelected ? "#9ca3af" : "#6b7280", children: proc.pid }) }), _jsx(Box, { width: 20, marginRight: colGap, children: _jsx(Text, { color: "#60a5fa", bold: isSelected, children: truncate(proc.processName, 19) }) }), _jsx(Box, { width: 9, marginRight: colGap, children: _jsx(Text, { color: "#6b7280", children: truncate(proc.user || "-", 8) }) }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { color: isSelected ? "#22c55e" : "#6b7280", dimColor: !isSelected, children: proc.cwd ? truncate(displayCmd, 60) : truncate(displayCmd, 50) }) })] }));
28
+ }
29
+ export function ProcessList({ processes, selectedIndex, scrollOffset, maxVisible, showScrollIndicators }) {
30
+ const colGap = 1;
31
+ const visible = processes.slice(scrollOffset, scrollOffset + maxVisible);
32
+ const showScrollUp = scrollOffset > 0;
33
+ const showScrollDown = scrollOffset + maxVisible < processes.length;
34
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { paddingX: 1, marginBottom: 1, children: [_jsx(Box, { width: 2, marginRight: colGap, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: 7, marginRight: colGap, children: _jsx(Text, { color: "#6b7280", bold: true, children: "PORT" }) }), _jsx(Box, { width: 5, marginRight: colGap, children: _jsx(Text, { color: "#6b7280", bold: true, children: "PROTO" }) }), _jsx(Box, { width: 8, marginRight: colGap, children: _jsx(Text, { color: "#6b7280", bold: true, children: "PID" }) }), _jsx(Box, { width: 20, marginRight: colGap, children: _jsx(Text, { color: "#6b7280", bold: true, children: "NAME" }) }), _jsx(Box, { width: 9, marginRight: colGap, children: _jsx(Text, { color: "#6b7280", bold: true, children: "USER" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { color: "#6b7280", bold: true, children: "COMMAND" }) })] }), showScrollIndicators && (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: "#6b7280", children: showScrollUp ? `↑ ${scrollOffset} more above` : " " }) })), visible.map((proc, i) => (_jsx(ProcessRow, { proc: proc, isSelected: scrollOffset + i === selectedIndex }, `${proc.pid}:${proc.port}:${proc.protocol}`))), showScrollIndicators && (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: "#6b7280", children: showScrollDown
35
+ ? `↓ ${processes.length - scrollOffset - maxVisible} more below`
36
+ : " " }) })), processes.length === 0 && (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: "#6b7280", children: "No listening ports found" }) }))] }));
37
+ }
@@ -0,0 +1,6 @@
1
+ interface Props {
2
+ value: string;
3
+ placeholder?: string;
4
+ }
5
+ export declare function SearchInput({ value, placeholder }: Props): import("react/jsx-runtime").JSX.Element;
6
+ export {};
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ export function SearchInput({ value, placeholder = "Search by port or process name..." }) {
4
+ return (_jsxs(Box, { paddingX: 1, borderStyle: "single", borderBottom: true, borderTop: false, borderLeft: false, borderRight: false, borderColor: "#3b3b3b", children: [_jsx(Text, { color: "#60a5fa", children: "/" }), _jsx(Text, { color: "#6b7280", children: " " }), value ? (_jsx(Text, { color: "#e5e7eb", children: value })) : (_jsx(Text, { color: "#6b7280", dimColor: true, children: placeholder })), _jsx(Text, { color: "#60a5fa", children: "\u258B" })] }));
5
+ }
@@ -0,0 +1,9 @@
1
+ import type { SortField, SortOrder } from "../types/index.js";
2
+ interface Props {
3
+ message?: string | null;
4
+ messageType?: "info" | "success" | "error";
5
+ sortField: SortField;
6
+ sortOrder: SortOrder;
7
+ }
8
+ export declare function StatusBar({ message, messageType, sortField, sortOrder }: Props): import("react/jsx-runtime").JSX.Element;
9
+ export {};
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ export function StatusBar({ message, messageType = "info", sortField, sortOrder }) {
4
+ const messageColor = messageType === "success" ? "#22c55e" : messageType === "error" ? "#f87171" : "#60a5fa";
5
+ const sortLabel = `${sortField} ${sortOrder === "asc" ? "↑" : "↓"}`;
6
+ return (_jsxs(Box, { borderStyle: "single", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, borderColor: "#3b3b3b", paddingX: 1, justifyContent: "space-between", children: [_jsx(Text, { color: "#6b7280", children: "j/k: nav | /: search | Enter: kill | i: details | r: refresh | s: sort | ?: help | q: quit" }), _jsx(Box, { children: message ? (_jsx(Text, { color: messageColor, children: message })) : (_jsxs(Text, { color: "#6b7280", children: ["sort: ", sortLabel] })) })] }));
7
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function run(initialPort?: number): void;
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { render } from "ink";
4
+ import { App } from "./App.js";
5
+ export function run(initialPort) {
6
+ const { unmount, waitUntilExit } = render(_jsx(App, { onExit: () => {
7
+ unmount();
8
+ process.exit(0);
9
+ }, initialPort: initialPort }));
10
+ waitUntilExit().catch(() => { });
11
+ }
@@ -0,0 +1,14 @@
1
+ export interface PortProcess {
2
+ pid: number;
3
+ port: number;
4
+ protocol: "TCP" | "UDP" | "BOTH";
5
+ state: string;
6
+ address: string;
7
+ processName: string;
8
+ command?: string;
9
+ user?: string;
10
+ cwd?: string;
11
+ }
12
+ export type ViewMode = "list" | "kill" | "help" | "search" | "details";
13
+ export type SortField = "port" | "pid" | "name";
14
+ export type SortOrder = "asc" | "desc";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ import type { PortProcess } from "../types/index.js";
2
+ export declare function getListeningPorts(): PortProcess[];
3
+ export declare function killProcess(pid: number, force?: boolean): {
4
+ success: boolean;
5
+ error?: string;
6
+ };
7
+ export declare function findProcessByPort(port: number): PortProcess | undefined;
8
+ export declare function getProcessDetails(pid: number): string;
@@ -0,0 +1,207 @@
1
+ import { execSync } from "child_process";
2
+ function run(cmd) {
3
+ try {
4
+ return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
5
+ }
6
+ catch {
7
+ return "";
8
+ }
9
+ }
10
+ function getProcessCwd(pid) {
11
+ // macOS: use lsof to get cwd
12
+ const output = run(`lsof -p ${pid} 2>/dev/null | grep cwd`);
13
+ if (output) {
14
+ const parts = output.split(/\s+/);
15
+ // cwd line format: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
16
+ const cwdPath = parts.slice(8).join(" ");
17
+ if (cwdPath && cwdPath !== "/") {
18
+ return cwdPath;
19
+ }
20
+ }
21
+ return undefined;
22
+ }
23
+ function normalizeProcessName(name) {
24
+ return name.replace(/\\x20/g, " ");
25
+ }
26
+ export function getListeningPorts() {
27
+ const platform = process.platform;
28
+ if (platform === "darwin" || platform === "linux") {
29
+ return getPortsUnix();
30
+ }
31
+ else if (platform === "win32") {
32
+ return getPortsWindows();
33
+ }
34
+ return [];
35
+ }
36
+ function getPortsUnix() {
37
+ const output = run("lsof -i -P -n -sTCP:LISTEN +c 0 2>/dev/null");
38
+ if (!output)
39
+ return [];
40
+ const lines = output.split("\n").slice(1); // Skip header
41
+ const processes = [];
42
+ const seen = new Set();
43
+ for (const line of lines) {
44
+ const parts = line.split(/\s+/);
45
+ if (parts.length < 9)
46
+ continue;
47
+ const [rawProcessName, pidStr, user, , , , , , addressPort] = parts;
48
+ const processName = normalizeProcessName(rawProcessName);
49
+ const pid = parseInt(pidStr, 10);
50
+ // Parse address:port
51
+ const lastColon = addressPort.lastIndexOf(":");
52
+ if (lastColon === -1)
53
+ continue;
54
+ const address = addressPort.slice(0, lastColon);
55
+ const port = parseInt(addressPort.slice(lastColon + 1), 10);
56
+ if (isNaN(port))
57
+ continue;
58
+ const key = `${pid}:${port}`;
59
+ if (seen.has(key))
60
+ continue;
61
+ seen.add(key);
62
+ // Get full command
63
+ const command = run(`ps -p ${pid} -o command= 2>/dev/null`);
64
+ // Get cwd for interpreters (node, python, ruby, etc.) to show project path
65
+ let cwd;
66
+ const interpreters = ["node", "python", "ruby", "php", "java", "deno", "bun"];
67
+ if (interpreters.some((i) => processName.toLowerCase().includes(i))) {
68
+ cwd = getProcessCwd(pid);
69
+ }
70
+ processes.push({
71
+ pid,
72
+ port,
73
+ protocol: "TCP",
74
+ state: "LISTEN",
75
+ address: address === "*" ? "0.0.0.0" : address,
76
+ processName,
77
+ command: command || processName,
78
+ user,
79
+ cwd
80
+ });
81
+ }
82
+ // Also get UDP
83
+ const udpOutput = run("lsof -i UDP -P -n +c 0 2>/dev/null");
84
+ if (udpOutput) {
85
+ const udpLines = udpOutput.split("\n").slice(1);
86
+ for (const line of udpLines) {
87
+ const parts = line.split(/\s+/);
88
+ if (parts.length < 9)
89
+ continue;
90
+ const [rawProcessName, pidStr, user, , , , , , addressPort] = parts;
91
+ const processName = normalizeProcessName(rawProcessName);
92
+ const pid = parseInt(pidStr, 10);
93
+ const lastColon = addressPort.lastIndexOf(":");
94
+ if (lastColon === -1)
95
+ continue;
96
+ const address = addressPort.slice(0, lastColon);
97
+ const port = parseInt(addressPort.slice(lastColon + 1), 10);
98
+ if (isNaN(port))
99
+ continue;
100
+ const key = `${pid}:${port}:udp`;
101
+ if (seen.has(key))
102
+ continue;
103
+ seen.add(key);
104
+ const command = run(`ps -p ${pid} -o command= 2>/dev/null`).slice(0, 100);
105
+ processes.push({
106
+ pid,
107
+ port,
108
+ protocol: "UDP",
109
+ state: "OPEN",
110
+ address: address === "*" ? "0.0.0.0" : address,
111
+ processName,
112
+ command: command || processName,
113
+ user
114
+ });
115
+ }
116
+ }
117
+ // Merge TCP and UDP entries for same port+pid
118
+ const merged = new Map();
119
+ for (const proc of processes) {
120
+ const key = `${proc.port}:${proc.pid}`;
121
+ const existing = merged.get(key);
122
+ if (existing) {
123
+ if (existing.protocol !== proc.protocol) {
124
+ existing.protocol = "BOTH";
125
+ }
126
+ }
127
+ else {
128
+ merged.set(key, { ...proc });
129
+ }
130
+ }
131
+ return Array.from(merged.values()).sort((a, b) => a.port - b.port);
132
+ }
133
+ function getPortsWindows() {
134
+ const output = run("netstat -ano -p TCP");
135
+ if (!output)
136
+ return [];
137
+ const lines = output.split("\n").slice(4); // Skip headers
138
+ const processes = [];
139
+ const seen = new Set();
140
+ for (const line of lines) {
141
+ const parts = line.trim().split(/\s+/);
142
+ if (parts.length < 5)
143
+ continue;
144
+ const [, localAddress, , state, pidStr] = parts;
145
+ if (state !== "LISTENING")
146
+ continue;
147
+ const lastColon = localAddress.lastIndexOf(":");
148
+ if (lastColon === -1)
149
+ continue;
150
+ const address = localAddress.slice(0, lastColon);
151
+ const port = parseInt(localAddress.slice(lastColon + 1), 10);
152
+ const pid = parseInt(pidStr, 10);
153
+ if (isNaN(port) || isNaN(pid))
154
+ continue;
155
+ const key = `${pid}:${port}`;
156
+ if (seen.has(key))
157
+ continue;
158
+ seen.add(key);
159
+ // Get process name
160
+ const tasklistOutput = run(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`);
161
+ const processName = tasklistOutput.split(",")[0]?.replace(/"/g, "") || "unknown";
162
+ processes.push({
163
+ pid,
164
+ port,
165
+ protocol: "TCP",
166
+ state: "LISTENING",
167
+ address,
168
+ processName,
169
+ command: processName
170
+ });
171
+ }
172
+ return processes.sort((a, b) => a.port - b.port);
173
+ }
174
+ export function killProcess(pid, force = false) {
175
+ try {
176
+ const platform = process.platform;
177
+ let cmd;
178
+ if (platform === "win32") {
179
+ cmd = force ? `taskkill /PID ${pid} /F` : `taskkill /PID ${pid}`;
180
+ }
181
+ else {
182
+ cmd = force ? `kill -9 ${pid}` : `kill ${pid}`;
183
+ }
184
+ execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
185
+ return { success: true };
186
+ }
187
+ catch (err) {
188
+ const error = err;
189
+ return { success: false, error: error.message };
190
+ }
191
+ }
192
+ export function findProcessByPort(port) {
193
+ const processes = getListeningPorts();
194
+ return processes.find((p) => p.port === port);
195
+ }
196
+ export function getProcessDetails(pid) {
197
+ const platform = process.platform;
198
+ if (platform === "darwin" || platform === "linux") {
199
+ const info = run(`ps -p ${pid} -o pid,ppid,user,%cpu,%mem,etime,command 2>/dev/null`);
200
+ return info || "Process not found";
201
+ }
202
+ else if (platform === "win32") {
203
+ const info = run(`tasklist /FI "PID eq ${pid}" /V /FO LIST`);
204
+ return info || "Process not found";
205
+ }
206
+ return "Unsupported platform";
207
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "port-patrol",
3
+ "version": "1.0.0",
4
+ "description": "TUI to inspect listening ports and terminate the owning process.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "mifwar",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/mifwar/port-patrol.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/mifwar/port-patrol/issues"
14
+ },
15
+ "homepage": "https://github.com/mifwar/port-patrol#readme",
16
+ "keywords": [
17
+ "tui",
18
+ "terminal",
19
+ "ports",
20
+ "network",
21
+ "process",
22
+ "cli",
23
+ "ink"
24
+ ],
25
+ "bin": {
26
+ "port-patrol": "dist/cli.js",
27
+ "pp": "dist/cli.js"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md"
32
+ ],
33
+ "scripts": {
34
+ "dev": "npx tsx src/cli.ts",
35
+ "build": "tsc -p tsconfig.build.json",
36
+ "postbuild": "chmod +x dist/cli.js",
37
+ "prepublishOnly": "npm run build",
38
+ "format": "prettier . --write",
39
+ "format:check": "prettier . --check"
40
+ },
41
+ "dependencies": {
42
+ "fuse.js": "^7.0.0",
43
+ "ink": "^5.1.0",
44
+ "react": "^18.3.1"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22.0.0",
48
+ "@types/react": "^18.3.0",
49
+ "prettier": "^3.3.3",
50
+ "tsx": "^4.19.0",
51
+ "typescript": "^5.7.0"
52
+ },
53
+ "engines": {
54
+ "node": ">=18.0.0"
55
+ }
56
+ }