slash-port 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +279 -33
- package/dist/cli.js +105 -23
- package/dist/describe.js +667 -173
- package/dist/docker.js +208 -0
- package/dist/explain.js +209 -0
- package/dist/format.js +43 -5
- package/dist/inspect.js +271 -0
- package/dist/kill.js +6 -6
- package/dist/mode.js +46 -0
- package/dist/ports.js +3 -3
- package/dist/scan/index.js +31 -10
- package/dist/scan/win32.js +1 -1
- package/dist/ui/App.js +187 -27
- package/dist/ui/theme.js +124 -10
- package/package.json +3 -3
package/dist/docker.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { request } from 'node:http';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { posix } from 'node:path';
|
|
5
|
+
import { browserUrl, matchSignatureEntry, redundantHint } from './describe.js';
|
|
6
|
+
/**
|
|
7
|
+
* Where the engine listens, in the order worth trying. Docker Desktop, Colima,
|
|
8
|
+
* Rancher, and a plain Linux install each put the socket somewhere different,
|
|
9
|
+
* and the alternative to knowing all four is telling a Colima user their
|
|
10
|
+
* containers do not exist.
|
|
11
|
+
*/
|
|
12
|
+
export function socketCandidates(env = process.env, platform = process.platform, home = homedir()) {
|
|
13
|
+
const host = env['DOCKER_HOST'];
|
|
14
|
+
// A TCP DOCKER_HOST is somebody else's machine, and slash-port does not make
|
|
15
|
+
// network connections. Falling through to the local sockets would answer a
|
|
16
|
+
// question about the wrong host, so it is left unanswered instead.
|
|
17
|
+
if (host?.startsWith('unix://'))
|
|
18
|
+
return [host.slice('unix://'.length)];
|
|
19
|
+
// Docker Desktop on Windows sets `npipe:////./pipe/docker_engine`, which is
|
|
20
|
+
// the same pipe written the other way round.
|
|
21
|
+
if (host?.startsWith('npipe://')) {
|
|
22
|
+
return [host.slice('npipe://'.length).replace(/\//g, '\\')];
|
|
23
|
+
}
|
|
24
|
+
if (host)
|
|
25
|
+
return [];
|
|
26
|
+
if (platform === 'win32')
|
|
27
|
+
return ['\\\\.\\pipe\\docker_engine'];
|
|
28
|
+
// Joined as posix paths rather than with `join`, which follows the host
|
|
29
|
+
// separator. These four are unix sockets whatever machine is asking about
|
|
30
|
+
// them, and the win32 branch above has already taken the one platform where
|
|
31
|
+
// a backslash would have been right.
|
|
32
|
+
return [
|
|
33
|
+
'/var/run/docker.sock',
|
|
34
|
+
posix.join(home, '.docker/run/docker.sock'),
|
|
35
|
+
posix.join(home, '.colima/default/docker.sock'),
|
|
36
|
+
posix.join(home, '.rd/docker.sock'),
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
function plausibleSockets(candidates) {
|
|
40
|
+
// A named pipe is not a file, so on Windows the only way to find out is to
|
|
41
|
+
// try it - which the request below does, and gives up on quickly.
|
|
42
|
+
return candidates.filter((path) => path.startsWith('\\\\') || existsSync(path));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Ask the local engine over its unix socket. Nothing here touches the network:
|
|
46
|
+
* this is a file on this machine, the same as `/proc/net/tcp` is.
|
|
47
|
+
*
|
|
48
|
+
* Every failure is the same failure - no annotations - because a machine
|
|
49
|
+
* without Docker, a machine whose user is not in the `docker` group, and a
|
|
50
|
+
* machine whose engine is starting up are all cases where the right thing to
|
|
51
|
+
* do is describe the port the way it would have been described anyway.
|
|
52
|
+
*/
|
|
53
|
+
/** Enough of a container list to name a port; a bigger reply is not one. */
|
|
54
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
55
|
+
async function listContainers(socketPath, timeoutMs) {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
let settled = false;
|
|
58
|
+
const finish = (value) => {
|
|
59
|
+
if (settled)
|
|
60
|
+
return;
|
|
61
|
+
settled = true;
|
|
62
|
+
clearTimeout(deadline);
|
|
63
|
+
call.destroy();
|
|
64
|
+
resolve(value);
|
|
65
|
+
};
|
|
66
|
+
const call = request({
|
|
67
|
+
socketPath,
|
|
68
|
+
path: '/containers/json',
|
|
69
|
+
method: 'GET',
|
|
70
|
+
// Not the global agent: its keep-alive would park a connection to the
|
|
71
|
+
// engine for the life of the interface.
|
|
72
|
+
agent: false,
|
|
73
|
+
}, (response) => {
|
|
74
|
+
if (response.statusCode !== 200) {
|
|
75
|
+
response.resume();
|
|
76
|
+
finish([]);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
let body = '';
|
|
80
|
+
response.setEncoding('utf8');
|
|
81
|
+
response.on('data', (chunk) => {
|
|
82
|
+
body += chunk;
|
|
83
|
+
if (body.length > MAX_BODY_BYTES)
|
|
84
|
+
finish([]);
|
|
85
|
+
});
|
|
86
|
+
response.on('end', () => {
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(body);
|
|
89
|
+
finish(Array.isArray(parsed) ? parsed : []);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
finish([]);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
// A deadline, not `timeout`, which arms `socket.setTimeout` and so only
|
|
97
|
+
// fires after that long with no traffic at all. An engine dribbling out a
|
|
98
|
+
// reply would keep resetting it and hold the scan open indefinitely.
|
|
99
|
+
const deadline = setTimeout(() => finish([]), timeoutMs);
|
|
100
|
+
call.on('error', () => finish([]));
|
|
101
|
+
call.end();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/** Published port to the container behind it. */
|
|
105
|
+
export function indexPorts(containers) {
|
|
106
|
+
const index = new Map();
|
|
107
|
+
for (const container of containers) {
|
|
108
|
+
const name = container.Names?.[0]?.replace(/^\//, '') ?? null;
|
|
109
|
+
const image = container.Image ?? null;
|
|
110
|
+
if (!name || !image)
|
|
111
|
+
continue;
|
|
112
|
+
const info = {
|
|
113
|
+
name,
|
|
114
|
+
image,
|
|
115
|
+
project: container.Labels?.['com.docker.compose.project'] ?? null,
|
|
116
|
+
service: container.Labels?.['com.docker.compose.service'] ?? null,
|
|
117
|
+
};
|
|
118
|
+
for (const published of container.Ports ?? []) {
|
|
119
|
+
// An unpublished port is not reachable from here and will not appear in
|
|
120
|
+
// the socket table, so indexing it would only invite a false match.
|
|
121
|
+
if (typeof published.PublicPort === 'number')
|
|
122
|
+
index.set(published.PublicPort, info);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return index;
|
|
126
|
+
}
|
|
127
|
+
/** `postgres:16-alpine` and `ghcr.io/org/api:sha-1234` are both one word. */
|
|
128
|
+
export function imageName(image) {
|
|
129
|
+
const withoutTag = image.replace(/@sha256:[\da-f]+$/i, '').replace(/:[^:/]+$/, '');
|
|
130
|
+
return withoutTag.split('/').pop() || image;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Fold what the container knows into the row.
|
|
134
|
+
*
|
|
135
|
+
* This is the difference between "Docker published port" - true, and useless -
|
|
136
|
+
* and "PostgreSQL in Docker (shop)", which names the thing and the project it
|
|
137
|
+
* belongs to. The image is run through the same signatures as a command line,
|
|
138
|
+
* so `postgres:16` reads as PostgreSQL and inherits everything that goes with
|
|
139
|
+
* being a database, including how carefully to treat it.
|
|
140
|
+
*/
|
|
141
|
+
export function annotate(entry, container) {
|
|
142
|
+
const signature = matchSignatureEntry(`${imageName(container.image)} ${container.name}`);
|
|
143
|
+
const what = signature?.label ?? imageName(container.image);
|
|
144
|
+
const label = `${what} in Docker`;
|
|
145
|
+
// A compose project called `docker` is Docker's own honest answer and still
|
|
146
|
+
// tells nobody anything, so the container's name takes over when the project
|
|
147
|
+
// would only repeat the label.
|
|
148
|
+
const project = container.project && !redundantHint(container.project, label)
|
|
149
|
+
? container.project
|
|
150
|
+
: container.name;
|
|
151
|
+
const category = signature?.category ?? 'container';
|
|
152
|
+
const annotated = {
|
|
153
|
+
...entry,
|
|
154
|
+
label,
|
|
155
|
+
source: 'docker',
|
|
156
|
+
category,
|
|
157
|
+
hint: project,
|
|
158
|
+
// The compose project is only mentioned when it survived the redundancy
|
|
159
|
+
// check above; naming a project called `docker` helps nobody.
|
|
160
|
+
summary: project === container.project
|
|
161
|
+
? `The container ${container.name}, from the ${container.project} compose project, running ${container.image}.`
|
|
162
|
+
: `The container ${container.name}, running ${container.image}.`,
|
|
163
|
+
// The compose form only makes sense when the project name survived the
|
|
164
|
+
// redundancy check; `docker start` names the container and always does.
|
|
165
|
+
restart: project === container.project
|
|
166
|
+
? `Bring it back with \`${['docker compose up -d', container.service].filter(Boolean).join(' ')}\`.`
|
|
167
|
+
: `Bring it back with \`docker start ${container.name}\`.`,
|
|
168
|
+
// Killing docker-proxy leaves the container running and the port
|
|
169
|
+
// unpublished, which is a stranger state than either stopping or leaving
|
|
170
|
+
// it. Saying so is worth more than a signal ever is.
|
|
171
|
+
riskReason: `stop the container instead: \`docker stop ${container.name}\``,
|
|
172
|
+
risk: 'caution',
|
|
173
|
+
};
|
|
174
|
+
// The category may have changed - a `container` row that turned out to be
|
|
175
|
+
// Adminer is a web page now - so whether a browser is the right thing to
|
|
176
|
+
// point at it has to be decided again.
|
|
177
|
+
return { ...annotated, url: browserUrl(annotated) };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Name the containers behind published ports, when there is a local engine to
|
|
181
|
+
* ask. Best effort and quick to give up: a machine with no Docker pays one
|
|
182
|
+
* `existsSync` for this, and one that has it pays a request to a unix socket.
|
|
183
|
+
*/
|
|
184
|
+
export async function annotateContainers(entries, options = {}) {
|
|
185
|
+
const wanted = entries.some((entry) => entry.category === 'container');
|
|
186
|
+
if (!wanted)
|
|
187
|
+
return [...entries];
|
|
188
|
+
// Every socket that might be there is tried in turn, not just the first that
|
|
189
|
+
// exists: a stopped Docker Desktop leaves /var/run/docker.sock behind, and
|
|
190
|
+
// giving up on it would hide a Colima engine that is running perfectly well.
|
|
191
|
+
let index = new Map();
|
|
192
|
+
for (const socketPath of plausibleSockets(options.candidates ?? socketCandidates())) {
|
|
193
|
+
index = indexPorts(await listContainers(socketPath, options.timeoutMs ?? 500));
|
|
194
|
+
if (index.size > 0)
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
if (index.size === 0)
|
|
198
|
+
return [...entries];
|
|
199
|
+
return entries.map((entry) => {
|
|
200
|
+
// Only rows Docker was already credited with: a coincidence between a
|
|
201
|
+
// published port and an unrelated process must not be dressed up as a
|
|
202
|
+
// container.
|
|
203
|
+
if (entry.category !== 'container')
|
|
204
|
+
return entry;
|
|
205
|
+
const container = index.get(entry.port);
|
|
206
|
+
return container ? annotate(entry, container) : entry;
|
|
207
|
+
});
|
|
208
|
+
}
|
package/dist/explain.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { CATEGORY_INFO, elevationRemedy, ownedByCurrentUser } from './describe.js';
|
|
2
|
+
/** The category as a noun phrase, so it can follow "is". */
|
|
3
|
+
const CATEGORY_NOUN = {
|
|
4
|
+
web: 'a web server',
|
|
5
|
+
database: 'a database',
|
|
6
|
+
container: 'a container port',
|
|
7
|
+
messaging: 'a message broker',
|
|
8
|
+
tooling: 'a development tool',
|
|
9
|
+
ai: 'a local model server',
|
|
10
|
+
system: 'a system service',
|
|
11
|
+
remote: 'a way in from another machine',
|
|
12
|
+
desktop: 'a desktop app',
|
|
13
|
+
runtime: 'a program',
|
|
14
|
+
unknown: 'something slash-port could not identify',
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* The short answer for the "can I close it" column. One or two words, because
|
|
18
|
+
* the column has to survive an eighty-column terminal, with the whole reason
|
|
19
|
+
* waiting in the panel underneath.
|
|
20
|
+
*/
|
|
21
|
+
export function riskWord(risk, platform = process.platform) {
|
|
22
|
+
switch (risk) {
|
|
23
|
+
case 'safe':
|
|
24
|
+
return 'Yes';
|
|
25
|
+
case 'caution':
|
|
26
|
+
return 'Probably';
|
|
27
|
+
case 'risky':
|
|
28
|
+
return 'Better not';
|
|
29
|
+
case 'protected':
|
|
30
|
+
return 'No';
|
|
31
|
+
default:
|
|
32
|
+
return platform === 'win32' ? 'Not as you' : 'Needs sudo';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The same answer with its reason, short enough for a table column. A table
|
|
37
|
+
* that wraps is a table that has stopped being one.
|
|
38
|
+
*/
|
|
39
|
+
export function riskSentence(entry) {
|
|
40
|
+
// ASCII, deliberately. This string reaches `--plain`, which is what a pipe
|
|
41
|
+
// and a batch script read, and an em dash under a legacy Windows code page
|
|
42
|
+
// is not a dash.
|
|
43
|
+
return `${riskWord(entry.risk)} - ${entry.riskReason}`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The answer with the way back, for the one place that has room for a sentence
|
|
47
|
+
* and no room for a second line.
|
|
48
|
+
*/
|
|
49
|
+
export function riskAdvice(entry) {
|
|
50
|
+
const restart = entry.risk === 'safe' || entry.risk === 'caution' ? entry.restart : null;
|
|
51
|
+
return [`${riskSentence(entry)}.`, restart].filter(Boolean).join(' ');
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The one-line answer to "what is this", built so that it never repeats the
|
|
55
|
+
* process name back at the reader. When nothing was identified it says so,
|
|
56
|
+
* because "unidentified" is a fact and `figma_agent` next to `figma_agent` is
|
|
57
|
+
* not.
|
|
58
|
+
*/
|
|
59
|
+
export function headline(entry) {
|
|
60
|
+
const noun = CATEGORY_NOUN[entry.category];
|
|
61
|
+
if (!entry.label) {
|
|
62
|
+
return entry.processName ? `${entry.processName} - not identified` : 'Not identified';
|
|
63
|
+
}
|
|
64
|
+
const named = entry.hint ? `${entry.label} (${entry.hint})` : entry.label;
|
|
65
|
+
// "Redis - a database" earns its dash; "a desktop app - a desktop app" does
|
|
66
|
+
// not, so a label that already is the noun does not get it twice.
|
|
67
|
+
return entry.label.toLowerCase() === noun.replace(/^an? /, '') ? named : `${named} - ${noun}`;
|
|
68
|
+
}
|
|
69
|
+
function ownerPhrase(entry) {
|
|
70
|
+
const parts = [];
|
|
71
|
+
if (entry.user)
|
|
72
|
+
parts.push(ownedByCurrentUser(entry) ? `you (${entry.user})` : entry.user);
|
|
73
|
+
else
|
|
74
|
+
parts.push('an owner slash-port cannot see');
|
|
75
|
+
if (entry.processName)
|
|
76
|
+
parts.push(entry.processName);
|
|
77
|
+
if (entry.pid !== null)
|
|
78
|
+
parts.push(`pid ${entry.pid}`);
|
|
79
|
+
return parts.join(' · ');
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* What beginner mode puts under the list: what it is, where to look at it, who
|
|
83
|
+
* started it, and whether closing it is a good idea. Everything a person needs
|
|
84
|
+
* to decide, and nothing that only matters once they have decided.
|
|
85
|
+
*/
|
|
86
|
+
export function beginnerBrief(entry, options = {}) {
|
|
87
|
+
const fields = [];
|
|
88
|
+
if (entry.hint && entry.label) {
|
|
89
|
+
fields.push({ label: entry.source === 'docker' ? 'Container' : 'Project', value: entry.hint });
|
|
90
|
+
}
|
|
91
|
+
else if (entry.category === 'container' && options.docker !== true) {
|
|
92
|
+
// Otherwise the default quietly costs the answer: "Docker Desktop" on 5432
|
|
93
|
+
// is true, useless, and one flag away from naming the container.
|
|
94
|
+
fields.push({ label: 'Container', value: 'not looked up - re-run with --docker to name it' });
|
|
95
|
+
}
|
|
96
|
+
if (entry.url)
|
|
97
|
+
fields.push({ label: 'Open', value: entry.url });
|
|
98
|
+
fields.push({ label: 'Started by', value: ownerPhrase(entry) });
|
|
99
|
+
fields.push({ label: 'Close it', value: riskSentence(entry) });
|
|
100
|
+
// The way back gets its own line rather than trailing the verdict, because
|
|
101
|
+
// it is the half of the answer that would be truncated away, and it is the
|
|
102
|
+
// half that turns "you can close this" into something a person will do.
|
|
103
|
+
const restart = entry.risk === 'safe' || entry.risk === 'caution' ? entry.restart : null;
|
|
104
|
+
if (restart)
|
|
105
|
+
fields.push({ label: 'Afterwards', value: restart });
|
|
106
|
+
if (entry.risk === 'blocked' && entry.pid !== null) {
|
|
107
|
+
fields.push({
|
|
108
|
+
label: 'To signal it',
|
|
109
|
+
value: `Run slash-port again with ${elevationRemedy()}.`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
headline: headline(entry),
|
|
114
|
+
summary: entry.summary,
|
|
115
|
+
fields,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** `2h 11m`, `4d 3h`, `38s` - the coarsest two units that are still true. */
|
|
119
|
+
export function formatDuration(seconds) {
|
|
120
|
+
if (!Number.isFinite(seconds) || seconds < 0)
|
|
121
|
+
return '-';
|
|
122
|
+
const days = Math.floor(seconds / 86400);
|
|
123
|
+
const hours = Math.floor((seconds % 86400) / 3600);
|
|
124
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
125
|
+
const rest = Math.floor(seconds % 60);
|
|
126
|
+
if (days > 0)
|
|
127
|
+
return `${days}d ${hours}h`;
|
|
128
|
+
if (hours > 0)
|
|
129
|
+
return `${hours}h ${minutes}m`;
|
|
130
|
+
if (minutes > 0)
|
|
131
|
+
return `${minutes}m ${rest}s`;
|
|
132
|
+
return `${rest}s`;
|
|
133
|
+
}
|
|
134
|
+
/** Bytes as the unit a person would have said out loud. */
|
|
135
|
+
export function formatBytes(bytes) {
|
|
136
|
+
if (!Number.isFinite(bytes) || bytes < 0)
|
|
137
|
+
return '-';
|
|
138
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
139
|
+
let value = bytes;
|
|
140
|
+
let unit = 0;
|
|
141
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
142
|
+
value /= 1024;
|
|
143
|
+
unit += 1;
|
|
144
|
+
}
|
|
145
|
+
return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
|
|
146
|
+
}
|
|
147
|
+
const SOURCE_PHRASE = {
|
|
148
|
+
signature: 'command line',
|
|
149
|
+
docker: 'the Docker engine',
|
|
150
|
+
registry: 'well-known port',
|
|
151
|
+
application: 'install path',
|
|
152
|
+
location: 'path shape only',
|
|
153
|
+
none: 'nothing matched',
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* What advanced mode puts under the list. The extra facts here are the ones
|
|
157
|
+
* you want once you have decided something is in the way and are working out
|
|
158
|
+
* which of two identical-looking processes it is: the working directory, the
|
|
159
|
+
* parent, how long it has been up, and whether anything is actually talking
|
|
160
|
+
* to it.
|
|
161
|
+
*
|
|
162
|
+
* `detail` is filled in asynchronously for the selected row only, so the lines
|
|
163
|
+
* it feeds appear a moment after the rest rather than holding up the list.
|
|
164
|
+
*/
|
|
165
|
+
export function advancedFields(entry, detail) {
|
|
166
|
+
const fields = [];
|
|
167
|
+
const process = [
|
|
168
|
+
entry.processName ?? 'unknown',
|
|
169
|
+
entry.pid === null ? null : `pid ${entry.pid}`,
|
|
170
|
+
detail?.parentPid === undefined
|
|
171
|
+
? null
|
|
172
|
+
: `parent ${detail.parentPid}${detail.parentName ? ` ${detail.parentName}` : ''}`,
|
|
173
|
+
]
|
|
174
|
+
.filter(Boolean)
|
|
175
|
+
.join(' · ');
|
|
176
|
+
fields.push({ label: 'Process', value: process });
|
|
177
|
+
fields.push({ label: 'User', value: entry.user ?? 'not visible' });
|
|
178
|
+
const listen = `${entry.addresses.join(', ')} · ${entry.protocol.toUpperCase()} · IPv${entry.families.join('/IPv')}`;
|
|
179
|
+
fields.push({ label: 'Listening', value: listen });
|
|
180
|
+
if (detail?.established !== undefined) {
|
|
181
|
+
fields.push({
|
|
182
|
+
label: 'Clients',
|
|
183
|
+
value: detail.established === 0
|
|
184
|
+
? 'nothing connected'
|
|
185
|
+
: `${detail.established} connection${detail.established === 1 ? '' : 's'} open`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
if (detail?.uptimeSeconds !== undefined) {
|
|
189
|
+
const started = detail.startedAt ? ` (since ${detail.startedAt})` : '';
|
|
190
|
+
fields.push({ label: 'Running', value: `${formatDuration(detail.uptimeSeconds)}${started}` });
|
|
191
|
+
}
|
|
192
|
+
if (detail?.rssBytes !== undefined) {
|
|
193
|
+
const cpu = detail.cpuPercent === undefined ? '' : ` · ${detail.cpuPercent.toFixed(1)}% CPU`;
|
|
194
|
+
fields.push({ label: 'Memory', value: `${formatBytes(detail.rssBytes)}${cpu}` });
|
|
195
|
+
}
|
|
196
|
+
if (detail?.cwd)
|
|
197
|
+
fields.push({ label: 'Directory', value: detail.cwd });
|
|
198
|
+
else if (detail?.cwdDenied) {
|
|
199
|
+
fields.push({ label: 'Directory', value: `not visible - re-run with ${elevationRemedy()}` });
|
|
200
|
+
}
|
|
201
|
+
if (entry.command)
|
|
202
|
+
fields.push({ label: 'Command', value: entry.command });
|
|
203
|
+
fields.push({
|
|
204
|
+
label: 'Identified',
|
|
205
|
+
value: `${entry.label ?? 'not identified'} · ${entry.category} · from ${SOURCE_PHRASE[entry.source]}`,
|
|
206
|
+
});
|
|
207
|
+
fields.push({ label: 'Kill', value: riskAdvice(entry) });
|
|
208
|
+
return fields;
|
|
209
|
+
}
|
package/dist/format.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { riskSentence } from './explain.js';
|
|
1
2
|
import { matchesPort, tryPortSelector } from './ports.js';
|
|
2
3
|
/** Shown wherever a value is genuinely absent, rather than an empty column. */
|
|
3
4
|
export const ABSENT = '-';
|
|
@@ -16,13 +17,23 @@ export function formatProcess(entry) {
|
|
|
16
17
|
export function formatAddresses(entry) {
|
|
17
18
|
return entry.addresses.join(', ');
|
|
18
19
|
}
|
|
19
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* The description column: what it is, which project, and whether you can do
|
|
22
|
+
* anything about it. A guarded row is never also marked `[locked]` - it is
|
|
23
|
+
* refused whoever you are, so the extra badge would only add noise.
|
|
24
|
+
*
|
|
25
|
+
* A row nothing could be worked out about shows `-`, never the process name
|
|
26
|
+
* over again. The process is already a column; repeating it here would dress
|
|
27
|
+
* "slash-port has no idea" up as an answer.
|
|
28
|
+
*/
|
|
20
29
|
export function formatDescription(entry) {
|
|
21
|
-
const parts = [entry.label];
|
|
30
|
+
const parts = [entry.label ?? ABSENT];
|
|
22
31
|
if (entry.hint)
|
|
23
32
|
parts.push(`(${entry.hint})`);
|
|
24
33
|
if (entry.guard)
|
|
25
34
|
parts.push('[protected]');
|
|
35
|
+
else if (entry.elevation)
|
|
36
|
+
parts.push('[locked]');
|
|
26
37
|
return parts.join(' ');
|
|
27
38
|
}
|
|
28
39
|
/** Everything a row can be matched on, lowercased once for filtering. */
|
|
@@ -33,7 +44,8 @@ export function searchText(entry) {
|
|
|
33
44
|
formatPid(entry),
|
|
34
45
|
entry.user ?? '',
|
|
35
46
|
entry.processName ?? '',
|
|
36
|
-
entry.label,
|
|
47
|
+
entry.label ?? '',
|
|
48
|
+
entry.category,
|
|
37
49
|
entry.hint ?? '',
|
|
38
50
|
entry.addresses.join(' '),
|
|
39
51
|
entry.command ?? '',
|
|
@@ -56,9 +68,17 @@ export function matchesFilter(entry, filter) {
|
|
|
56
68
|
* The plain-text table used whenever output is not a terminal. Aligned to the
|
|
57
69
|
* widest value in each column and containing no control codes at all, so it
|
|
58
70
|
* survives a pipe, a redirect, and a `grep`.
|
|
71
|
+
*
|
|
72
|
+
* The first six columns are the same in both modes and always will be: a
|
|
73
|
+
* script that already splits this output keeps working. Mode only ever adds a
|
|
74
|
+
* column on the end - what closing it would mean for a beginner, the command
|
|
75
|
+
* line for everybody else - because appending is the one change to a table
|
|
76
|
+
* that cannot break a reader of it.
|
|
59
77
|
*/
|
|
60
|
-
export function plainTable(entries) {
|
|
78
|
+
export function plainTable(entries, mode = 'beginner') {
|
|
79
|
+
const beginner = mode === 'beginner';
|
|
61
80
|
const header = ['PORT', 'PID', 'USER', 'PROCESS', 'ADDRESS', 'DESCRIPTION'];
|
|
81
|
+
header.push(beginner ? 'CAN I CLOSE IT?' : 'COMMAND');
|
|
62
82
|
const rows = entries.map((entry) => [
|
|
63
83
|
formatPort(entry),
|
|
64
84
|
formatPid(entry),
|
|
@@ -66,6 +86,7 @@ export function plainTable(entries) {
|
|
|
66
86
|
formatProcess(entry),
|
|
67
87
|
formatAddresses(entry),
|
|
68
88
|
formatDescription(entry),
|
|
89
|
+
beginner ? riskSentence(entry) : (entry.command ?? ABSENT),
|
|
69
90
|
]);
|
|
70
91
|
const widths = header.map((label, column) => Math.max(label.length, ...rows.map((row) => row[column].length), 0));
|
|
71
92
|
const render = (row) => row
|
|
@@ -74,7 +95,15 @@ export function plainTable(entries) {
|
|
|
74
95
|
.trimEnd();
|
|
75
96
|
return [render(header), ...rows.map(render)].join('\n');
|
|
76
97
|
}
|
|
77
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* The `--json` shape: a superset of every mode, because a program reading this
|
|
100
|
+
* has no mode. Fields are added over time and never repurposed, so `jq` on an
|
|
101
|
+
* old field keeps working.
|
|
102
|
+
*
|
|
103
|
+
* `description` is null rather than a repeat of `process` when nothing could be
|
|
104
|
+
* identified, which is the one thing here that a reader has to know: absence is
|
|
105
|
+
* reported as absence.
|
|
106
|
+
*/
|
|
78
107
|
export function toJson(entries) {
|
|
79
108
|
return entries.map((entry) => ({
|
|
80
109
|
port: entry.port,
|
|
@@ -86,8 +115,17 @@ export function toJson(entries) {
|
|
|
86
115
|
process: entry.processName,
|
|
87
116
|
command: entry.command,
|
|
88
117
|
description: entry.label,
|
|
118
|
+
descriptionSource: entry.source,
|
|
119
|
+
category: entry.category,
|
|
89
120
|
project: entry.hint,
|
|
121
|
+
summary: entry.summary,
|
|
122
|
+
url: entry.url,
|
|
123
|
+
restart: entry.restart,
|
|
124
|
+
risk: entry.risk,
|
|
125
|
+
riskReason: entry.riskReason,
|
|
90
126
|
protected: entry.guard !== null,
|
|
91
127
|
protectedReason: entry.guard,
|
|
128
|
+
locked: entry.elevation !== null,
|
|
129
|
+
lockedReason: entry.elevation,
|
|
92
130
|
}));
|
|
93
131
|
}
|