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 +78 -11
- package/dist/cli.js +61 -18
- package/dist/describe.js +60 -2
- package/dist/format.js +17 -1
- package/dist/kill.js +5 -4
- package/dist/ports.js +3 -3
- package/dist/scan/index.js +2 -1
- package/dist/scan/win32.js +1 -1
- package/dist/ui/App.js +11 -5
- package/dist/ui/theme.js +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# slash-port
|
|
2
2
|
|
|
3
|
+
<!-- release:badge -->[](https://github.com/Slash-ui/slash-port/releases/tag/v0.2.1)<!-- /release:badge -->
|
|
3
4
|
[](https://www.npmjs.com/package/slash-port)
|
|
4
5
|
[](https://www.npmjs.com/package/slash-port)
|
|
5
6
|
[](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
|
|
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` |
|
|
127
|
-
| `2` |
|
|
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 `
|
|
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`
|
|
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
|
|
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
|
|
169
|
-
`[protected]` as well as
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
12
|
-
const
|
|
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
|
|
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
|
|
157
|
-
2
|
|
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
|
|
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
|
-
|
|
221
|
+
if (!ok) {
|
|
222
|
+
if (result.status === 'refused')
|
|
223
|
+
refusals += 1;
|
|
224
|
+
else
|
|
225
|
+
failures += 1;
|
|
226
|
+
}
|
|
195
227
|
}
|
|
196
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
|
246
|
-
// result
|
|
247
|
-
return options.port !== null && selected.length === 0 ?
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
*/
|
package/dist/scan/index.js
CHANGED
|
@@ -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);
|
package/dist/scan/win32.js
CHANGED
|
@@ -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
|
|
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
|
|
79
|
-
// rescan after a kill
|
|
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
|
-
|
|
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
|
|
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.
|
|
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": "^
|
|
51
|
+
"@types/node": "^26.4.0",
|
|
52
52
|
"@types/react": "^19.2.0",
|
|
53
53
|
"ink-testing-library": "^4.0.0",
|
|
54
|
-
"typescript": "^
|
|
54
|
+
"typescript": "^7.0.2",
|
|
55
55
|
"vitest": "^4.1.0"
|
|
56
56
|
}
|
|
57
57
|
}
|