slash-port 0.1.0 → 0.2.1

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
@@ -1,5 +1,6 @@
1
1
  # slash-port
2
2
 
3
+ <!-- release:badge -->[![github](https://img.shields.io/badge/github-v0.2.1-0f8b7d?logo=github&logoColor=white)](https://github.com/Slash-ui/slash-port/releases/tag/v0.2.1)<!-- /release:badge -->
3
4
  [![npm](https://img.shields.io/npm/v/slash-port?logo=npm&logoColor=white&color=0f8b7d)](https://www.npmjs.com/package/slash-port)
4
5
  [![downloads](https://img.shields.io/npm/dm/slash-port?color=0f8b7d)](https://www.npmjs.com/package/slash-port)
5
6
  [![CI](https://github.com/Slash-ui/slash-port/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Slash-ui/slash-port/actions/workflows/ci.yml)
@@ -43,22 +44,89 @@ npx slash-port
43
44
 
44
45
  Requires Node 22 or newer. Works on Linux, macOS, and Windows.
45
46
 
47
+ ### Updating
48
+
49
+ The current release is
50
+ <!-- release:version -->0.2.1<!-- /release:version -->. To see what you have,
51
+ and what is published:
52
+
53
+ ```sh
54
+ slash-port --version # the one you are running
55
+ npm view slash-port version # the one on npm
56
+ ```
57
+
58
+ To move to the latest:
59
+
60
+ ```sh
61
+ npm install -g slash-port@latest
62
+ ```
63
+
64
+ `npm install -g …@latest` rather than `npm update -g slash-port`, because
65
+ `update` will not cross a major version - and before 1.0.0 it will not cross a
66
+ minor one either, which is every release this tool has had so far. Naming
67
+ `@latest` always gets you the newest published version.
68
+
69
+ `npx` caches the version it first downloaded, so ask it for the latest
70
+ explicitly:
71
+
72
+ ```sh
73
+ npx slash-port@latest
74
+ ```
75
+
76
+ There is no automatic update check. `slash-port` makes no network connections
77
+ at all, which means it will never tell you a new version exists - you find out
78
+ here, or from npm. Upgrading is safe: there is no state, no configuration file,
79
+ and nothing to migrate. To go the other way, name the version you want -
80
+ `npm install -g slash-port@0.1.0` - and to remove it entirely:
81
+
82
+ ```sh
83
+ npm uninstall -g slash-port
84
+ ```
85
+
86
+ Breaking changes are listed under **Breaking changes** in the
87
+ [changelog](CHANGELOG.md), so a major - or, before 1.0.0, a minor - is worth
88
+ reading before you take it.
89
+
46
90
  ## Use
47
91
 
48
92
  ```sh
49
93
  slash-port # the interactive list
50
94
  slash-port 3000 # open on port 3000
95
+ slash-port 3xxx # every port from 3000 to 3999
96
+ slash-port 3000:3005 # every port in that range
51
97
  slash-port --plain # a plain table, for a pipe or a script
52
98
  slash-port --json # the same data as JSON
53
99
  slash-port --udp # include UDP as well as TCP
54
100
  ```
55
101
 
102
+ ### Naming ports
103
+
104
+ `--port` and the interactive filter take the same three forms:
105
+
106
+ | Form | Means |
107
+ | --- | --- |
108
+ | `3000` | One port |
109
+ | `3xxx` | Every port from 3000 to 3999. `x` is any digit, in any position: `8x80` is 8080, 8180, and so on to 8980 |
110
+ | `3000:3005` | Every port from 3000 to 3005, both ends included |
111
+
112
+ A pattern is read as digits, not as a number, so `3xxx` is the four-digit ports
113
+ beginning with 3 and does not include 300. Ports above 65535 do not exist, so a
114
+ pattern that can only match them - `7xxxx` - is a usage error rather than an
115
+ empty list.
116
+
56
117
  To kill something from a script, name it and confirm it:
57
118
 
58
119
  ```sh
59
120
  slash-port --kill --port 3000 --yes
60
121
  ```
61
122
 
123
+ A pattern or a range can match several ports, and the same command matches more
124
+ of them tomorrow than it does today, so killing with one takes `--all` as well:
125
+
126
+ ```sh
127
+ slash-port --kill --port 3000:3005 --yes --all
128
+ ```
129
+
62
130
  There is no flag combination that kills something without naming it first.
63
131
 
64
132
  ### Keys
@@ -68,7 +136,7 @@ There is no flag combination that kills something without naming it first.
68
136
  | `↑` `↓` or `j` `k` | Move |
69
137
  | `PgUp` `PgDn` | Page |
70
138
  | `g` `G` | First, last |
71
- | `/` | Filter; `Enter` keeps it, `Esc` clears it |
139
+ | `/` | Filter on anything in the row, or on ports with `3xxx` and `3000:3005`; `Enter` keeps it, `Esc` clears it |
72
140
  | `x` or `Enter` | Kill the selected port |
73
141
  | `r` | Rescan |
74
142
  | `u` | Show UDP as well as TCP |
@@ -81,13 +149,14 @@ cancels. The kill key is deliberately not next to a navigation key.
81
149
 
82
150
  | Option | Does |
83
151
  | --- | --- |
84
- | `-p`, `--port <number>` | Only this port |
152
+ | `-p`, `--port <ports>` | Only these ports: `3000`, `3xxx`, or `3000:3005` |
85
153
  | `-u`, `--udp` | Include UDP sockets |
86
154
  | `--json` | Print JSON and exit |
87
155
  | `--plain` | Print a plain table and exit |
88
156
  | `--kill` | Kill the process on `--port`. Requires `--yes` |
89
157
  | `--force` | Escalate to SIGKILL instead of SIGTERM |
90
158
  | `-y`, `--yes` | Confirm a kill made from the command line |
159
+ | `--all` | Kill every port a pattern or a range matches. Requires `--kill` |
91
160
  | `--grace <ms>` | Wait this long for a graceful exit (default 3000) |
92
161
  | `--no-color` | Disable colour. `NO_COLOR` is honoured too |
93
162
  | `-h`, `--help` | Show help |
@@ -95,15 +164,27 @@ cancels. The kill key is deliberately not next to a navigation key.
95
164
 
96
165
  ### Exit codes
97
166
 
167
+ The same codes across every `slash-*` tool, so a script that wraps one can wrap
168
+ any of them:
169
+
98
170
  | Code | Means |
99
171
  | --- | --- |
100
172
  | `0` | Success |
101
- | `1` | The requested action could not be completed |
102
- | `2` | Invalid usage |
103
-
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.
173
+ | `1` | Invalid arguments or usage |
174
+ | `2` | Nothing is listening on the ports asked about |
175
+ | `3` | Refused: a confirmation was missing, or a guardrail tripped |
176
+ | `4` | The operation was attempted and failed |
177
+
178
+ Asking about a port is a question with a yes-or-no answer, so
179
+ `slash-port --port 3000 --plain` exits `2` when nothing is listening there, and
180
+ so does `--port 3xxx` when nothing matches. Listing every port exits `0` even
181
+ when the list is empty.
182
+
183
+ The distinction between `1` and `3` is whether the command made sense:
184
+ `--kill` with no `--port` did not name a target and exits `1`, while `--kill`
185
+ with a target but no `--yes` named one and did not confirm it, and exits `3`.
186
+ The standard reserves `5` for an integrity failure, which this tool has nothing
187
+ to verify and never returns.
107
188
 
108
189
  ## Safety
109
190
 
@@ -112,8 +193,15 @@ are fixed rather than configurable:
112
193
 
113
194
  - **Nothing is killed without a confirmation that names it.** Not in the
114
195
  interactive list, and not with flags.
196
+ - **A pattern or a range never kills on its own.** `--kill --port 3xxx --yes`
197
+ is refused without `--all`, because what a pattern matches depends on what
198
+ happens to be running when the command is run.
199
+ - **A signal that will bounce is flagged before you decide, not after.** A row
200
+ owned by somebody else is marked `[locked]`, and the confirmation says what
201
+ it would take to signal it - rather than letting you confirm a kill that was
202
+ never going to land.
115
203
  - **Some processes are refused outright**, before any dialog is offered: the
116
- init process, `sshd` killing it locks you out of a remote machine macOS
204
+ init process, `sshd` - killing it locks you out of a remote machine - macOS
117
205
  and Windows session processes, `slash-port` itself, and the shell that
118
206
  launched it.
119
207
  - **SIGTERM before SIGKILL.** A process that ignores SIGTERM is reported as
@@ -123,7 +211,7 @@ are fixed rather than configurable:
123
211
 
124
212
  On Windows there is no signal delivery: SIGTERM becomes `TerminateProcess`,
125
213
  which a process cannot catch or ignore, so nothing there gets the chance to
126
- shut down cleanly. The confirmation still applies but "terminate" and "force"
214
+ shut down cleanly. The confirmation still applies - but "terminate" and "force"
127
215
  do the same thing.
128
216
 
129
217
  ## Privacy
@@ -136,8 +224,9 @@ telemetry, no update check, and no configuration file.
136
224
 
137
225
  - Only the sixteen named terminal colours, so the display inherits your theme
138
226
  rather than fighting it.
139
- - Colour never carries meaning on its own a protected row is labelled
140
- `[protected]` as well as coloured.
227
+ - Colour never carries meaning on its own - a protected row is labelled
228
+ `[protected]` and one you cannot signal is labelled `[locked]`, as well as
229
+ being coloured.
141
230
  - `NO_COLOR` and `--no-color` are honoured.
142
231
  - Redirected or piped output is plain text with no control codes, and the
143
232
  interactive interface never starts unless both streams are a terminal.
@@ -155,7 +244,7 @@ Three sources, in priority order:
155
244
  2. **The project.** The directory above `node_modules` in the command line, so
156
245
  two Vite servers on 5173 and 5174 can be told apart.
157
246
  3. **A well-known port registry**, used only when the process itself could not
158
- be identified mostly other users' processes. Entries that would add
247
+ be identified - mostly other users' processes. Entries that would add
159
248
  nothing are suppressed: "dev server" on port 3000 is not information.
160
249
 
161
250
  Per platform:
@@ -163,7 +252,7 @@ Per platform:
163
252
  - **Linux** reads `/proc/net/tcp` and maps socket inodes through
164
253
  `/proc/[pid]/fd`. No `lsof`, which many container images do not have, and no
165
254
  subprocess. Descriptors belonging to other users are not readable without
166
- privileges, so those rows show no owner rather than failing the scan run
255
+ privileges, so those rows show no owner rather than failing the scan - run
167
256
  with `sudo` to resolve them.
168
257
  - **macOS** uses `lsof` in field-output mode, plus `ps` for full command lines.
169
258
  - **Windows** uses `netstat -ano` and `tasklist`, which exist on every edition
@@ -181,7 +270,7 @@ oversights:
181
270
  `--tree` option would signal the whole group.
182
271
  - **Watch mode.** The list rescans on `r`, not on a timer.
183
272
  - **Port history.** "What was on 3000 an hour ago" needs persistent state, and
184
- this tool currently has none which is worth keeping.
273
+ this tool currently has none - which is worth keeping.
185
274
 
186
275
  ## Versioning and releases
187
276
 
@@ -193,6 +282,13 @@ and a patch. Every release is published with npm
193
282
  [provenance](https://docs.npmjs.com/generating-provenance-statements), so the
194
283
  tarball can be traced to the exact commit and workflow that built it.
195
284
 
285
+ One commit does the whole bump: `package.json`, the changelog entry, and the
286
+ version badge at the top of this file are written together, so the three cannot
287
+ name different versions. The badges either side of it are read live - npm and
288
+ the download count from the registry, CI and Release from the Actions API - so
289
+ the `github` badge and the `npm` badge agreeing means the publish landed, and
290
+ them disagreeing means it did not.
291
+
196
292
  See the [changelog](CHANGELOG.md) for what changed when.
197
293
 
198
294
  ## Contributing
package/dist/cli.js CHANGED
@@ -3,13 +3,38 @@ 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
- /** 0 success · 1 the action could not be completed · 2 invalid usage. */
9
+ /**
10
+ * The exit codes every slash-* tool shares, so a script that wraps one can
11
+ * wrap any of them:
12
+ *
13
+ * 0 success
14
+ * 1 invalid arguments or usage
15
+ * 2 the thing asked about was not found
16
+ * 3 refused - a confirmation was missing, or a guardrail tripped
17
+ * 4 the operation was attempted and failed
18
+ *
19
+ * The standard reserves 5 for an integrity failure, which this tool has
20
+ * nothing to verify and so never returns.
21
+ */
9
22
  const EXIT_OK = 0;
10
- const EXIT_FAILED = 1;
11
- const EXIT_USAGE = 2;
23
+ const EXIT_USAGE = 1;
24
+ const EXIT_NOT_FOUND = 2;
25
+ const EXIT_REFUSED = 3;
26
+ const EXIT_FAILED = 4;
27
+ /**
28
+ * A command line that cannot be run. Most of these are malformed and exit 1,
29
+ * but a missing confirmation is a refusal rather than a mistake, so the code
30
+ * travels with the message.
31
+ */
12
32
  class UsageError extends Error {
33
+ code;
34
+ constructor(message, code = EXIT_USAGE) {
35
+ super(message);
36
+ this.code = code;
37
+ }
13
38
  }
14
39
  function parseArgs(argv) {
15
40
  const options = {
@@ -20,6 +45,7 @@ function parseArgs(argv) {
20
45
  kill: false,
21
46
  force: false,
22
47
  yes: false,
48
+ all: false,
23
49
  graceMs: 3000,
24
50
  color: true,
25
51
  help: false,
@@ -30,19 +56,23 @@ function parseArgs(argv) {
30
56
  throw new UsageError(`${flag} needs a value.`);
31
57
  return next;
32
58
  };
59
+ // A bad port is a usage error like any other, so it exits 2 with the same
60
+ // shape of message rather than as an unhandled failure.
61
+ const selector = (raw) => {
62
+ try {
63
+ return parsePortSelector(raw);
64
+ }
65
+ catch (error) {
66
+ throw new UsageError(error.message);
67
+ }
68
+ };
33
69
  for (let index = 0; index < argv.length; index += 1) {
34
70
  const argument = argv[index];
35
71
  switch (argument) {
36
72
  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;
73
+ case '--port':
74
+ options.port = selector(value(argument, argv[++index]));
44
75
  break;
45
- }
46
76
  case '-u':
47
77
  case '--udp':
48
78
  options.udp = true;
@@ -63,6 +93,9 @@ function parseArgs(argv) {
63
93
  case '--yes':
64
94
  options.yes = true;
65
95
  break;
96
+ case '--all':
97
+ options.all = true;
98
+ break;
66
99
  case '--grace': {
67
100
  const raw = value(argument, argv[++index]);
68
101
  const ms = Number.parseInt(raw, 10);
@@ -84,12 +117,9 @@ function parseArgs(argv) {
84
117
  options.version = true;
85
118
  break;
86
119
  default:
87
- if (/^\d+$/.test(argument) && options.port === null) {
120
+ if (looksLikePort(argument) && options.port === null) {
88
121
  // `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;
122
+ options.port = selector(argument);
93
123
  break;
94
124
  }
95
125
  throw new UsageError(`Unknown option: ${argument}`);
@@ -102,26 +132,40 @@ function parseArgs(argv) {
102
132
  if (options.kill) {
103
133
  if (options.port === null)
104
134
  throw new UsageError('--kill needs --port, so the target is named.');
105
- if (!options.yes)
106
- throw new UsageError('--kill needs --yes, so the kill is confirmed.');
135
+ if (!options.yes) {
136
+ throw new UsageError('--kill needs --yes, so the kill is confirmed.', EXIT_REFUSED);
137
+ }
138
+ // Whether a pattern happens to match one port today is not the point: the
139
+ // same command matches more tomorrow, so the plural is confirmed up front.
140
+ if (options.port.kind !== 'exact' && !options.all) {
141
+ throw new UsageError(`--kill needs --all to use ${options.port.text}, so killing every match is deliberate.`, EXIT_REFUSED);
142
+ }
107
143
  }
108
144
  if (options.force && !options.kill)
109
145
  throw new UsageError('--force only means something with --kill.');
146
+ if (options.all && !options.kill)
147
+ throw new UsageError('--all only means something with --kill.');
110
148
  return options;
111
149
  }
112
- const HELP = `slash-port see what is listening on your ports, and kill it safely.
150
+ const HELP = `slash-port - see what is listening on your ports, and kill it safely.
113
151
 
114
152
  Usage
115
- slash-port [port] [options]
153
+ slash-port [ports] [options]
154
+
155
+ Ports
156
+ 3000 one port
157
+ 3xxx every port from 3000 to 3999; x is any digit
158
+ 3000:3005 every port in the range, both ends included
116
159
 
117
160
  Options
118
- -p, --port <number> Only this port
161
+ -p, --port <ports> Only these ports, in any of the forms above
119
162
  -u, --udp Include UDP sockets as well as TCP
120
163
  --json Print JSON to stdout and exit
121
164
  --plain Print a plain table and exit
122
165
  --kill Kill the process on --port. Requires --yes
123
166
  --force Escalate to SIGKILL instead of SIGTERM
124
167
  -y, --yes Confirm a kill made from the command line
168
+ --all Kill every port a pattern or a range matches
125
169
  --grace <ms> Wait this long for a graceful exit (default 3000)
126
170
  --no-color Disable colour
127
171
  -h, --help Show this help
@@ -134,8 +178,10 @@ Keys
134
178
 
135
179
  Exit codes
136
180
  0 success
137
- 1 the requested action could not be completed
138
- 2 invalid usage
181
+ 1 invalid arguments or usage
182
+ 2 nothing is listening on the ports asked about
183
+ 3 refused: a confirmation was missing, or a guardrail tripped
184
+ 4 the operation was attempted and failed
139
185
 
140
186
  slash-port never kills without confirmation; never kills init, sshd, your
141
187
  session, or the shell that launched it; sends SIGTERM before SIGKILL; and
@@ -150,16 +196,20 @@ function readVersion() {
150
196
  }
151
197
  }
152
198
  function selectPort(entries, options) {
153
- if (options.port === null)
199
+ const selector = options.port;
200
+ if (selector === null)
154
201
  return [...entries];
155
- return entries.filter((entry) => entry.port === options.port && (options.udp || entry.protocol === 'tcp'));
202
+ return entries.filter((entry) => matchesPort(selector, entry.port) && (options.udp || entry.protocol === 'tcp'));
156
203
  }
157
204
  async function killFromCli(entries, options) {
205
+ // parseArgs refuses --kill without --port, so there is always a selector here.
206
+ const selector = options.port;
158
207
  const targets = selectPort(entries, options);
159
208
  if (targets.length === 0) {
160
- process.stderr.write(`Nothing is listening on port ${options.port}.\n`);
161
- return EXIT_FAILED;
209
+ process.stderr.write(`Nothing is listening on ${describePortSelector(selector)}.\n`);
210
+ return EXIT_NOT_FOUND;
162
211
  }
212
+ let refusals = 0;
163
213
  let failures = 0;
164
214
  for (const target of targets) {
165
215
  const result = await killEntry(target, {
@@ -168,10 +218,20 @@ async function killFromCli(entries, options) {
168
218
  });
169
219
  const ok = result.status === 'terminated' || result.status === 'gone';
170
220
  (ok ? process.stdout : process.stderr).write(`${result.message}\n`);
171
- if (!ok)
172
- failures += 1;
221
+ if (!ok) {
222
+ if (result.status === 'refused')
223
+ refusals += 1;
224
+ else
225
+ failures += 1;
226
+ }
173
227
  }
174
- return failures === 0 ? EXIT_OK : EXIT_FAILED;
228
+ // A guardrail that stopped a kill is a refusal; a signal that was sent and
229
+ // did not work is a failure, and outranks it when one command did both.
230
+ if (failures > 0)
231
+ return EXIT_FAILED;
232
+ if (refusals > 0)
233
+ return EXIT_REFUSED;
234
+ return EXIT_OK;
175
235
  }
176
236
  async function main(argv) {
177
237
  let options;
@@ -179,8 +239,13 @@ async function main(argv) {
179
239
  options = parseArgs(argv);
180
240
  }
181
241
  catch (error) {
182
- process.stderr.write(`${error.message}\n\nRun slash-port --help for usage.\n`);
183
- return EXIT_USAGE;
242
+ const code = error instanceof UsageError ? error.code : EXIT_USAGE;
243
+ process.stderr.write(`${error.message}\n`);
244
+ // A refusal names the flag that answers it, so the help pointer would be
245
+ // noise. A malformed command line is the case that needs pointing.
246
+ if (code === EXIT_USAGE)
247
+ process.stderr.write('\nRun slash-port --help for usage.\n');
248
+ return code;
184
249
  }
185
250
  if (options.help) {
186
251
  process.stdout.write(`${HELP}\n`);
@@ -220,12 +285,12 @@ async function main(argv) {
220
285
  else {
221
286
  process.stdout.write(`${plainTable(selected)}\n`);
222
287
  }
223
- // Asking about one port is a question with a yes-or-no answer, so an empty
224
- // result is a failure. Asking for the whole list is not.
225
- return options.port !== null && selected.length === 0 ? EXIT_FAILED : EXIT_OK;
288
+ // Asking about a port is a question with a yes-or-no answer, so an empty
289
+ // result means not found. Asking for the whole list is not a question.
290
+ return options.port !== null && selected.length === 0 ? EXIT_NOT_FOUND : EXIT_OK;
226
291
  }
227
292
  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 }));
293
+ const instance = render(_jsx(App, { initialEntries: entries, initialFilter: options.port === null ? '' : options.port.text, udp: options.udp }));
229
294
  await instance.waitUntilExit();
230
295
  return EXIT_OK;
231
296
  }
package/dist/describe.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { userInfo } from 'node:os';
1
2
  import { basename } from 'node:path';
2
3
  export const SIGNATURES = [
3
4
  // Node frameworks and dev servers, ahead of the runtime that hosts them.
@@ -228,8 +229,14 @@ const PROTECTED_NAMES = {
228
229
  systemd: 'the init process',
229
230
  launchd: 'the init process',
230
231
  kernel_task: 'the kernel',
231
- sshd: 'the SSH daemon killing it locks you out of a remote machine',
232
+ sshd: 'the SSH daemon - killing it locks you out of a remote machine',
232
233
  'ssh-agent': 'the SSH agent',
234
+ 'dbus-daemon': 'the D-Bus message bus - the desktop session is built on it',
235
+ // `/proc/[pid]/comm` is capped at fifteen characters, so the resolver
236
+ // arrives with its last letter missing. Both spellings are the same daemon.
237
+ 'systemd-resolve': 'the systemd DNS resolver - killing it takes DNS down for everything',
238
+ 'systemd-resolved': 'the systemd DNS resolver - killing it takes DNS down for everything',
239
+ 'systemd-logind': 'the systemd login manager - killing it ends every session on the machine',
233
240
  loginwindow: 'the macOS session',
234
241
  windowserver: 'the macOS window server',
235
242
  systemuiserver: 'the macOS session',
@@ -241,7 +248,7 @@ const PROTECTED_NAMES = {
241
248
  services: 'the Windows service controller',
242
249
  lsass: 'the Windows security subsystem',
243
250
  smss: 'a Windows session process',
244
- svchost: 'a Windows service host it runs many unrelated services',
251
+ svchost: 'a Windows service host - it runs many unrelated services',
245
252
  system: 'the Windows kernel',
246
253
  };
247
254
  /**
@@ -262,3 +269,54 @@ export function guardReason(socket, context = { self: process.pid, parent: proce
262
269
  return PROTECTED_NAMES[name];
263
270
  return null;
264
271
  }
272
+ function currentOwner() {
273
+ return {
274
+ uid: typeof process.getuid === 'function' ? process.getuid() : null,
275
+ // A uid with no passwd entry - a container running as a bare number -
276
+ // makes this throw rather than return anything useful.
277
+ user: (() => {
278
+ try {
279
+ return userInfo().username;
280
+ }
281
+ catch {
282
+ return null;
283
+ }
284
+ })(),
285
+ platform: process.platform,
286
+ };
287
+ }
288
+ /** Windows writes `MACHINE\amin`, and is not case sensitive about either half. */
289
+ function sameUser(a, b, platform) {
290
+ if (platform !== 'win32')
291
+ return a === b;
292
+ const bare = (name) => (name.split('\\').pop() ?? name).toLowerCase();
293
+ return bare(a) === bare(b);
294
+ }
295
+ /**
296
+ * Why a signal to this process is expected to be refused, or `null` when it
297
+ * should land.
298
+ *
299
+ * The scan already knows who owns every socket, so this is settled while the
300
+ * list is built rather than discovered after a confirmation. That is the whole
301
+ * point: "sudo" is worth knowing before you decide, not after. Nothing here
302
+ * refuses anything - `killEntry` still asks the kernel, which is the only
303
+ * authority on the answer.
304
+ */
305
+ export function elevationReason(socket, context = currentOwner()) {
306
+ // Root can signal anything.
307
+ if (context.uid === 0)
308
+ return null;
309
+ if (socket.pid === null) {
310
+ // Windows reports pid 0 for the idle process rather than hiding an owner,
311
+ // so an unresolved pid there is not a permission wall.
312
+ return context.platform === 'win32' ? null : 'its owner is not visible';
313
+ }
314
+ if (socket.user === null || context.user === null)
315
+ return null;
316
+ if (sameUser(socket.user, context.user, context.platform))
317
+ return null;
318
+ // The scanners fall back to the numeric uid when /etc/passwd has no name.
319
+ if (context.uid !== null && socket.user === String(context.uid))
320
+ return null;
321
+ return `it belongs to ${socket.user}`;
322
+ }
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) {
@@ -15,15 +16,29 @@ export function formatProcess(entry) {
15
16
  export function formatAddresses(entry) {
16
17
  return entry.addresses.join(', ');
17
18
  }
18
- /** The description column: what it is, which project, and whether it is protected. */
19
+ /**
20
+ * The description column: what it is, which project, and whether you can do
21
+ * anything about it. A guarded row is never also marked `[locked]` - it is
22
+ * refused whoever you are, so the extra badge would only add noise.
23
+ */
19
24
  export function formatDescription(entry) {
20
25
  const parts = [entry.label];
21
26
  if (entry.hint)
22
27
  parts.push(`(${entry.hint})`);
23
28
  if (entry.guard)
24
29
  parts.push('[protected]');
30
+ else if (entry.elevation)
31
+ parts.push('[locked]');
25
32
  return parts.join(' ');
26
33
  }
34
+ /**
35
+ * What to do about a locked row. `sudo` is the answer almost everywhere and
36
+ * the wrong word on Windows, so the remedy is named per platform while the
37
+ * badge stays the same in every terminal.
38
+ */
39
+ export function elevationRemedy(platform = process.platform) {
40
+ return platform === 'win32' ? 'an elevated terminal' : 'sudo';
41
+ }
27
42
  /** Everything a row can be matched on, lowercased once for filtering. */
28
43
  export function searchText(entry) {
29
44
  return [
@@ -44,6 +59,11 @@ export function matchesFilter(entry, filter) {
44
59
  const needle = filter.trim().toLowerCase();
45
60
  if (!needle)
46
61
  return true;
62
+ // `3xxx` and `3000:3005` can mean nothing but ports, so they are matched as
63
+ // ports. A bare `3000` stays a substring, because it is also half a pid.
64
+ const selector = tryPortSelector(needle);
65
+ if (selector)
66
+ return matchesPort(selector, entry.port);
47
67
  return searchText(entry).includes(needle);
48
68
  }
49
69
  /**
@@ -83,5 +103,7 @@ export function toJson(entries) {
83
103
  project: entry.hint,
84
104
  protected: entry.guard !== null,
85
105
  protectedReason: entry.guard,
106
+ locked: entry.elevation !== null,
107
+ lockedReason: entry.elevation,
86
108
  }));
87
109
  }
package/dist/kill.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { guardReason } from './describe.js';
2
+ import { elevationRemedy } from './format.js';
2
3
  const defaultWait = (ms) => new Promise((resolve) => {
3
4
  setTimeout(resolve, ms);
4
5
  });
@@ -35,7 +36,7 @@ function describeTarget(entry) {
35
36
  * On Windows there is no signal delivery: SIGTERM becomes TerminateProcess,
36
37
  * which cannot be caught or ignored, so a process never gets the chance to
37
38
  * shut down cleanly and `survived` is unreachable. The grace period still
38
- * applies it is how long the process is given to disappear.
39
+ * applies - it is how long the process is given to disappear.
39
40
  */
40
41
  export async function killEntry(entry, options = {}) {
41
42
  const { signal = 'SIGTERM', graceMs = 3000, pollMs = 50, kill = process.kill, wait = defaultWait } = options;
@@ -51,7 +52,7 @@ export async function killEntry(entry, options = {}) {
51
52
  return {
52
53
  status: 'unresolved',
53
54
  signal: null,
54
- message: `Port ${entry.port} has an owner slash-port cannot see. Re-run with sudo to resolve it.`,
55
+ message: `Port ${entry.port} has an owner slash-port cannot see. Re-run with ${elevationRemedy()} to resolve it.`,
55
56
  };
56
57
  }
57
58
  const before = probe(entry.pid, kill);
@@ -66,7 +67,7 @@ export async function killEntry(entry, options = {}) {
66
67
  return {
67
68
  status: 'denied',
68
69
  signal: null,
69
- message: `${describeTarget(entry)} belongs to another user. Re-run with sudo to signal it.`,
70
+ message: `${describeTarget(entry)} belongs to another user. Re-run with ${elevationRemedy()} to signal it.`,
70
71
  };
71
72
  }
72
73
  try {
@@ -85,7 +86,7 @@ export async function killEntry(entry, options = {}) {
85
86
  return {
86
87
  status: 'denied',
87
88
  signal: null,
88
- message: `${describeTarget(entry)} belongs to another user. Re-run with sudo to signal it.`,
89
+ message: `${describeTarget(entry)} belongs to another user. Re-run with ${elevationRemedy()} to signal it.`,
89
90
  };
90
91
  }
91
92
  return {
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
+ }
@@ -1,4 +1,4 @@
1
- import { describe, guardReason } from '../describe.js';
1
+ import { describe, elevationReason, guardReason } from '../describe.js';
2
2
  import { ScanError } from '../types.js';
3
3
  import { scanDarwin } from './darwin.js';
4
4
  import { scanLinux } from './linux.js';
@@ -51,6 +51,7 @@ export function collapse(sockets) {
51
51
  label: description.label,
52
52
  hint: description.hint,
53
53
  guard: guardReason(base),
54
+ elevation: elevationReason(base),
54
55
  });
55
56
  }
56
57
  return sortEntries(entries);
@@ -18,7 +18,7 @@ export function parseNetstat(output) {
18
18
  if (protocol !== 'TCP' && protocol !== 'UDP')
19
19
  continue;
20
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.
21
+ // listening when it has no peer - only LISTEN has a foreign port of zero.
22
22
  if (protocol === 'TCP') {
23
23
  if (fields.length < 5)
24
24
  continue;
package/dist/ui/App.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useApp, useInput, useStdout } from 'ink';
3
3
  import { useCallback, useEffect, useMemo, useState } from 'react';
4
- import { formatAddresses, formatDescription, formatPid, formatPort, formatProcess, formatUser, matchesFilter, } from '../format.js';
4
+ import { elevationRemedy, formatAddresses, formatDescription, formatPid, formatPort, formatProcess, formatUser, matchesFilter, } from '../format.js';
5
5
  import { killEntry } from '../kill.js';
6
6
  import { scan } from '../scan/index.js';
7
7
  import { cell, color, layout, truncate } from './theme.js';
@@ -75,8 +75,8 @@ export function App({ initialEntries, initialFilter = '', udp: initialUdp = fals
75
75
  }, []);
76
76
  const visible = useMemo(() => entries.filter((entry) => matchesFilter(entry, filter)), [entries, filter]);
77
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.
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
80
  useEffect(() => {
81
81
  setSelected((current) => Math.min(current, Math.max(0, visible.length - 1)));
82
82
  }, [visible.length]);
@@ -199,7 +199,13 @@ export function App({ initialEntries, initialFilter = '', udp: initialUdp = fals
199
199
  ? 'scanning…'
200
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
201
  const isSelected = offset + index === selected;
202
- const rowColor = entry.guard ? color('warn') : entry.pid === null ? color('muted') : undefined;
202
+ // Muted means "you can look at this but not touch it": either the
203
+ // owner is somebody else, or there is no owner to signal at all.
204
+ const rowColor = entry.guard
205
+ ? color('warn')
206
+ : entry.elevation || entry.pid === null
207
+ ? color('muted')
208
+ : undefined;
203
209
  const left = columnise({
204
210
  port: formatPort(entry),
205
211
  pid: formatPid(entry),
@@ -219,5 +225,5 @@ function ConfirmDialog({ entry, width }) {
219
225
  ]
220
226
  .filter(Boolean)
221
227
  .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"] })] }));
228
+ 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) }), entry.elevation && (_jsx(Text, { color: color('warn'), children: truncate(`Without ${elevationRemedy()} this will be refused: ${entry.elevation}.`, 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
229
  }
package/dist/ui/theme.js CHANGED
@@ -51,7 +51,7 @@ const TARGET_DESCRIPTION = 28;
51
51
  *
52
52
  * Columns are allocated by how much they carry. The port is never dropped,
53
53
  * the description is reserved next because it is the reason the tool exists,
54
- * and the rest compete for what is left so an 80-column window keeps the
54
+ * and the rest compete for what is left - so an 80-column window keeps the
55
55
  * columns that matter and a 40-column one still reads.
56
56
  *
57
57
  * A zero width means the column is not shown at all.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slash-port",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "See what is listening on your ports, understand what it is, and kill it safely.",
5
5
  "keywords": [
6
6
  "port",
@@ -48,10 +48,10 @@
48
48
  "react": "^19.2.0"
49
49
  },
50
50
  "devDependencies": {
51
- "@types/node": "^24.0.0",
51
+ "@types/node": "^26.4.0",
52
52
  "@types/react": "^19.2.0",
53
53
  "ink-testing-library": "^4.0.0",
54
- "typescript": "^5.9.0",
54
+ "typescript": "^7.0.2",
55
55
  "vitest": "^4.1.0"
56
56
  }
57
57
  }