slash-port 0.2.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,6 +44,49 @@ 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
@@ -67,7 +111,7 @@ slash-port --udp # include UDP as well as TCP
67
111
 
68
112
  A pattern is read as digits, not as a number, so `3xxx` is the four-digit ports
69
113
  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
114
+ pattern that can only match them - `7xxxx` - is a usage error rather than an
71
115
  empty list.
72
116
 
73
117
  To kill something from a script, name it and confirm it:
@@ -120,17 +164,28 @@ cancels. The kill key is deliberately not next to a navigation key.
120
164
 
121
165
  ### Exit codes
122
166
 
167
+ The same codes across every `slash-*` tool, so a script that wraps one can wrap
168
+ any of them:
169
+
123
170
  | Code | Means |
124
171
  | --- | --- |
125
172
  | `0` | Success |
126
- | `1` | The requested action could not be completed |
127
- | `2` | Invalid usage |
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 |
128
177
 
129
178
  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
179
+ `slash-port --port 3000 --plain` exits `2` when nothing is listening there, and
131
180
  so does `--port 3xxx` when nothing matches. Listing every port exits `0` even
132
181
  when the list is empty.
133
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.
188
+
134
189
  ## Safety
135
190
 
136
191
  Killing the wrong process at a terminal is easy and unrecoverable, so the rules
@@ -141,8 +196,12 @@ are fixed rather than configurable:
141
196
  - **A pattern or a range never kills on its own.** `--kill --port 3xxx --yes`
142
197
  is refused without `--all`, because what a pattern matches depends on what
143
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.
144
203
  - **Some processes are refused outright**, before any dialog is offered: the
145
- 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
146
205
  and Windows session processes, `slash-port` itself, and the shell that
147
206
  launched it.
148
207
  - **SIGTERM before SIGKILL.** A process that ignores SIGTERM is reported as
@@ -152,7 +211,7 @@ are fixed rather than configurable:
152
211
 
153
212
  On Windows there is no signal delivery: SIGTERM becomes `TerminateProcess`,
154
213
  which a process cannot catch or ignore, so nothing there gets the chance to
155
- shut down cleanly. The confirmation still applies but "terminate" and "force"
214
+ shut down cleanly. The confirmation still applies - but "terminate" and "force"
156
215
  do the same thing.
157
216
 
158
217
  ## Privacy
@@ -165,8 +224,9 @@ telemetry, no update check, and no configuration file.
165
224
 
166
225
  - Only the sixteen named terminal colours, so the display inherits your theme
167
226
  rather than fighting it.
168
- - Colour never carries meaning on its own a protected row is labelled
169
- `[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.
170
230
  - `NO_COLOR` and `--no-color` are honoured.
171
231
  - Redirected or piped output is plain text with no control codes, and the
172
232
  interactive interface never starts unless both streams are a terminal.
@@ -184,7 +244,7 @@ Three sources, in priority order:
184
244
  2. **The project.** The directory above `node_modules` in the command line, so
185
245
  two Vite servers on 5173 and 5174 can be told apart.
186
246
  3. **A well-known port registry**, used only when the process itself could not
187
- be identified mostly other users' processes. Entries that would add
247
+ be identified - mostly other users' processes. Entries that would add
188
248
  nothing are suppressed: "dev server" on port 3000 is not information.
189
249
 
190
250
  Per platform:
@@ -192,7 +252,7 @@ Per platform:
192
252
  - **Linux** reads `/proc/net/tcp` and maps socket inodes through
193
253
  `/proc/[pid]/fd`. No `lsof`, which many container images do not have, and no
194
254
  subprocess. Descriptors belonging to other users are not readable without
195
- 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
196
256
  with `sudo` to resolve them.
197
257
  - **macOS** uses `lsof` in field-output mode, plus `ps` for full command lines.
198
258
  - **Windows** uses `netstat -ano` and `tasklist`, which exist on every edition
@@ -210,7 +270,7 @@ oversights:
210
270
  `--tree` option would signal the whole group.
211
271
  - **Watch mode.** The list rescans on `r`, not on a timer.
212
272
  - **Port history.** "What was on 3000 an hour ago" needs persistent state, and
213
- this tool currently has none which is worth keeping.
273
+ this tool currently has none - which is worth keeping.
214
274
 
215
275
  ## Versioning and releases
216
276
 
@@ -222,6 +282,13 @@ and a patch. Every release is published with npm
222
282
  [provenance](https://docs.npmjs.com/generating-provenance-statements), so the
223
283
  tarball can be traced to the exact commit and workflow that built it.
224
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
+
225
292
  See the [changelog](CHANGELOG.md) for what changed when.
226
293
 
227
294
  ## Contributing
package/dist/cli.js CHANGED
@@ -6,11 +6,35 @@ import { killEntry } from './kill.js';
6
6
  import { describePortSelector, looksLikePort, matchesPort, parsePortSelector } from './ports.js';
7
7
  import { scan } from './scan/index.js';
8
8
  import { ScanError } from './types.js';
9
- /** 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
+ */
10
22
  const EXIT_OK = 0;
11
- const EXIT_FAILED = 1;
12
- 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
+ */
13
32
  class UsageError extends Error {
33
+ code;
34
+ constructor(message, code = EXIT_USAGE) {
35
+ super(message);
36
+ this.code = code;
37
+ }
14
38
  }
15
39
  function parseArgs(argv) {
16
40
  const options = {
@@ -108,12 +132,13 @@ function parseArgs(argv) {
108
132
  if (options.kill) {
109
133
  if (options.port === null)
110
134
  throw new UsageError('--kill needs --port, so the target is named.');
111
- if (!options.yes)
112
- 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
+ }
113
138
  // Whether a pattern happens to match one port today is not the point: the
114
139
  // same command matches more tomorrow, so the plural is confirmed up front.
115
140
  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.`);
