slash-port 0.1.0 → 0.2.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 +34 -5
- package/dist/cli.js +41 -19
- package/dist/format.js +6 -0
- package/dist/ports.js +113 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,17 +48,41 @@ Requires Node 22 or newer. Works on Linux, macOS, and Windows.
|
|
|
48
48
|
```sh
|
|
49
49
|
slash-port # the interactive list
|
|
50
50
|
slash-port 3000 # open on port 3000
|
|
51
|
+
slash-port 3xxx # every port from 3000 to 3999
|
|
52
|
+
slash-port 3000:3005 # every port in that range
|
|
51
53
|
slash-port --plain # a plain table, for a pipe or a script
|
|
52
54
|
slash-port --json # the same data as JSON
|
|
53
55
|
slash-port --udp # include UDP as well as TCP
|
|
54
56
|
```
|
|
55
57
|
|
|
58
|
+
### Naming ports
|
|
59
|
+
|
|
60
|
+
`--port` and the interactive filter take the same three forms:
|
|
61
|
+
|
|
62
|
+
| Form | Means |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `3000` | One port |
|
|
65
|
+
| `3xxx` | Every port from 3000 to 3999. `x` is any digit, in any position: `8x80` is 8080, 8180, and so on to 8980 |
|
|
66
|
+
| `3000:3005` | Every port from 3000 to 3005, both ends included |
|
|
67
|
+
|
|
68
|
+
A pattern is read as digits, not as a number, so `3xxx` is the four-digit ports
|
|
69
|
+
beginning with 3 and does not include 300. Ports above 65535 do not exist, so a
|
|
70
|
+
pattern that can only match them — `7xxxx` — is a usage error rather than an
|
|
71
|
+
empty list.
|
|
72
|
+
|
|
56
73
|
To kill something from a script, name it and confirm it:
|
|
57
74
|
|
|
58
75
|
```sh
|
|
59
76
|
slash-port --kill --port 3000 --yes
|
|
60
77
|
```
|
|
61
78
|
|
|
79
|
+
A pattern or a range can match several ports, and the same command matches more
|
|
80
|
+
of them tomorrow than it does today, so killing with one takes `--all` as well:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
slash-port --kill --port 3000:3005 --yes --all
|
|
84
|
+
```
|
|
85
|
+
|
|
62
86
|
There is no flag combination that kills something without naming it first.
|
|
63
87
|
|
|
64
88
|
### Keys
|
|
@@ -68,7 +92,7 @@ There is no flag combination that kills something without naming it first.
|
|
|
68
92
|
| `↑` `↓` or `j` `k` | Move |
|
|
69
93
|
| `PgUp` `PgDn` | Page |
|
|
70
94
|
| `g` `G` | First, last |
|
|
71
|
-
| `/` | Filter
|
|
95
|
+
| `/` | Filter on anything in the row, or on ports with `3xxx` and `3000:3005`; `Enter` keeps it, `Esc` clears it |
|
|
72
96
|
| `x` or `Enter` | Kill the selected port |
|
|
73
97
|
| `r` | Rescan |
|
|
74
98
|
| `u` | Show UDP as well as TCP |
|
|
@@ -81,13 +105,14 @@ cancels. The kill key is deliberately not next to a navigation key.
|
|
|
81
105
|
|
|
82
106
|
| Option | Does |
|
|
83
107
|
| --- | --- |
|
|
84
|
-
| `-p`, `--port <
|
|
108
|
+
| `-p`, `--port <ports>` | Only these ports: `3000`, `3xxx`, or `3000:3005` |
|
|
85
109
|
| `-u`, `--udp` | Include UDP sockets |
|
|
86
110
|
| `--json` | Print JSON and exit |
|
|
87
111
|
| `--plain` | Print a plain table and exit |
|
|
88
112
|
| `--kill` | Kill the process on `--port`. Requires `--yes` |
|
|
89
113
|
| `--force` | Escalate to SIGKILL instead of SIGTERM |
|
|
90
114
|
| `-y`, `--yes` | Confirm a kill made from the command line |
|
|
115
|
+
| `--all` | Kill every port a pattern or a range matches. Requires `--kill` |
|
|
91
116
|
| `--grace <ms>` | Wait this long for a graceful exit (default 3000) |
|
|
92
117
|
| `--no-color` | Disable colour. `NO_COLOR` is honoured too |
|
|
93
118
|
| `-h`, `--help` | Show help |
|
|
@@ -101,9 +126,10 @@ cancels. The kill key is deliberately not next to a navigation key.
|
|
|
101
126
|
| `1` | The requested action could not be completed |
|
|
102
127
|
| `2` | Invalid usage |
|
|
103
128
|
|
|
104
|
-
Asking about
|
|
105
|
-
`slash-port --port 3000 --plain` exits `1` when nothing is listening there
|
|
106
|
-
Listing every port exits `0` even
|
|
129
|
+
Asking about a port is a question with a yes-or-no answer, so
|
|
130
|
+
`slash-port --port 3000 --plain` exits `1` when nothing is listening there, and
|
|
131
|
+
so does `--port 3xxx` when nothing matches. Listing every port exits `0` even
|
|
132
|
+
when the list is empty.
|
|
107
133
|
|
|
108
134
|
## Safety
|
|
109
135
|
|
|
@@ -112,6 +138,9 @@ are fixed rather than configurable:
|
|
|
112
138
|
|
|
113
139
|
- **Nothing is killed without a confirmation that names it.** Not in the
|
|
114
140
|
interactive list, and not with flags.
|
|
141
|
+
- **A pattern or a range never kills on its own.** `--kill --port 3xxx --yes`
|
|
142
|
+
is refused without `--all`, because what a pattern matches depends on what
|
|
143
|
+
happens to be running when the command is run.
|
|
115
144
|
- **Some processes are refused outright**, before any dialog is offered: the
|
|
116
145
|
init process, `sshd` — killing it locks you out of a remote machine — macOS
|
|
117
146
|
and Windows session processes, `slash-port` itself, and the shell that
|
package/dist/cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { plainTable, toJson } from './format.js';
|
|
5
5
|
import { killEntry } from './kill.js';
|
|
6
|
+
import { describePortSelector, looksLikePort, matchesPort, parsePortSelector } from './ports.js';
|
|
6
7
|
import { scan } from './scan/index.js';
|
|
7
8
|
import { ScanError } from './types.js';
|
|
8
9
|
/** 0 success · 1 the action could not be completed · 2 invalid usage. */
|
|
@@ -20,6 +21,7 @@ function parseArgs(argv) {
|
|
|
20
21
|
kill: false,
|
|
21
22
|
force: false,
|
|
22
23
|
yes: false,
|
|
24
|
+
all: false,
|
|
23
25
|
graceMs: 3000,
|
|
24
26
|
color: true,
|
|
25
27
|
help: false,
|
|
@@ -30,19 +32,23 @@ function parseArgs(argv) {
|
|
|
30
32
|
throw new UsageError(`${flag} needs a value.`);
|
|
31
33
|
return next;
|
|
32
34
|
};
|
|
35
|
+
// A bad port is a usage error like any other, so it exits 2 with the same
|
|
36
|
+
// shape of message rather than as an unhandled failure.
|
|
37
|
+
const selector = (raw) => {
|
|
38
|
+
try {
|
|
39
|
+
return parsePortSelector(raw);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw new UsageError(error.message);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
33
45
|
for (let index = 0; index < argv.length; index += 1) {
|
|
34
46
|
const argument = argv[index];
|
|
35
47
|
switch (argument) {
|
|
36
48
|
case '-p':
|
|
37
|
-
case '--port':
|
|
38
|
-
|
|
39
|
-
const port = Number.parseInt(raw, 10);
|
|
40
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
41
|
-
throw new UsageError(`${raw} is not a port number between 1 and 65535.`);
|
|
42
|
-
}
|
|
43
|
-
options.port = port;
|
|
49
|
+
case '--port':
|
|
50
|
+
options.port = selector(value(argument, argv[++index]));
|
|
44
51
|
break;
|
|
45
|
-
}
|
|
46
52
|
case '-u':
|
|
47
53
|
case '--udp':
|
|
48
54
|
options.udp = true;
|
|
@@ -63,6 +69,9 @@ function parseArgs(argv) {
|
|
|
63
69
|
case '--yes':
|
|
64
70
|
options.yes = true;
|
|
65
71
|
break;
|
|
72
|
+
case '--all':
|
|
73
|
+
options.all = true;
|
|
74
|
+
break;
|
|
66
75
|
case '--grace': {
|
|
67
76
|
const raw = value(argument, argv[++index]);
|
|
68
77
|
const ms = Number.parseInt(raw, 10);
|
|
@@ -84,12 +93,9 @@ function parseArgs(argv) {
|
|
|
84
93
|
options.version = true;
|
|
85
94
|
break;
|
|
86
95
|
default:
|
|
87
|
-
if (
|
|
96
|
+
if (looksLikePort(argument) && options.port === null) {
|
|
88
97
|
// `slash-port 3000` is what everyone tries first.
|
|
89
|
-
|
|
90
|
-
if (port < 1 || port > 65535)
|
|
91
|
-
throw new UsageError(`${argument} is not a port number.`);
|
|
92
|
-
options.port = port;
|
|
98
|
+
options.port = selector(argument);
|
|
93
99
|
break;
|
|
94
100
|
}
|
|
95
101
|
throw new UsageError(`Unknown option: ${argument}`);
|
|
@@ -104,24 +110,37 @@ function parseArgs(argv) {
|
|
|
104
110
|
throw new UsageError('--kill needs --port, so the target is named.');
|
|
105
111
|
if (!options.yes)
|
|
106
112
|
throw new UsageError('--kill needs --yes, so the kill is confirmed.');
|
|
113
|
+
// Whether a pattern happens to match one port today is not the point: the
|
|
114
|
+
// same command matches more tomorrow, so the plural is confirmed up front.
|
|
115
|
+
if (options.port.kind !== 'exact' && !options.all) {
|
|
116
|
+
throw new UsageError(`--kill needs --all to use ${options.port.text}, so killing every match is deliberate.`);
|
|
117
|
+
}
|
|
107
118
|
}
|
|
108
119
|
if (options.force && !options.kill)
|
|
109
120
|
throw new UsageError('--force only means something with --kill.');
|
|
121
|
+
if (options.all && !options.kill)
|
|
122
|
+
throw new UsageError('--all only means something with --kill.');
|
|
110
123
|
return options;
|
|
111
124
|
}
|
|
112
125
|
const HELP = `slash-port — see what is listening on your ports, and kill it safely.
|
|
113
126
|
|
|
114
127
|
Usage
|
|
115
|
-
slash-port [
|
|
128
|
+
slash-port [ports] [options]
|
|
129
|
+
|
|
130
|
+
Ports
|
|
131
|
+
3000 one port
|
|
132
|
+
3xxx every port from 3000 to 3999; x is any digit
|
|
133
|
+
3000:3005 every port in the range, both ends included
|
|
116
134
|
|
|
117
135
|
Options
|
|
118
|
-
-p, --port <
|
|
136
|
+
-p, --port <ports> Only these ports, in any of the forms above
|
|
119
137
|
-u, --udp Include UDP sockets as well as TCP
|
|
120
138
|
--json Print JSON to stdout and exit
|
|
121
139
|
--plain Print a plain table and exit
|
|
122
140
|
--kill Kill the process on --port. Requires --yes
|
|
123
141
|
--force Escalate to SIGKILL instead of SIGTERM
|
|
124
142
|
-y, --yes Confirm a kill made from the command line
|
|
143
|
+
--all Kill every port a pattern or a range matches
|
|
125
144
|
--grace <ms> Wait this long for a graceful exit (default 3000)
|
|
126
145
|
--no-color Disable colour
|
|
127
146
|
-h, --help Show this help
|
|
@@ -150,14 +169,17 @@ function readVersion() {
|
|
|
150
169
|
}
|
|
151
170
|
}
|
|
152
171
|
function selectPort(entries, options) {
|
|
153
|
-
|
|
172
|
+
const selector = options.port;
|
|
173
|
+
if (selector === null)
|
|
154
174
|
return [...entries];
|
|
155
|
-
return entries.filter((entry) => entry.port
|
|
175
|
+
return entries.filter((entry) => matchesPort(selector, entry.port) && (options.udp || entry.protocol === 'tcp'));
|
|
156
176
|
}
|
|
157
177
|
async function killFromCli(entries, options) {
|
|
178
|
+
// parseArgs refuses --kill without --port, so there is always a selector here.
|
|
179
|
+
const selector = options.port;
|
|
158
180
|
const targets = selectPort(entries, options);
|
|
159
181
|
if (targets.length === 0) {
|
|
160
|
-
process.stderr.write(`Nothing is listening on
|
|
182
|
+
process.stderr.write(`Nothing is listening on ${describePortSelector(selector)}.\n`);
|
|
161
183
|
return EXIT_FAILED;
|
|
162
184
|
}
|
|
163
185
|
let failures = 0;
|
|
@@ -225,7 +247,7 @@ async function main(argv) {
|
|
|
225
247
|
return options.port !== null && selected.length === 0 ? EXIT_FAILED : EXIT_OK;
|
|
226
248
|
}
|
|
227
249
|
const [{ render }, { App }] = await Promise.all([import('ink'), import('./ui/App.js')]);
|
|
228
|
-
const instance = render(_jsx(App, { initialEntries: entries, initialFilter: options.port === null ? '' :
|
|
250
|
+
const instance = render(_jsx(App, { initialEntries: entries, initialFilter: options.port === null ? '' : options.port.text, udp: options.udp }));
|
|
229
251
|
await instance.waitUntilExit();
|
|
230
252
|
return EXIT_OK;
|
|
231
253
|
}
|
package/dist/format.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { matchesPort, tryPortSelector } from './ports.js';
|
|
1
2
|
/** Shown wherever a value is genuinely absent, rather than an empty column. */
|
|
2
3
|
export const ABSENT = '-';
|
|
3
4
|
export function formatPort(entry) {
|
|
@@ -44,6 +45,11 @@ export function matchesFilter(entry, filter) {
|
|
|
44
45
|
const needle = filter.trim().toLowerCase();
|
|
45
46
|
if (!needle)
|
|
46
47
|
return true;
|
|
48
|
+
// `3xxx` and `3000:3005` can mean nothing but ports, so they are matched as
|
|
49
|
+
// ports. A bare `3000` stays a substring, because it is also half a pid.
|
|
50
|
+
const selector = tryPortSelector(needle);
|
|
51
|
+
if (selector)
|
|
52
|
+
return matchesPort(selector, entry.port);
|
|
47
53
|
return searchText(entry).includes(needle);
|
|
48
54
|
}
|
|
49
55
|
/**
|
package/dist/ports.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three ways to name ports, shared by `--port` and the filter box: one
|
|
3
|
+
* port, a pattern where `x` stands for any digit, or an inclusive range.
|
|
4
|
+
*/
|
|
5
|
+
const MIN_PORT = 1;
|
|
6
|
+
const MAX_PORT = 65535;
|
|
7
|
+
/** A selector that could not be read. The message is written for the user. */
|
|
8
|
+
export class PortSelectorError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'PortSelectorError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function exact(port) {
|
|
15
|
+
return { kind: 'exact', text: String(port), from: port, to: port, pattern: null };
|
|
16
|
+
}
|
|
17
|
+
function portNumber(raw) {
|
|
18
|
+
const port = /^\d{1,5}$/.test(raw) ? Number.parseInt(raw, 10) : Number.NaN;
|
|
19
|
+
if (!(port >= MIN_PORT && port <= MAX_PORT)) {
|
|
20
|
+
throw new PortSelectorError(`${raw} is not a port number between 1 and 65535.`);
|
|
21
|
+
}
|
|
22
|
+
return port;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* `3xxx` is every four-digit port beginning with 3, so a pattern is matched
|
|
26
|
+
* against the port's digits rather than its value: `xxx` is 100 to 999, not 0
|
|
27
|
+
* to 999. The bounds come from replacing every `x` with 0 and with 9 — a
|
|
28
|
+
* leading `x` with 1, because no port is written with a leading zero.
|
|
29
|
+
*/
|
|
30
|
+
function parsePattern(text) {
|
|
31
|
+
if (!/^[\dx]+$/.test(text)) {
|
|
32
|
+
throw new PortSelectorError(`${text} is not a port pattern. Write it as 3xxx, where x is any digit.`);
|
|
33
|
+
}
|
|
34
|
+
const from = Number.parseInt(text.replace(/^x/, '1').replace(/x/g, '0'), 10);
|
|
35
|
+
const to = Number.parseInt(text.replace(/x/g, '9'), 10);
|
|
36
|
+
if (from < MIN_PORT || from > MAX_PORT) {
|
|
37
|
+
throw new PortSelectorError(`No port between 1 and 65535 matches ${text}.`);
|
|
38
|
+
}
|
|
39
|
+
return { kind: 'pattern', text, from, to: Math.min(to, MAX_PORT), pattern: text };
|
|
40
|
+
}
|
|
41
|
+
function parseRange(text) {
|
|
42
|
+
const halves = text.split(':');
|
|
43
|
+
if (halves.length !== 2 || halves[0] === '' || halves[1] === '') {
|
|
44
|
+
throw new PortSelectorError(`${text} is not a port range. Write it as 3000:3005.`);
|
|
45
|
+
}
|
|
46
|
+
const from = portNumber(halves[0]);
|
|
47
|
+
const to = portNumber(halves[1]);
|
|
48
|
+
if (from > to) {
|
|
49
|
+
throw new PortSelectorError(`The range ${text} runs backwards. Write it as ${to}:${from}.`);
|
|
50
|
+
}
|
|
51
|
+
// A range of one port is that port, and is treated as one everywhere after.
|
|
52
|
+
if (from === to)
|
|
53
|
+
return exact(from);
|
|
54
|
+
return { kind: 'range', text: `${from}:${to}`, from, to, pattern: null };
|
|
55
|
+
}
|
|
56
|
+
/** Reads `3000`, `3xxx`, or `3000:3005`. Throws a `PortSelectorError` otherwise. */
|
|
57
|
+
export function parsePortSelector(raw) {
|
|
58
|
+
const text = raw.trim().toLowerCase();
|
|
59
|
+
if (text.includes(':'))
|
|
60
|
+
return parseRange(text);
|
|
61
|
+
if (text.includes('x'))
|
|
62
|
+
return parsePattern(text);
|
|
63
|
+
if (!/^\d+$/.test(text)) {
|
|
64
|
+
throw new PortSelectorError(`${raw} is not a port, a pattern like 3xxx, or a range like 3000:3005.`);
|
|
65
|
+
}
|
|
66
|
+
return exact(portNumber(text));
|
|
67
|
+
}
|
|
68
|
+
export function matchesPort(selector, port) {
|
|
69
|
+
if (port < selector.from || port > selector.to)
|
|
70
|
+
return false;
|
|
71
|
+
if (selector.pattern === null)
|
|
72
|
+
return true;
|
|
73
|
+
const digits = String(port);
|
|
74
|
+
if (digits.length !== selector.pattern.length)
|
|
75
|
+
return false;
|
|
76
|
+
return [...selector.pattern].every((character, index) => character === 'x' || character === digits[index]);
|
|
77
|
+
}
|
|
78
|
+
/** How a selector is named in a message, e.g. "Nothing is listening on …". */
|
|
79
|
+
export function describePortSelector(selector) {
|
|
80
|
+
switch (selector.kind) {
|
|
81
|
+
case 'exact':
|
|
82
|
+
return `port ${selector.from}`;
|
|
83
|
+
case 'pattern':
|
|
84
|
+
return `ports matching ${selector.text}`;
|
|
85
|
+
default:
|
|
86
|
+
return `ports ${selector.from} to ${selector.to}`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Whether a bare argument was meant as a port, so `slash-port 3xxx` works like
|
|
91
|
+
* `slash-port 3000` and a typo in one is reported as a bad port rather than as
|
|
92
|
+
* an unknown option.
|
|
93
|
+
*/
|
|
94
|
+
export function looksLikePort(raw) {
|
|
95
|
+
return /^\d/.test(raw) || /^x[\dx]*$/i.test(raw);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The filter box reads `3xxx` and `3000:3005` as ports, and everything else —
|
|
99
|
+
* including a bare `3000` — as a substring, because the filter still has to
|
|
100
|
+
* find pids, users, and project names. Half-typed input is not an error there,
|
|
101
|
+
* so an unreadable selector is simply not one.
|
|
102
|
+
*/
|
|
103
|
+
export function tryPortSelector(raw) {
|
|
104
|
+
const text = raw.trim().toLowerCase();
|
|
105
|
+
if (!/^\d*x[\dx]*$/.test(text) && !/^\d+:\d+$/.test(text))
|
|
106
|
+
return null;
|
|
107
|
+
try {
|
|
108
|
+
return parsePortSelector(text);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|