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 +111 -15
- package/dist/cli.js +101 -36
- package/dist/describe.js +60 -2
- package/dist/format.js +23 -1
- package/dist/kill.js +5 -4
- package/dist/ports.js +113 -0
- 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,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
|
|
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 <
|
|
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` |
|
|
102
|
-
| `2` |
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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`
|
|
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
|
|
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
|
|
140
|
-
`[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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
11
|
-
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
|
+
*/
|
|
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
|
-
|
|
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 (
|
|
120
|
+
if (looksLikePort(argument) && options.port === null) {
|
|
88
121
|
// `slash-port 3000` is what everyone tries first.
|
|
89
|
-
|
|
90
|
-
if (port < 1 || port > 65535)
|
|
91
|
-
throw new UsageError(`${argument} is not a port number.`);
|
|
92
|
-
options.port = port;
|
|
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
|
|
150
|
+
const HELP = `slash-port - see what is listening on your ports, and kill it safely.
|
|
113
151
|
|
|
114
152
|
Usage
|
|
115
|
-
slash-port [
|
|
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 <
|
|
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
|
|
138
|
-
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
|
|
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
|
-
|
|
199
|
+
const selector = options.port;
|
|
200
|
+
if (selector === null)
|
|
154
201
|
return [...entries];
|
|
155
|
-
return entries.filter((entry) => entry.port
|
|
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
|
|
161
|
-
return
|
|
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
|
-
|
|
221
|
+
if (!ok) {
|
|
222
|
+
if (result.status === 'refused')
|
|
223
|
+
refusals += 1;
|
|
224
|
+
else
|
|
225
|
+
failures += 1;
|
|
226
|
+
}
|
|
173
227
|
}
|
|
174
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
|
224
|
-
// result
|
|
225
|
-
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;
|
|
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 ? '' :
|
|
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
|
|
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
|
@@ -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
|
-
/**
|
|
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
|
|
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
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three ways to name ports, shared by `--port` and the filter box: one
|
|
3
|
+
* port, a pattern where `x` stands for any digit, or an inclusive range.
|
|
4
|
+
*/
|
|
5
|
+
const MIN_PORT = 1;
|
|
6
|
+
const MAX_PORT = 65535;
|
|
7
|
+
/** A selector that could not be read. The message is written for the user. */
|
|
8
|
+
export class PortSelectorError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'PortSelectorError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function exact(port) {
|
|
15
|
+
return { kind: 'exact', text: String(port), from: port, to: port, pattern: null };
|
|
16
|
+
}
|
|
17
|
+
function portNumber(raw) {
|
|
18
|
+
const port = /^\d{1,5}$/.test(raw) ? Number.parseInt(raw, 10) : Number.NaN;
|
|
19
|
+
if (!(port >= MIN_PORT && port <= MAX_PORT)) {
|
|
20
|
+
throw new PortSelectorError(`${raw} is not a port number between 1 and 65535.`);
|
|
21
|
+
}
|
|
22
|
+
return port;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* `3xxx` is every four-digit port beginning with 3, so a pattern is matched
|
|
26
|
+
* against the port's digits rather than its value: `xxx` is 100 to 999, not 0
|
|
27
|
+
* to 999. The bounds come from replacing every `x` with 0 and with 9 - a
|
|
28
|
+
* leading `x` with 1, because no port is written with a leading zero.
|
|
29
|
+
*/
|
|
30
|
+
function parsePattern(text) {
|
|
31
|
+
if (!/^[\dx]+$/.test(text)) {
|
|
32
|
+
throw new PortSelectorError(`${text} is not a port pattern. Write it as 3xxx, where x is any digit.`);
|
|
33
|
+
}
|
|
34
|
+
const from = Number.parseInt(text.replace(/^x/, '1').replace(/x/g, '0'), 10);
|
|
35
|
+
const to = Number.parseInt(text.replace(/x/g, '9'), 10);
|
|
36
|
+
if (from < MIN_PORT || from > MAX_PORT) {
|
|
37
|
+
throw new PortSelectorError(`No port between 1 and 65535 matches ${text}.`);
|
|
38
|
+
}
|
|
39
|
+
return { kind: 'pattern', text, from, to: Math.min(to, MAX_PORT), pattern: text };
|
|
40
|
+
}
|
|
41
|
+
function parseRange(text) {
|
|
42
|
+
const halves = text.split(':');
|
|
43
|
+
if (halves.length !== 2 || halves[0] === '' || halves[1] === '') {
|
|
44
|
+
throw new PortSelectorError(`${text} is not a port range. Write it as 3000:3005.`);
|
|
45
|
+
}
|
|
46
|
+
const from = portNumber(halves[0]);
|
|
47
|
+
const to = portNumber(halves[1]);
|
|
48
|
+
if (from > to) {
|
|
49
|
+
throw new PortSelectorError(`The range ${text} runs backwards. Write it as ${to}:${from}.`);
|
|
50
|
+
}
|
|
51
|
+
// A range of one port is that port, and is treated as one everywhere after.
|
|
52
|
+
if (from === to)
|
|
53
|
+
return exact(from);
|
|
54
|
+
return { kind: 'range', text: `${from}:${to}`, from, to, pattern: null };
|
|
55
|
+
}
|
|
56
|
+
/** Reads `3000`, `3xxx`, or `3000:3005`. Throws a `PortSelectorError` otherwise. */
|
|
57
|
+
export function parsePortSelector(raw) {
|
|
58
|
+
const text = raw.trim().toLowerCase();
|
|
59
|
+
if (text.includes(':'))
|
|
60
|
+
return parseRange(text);
|
|
61
|
+
if (text.includes('x'))
|
|
62
|
+
return parsePattern(text);
|
|
63
|
+
if (!/^\d+$/.test(text)) {
|
|
64
|
+
throw new PortSelectorError(`${raw} is not a port, a pattern like 3xxx, or a range like 3000:3005.`);
|
|
65
|
+
}
|
|
66
|
+
return exact(portNumber(text));
|
|
67
|
+
}
|
|
68
|
+
export function matchesPort(selector, port) {
|
|
69
|
+
if (port < selector.from || port > selector.to)
|
|
70
|
+
return false;
|
|
71
|
+
if (selector.pattern === null)
|
|
72
|
+
return true;
|
|
73
|
+
const digits = String(port);
|
|
74
|
+
if (digits.length !== selector.pattern.length)
|
|
75
|
+
return false;
|
|
76
|
+
return [...selector.pattern].every((character, index) => character === 'x' || character === digits[index]);
|
|
77
|
+
}
|
|
78
|
+
/** How a selector is named in a message, e.g. "Nothing is listening on …". */
|
|
79
|
+
export function describePortSelector(selector) {
|
|
80
|
+
switch (selector.kind) {
|
|
81
|
+
case 'exact':
|
|
82
|
+
return `port ${selector.from}`;
|
|
83
|
+
case 'pattern':
|
|
84
|
+
return `ports matching ${selector.text}`;
|
|
85
|
+
default:
|
|
86
|
+
return `ports ${selector.from} to ${selector.to}`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Whether a bare argument was meant as a port, so `slash-port 3xxx` works like
|
|
91
|
+
* `slash-port 3000` and a typo in one is reported as a bad port rather than as
|
|
92
|
+
* an unknown option.
|
|
93
|
+
*/
|
|
94
|
+
export function looksLikePort(raw) {
|
|
95
|
+
return /^\d/.test(raw) || /^x[\dx]*$/i.test(raw);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The filter box reads `3xxx` and `3000:3005` as ports, and everything else -
|
|
99
|
+
* including a bare `3000` - as a substring, because the filter still has to
|
|
100
|
+
* find pids, users, and project names. Half-typed input is not an error there,
|
|
101
|
+
* so an unreadable selector is simply not one.
|
|
102
|
+
*/
|
|
103
|
+
export function tryPortSelector(raw) {
|
|
104
|
+
const text = raw.trim().toLowerCase();
|
|
105
|
+
if (!/^\d*x[\dx]*$/.test(text) && !/^\d+:\d+$/.test(text))
|
|
106
|
+
return null;
|
|
107
|
+
try {
|
|
108
|
+
return parsePortSelector(text);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
package/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.1
|
|
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
|
}
|