slash-port 0.1.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.
@@ -0,0 +1,239 @@
1
+ import { readFile, readdir, readlink } from 'node:fs/promises';
2
+ import { mapConcurrent, normaliseAddress } from './shared.js';
3
+ /** TCP_LISTEN as `/proc/net/tcp` writes it. */
4
+ export const TCP_LISTEN = '0A';
5
+ /**
6
+ * Decode a `/proc/net` address literal.
7
+ *
8
+ * The kernel writes each 32-bit word in host byte order, which on every
9
+ * platform this runs on is little-endian, so `0100007F` is 127.0.0.1 and not
10
+ * 1.0.0.127. IPv6 is four such words, each independently byte-swapped.
11
+ */
12
+ export function decodeAddress(hex) {
13
+ if (hex.length === 8) {
14
+ const bytes = hexBytes(hex).reverse();
15
+ return { address: bytes.join('.'), family: 4 };
16
+ }
17
+ if (hex.length === 32) {
18
+ const bytes = [];
19
+ for (let word = 0; word < 4; word += 1) {
20
+ bytes.push(...hexBytes(hex.slice(word * 8, word * 8 + 8)).reverse());
21
+ }
22
+ return { address: formatIpv6(bytes), family: 6 };
23
+ }
24
+ throw new Error(`unrecognised /proc address literal: ${hex}`);
25
+ }
26
+ function hexBytes(hex) {
27
+ const bytes = [];
28
+ for (let index = 0; index < hex.length; index += 2) {
29
+ bytes.push(Number.parseInt(hex.slice(index, index + 2), 16));
30
+ }
31
+ return bytes;
32
+ }
33
+ function formatIpv6(bytes) {
34
+ // IPv4-mapped addresses read far better in their dotted form.
35
+ const mapped = bytes.slice(0, 10).every((byte) => byte === 0) && bytes[10] === 0xff && bytes[11] === 0xff;
36
+ if (mapped) {
37
+ return `::ffff:${bytes.slice(12).join('.')}`;
38
+ }
39
+ const groups = [];
40
+ for (let index = 0; index < 16; index += 2) {
41
+ groups.push((bytes[index] << 8) | bytes[index + 1]);
42
+ }
43
+ // Compress the longest run of two or more zero groups, per RFC 5952.
44
+ let bestStart = -1;
45
+ let bestLength = 0;
46
+ let runStart = -1;
47
+ for (let index = 0; index <= groups.length; index += 1) {
48
+ if (index < groups.length && groups[index] === 0) {
49
+ if (runStart === -1)
50
+ runStart = index;
51
+ }
52
+ else if (runStart !== -1) {
53
+ const length = index - runStart;
54
+ if (length > bestLength && length > 1) {
55
+ bestStart = runStart;
56
+ bestLength = length;
57
+ }
58
+ runStart = -1;
59
+ }
60
+ }
61
+ const parts = groups.map((group) => group.toString(16));
62
+ if (bestStart === -1)
63
+ return parts.join(':');
64
+ const head = parts.slice(0, bestStart).join(':');
65
+ const tail = parts.slice(bestStart + bestLength).join(':');
66
+ return `${head}::${tail}`;
67
+ }
68
+ /**
69
+ * Parse the whole of a `/proc/net` socket table, listening or not. Split from
70
+ * file reading so it can be tested against a fixture instead of whatever the
71
+ * host happens to have open.
72
+ */
73
+ export function parseProcNetRows(content) {
74
+ const rows = [];
75
+ for (const line of content.split('\n')) {
76
+ const fields = line.trim().split(/\s+/);
77
+ // sl local rem st tx:rx tr:when retrnsmt uid timeout inode
78
+ if (fields.length < 10)
79
+ continue;
80
+ if (!/^\d+:$/.test(fields[0]))
81
+ continue;
82
+ const local = fields[1].split(':');
83
+ const remote = fields[2].split(':');
84
+ if (local.length !== 2 || remote.length !== 2)
85
+ continue;
86
+ let decoded;
87
+ let decodedRemote;
88
+ try {
89
+ decoded = decodeAddress(local[0]);
90
+ decodedRemote = decodeAddress(remote[0]);
91
+ }
92
+ catch {
93
+ continue;
94
+ }
95
+ rows.push({
96
+ address: decoded.address,
97
+ port: Number.parseInt(local[1], 16),
98
+ family: decoded.family,
99
+ remoteAddress: decodedRemote.address,
100
+ remotePort: Number.parseInt(remote[1], 16),
101
+ state: fields[3].toUpperCase(),
102
+ uid: Number.parseInt(fields[7], 10),
103
+ inode: Number.parseInt(fields[9], 10),
104
+ });
105
+ }
106
+ return rows;
107
+ }
108
+ /**
109
+ * The rows worth showing: TCP sockets in LISTEN, and UDP sockets with no peer,
110
+ * which is as close as UDP gets to the idea of listening.
111
+ */
112
+ export function parseProcNet(content, protocol) {
113
+ const rows = parseProcNetRows(content);
114
+ if (protocol === 'tcp') {
115
+ return rows.filter((row) => row.state === TCP_LISTEN);
116
+ }
117
+ return rows.filter((row) => row.remotePort === 0);
118
+ }
119
+ async function readTable(path, protocol) {
120
+ try {
121
+ return parseProcNet(await readFile(path, 'utf8'), protocol);
122
+ }
123
+ catch {
124
+ // tcp6/udp6 are absent on kernels built without IPv6. That is not an error.
125
+ return [];
126
+ }
127
+ }
128
+ /** uid → user name, read straight from `/etc/passwd` so there is no dependency. */
129
+ async function readPasswd() {
130
+ const users = new Map();
131
+ try {
132
+ const content = await readFile('/etc/passwd', 'utf8');
133
+ for (const line of content.split('\n')) {
134
+ const fields = line.split(':');
135
+ if (fields.length < 3)
136
+ continue;
137
+ const uid = Number.parseInt(fields[2], 10);
138
+ if (Number.isInteger(uid) && fields[0])
139
+ users.set(uid, fields[0]);
140
+ }
141
+ }
142
+ catch {
143
+ // Unreadable /etc/passwd only costs us the names; uids still display.
144
+ }
145
+ return users;
146
+ }
147
+ async function listProcessIds() {
148
+ const entries = await readdir('/proc');
149
+ const pids = [];
150
+ for (const entry of entries) {
151
+ if (/^\d+$/.test(entry))
152
+ pids.push(Number.parseInt(entry, 10));
153
+ }
154
+ return pids;
155
+ }
156
+ /**
157
+ * Map socket inodes to the pids holding them by walking `/proc/[pid]/fd`.
158
+ *
159
+ * Descriptors belonging to other users are not readable without privileges, so
160
+ * those inodes stay unmapped and the row reports a null pid rather than
161
+ * failing the whole scan.
162
+ */
163
+ async function mapInodesToPids(wanted) {
164
+ const owners = new Map();
165
+ const pids = await listProcessIds();
166
+ await mapConcurrent(pids, 64, async (pid) => {
167
+ let descriptors;
168
+ try {
169
+ descriptors = await readdir(`/proc/${pid}/fd`);
170
+ }
171
+ catch {
172
+ return;
173
+ }
174
+ for (const descriptor of descriptors) {
175
+ let target;
176
+ try {
177
+ target = await readlink(`/proc/${pid}/fd/${descriptor}`);
178
+ }
179
+ catch {
180
+ continue;
181
+ }
182
+ const match = /^socket:\[(\d+)\]$/.exec(target);
183
+ if (!match)
184
+ continue;
185
+ const inode = Number.parseInt(match[1], 10);
186
+ if (wanted.has(inode) && !owners.has(inode))
187
+ owners.set(inode, pid);
188
+ }
189
+ });
190
+ return owners;
191
+ }
192
+ async function readProcessInfo(pid) {
193
+ const [name, command] = await Promise.all([
194
+ readFile(`/proc/${pid}/comm`, 'utf8')
195
+ .then((value) => value.trim() || null)
196
+ .catch(() => null),
197
+ readFile(`/proc/${pid}/cmdline`, 'utf8')
198
+ .then((value) => value.replace(/\0+$/, '').split('\0').join(' ').trim() || null)
199
+ .catch(() => null),
200
+ ]);
201
+ return { name, command };
202
+ }
203
+ export async function scanLinux(options = {}) {
204
+ const tables = [
205
+ readTable('/proc/net/tcp', 'tcp'),
206
+ readTable('/proc/net/tcp6', 'tcp'),
207
+ ];
208
+ if (options.udp) {
209
+ tables.push(readTable('/proc/net/udp', 'udp'), readTable('/proc/net/udp6', 'udp'));
210
+ }
211
+ const [tcp4, tcp6, udp4 = [], udp6 = []] = await Promise.all(tables);
212
+ const tagged = [
213
+ ...tcp4.map((row) => ({ row, protocol: 'tcp' })),
214
+ ...tcp6.map((row) => ({ row, protocol: 'tcp' })),
215
+ ...udp4.map((row) => ({ row, protocol: 'udp' })),
216
+ ...udp6.map((row) => ({ row, protocol: 'udp' })),
217
+ ];
218
+ const wanted = new Set(tagged.map(({ row }) => row.inode).filter((inode) => inode > 0));
219
+ const [owners, users] = await Promise.all([mapInodesToPids(wanted), readPasswd()]);
220
+ const uniquePids = [...new Set(owners.values())];
221
+ const details = new Map();
222
+ await mapConcurrent(uniquePids, 64, async (pid) => {
223
+ details.set(pid, await readProcessInfo(pid));
224
+ });
225
+ return tagged.map(({ row, protocol }) => {
226
+ const pid = owners.get(row.inode) ?? null;
227
+ const info = pid === null ? undefined : details.get(pid);
228
+ return {
229
+ protocol,
230
+ family: row.family,
231
+ address: normaliseAddress(row.address),
232
+ port: row.port,
233
+ pid,
234
+ processName: info?.name ?? null,
235
+ command: info?.command ?? null,
236
+ user: users.get(row.uid) ?? String(row.uid),
237
+ };
238
+ });
239
+ }
@@ -0,0 +1,56 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ /** Wildcard bind addresses, shown as `*` the way lsof and netstat do. */
5
+ const WILDCARD = new Set(['0.0.0.0', '::', '*', '[::]', '0000:0000:0000:0000:0000:0000:0000:0000']);
6
+ export function normaliseAddress(address) {
7
+ const trimmed = address.trim();
8
+ if (WILDCARD.has(trimmed))
9
+ return '*';
10
+ // Strip the brackets netstat puts round IPv6 literals.
11
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
12
+ const inner = trimmed.slice(1, -1);
13
+ return WILDCARD.has(inner) ? '*' : inner;
14
+ }
15
+ return trimmed;
16
+ }
17
+ /**
18
+ * Split `host:port` where the host may itself contain colons. Everything after
19
+ * the last colon is the port, which is why IPv6 literals arrive bracketed.
20
+ */
21
+ export function splitHostPort(value) {
22
+ const separator = value.lastIndexOf(':');
23
+ if (separator === -1)
24
+ return null;
25
+ const rawPort = value.slice(separator + 1);
26
+ const port = Number.parseInt(rawPort, 10);
27
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
28
+ return null;
29
+ return { address: normaliseAddress(value.slice(0, separator)), port };
30
+ }
31
+ /**
32
+ * Run a local helper binary. Nothing here touches the network; these are the
33
+ * platform tools that expose the socket table.
34
+ */
35
+ export async function run(command, args) {
36
+ const { stdout, stderr } = await execFileAsync(command, args, {
37
+ encoding: 'utf8',
38
+ maxBuffer: 16 * 1024 * 1024,
39
+ windowsHide: true,
40
+ });
41
+ return { stdout, stderr };
42
+ }
43
+ /** Map over items with a bounded number of in-flight operations. */
44
+ export async function mapConcurrent(items, limit, fn) {
45
+ const results = new Array(items.length);
46
+ let cursor = 0;
47
+ const worker = async () => {
48
+ while (cursor < items.length) {
49
+ const index = cursor++;
50
+ results[index] = await fn(items[index]);
51
+ }
52
+ };
53
+ const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
54
+ await Promise.all(workers);
55
+ return results;
56
+ }
@@ -0,0 +1,148 @@
1
+ import { normaliseAddress, run, splitHostPort } from './shared.js';
2
+ import { ScanError } from '../types.js';
3
+ /**
4
+ * Parse `netstat -ano`.
5
+ *
6
+ * TCP rows carry a state column and UDP rows do not, so the pid is the last
7
+ * field rather than a fixed index. `netstat` is used in preference to
8
+ * `Get-NetTCPConnection` because it exists on every edition of Windows and
9
+ * costs nothing to start, where PowerShell costs about a second.
10
+ */
11
+ export function parseNetstat(output) {
12
+ const rows = [];
13
+ for (const line of output.split('\n')) {
14
+ const fields = line.trim().split(/\s+/);
15
+ if (fields.length < 4)
16
+ continue;
17
+ const protocol = fields[0].toUpperCase();
18
+ if (protocol !== 'TCP' && protocol !== 'UDP')
19
+ continue;
20
+ // Localised builds translate the state word, so a TCP row also counts as
21
+ // listening when it has no peer — only LISTEN has a foreign port of zero.
22
+ if (protocol === 'TCP') {
23
+ if (fields.length < 5)
24
+ continue;
25
+ const listening = fields[3].toUpperCase() === 'LISTENING' || splitHostPort(fields[2])?.port === 0;
26
+ if (!listening)
27
+ continue;
28
+ }
29
+ const pid = Number.parseInt(fields[fields.length - 1], 10);
30
+ if (!Number.isInteger(pid))
31
+ continue;
32
+ const local = fields[1];
33
+ const split = splitHostPort(local);
34
+ if (!split)
35
+ continue;
36
+ rows.push({
37
+ protocol: protocol === 'TCP' ? 'tcp' : 'udp',
38
+ address: split.address,
39
+ port: split.port,
40
+ family: local.startsWith('[') ? 6 : 4,
41
+ pid,
42
+ });
43
+ }
44
+ return rows;
45
+ }
46
+ /** Minimal RFC 4180 reader; tasklist quotes every field and escapes `"` as `""`. */
47
+ export function parseCsvLine(line) {
48
+ const fields = [];
49
+ let current = '';
50
+ let quoted = false;
51
+ for (let index = 0; index < line.length; index += 1) {
52
+ const character = line[index];
53
+ if (quoted) {
54
+ if (character === '"') {
55
+ if (line[index + 1] === '"') {
56
+ current += '"';
57
+ index += 1;
58
+ }
59
+ else {
60
+ quoted = false;
61
+ }
62
+ }
63
+ else {
64
+ current += character;
65
+ }
66
+ }
67
+ else if (character === '"') {
68
+ quoted = true;
69
+ }
70
+ else if (character === ',') {
71
+ fields.push(current);
72
+ current = '';
73
+ }
74
+ else {
75
+ current += character;
76
+ }
77
+ }
78
+ fields.push(current);
79
+ return fields;
80
+ }
81
+ /**
82
+ * Parse `tasklist /V /FO CSV /NH`: image name, pid, session, session number,
83
+ * memory, status, user name, cpu time, window title. The column *positions*
84
+ * are stable across locales even though the headings are not, which is why the
85
+ * headings are suppressed with `/NH`.
86
+ */
87
+ export function parseTasklist(output) {
88
+ const processes = new Map();
89
+ for (const line of output.split('\n')) {
90
+ if (!line.trim())
91
+ continue;
92
+ const fields = parseCsvLine(line.trim());
93
+ if (fields.length < 2)
94
+ continue;
95
+ const pid = Number.parseInt(fields[1], 10);
96
+ if (!Number.isInteger(pid))
97
+ continue;
98
+ const user = fields[6]?.trim();
99
+ processes.set(pid, {
100
+ name: fields[0].trim(),
101
+ user: user && user !== 'N/A' ? user : null,
102
+ });
103
+ }
104
+ return processes;
105
+ }
106
+ async function runNetstat(args) {
107
+ try {
108
+ const { stdout } = await run('netstat', args);
109
+ return stdout;
110
+ }
111
+ catch (error) {
112
+ const failure = error;
113
+ if (failure.code === 'ENOENT') {
114
+ throw new ScanError('netstat could not be found on PATH.', 'Check that %SystemRoot%\\System32 is on PATH.');
115
+ }
116
+ if (typeof failure.stdout === 'string')
117
+ return failure.stdout;
118
+ throw error;
119
+ }
120
+ }
121
+ export async function scanWin32(options = {}) {
122
+ const netstatArgs = options.udp ? ['-ano'] : ['-ano', '-p', 'TCP'];
123
+ const [netstatOutput, tasklistOutput] = await Promise.all([
124
+ runNetstat(netstatArgs),
125
+ // Losing tasklist costs the process names, not the scan.
126
+ run('tasklist', ['/V', '/FO', 'CSV', '/NH']).then((result) => result.stdout, () => ''),
127
+ ]);
128
+ const processes = parseTasklist(tasklistOutput);
129
+ return parseNetstat(netstatOutput)
130
+ .filter((row) => options.udp || row.protocol === 'tcp')
131
+ .map((row) => {
132
+ const process = processes.get(row.pid);
133
+ return {
134
+ protocol: row.protocol,
135
+ family: row.family,
136
+ address: normaliseAddress(row.address),
137
+ port: row.port,
138
+ // Windows reports pid 0 for the system idle process, which owns
139
+ // nothing a user can act on.
140
+ pid: row.pid === 0 ? null : row.pid,
141
+ processName: process?.name ?? null,
142
+ // netstat and tasklist cannot supply a command line; the description
143
+ // heuristics fall back to the image name.
144
+ command: null,
145
+ user: process?.user ?? null,
146
+ };
147
+ });
148
+ }
package/dist/types.js ADDED
@@ -0,0 +1,9 @@
1
+ /** A scan failure the user can act on, as opposed to a crash. */
2
+ export class ScanError extends Error {
3
+ hint;
4
+ constructor(message, hint) {
5
+ super(message);
6
+ this.name = 'ScanError';
7
+ this.hint = hint;
8
+ }
9
+ }
package/dist/ui/App.js ADDED
@@ -0,0 +1,223 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useApp, useInput, useStdout } from 'ink';
3
+ import { useCallback, useEffect, useMemo, useState } from 'react';
4
+ import { formatAddresses, formatDescription, formatPid, formatPort, formatProcess, formatUser, matchesFilter, } from '../format.js';
5
+ import { killEntry } from '../kill.js';
6
+ import { scan } from '../scan/index.js';
7
+ import { cell, color, layout, truncate } from './theme.js';
8
+ /** Header, column titles, filter line, status line, and the two help lines. */
9
+ const CHROME_ROWS = 7;
10
+ function useTerminalSize() {
11
+ const { stdout } = useStdout();
12
+ // A stdout that reports zero columns is one that does not know its own
13
+ // size, not a terminal zero characters wide.
14
+ const read = useCallback(() => ({ columns: stdout?.columns || 80, rows: stdout?.rows || 24 }), [stdout]);
15
+ const [size, setSize] = useState(read);
16
+ useEffect(() => {
17
+ if (!stdout || typeof stdout.on !== 'function')
18
+ return;
19
+ const onResize = () => setSize(read());
20
+ stdout.on('resize', onResize);
21
+ return () => {
22
+ stdout.off?.('resize', onResize);
23
+ };
24
+ }, [stdout, read]);
25
+ return size;
26
+ }
27
+ const STATUS_COLOR = {
28
+ info: 'muted',
29
+ ok: 'ok',
30
+ warn: 'warn',
31
+ error: 'danger',
32
+ };
33
+ function statusFor(result) {
34
+ switch (result.status) {
35
+ case 'terminated':
36
+ return { kind: 'ok', text: result.message };
37
+ case 'gone':
38
+ return { kind: 'info', text: result.message };
39
+ case 'failed':
40
+ return { kind: 'error', text: result.message };
41
+ default:
42
+ return { kind: 'warn', text: result.message };
43
+ }
44
+ }
45
+ export function App({ initialEntries, initialFilter = '', udp: initialUdp = false, scanner = scan, killer = killEntry, }) {
46
+ const { exit } = useApp();
47
+ const { columns, rows } = useTerminalSize();
48
+ const [entries, setEntries] = useState(initialEntries ?? []);
49
+ const [loading, setLoading] = useState(initialEntries === undefined);
50
+ const [udp, setUdp] = useState(initialUdp);
51
+ const [filter, setFilter] = useState(initialFilter);
52
+ const [filtering, setFiltering] = useState(false);
53
+ const [selected, setSelected] = useState(0);
54
+ const [offset, setOffset] = useState(0);
55
+ const [dialog, setDialog] = useState(null);
56
+ const [busy, setBusy] = useState(false);
57
+ const [status, setStatus] = useState(null);
58
+ const refresh = useCallback(async (options) => {
59
+ setLoading(true);
60
+ try {
61
+ setEntries(await scanner(options));
62
+ }
63
+ catch (error) {
64
+ setStatus({ kind: 'error', text: error.message });
65
+ }
66
+ finally {
67
+ setLoading(false);
68
+ }
69
+ }, [scanner]);
70
+ useEffect(() => {
71
+ if (initialEntries !== undefined)
72
+ return;
73
+ void refresh({ udp });
74
+ // Re-scanning is driven by `r` and `u`; this effect only covers the first load.
75
+ }, []);
76
+ const visible = useMemo(() => entries.filter((entry) => matchesFilter(entry, filter)), [entries, filter]);
77
+ const viewport = Math.max(3, rows - CHROME_ROWS);
78
+ // The list can shrink underneath the cursor — a filter keystroke, or a
79
+ // rescan after a kill — so the selection is clamped rather than left dangling.
80
+ useEffect(() => {
81
+ setSelected((current) => Math.min(current, Math.max(0, visible.length - 1)));
82
+ }, [visible.length]);
83
+ useEffect(() => {
84
+ setOffset((current) => {
85
+ if (selected < current)
86
+ return selected;
87
+ if (selected >= current + viewport)
88
+ return selected - viewport + 1;
89
+ return Math.min(current, Math.max(0, visible.length - viewport));
90
+ });
91
+ }, [selected, viewport, visible.length]);
92
+ const current = visible[selected];
93
+ const performKill = useCallback(async (entry, signal) => {
94
+ setDialog(null);
95
+ setBusy(true);
96
+ try {
97
+ setStatus(statusFor(await killer(entry, { signal })));
98
+ }
99
+ catch (error) {
100
+ setStatus({ kind: 'error', text: error.message });
101
+ }
102
+ finally {
103
+ setBusy(false);
104
+ }
105
+ await refresh({ udp });
106
+ }, [killer, refresh, udp]);
107
+ useInput((input, key) => {
108
+ if (busy)
109
+ return;
110
+ if (dialog) {
111
+ if (input === 'y')
112
+ void performKill(dialog, 'SIGTERM');
113
+ else if (input === 'f')
114
+ void performKill(dialog, 'SIGKILL');
115
+ else if (input === 'n' || key.escape) {
116
+ setDialog(null);
117
+ setStatus({ kind: 'info', text: 'Cancelled. Nothing was signalled.' });
118
+ }
119
+ return;
120
+ }
121
+ if (filtering) {
122
+ if (key.escape) {
123
+ setFiltering(false);
124
+ setFilter('');
125
+ }
126
+ else if (key.return) {
127
+ setFiltering(false);
128
+ }
129
+ else if (key.backspace || key.delete) {
130
+ setFilter((value) => value.slice(0, -1));
131
+ }
132
+ else if (input && !key.ctrl && !key.meta) {
133
+ setFilter((value) => value + input);
134
+ }
135
+ return;
136
+ }
137
+ if (input === 'q' || key.escape) {
138
+ exit();
139
+ return;
140
+ }
141
+ if (key.upArrow || input === 'k')
142
+ setSelected((value) => Math.max(0, value - 1));
143
+ else if (key.downArrow || input === 'j')
144
+ setSelected((value) => Math.min(visible.length - 1, value + 1));
145
+ else if (key.pageUp)
146
+ setSelected((value) => Math.max(0, value - viewport));
147
+ else if (key.pageDown)
148
+ setSelected((value) => Math.min(visible.length - 1, value + viewport));
149
+ else if (input === 'g')
150
+ setSelected(0);
151
+ else if (input === 'G')
152
+ setSelected(Math.max(0, visible.length - 1));
153
+ else if (input === '/') {
154
+ setFiltering(true);
155
+ setStatus(null);
156
+ }
157
+ else if (input === 'r')
158
+ void refresh({ udp });
159
+ else if (input === 'u') {
160
+ const next = !udp;
161
+ setUdp(next);
162
+ setStatus({ kind: 'info', text: next ? 'Showing UDP as well as TCP.' : 'Showing TCP only.' });
163
+ void refresh({ udp: next });
164
+ }
165
+ else if (input === 'x' || key.return) {
166
+ if (!current)
167
+ return;
168
+ // Protected rows are refused here, before any dialog: there is no
169
+ // confirmation that lets you kill your own shell.
170
+ if (current.guard) {
171
+ setStatus({ kind: 'warn', text: `Refusing to kill ${current.guard}.` });
172
+ return;
173
+ }
174
+ setStatus(null);
175
+ setDialog(current);
176
+ }
177
+ });
178
+ const columnLayout = layout(columns);
179
+ const window = visible.slice(offset, offset + viewport);
180
+ // A zero-width column is one the terminal is too narrow for, and is dropped
181
+ // entirely rather than rendered as an empty gap.
182
+ const columnise = (values) => [
183
+ cell(values.port, columnLayout.port),
184
+ columnLayout.pid && cell(values.pid, columnLayout.pid),
185
+ columnLayout.user && cell(values.user, columnLayout.user),
186
+ columnLayout.process && cell(values.process, columnLayout.process),
187
+ columnLayout.address && cell(values.address, columnLayout.address),
188
+ ]
189
+ .filter((value) => typeof value === 'string')
190
+ .join(' ');
191
+ const header = columnise({
192
+ port: 'PORT',
193
+ pid: 'PID',
194
+ user: 'USER',
195
+ process: 'PROCESS',
196
+ address: 'ADDRESS',
197
+ });
198
+ return (_jsxs(Box, { flexDirection: "column", width: columnLayout.total, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: color('heading'), bold: true, children: "slash-port" }), _jsx(Text, { color: color('muted'), children: loading
199
+ ? 'scanning…'
200
+ : `${visible.length}/${entries.length} ${udp ? 'tcp+udp' : 'tcp'}` })] }), _jsxs(Box, { children: [_jsxs(Text, { color: color('muted'), children: [header, " "] }), _jsx(Text, { color: color('muted'), children: cell('DESCRIPTION', columnLayout.description) })] }), window.map((entry, index) => {
201
+ const isSelected = offset + index === selected;
202
+ const rowColor = entry.guard ? color('warn') : entry.pid === null ? color('muted') : undefined;
203
+ const left = columnise({
204
+ port: formatPort(entry),
205
+ pid: formatPid(entry),
206
+ user: formatUser(entry),
207
+ process: formatProcess(entry),
208
+ address: formatAddresses(entry),
209
+ });
210
+ return (_jsxs(Box, { children: [_jsxs(Text, { inverse: isSelected, color: rowColor, children: [left, ' '] }), _jsx(Text, { inverse: isSelected, color: rowColor ?? color('accent'), children: cell(formatDescription(entry), columnLayout.description) })] }, entry.id));
211
+ }), !loading && entries.length === 0 && (_jsxs(Text, { color: color('muted'), children: ["Nothing is listening", udp ? '' : ' on TCP', ". Press u to include UDP, r to rescan."] })), !loading && entries.length > 0 && visible.length === 0 && (_jsxs(Text, { color: color('muted'), children: ["No port matches \u201C", filter, "\u201D. Press Esc to clear the filter."] })), dialog && _jsx(ConfirmDialog, { entry: dialog, width: columnLayout.total }), filtering && (_jsxs(Text, { children: [_jsx(Text, { color: color('heading'), children: "filter " }), _jsx(Text, { children: filter }), _jsx(Text, { color: color('muted'), children: "\u258E" })] })), !filtering && filter !== '' && (_jsxs(Text, { color: color('muted'), children: ["filter: ", truncate(filter, Math.max(8, columnLayout.total - 10))] })), status && (_jsx(Text, { color: color(STATUS_COLOR[status.kind]), children: truncate(status.text, columnLayout.total) })), !dialog && (_jsx(Text, { color: color('muted'), children: truncate('↑↓/jk move · PgUp/PgDn/g/G jump · / filter · x kill · r rescan · u udp · q quit', columnLayout.total) }))] }));
212
+ }
213
+ function ConfirmDialog({ entry, width }) {
214
+ const inner = Math.max(20, width - 4);
215
+ const owner = [
216
+ entry.processName ?? 'unknown process',
217
+ `pid ${entry.pid ?? 'unknown'}`,
218
+ entry.user ? `user ${entry.user}` : null,
219
+ ]
220
+ .filter(Boolean)
221
+ .join(' · ');
222
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: color('warn'), paddingX: 1, children: [_jsx(Text, { color: color('warn'), bold: true, children: truncate(`Kill whatever holds port ${entry.port}/${entry.protocol}?`, inner) }), _jsx(Text, { children: truncate(formatDescription(entry), inner) }), _jsx(Text, { color: color('muted'), children: truncate(owner, inner) }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: color('ok'), children: "y" }), " terminate (SIGTERM) \u00B7", ' ', _jsx(Text, { color: color('danger'), children: "f" }), " force (SIGKILL) \u00B7 ", _jsx(Text, { color: color('heading'), children: "n" }), " cancel"] })] }));
223
+ }