141
+ throw new UsageError(`--kill needs --all to use ${options.port.text}, so killing every match is deliberate.`, EXIT_REFUSED);
117
142
  }
118
143
  }
119
144
  if (options.force && !options.kill)
@@ -122,7 +147,7 @@ function parseArgs(argv) {
122
147
  throw new UsageError('--all only means something with --kill.');
123
148
  return options;
124
149
  }
125
- 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.
126
151
 
127
152
  Usage
128
153
  slash-port [ports] [options]
@@ -153,8 +178,10 @@ Keys
153
178
 
154
179
  Exit codes
155
180
  0 success
156
- 1 the requested action could not be completed
157
- 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
158
185
 
159
186
  slash-port never kills without confirmation; never kills init, sshd, your
160
187
  session, or the shell that launched it; sends SIGTERM before SIGKILL; and
@@ -180,8 +207,9 @@ async function killFromCli(entries, options) {
180
207
  const targets = selectPort(entries, options);
181
208
  if (targets.length === 0) {
182
209
  process.stderr.write(`Nothing is listening on ${describePortSelector(selector)}.\n`);
183
- return EXIT_FAILED;
210
+ return EXIT_NOT_FOUND;
184
211
  }
212
+ let refusals = 0;
185
213
  let failures = 0;
186
214
  for (const target of targets) {
187
215
  const result = await killEntry(target, {
@@ -190,10 +218,20 @@ async function killFromCli(entries, options) {
190
218
  });
191
219
  const ok = result.status === 'terminated' || result.status === 'gone';
192
220
  (ok ? process.stdout : process.stderr).write(`${result.message}\n`);
193
- if (!ok)
194
- failures += 1;
221
+ if (!ok) {
222
+ if (result.status === 'refused')
223
+ refusals += 1;
224
+ else
225
+ failures += 1;
226
+ }
195
227
  }
196
- 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;
197
235
  }
198
236
  async function main(argv) {
199
237
  let options;
@@ -201,8 +239,13 @@ async function main(argv) {
201
239
  options = parseArgs(argv);
202
240
  }
203
241
  catch (error) {
204
- process.stderr.write(`${error.message}\n\nRun slash-port --help for usage.\n`);
205
- 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;
206
249
  }
207
250
  if (options.help) {
208
251
  process.stdout.write(`${HELP}\n`);
@@ -242,9 +285,9 @@ async function main(argv) {
242
285
  else {
243
286
  process.stdout.write(`${plainTable(selected)}\n`);
244
287
  }
245
- // Asking about one port is a question with a yes-or-no answer, so an empty
246
- // result is a failure. Asking for the whole list is not.
247
- 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;
248
291
  }
249
292
  const [{ render }, { App }] = await Promise.all([import('ink'), import('./ui/App.js')]);
250
293
  const instance = render(_jsx(App, { initialEntries: entries, initialFilter: options.port === null ? '' : options.port.text, udp: options.udp }));
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
@@ -16,15 +16,29 @@ export function formatProcess(entry) {
16
16
  export function formatAddresses(entry) {
17
17
  return entry.addresses.join(', ');
18
18
  }
19
- /** 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
+ */
20
24
  export function formatDescription(entry) {
21
25
  const parts = [entry.label];
22
26
  if (entry.hint)
23
27
  parts.push(`(${entry.hint})`);
24
28
  if (entry.guard)
25
29
  parts.push('[protected]');
30
+ else if (entry.elevation)
31
+ parts.push('[locked]');
26
32
  return parts.join(' ');
27
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
+ }
28
42
  /** Everything a row can be matched on, lowercased once for filtering. */
29
43
  export function searchText(entry) {
30
44
  return [
@@ -89,5 +103,7 @@ export function toJson(entries) {
89
103
  project: entry.hint,
90
104
  protected: entry.guard !== null,
91
105
  protectedReason: entry.guard,
106
+ locked: entry.elevation !== null,
107
+ lockedReason: entry.elevation,
92
108
  }));
93
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 CHANGED
@@ -24,7 +24,7 @@ function portNumber(raw) {
24
24
  /**
25
25
  * `3xxx` is every four-digit port beginning with 3, so a pattern is matched
26
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
27
+ * to 999. The bounds come from replacing every `x` with 0 and with 9 - a
28
28
  * leading `x` with 1, because no port is written with a leading zero.
29
29
  */
30
30
  function parsePattern(text) {
@@ -95,8 +95,8 @@ export function looksLikePort(raw) {
95
95
  return /^\d/.test(raw) || /^x[\dx]*$/i.test(raw);
96
96
  }
97
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
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
100
  * find pids, users, and project names. Half-typed input is not an error there,
101
101
  * so an unreadable selector is simply not one.
102
102
  */
@@ -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.2.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
  }