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 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; `Enter` keeps it, `Esc` clears it |
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 <number>` | Only this 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 one port is a question with a yes-or-no answer, so
105
- `slash-port --port 3000 --plain` exits `1` when nothing is listening there.
106
- Listing every port exits `0` even when the list is empty.
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
- const raw = value(argument, argv[++index]);
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 (/^\d+$/.test(argument) && options.port === null) {
96
+ if (looksLikePort(argument) && options.port === null) {
88
97
  // `slash-port 3000` is what everyone tries first.
89
- const port = Number.parseInt(argument, 10);
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 [port] [options]
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 <number> Only this 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
- if (options.port === null)
172
+ const selector = options.port;
173
+ if (selector === null)
154
174
  return [...entries];
155
- return entries.filter((entry) => entry.port === options.port && (options.udp || entry.protocol === 'tcp'));
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 port ${options.port}.\n`);
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 ? '' : String(options.port), udp: options.udp }));
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slash-port",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "See what is listening on your ports, understand what it is, and kill it safely.",
5
5
  "keywords": [
6
6
  "port",