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/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
+ }
@@ -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
- /** The description column: what it is, which project, and whether it is protected. */
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
- /** The `--json` shape. Stable, and a superset of what the table shows. */
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
  }