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/describe.js CHANGED
@@ -1,97 +1,242 @@
1
- import { basename } from 'node:path';
1
+ import { userInfo } from 'node:os';
2
+ /**
3
+ * The last component of a path, splitting on both separators whatever platform
4
+ * this is running on. `node:path`'s own `basename` follows the host, so on
5
+ * Linux it reads a Windows command line as one long directory name - and the
6
+ * CI legs that would catch that are the ones where it cannot happen.
7
+ */
8
+ function lastComponent(path) {
9
+ return path.split(/[/\\]/).filter(Boolean).pop() ?? '';
10
+ }
11
+ export const CATEGORY_INFO = {
12
+ web: {
13
+ summary: 'Point a browser at it - that is what it is there for.',
14
+ risk: 'safe',
15
+ browsable: true,
16
+ },
17
+ database: {
18
+ summary: 'Anything connected to it loses its connection when it stops.',
19
+ risk: 'caution',
20
+ },
21
+ container: {
22
+ summary: 'The container behind it keeps running unless you stop that too.',
23
+ risk: 'caution',
24
+ },
25
+ messaging: {
26
+ summary: 'Whatever is producing and consuming messages will drop off.',
27
+ risk: 'caution',
28
+ },
29
+ tooling: {
30
+ summary: 'Closing it stops the tool and nothing else.',
31
+ risk: 'safe',
32
+ },
33
+ ai: {
34
+ summary: 'Requests already in flight will fail.',
35
+ risk: 'caution',
36
+ },
37
+ system: {
38
+ summary: 'Part of the operating system rather than anything you started.',
39
+ risk: 'risky',
40
+ },
41
+ remote: {
42
+ summary: 'Closing it can lock you out of this machine from elsewhere.',
43
+ risk: 'risky',
44
+ },
45
+ desktop: {
46
+ summary: 'Quitting it from the app itself is tidier than signalling it.',
47
+ risk: 'caution',
48
+ },
49
+ runtime: {
50
+ summary: 'slash-port could name the runtime but not the project it belongs to.',
51
+ risk: 'caution',
52
+ },
53
+ unknown: {
54
+ summary: 'slash-port could not work out what this is.',
55
+ risk: 'caution',
56
+ },
57
+ };
58
+ const DEV_SERVER_RESTART = 'Start it again with your dev command, usually `npm run dev`.';
2
59
  export const SIGNATURES = [
3
60
  // Node frameworks and dev servers, ahead of the runtime that hosts them.
4
- { pattern: /\bnext(-server)?\b|[/\\]next[/\\]dist[/\\]/i, label: 'Next.js' },
5
- { pattern: /\bnuxt\b/i, label: 'Nuxt' },
6
- { pattern: /[/\\]vite[/\\]|\bvite\b/i, label: 'Vite dev server' },
7
- { pattern: /\bastro\b/i, label: 'Astro' },
8
- { pattern: /\bremix\b/i, label: 'Remix' },
9
- { pattern: /\bsvelte-kit\b|\bsveltekit\b/i, label: 'SvelteKit' },
10
- { pattern: /\bgatsby\b/i, label: 'Gatsby' },
11
- { pattern: /\bnest\b|\bnestjs\b/i, label: 'NestJS' },
12
- { pattern: /\breact-scripts\b/i, label: 'Create React App' },
13
- { pattern: /\bwebpack(-dev-server)?\b/i, label: 'webpack dev server' },
14
- { pattern: /\bparcel\b/i, label: 'Parcel' },
15
- { pattern: /\bstorybook\b/i, label: 'Storybook' },
16
- { pattern: /\bexpo\b/i, label: 'Expo' },
17
- { pattern: /\bmetro\b/i, label: 'Metro bundler' },
18
- { pattern: /\bvitest\b/i, label: 'Vitest' },
19
- { pattern: /\bjest\b/i, label: 'Jest' },
20
- { pattern: /\bnodemon\b/i, label: 'nodemon' },
21
- { pattern: /\btsx\b|\bts-node\b/i, label: 'TypeScript dev process' },
61
+ { pattern: /\bnext(-server)?\b|[/\\]next[/\\]dist[/\\]/i, label: 'Next.js', category: 'web', restart: DEV_SERVER_RESTART },
62
+ { pattern: /\bnuxt\b/i, label: 'Nuxt', category: 'web', restart: DEV_SERVER_RESTART },
63
+ { pattern: /[/\\]vite[/\\]|\bvite\b/i, label: 'Vite dev server', category: 'web', restart: DEV_SERVER_RESTART },
64
+ { pattern: /\bastro\b/i, label: 'Astro', category: 'web', restart: DEV_SERVER_RESTART },
65
+ { pattern: /\bremix\b/i, label: 'Remix', category: 'web', restart: DEV_SERVER_RESTART },
66
+ { pattern: /\bsvelte-kit\b|\bsveltekit\b/i, label: 'SvelteKit', category: 'web', restart: DEV_SERVER_RESTART },
67
+ { pattern: /\bgatsby\b/i, label: 'Gatsby', category: 'web', restart: DEV_SERVER_RESTART },
68
+ { pattern: /\bnest\b|\bnestjs\b/i, label: 'NestJS', category: 'web', restart: DEV_SERVER_RESTART },
69
+ { pattern: /\breact-scripts\b/i, label: 'Create React App', category: 'web', restart: DEV_SERVER_RESTART },
70
+ { pattern: /\bwebpack(-dev-server)?\b/i, label: 'webpack dev server', category: 'web', restart: DEV_SERVER_RESTART },
71
+ { pattern: /\bparcel\b/i, label: 'Parcel', category: 'web', restart: DEV_SERVER_RESTART },
72
+ {
73
+ pattern: /\bstorybook\b/i,
74
+ label: 'Storybook',
75
+ category: 'web',
76
+ summary: 'Nothing but the component catalogue depends on it.',
77
+ restart: 'Start it again with `npm run storybook`.',
78
+ },
79
+ { pattern: /\bexpo\b/i, label: 'Expo', category: 'web', restart: 'Start it again with `npx expo start`.' },
80
+ { pattern: /\bmetro\b/i, label: 'Metro bundler', category: 'tooling', restart: 'It restarts with the React Native app.' },
81
+ {
82
+ pattern: /\bvitest\b/i,
83
+ label: 'Vitest',
84
+ category: 'tooling',
85
+ summary: 'A test runner in watch mode: closing it abandons the run in progress.',
86
+ restart: 'Start it again with `npm test`.',
87
+ },
88
+ {
89
+ pattern: /\bjest\b/i,
90
+ label: 'Jest',
91
+ category: 'tooling',
92
+ summary: 'A test runner in watch mode: closing it abandons the run in progress.',
93
+ restart: 'Start it again with `npm test`.',
94
+ },
95
+ { pattern: /\bnodemon\b/i, label: 'nodemon', category: 'tooling', restart: DEV_SERVER_RESTART },
96
+ { pattern: /\btsx\b|\bts-node\b/i, label: 'TypeScript dev process', category: 'tooling', restart: DEV_SERVER_RESTART },
22
97
  // Deliberately narrow: a bare "serve" appears in half the daemons on a
23
98
  // machine, so only the npm package's own paths count.
24
- { pattern: /\bhttp-server\b|npx\s+serve\b|node_modules[/\\]\.bin[/\\]serve\b/i, label: 'static file server' },
25
- { pattern: /\bpm2\b/i, label: 'PM2' },
99
+ {
100
+ pattern: /\bhttp-server\b|npx\s+serve\b|node_modules[/\\]\.bin[/\\]serve\b/i,
101
+ label: 'static file server',
102
+ category: 'web',
103
+ restart: 'Start it again with `npx serve`.',
104
+ },
105
+ {
106
+ pattern: /\bpm2\b/i,
107
+ label: 'PM2',
108
+ category: 'tooling',
109
+ summary: 'It restarts the apps it supervises, so stopping it stops them too.',
110
+ risk: 'caution',
111
+ restart: 'Bring it back with `pm2 resurrect`.',
112
+ },
26
113
  // Python.
27
- { pattern: /\bgunicorn\b/i, label: 'Gunicorn' },
28
- { pattern: /\buvicorn\b/i, label: 'Uvicorn' },
29
- { pattern: /\bhypercorn\b/i, label: 'Hypercorn' },
30
- { pattern: /manage\.py\s+runserver/i, label: 'Django dev server' },
31
- { pattern: /\bdjango\b/i, label: 'Django' },
32
- { pattern: /\bflask\b/i, label: 'Flask' },
33
- { pattern: /\bfastapi\b/i, label: 'FastAPI' },
34
- { pattern: /\bstreamlit\b/i, label: 'Streamlit' },
35
- { pattern: /\bjupyter\b|\bipykernel\b/i, label: 'Jupyter' },
36
- { pattern: /\bcelery\b/i, label: 'Celery' },
37
- { pattern: /\bhttp\.server\b|SimpleHTTPServer/i, label: 'Python http.server' },
114
+ { pattern: /\bgunicorn\b/i, label: 'Gunicorn', category: 'web' },
115
+ { pattern: /\buvicorn\b/i, label: 'Uvicorn', category: 'web' },
116
+ { pattern: /\bhypercorn\b/i, label: 'Hypercorn', category: 'web' },
117
+ {
118
+ pattern: /manage\.py\s+runserver/i,
119
+ label: 'Django dev server',
120
+ category: 'web',
121
+ restart: 'Start it again with `python manage.py runserver`.',
122
+ },
123
+ { pattern: /\bdjango\b/i, label: 'Django', category: 'web' },
124
+ { pattern: /\bflask\b/i, label: 'Flask', category: 'web', restart: 'Start it again with `flask run`.' },
125
+ { pattern: /\bfastapi\b/i, label: 'FastAPI', category: 'web' },
126
+ { pattern: /\bstreamlit\b/i, label: 'Streamlit', category: 'web', restart: 'Start it again with `streamlit run`.' },
127
+ {
128
+ pattern: /\bjupyter\b|\bipykernel\b/i,
129
+ label: 'Jupyter',
130
+ category: 'tooling',
131
+ summary: 'Unsaved cells in an open notebook are lost when it stops.',
132
+ risk: 'caution',
133
+ restart: 'Start it again with `jupyter lab`.',
134
+ },
135
+ { pattern: /\bcelery\b/i, label: 'Celery', category: 'messaging' },
136
+ { pattern: /\bhttp\.server\b|SimpleHTTPServer/i, label: 'Python http.server', category: 'web' },
38
137
  // Ruby, PHP, Java, Go, .NET.
39
- { pattern: /\bpuma\b/i, label: 'Puma' },
40
- { pattern: /\bunicorn\b/i, label: 'Unicorn' },
41
- { pattern: /\bsidekiq\b/i, label: 'Sidekiq' },
42
- { pattern: /\brails\b/i, label: 'Rails' },
43
- { pattern: /\bjekyll\b/i, label: 'Jekyll' },
44
- { pattern: /artisan\s+serve/i, label: 'Laravel dev server' },
45
- { pattern: /\bphp\b.*\s-S\s/i, label: 'PHP built-in server' },
46
- { pattern: /\bspring-boot\b|\borg\.springframework\b/i, label: 'Spring Boot' },
47
- { pattern: /\bgradle\b/i, label: 'Gradle' },
48
- { pattern: /\btomcat\b|\bcatalina\b/i, label: 'Tomcat' },
49
- { pattern: /(?:^|[/\\])air(?:\s|$)/i, label: 'Air (Go live reload)' },
50
- { pattern: /\bdotnet\b/i, label: '.NET' },
138
+ { pattern: /\bpuma\b/i, label: 'Puma', category: 'web' },
139
+ { pattern: /\bunicorn\b/i, label: 'Unicorn', category: 'web' },
140
+ { pattern: /\bsidekiq\b/i, label: 'Sidekiq', category: 'messaging' },
141
+ { pattern: /\brails\b/i, label: 'Rails', category: 'web', restart: 'Start it again with `bin/rails server`.' },
142
+ { pattern: /\bjekyll\b/i, label: 'Jekyll', category: 'web' },
143
+ { pattern: /artisan\s+serve/i, label: 'Laravel dev server', category: 'web', restart: 'Start it again with `php artisan serve`.' },
144
+ { pattern: /\bphp\b.*\s-S\s/i, label: 'PHP built-in server', category: 'web' },
145
+ { pattern: /\bspring-boot\b|\borg\.springframework\b/i, label: 'Spring Boot', category: 'web' },
146
+ { pattern: /\bgradle\b/i, label: 'Gradle', category: 'tooling', summary: 'A build daemon: the next build starts it again.' },
147
+ { pattern: /\btomcat\b|\bcatalina\b/i, label: 'Tomcat', category: 'web' },
148
+ { pattern: /(?:^|[/\\])air(?:\s|$)/i, label: 'Air (Go live reload)', category: 'web', restart: 'Start it again with `air`.' },
149
+ { pattern: /\bdotnet\b/i, label: '.NET', category: 'web' },
51
150
  // Datastores and queues, matched on the binary name.
52
- { pattern: /\bpostgres(ql)?\b/i, label: 'PostgreSQL' },
53
- { pattern: /\bmysqld\b|\bmariadbd\b/i, label: 'MySQL / MariaDB' },
54
- { pattern: /\bmongod\b/i, label: 'MongoDB' },
55
- { pattern: /\bredis-(server|stack)\b/i, label: 'Redis' },
56
- { pattern: /\bvalkey\b/i, label: 'Valkey' },
57
- { pattern: /\bmemcached\b/i, label: 'Memcached' },
58
- { pattern: /\belasticsearch\b|\bopensearch\b/i, label: 'Elasticsearch / OpenSearch' },
59
- { pattern: /\bclickhouse\b/i, label: 'ClickHouse' },
60
- { pattern: /\brabbitmq\b|\bbeam\.smp\b/i, label: 'RabbitMQ' },
61
- { pattern: /\bkafka\b/i, label: 'Kafka' },
62
- { pattern: /\bzookeeper\b/i, label: 'ZooKeeper' },
63
- { pattern: /\betcd\b/i, label: 'etcd' },
64
- { pattern: /\bminio\b/i, label: 'MinIO' },
65
- { pattern: /\blocalstack\b/i, label: 'LocalStack' },
66
- { pattern: /\bollama\b/i, label: 'Ollama' },
67
- { pattern: /\blm-?studio\b/i, label: 'LM Studio' },
68
- { pattern: /\bsupabase\b/i, label: 'Supabase' },
151
+ {
152
+ pattern: /\bpostgres(ql)?\b/i,
153
+ label: 'PostgreSQL',
154
+ category: 'database',
155
+ summary: 'Open transactions are rolled back when it stops.',
156
+ },
157
+ { pattern: /\bmysqld\b|\bmariadbd\b/i, label: 'MySQL / MariaDB', category: 'database' },
158
+ { pattern: /\bmongod\b/i, label: 'MongoDB', category: 'database' },
159
+ {
160
+ pattern: /\bredis(-(server|stack))?\b/i,
161
+ label: 'Redis',
162
+ category: 'database',
163
+ summary: 'Anything it holds only in memory is gone when it stops.',
164
+ },
165
+ { pattern: /\bvalkey\b/i, label: 'Valkey', category: 'database' },
166
+ {
167
+ pattern: /\bmemcached\b/i,
168
+ label: 'Memcached',
169
+ category: 'database',
170
+ summary: 'Losing its contents is survivable by design.',
171
+ risk: 'safe',
172
+ },
173
+ { pattern: /\belasticsearch\b|\bopensearch\b/i, label: 'Elasticsearch / OpenSearch', category: 'database' },
174
+ { pattern: /\bclickhouse\b/i, label: 'ClickHouse', category: 'database' },
175
+ { pattern: /\brabbitmq\b|\bbeam\.smp\b/i, label: 'RabbitMQ', category: 'messaging' },
176
+ { pattern: /\bkafka\b/i, label: 'Kafka', category: 'messaging' },
177
+ { pattern: /\bzookeeper\b/i, label: 'ZooKeeper', category: 'messaging' },
178
+ { pattern: /\betcd\b/i, label: 'etcd', category: 'database' },
179
+ { pattern: /\bminio\b/i, label: 'MinIO', category: 'database' },
180
+ { pattern: /\blocalstack\b/i, label: 'LocalStack', category: 'tooling' },
181
+ {
182
+ pattern: /\bollama\b/i,
183
+ label: 'Ollama',
184
+ category: 'ai',
185
+ restart: 'Start it again with `ollama serve`, or by opening the app.',
186
+ },
187
+ { pattern: /\blm-?studio\b/i, label: 'LM Studio', category: 'ai' },
188
+ {
189
+ pattern: /\bsupabase\b/i,
190
+ label: 'Supabase',
191
+ category: 'database',
192
+ summary: 'Several containers rather than one process.',
193
+ restart: 'Bring it back with `supabase start`.',
194
+ },
69
195
  // Containers, proxies, and system services.
70
- { pattern: /\bdocker-proxy\b/i, label: 'Docker published port' },
71
- { pattern: /com\.docker\./i, label: 'Docker Desktop' },
72
- { pattern: /\bcontainerd\b/i, label: 'containerd' },
73
- { pattern: /\bkubelet\b|\bk3s\b|\bminikube\b/i, label: 'Kubernetes' },
74
- { pattern: /\bnginx\b/i, label: 'nginx' },
75
- { pattern: /\bcaddy\b/i, label: 'Caddy' },
76
- { pattern: /\btraefik\b/i, label: 'Traefik' },
77
- { pattern: /\bhttpd\b|\bapache2\b/i, label: 'Apache' },
78
- { pattern: /\bngrok\b|\bcloudflared\b/i, label: 'tunnel client' },
79
- { pattern: /\bsshd\b/i, label: 'OpenSSH server' },
80
- { pattern: /\bmDNSResponder\b|\bavahi\b/i, label: 'mDNS / Bonjour' },
81
- { pattern: /\brapportd\b/i, label: 'macOS Handoff' },
82
- { pattern: /\bControlCe(nter|ntre)\b/i, label: 'macOS Control Center' },
83
- { pattern: /\bcupsd\b/i, label: 'CUPS printing' },
84
- { pattern: /\bsystemd-resolve/i, label: 'systemd-resolved' },
85
- { pattern: /\bdnsmasq\b/i, label: 'dnsmasq' },
86
- // Runtimes last: they only win when nothing more specific matched.
87
- { pattern: /\bdeno\b/i, label: 'Deno' },
88
- { pattern: /\bbun\b/i, label: 'Bun' },
89
- { pattern: /\bnode(js)?\b/i, label: 'Node.js' },
90
- { pattern: /\bpython[\d.]*\b/i, label: 'Python' },
91
- { pattern: /\bruby\b/i, label: 'Ruby' },
92
- { pattern: /\bjava\b/i, label: 'Java' },
93
- { pattern: /\bphp\b/i, label: 'PHP' },
94
- { pattern: /\bperl\b/i, label: 'Perl' },
196
+ {
197
+ pattern: /\bdocker-proxy\b/i,
198
+ label: 'Docker published port',
199
+ category: 'container',
200
+ summary: 'The forwarder Docker puts in front of a container port, not the container itself.',
201
+ restart: 'It comes back on its own when the container is restarted.',
202
+ },
203
+ {
204
+ pattern: /com\.docker\./i,
205
+ label: 'Docker Desktop',
206
+ category: 'container',
207
+ summary: 'Publishing a port on behalf of a container.',
208
+ restart: 'Stopping the container that owns the port is the change you probably want.',
209
+ },
210
+ { pattern: /\bcontainerd\b/i, label: 'containerd', category: 'container' },
211
+ { pattern: /\bkubelet\b|\bk3s\b|\bminikube\b/i, label: 'Kubernetes', category: 'container' },
212
+ { pattern: /\bnginx\b/i, label: 'nginx', category: 'web', risk: 'caution' },
213
+ { pattern: /\bcaddy\b/i, label: 'Caddy', category: 'web', risk: 'caution' },
214
+ { pattern: /\btraefik\b/i, label: 'Traefik', category: 'web', risk: 'caution' },
215
+ { pattern: /\bhttpd\b|\bapache2\b/i, label: 'Apache', category: 'web', risk: 'caution' },
216
+ {
217
+ pattern: /\bngrok\b|\bcloudflared\b/i,
218
+ label: 'tunnel client',
219
+ category: 'remote',
220
+ summary: 'A tunnel exposing something on this machine to the internet.',
221
+ risk: 'caution',
222
+ },
223
+ { pattern: /\bsshd\b/i, label: 'OpenSSH server', category: 'remote' },
224
+ { pattern: /\bmDNSResponder\b|\bavahi\b/i, label: 'mDNS / Bonjour', category: 'system' },
225
+ { pattern: /\brapportd\b/i, label: 'macOS Handoff', category: 'system' },
226
+ { pattern: /\bControlCe(nter|ntre)\b/i, label: 'macOS Control Center', category: 'system' },
227
+ { pattern: /\bcupsd\b/i, label: 'CUPS printing', category: 'system' },
228
+ { pattern: /\bsystemd-resolve/i, label: 'systemd-resolved', category: 'system' },
229
+ { pattern: /\bdnsmasq\b/i, label: 'dnsmasq', category: 'system' },
230
+ // Runtimes last: they only win when nothing more specific matched, and even
231
+ // then they lose their place as the label to an application name.
232
+ { pattern: /\bdeno\b/i, label: 'Deno', category: 'runtime', runtime: true },
233
+ { pattern: /\bbun\b/i, label: 'Bun', category: 'runtime', runtime: true },
234
+ { pattern: /\bnode(js)?\b/i, label: 'Node.js', category: 'runtime', runtime: true },
235
+ { pattern: /\bpython[\d.]*\b/i, label: 'Python', category: 'runtime', runtime: true },
236
+ { pattern: /\bruby\b/i, label: 'Ruby', category: 'runtime', runtime: true },
237
+ { pattern: /\bjava\b/i, label: 'Java', category: 'runtime', runtime: true },
238
+ { pattern: /\bphp\b/i, label: 'PHP', category: 'runtime', runtime: true },
239
+ { pattern: /\bperl\b/i, label: 'Perl', category: 'runtime', runtime: true },
95
240
  ];
96
241
  /**
97
242
  * Well-known ports, biased towards the ones a developer actually meets rather
@@ -99,125 +244,403 @@ export const SIGNATURES = [
99
244
  * identified, which is mostly other users' processes.
100
245
  */
101
246
  export const PORT_REGISTRY = {
102
- 20: { label: 'FTP data' },
103
- 21: { label: 'FTP' },
104
- 22: { label: 'SSH' },
105
- 25: { label: 'SMTP' },
106
- 53: { label: 'DNS' },
107
- 80: { label: 'HTTP' },
108
- 110: { label: 'POP3' },
109
- 111: { label: 'rpcbind' },
110
- 123: { label: 'NTP' },
111
- 143: { label: 'IMAP' },
112
- 389: { label: 'LDAP' },
113
- 443: { label: 'HTTPS' },
114
- 445: { label: 'SMB' },
115
- 465: { label: 'SMTPS' },
116
- 514: { label: 'syslog' },
117
- 548: { label: 'Apple Filing Protocol' },
118
- 587: { label: 'SMTP submission' },
119
- 631: { label: 'CUPS printing' },
120
- 853: { label: 'DNS over TLS' },
121
- 993: { label: 'IMAPS' },
122
- 1080: { label: 'SOCKS proxy' },
123
- 1433: { label: 'SQL Server' },
124
- 1521: { label: 'Oracle database' },
125
- 1883: { label: 'MQTT' },
126
- 1900: { label: 'SSDP / UPnP' },
127
- 2049: { label: 'NFS' },
128
- 2375: { label: 'Docker daemon (unencrypted)' },
129
- 2376: { label: 'Docker daemon (TLS)' },
130
- 3000: { label: 'dev server', generic: true },
131
- 3001: { label: 'dev server', generic: true },
132
- 3306: { label: 'MySQL / MariaDB' },
133
- 3478: { label: 'STUN / TURN' },
134
- 4200: { label: 'Angular dev server' },
135
- 4321: { label: 'Astro dev server' },
136
- 4873: { label: 'Verdaccio npm registry' },
137
- 5000: { label: 'AirPlay receiver or Flask' },
138
- 5173: { label: 'Vite dev server' },
139
- 5432: { label: 'PostgreSQL' },
140
- 5601: { label: 'Kibana' },
141
- 5672: { label: 'RabbitMQ (AMQP)' },
142
- 5900: { label: 'VNC / screen sharing' },
143
- 6006: { label: 'Storybook or TensorBoard' },
144
- 6379: { label: 'Redis' },
145
- 7000: { label: 'AirPlay' },
146
- 8000: { label: 'dev server', generic: true },
147
- 8025: { label: 'Mailpit / MailHog' },
148
- 8080: { label: 'HTTP alternate', generic: true },
149
- 8081: { label: 'HTTP alternate', generic: true },
150
- 8443: { label: 'HTTPS alternate' },
151
- 8888: { label: 'Jupyter' },
152
- 9000: { label: 'PHP-FPM or MinIO' },
153
- 9090: { label: 'Prometheus' },
154
- 9092: { label: 'Kafka' },
155
- 9200: { label: 'Elasticsearch' },
156
- 9229: { label: 'Node.js inspector' },
157
- 11211: { label: 'Memcached' },
158
- 15672: { label: 'RabbitMQ management' },
159
- 27017: { label: 'MongoDB' },
160
- 50000: { label: 'DB2 or SIP' },
161
- 54321: { label: 'Supabase / H2O' },
247
+ 20: { label: 'FTP data', category: 'system' },
248
+ 21: { label: 'FTP', category: 'system' },
249
+ 22: { label: 'SSH', category: 'remote' },
250
+ 25: { label: 'SMTP', category: 'system' },
251
+ 53: { label: 'DNS', category: 'system' },
252
+ 80: { label: 'HTTP', category: 'web' },
253
+ 110: { label: 'POP3', category: 'system' },
254
+ 111: { label: 'rpcbind', category: 'system' },
255
+ 123: { label: 'NTP', category: 'system' },
256
+ 143: { label: 'IMAP', category: 'system' },
257
+ 389: { label: 'LDAP', category: 'system' },
258
+ 443: { label: 'HTTPS', category: 'web' },
259
+ 445: { label: 'SMB', category: 'system' },
260
+ 465: { label: 'SMTPS', category: 'system' },
261
+ 514: { label: 'syslog', category: 'system' },
262
+ 548: { label: 'Apple Filing Protocol', category: 'system' },
263
+ 587: { label: 'SMTP submission', category: 'system' },
264
+ 631: { label: 'CUPS printing', category: 'system' },
265
+ 853: { label: 'DNS over TLS', category: 'system' },
266
+ 993: { label: 'IMAPS', category: 'system' },
267
+ 1080: { label: 'SOCKS proxy', category: 'remote' },
268
+ 1433: { label: 'SQL Server', category: 'database' },
269
+ 1521: { label: 'Oracle database', category: 'database' },
270
+ 1883: { label: 'MQTT', category: 'messaging' },
271
+ 1900: { label: 'SSDP / UPnP', category: 'system' },
272
+ 2049: { label: 'NFS', category: 'system' },
273
+ 2375: { label: 'Docker daemon (unencrypted)', category: 'container' },
274
+ 2376: { label: 'Docker daemon (TLS)', category: 'container' },
275
+ 3000: { label: 'dev server', category: 'web', generic: true },
276
+ 3001: { label: 'dev server', category: 'web', generic: true },
277
+ 3306: { label: 'MySQL / MariaDB', category: 'database' },
278
+ 3478: { label: 'STUN / TURN', category: 'system' },
279
+ 4200: { label: 'Angular dev server', category: 'web' },
280
+ 4321: { label: 'Astro dev server', category: 'web' },
281
+ 4873: { label: 'Verdaccio npm registry', category: 'tooling' },
282
+ 5000: { label: 'AirPlay receiver or Flask', category: 'web' },
283
+ 5173: { label: 'Vite dev server', category: 'web' },
284
+ 5432: { label: 'PostgreSQL', category: 'database' },
285
+ 5601: { label: 'Kibana', category: 'web' },
286
+ 5672: { label: 'RabbitMQ (AMQP)', category: 'messaging' },
287
+ 5900: { label: 'VNC / screen sharing', category: 'remote' },
288
+ 6006: { label: 'Storybook or TensorBoard', category: 'web' },
289
+ 6379: { label: 'Redis', category: 'database' },
290
+ 7000: { label: 'AirPlay', category: 'system' },
291
+ 8000: { label: 'dev server', category: 'web', generic: true },
292
+ 8025: { label: 'Mailpit / MailHog', category: 'web' },
293
+ 8080: { label: 'HTTP alternate', category: 'web', generic: true },
294
+ 8081: { label: 'HTTP alternate', category: 'web', generic: true },
295
+ 8443: { label: 'HTTPS alternate', category: 'web' },
296
+ 8888: { label: 'Jupyter', category: 'tooling' },
297
+ 9000: { label: 'PHP-FPM or MinIO', category: 'web' },
298
+ 9090: { label: 'Prometheus', category: 'web' },
299
+ 9092: { label: 'Kafka', category: 'messaging' },
300
+ 9200: { label: 'Elasticsearch', category: 'database' },
301
+ 9229: { label: 'Node.js inspector', category: 'tooling' },
302
+ 11211: { label: 'Memcached', category: 'database' },
303
+ 15672: { label: 'RabbitMQ management', category: 'web' },
304
+ 27017: { label: 'MongoDB', category: 'database' },
305
+ 50000: { label: 'DB2 or SIP', category: 'database' },
306
+ 54321: { label: 'Supabase / H2O', category: 'web' },
162
307
  };
163
308
  /** The text the heuristics run against: full command line, else process name. */
164
309
  function searchable(socket) {
165
310
  return socket.command?.trim() || socket.processName?.trim() || '';
166
311
  }
167
- export function matchSignature(text) {
312
+ function findSignature(text) {
168
313
  if (!text)
169
314
  return null;
170
315
  for (const signature of SIGNATURES) {
171
316
  if (signature.pattern.test(text))
172
- return signature.label;
317
+ return signature;
173
318
  }
174
319
  return null;
175
320
  }
321
+ export function matchSignature(text) {
322
+ return findSignature(text)?.label ?? null;
323
+ }
324
+ /**
325
+ * The whole record, for callers that have their own text to identify - a
326
+ * container image, say - and want the category and the advice that come with
327
+ * a match rather than only its name.
328
+ */
329
+ export function matchSignatureEntry(text) {
330
+ return findSignature(text);
331
+ }
332
+ /**
333
+ * Directory names that name a convention rather than a thing. Seeing `lib` as
334
+ * the project a server belongs to is worse than seeing nothing, because it
335
+ * reads like an answer.
336
+ */
337
+ const ANONYMOUS_DIRECTORIES = new Set([
338
+ 'app',
339
+ 'apps',
340
+ 'bin',
341
+ 'build',
342
+ 'cellar',
343
+ 'code',
344
+ 'contents',
345
+ 'current',
346
+ 'dev',
347
+ 'dist',
348
+ 'docs',
349
+ 'home',
350
+ 'homebrew',
351
+ 'lib',
352
+ 'lib64',
353
+ 'libexec',
354
+ 'linuxbrew',
355
+ 'local',
356
+ 'macos',
357
+ 'opt',
358
+ 'out',
359
+ 'packages',
360
+ 'projects',
361
+ 'repos',
362
+ 'resources',
363
+ 'sbin',
364
+ 'scripts',
365
+ 'server',
366
+ 'share',
367
+ 'site-packages',
368
+ 'source',
369
+ 'src',
370
+ 'srv',
371
+ 'tmp',
372
+ 'usr',
373
+ 'users',
374
+ 'var',
375
+ 'work',
376
+ 'workspace',
377
+ ]);
378
+ function meaningfulDirectory(name) {
379
+ if (!name || name === '.' || name === '/' || name === '..')
380
+ return null;
381
+ if (ANONYMOUS_DIRECTORIES.has(name.toLowerCase()))
382
+ return null;
383
+ return name;
384
+ }
176
385
  /**
177
386
  * The project a dev server belongs to, taken as the directory above
178
387
  * `node_modules` in its command line. Without it two Vite servers on 5173 and
179
388
  * 5174 are indistinguishable, which is exactly when you need to tell them
180
389
  * apart.
390
+ *
391
+ * A script under a directory that only names a convention - `lib`, `src`,
392
+ * `bin` - falls back to the script itself, because `gcloud` says more about
393
+ * what is running than `lib` does.
181
394
  */
182
395
  export function projectHint(command) {
183
396
  if (!command)
184
397
  return null;
185
- const modules = /([^\s"']+)[/\\]node_modules[/\\]/.exec(command);
186
- if (modules?.[1]) {
187
- const name = basename(modules[1]);
188
- if (name && name !== '.' && name !== '/')
398
+ // Anchored on the marker and read backwards, never split on whitespace: a
399
+ // project at `/Users/amin/My Project/node_modules/...` is called `My
400
+ // Project`, and a capture that stops at the space calls it `Project`.
401
+ const modules = /[/\\]node_modules[/\\]/.exec(command);
402
+ if (modules) {
403
+ const name = meaningfulDirectory(enclosingDirectory(command.slice(0, modules.index)));
404
+ if (name)
189
405
  return name;
190
406
  }
191
407
  // Python and Ruby have no node_modules; fall back to the directory of the
192
- // script argument when the command line names one.
193
- const script = /(?:^|\s)([^\s"']*[/\\][^\s"']+\.(?:py|rb|js|ts|mjs|cjs))(?:\s|$)/.exec(command);
408
+ // script argument when the command line names one. The file name may not
409
+ // contain spaces, but every directory above it may.
410
+ const script = /[/\\]([^/\\\s"']+\.(?:py|rb|js|ts|mjs|cjs))(?=$|[\s"'])/.exec(command);
194
411
  if (script?.[1]) {
195
- const directory = script[1].replace(/[/\\][^/\\]+$/, '');
196
- const name = basename(directory);
197
- if (name && name !== '.' && name !== '/')
412
+ const directory = meaningfulDirectory(enclosingDirectory(command.slice(0, script.index)));
413
+ if (directory)
414
+ return directory;
415
+ // `.../google-cloud-sdk/lib/gcloud.py` is gcloud, whatever `lib` says.
416
+ const file = script[1].replace(/\.\w+$/, '');
417
+ if (file && file !== 'main' && file !== 'index' && file !== '__main__')
418
+ return file;
419
+ }
420
+ return null;
421
+ }
422
+ /**
423
+ * The directory a marker sits in, given everything to the left of it. Its own
424
+ * last component is the answer, spaces and all - which is the whole reason
425
+ * this reads backwards from the marker instead of matching a path forwards.
426
+ */
427
+ function enclosingDirectory(prefix) {
428
+ const trimmed = prefix.replace(/[/\\]+$/, '');
429
+ const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
430
+ const name = (separator === -1 ? trimmed : trimmed.slice(separator + 1)).replace(/^["']/, '').trim();
431
+ return name || undefined;
432
+ }
433
+ /**
434
+ * The product a binary belongs to, read off the path it was installed at.
435
+ *
436
+ * This is the answer to the commonest kind of unhelpful row: a process called
437
+ * `figma_agent` described as "figma_agent". A path says which application put
438
+ * it there, which is the thing the reader wanted and the process name was
439
+ * never going to give them.
440
+ */
441
+ export function applicationName(command) {
442
+ if (!command)
443
+ return null;
444
+ // Vendor directories are more specific than bundles: `Figma/FigmaAgent.app`
445
+ // is Figma's, and saying so beats repeating the helper's own name.
446
+ const support = /[/\\]Application Support[/\\]([^/\\]+)[/\\]/.exec(command);
447
+ if (support?.[1]) {
448
+ const name = meaningfulDirectory(support[1].replace(/\.localized$/, ''));
449
+ if (name)
450
+ return name;
451
+ }
452
+ // The outermost bundle, so a helper nested inside Visual Studio Code reports
453
+ // the editor rather than the helper.
454
+ const bundle = /([^/\\]+)\.app[/\\]/.exec(command);
455
+ if (bundle?.[1]) {
456
+ const name = meaningfulDirectory(bundle[1]);
457
+ if (name)
458
+ return name;
459
+ }
460
+ const windows = /[/\\]Program Files(?: \(x86\))?[/\\]([^/\\]+)[/\\]/i.exec(command);
461
+ if (windows?.[1]) {
462
+ const name = meaningfulDirectory(windows[1]);
463
+ if (name)
464
+ return name;
465
+ }
466
+ const unix = /[/\\](?:opt|snap)[/\\]([^/\\]+)[/\\]/.exec(command);
467
+ if (unix?.[1]) {
468
+ const name = meaningfulDirectory(unix[1]);
469
+ if (name)
198
470
  return name;
199
471
  }
200
472
  return null;
201
473
  }
202
474
  /**
203
- * Describe a port from three sources, in priority order: what the command line
204
- * says it is, then the project it belongs to, then the well-known port
205
- * registry as a fallback for processes that could not be identified at all.
475
+ * What the shape of a path says when its name says nothing: a binary under
476
+ * `/usr/libexec` is a system service whoever wrote it, and that is worth more
477
+ * to a reader than its own name repeated back at them.
478
+ */
479
+ export function locationCategory(command) {
480
+ if (!command)
481
+ return null;
482
+ if (/^\/(?:System|usr\/libexec|sbin|usr\/sbin)\//.test(command))
483
+ return 'system';
484
+ if (/[/\\](?:LaunchDaemons|LaunchAgents)[/\\]/.test(command))
485
+ return 'system';
486
+ if (/^\/(?:lib|usr\/lib)\/systemd\//.test(command))
487
+ return 'system';
488
+ if (/[/\\]Windows[/\\]System32[/\\]/i.test(command))
489
+ return 'system';
490
+ if (/\.app[/\\]/.test(command) || /^\/Applications\//.test(command))
491
+ return 'desktop';
492
+ if (/[/\\]Program Files(?: \(x86\))?[/\\]/i.test(command))
493
+ return 'desktop';
494
+ return null;
495
+ }
496
+ const LOCATION_LABEL = {
497
+ system: 'system service',
498
+ desktop: 'desktop app',
499
+ };
500
+ /**
501
+ * Describe a port from everything that is knowable without asking anything to
502
+ * identify itself, in priority order: what the command line says it is, the
503
+ * application it was installed as, the well-known port registry, and finally
504
+ * the shape of its path.
505
+ *
506
+ * The one rule the order exists to enforce is that the description never
507
+ * repeats the process name. A column that says `figma_agent` next to a column
508
+ * that says `figma_agent` is a column that has told the reader nothing, and it
509
+ * is better to say so than to fill the space.
206
510
  */
207
511
  export function describe(socket) {
208
512
  const text = searchable(socket);
209
- const signature = matchSignature(text);
210
- const hint = projectHint(socket.command);
211
- if (signature)
212
- return { label: signature, hint };
513
+ const signature = findSignature(text);
514
+ const application = applicationName(socket.command);
213
515
  const registered = PORT_REGISTRY[socket.port];
214
- if (registered && !registered.generic)
215
- return { label: registered.label, hint };
216
- if (socket.processName)
217
- return { label: socket.processName, hint };
218
- if (socket.pid === null)
219
- return { label: 'owner not visible', hint };
220
- return { label: 'unknown', hint };
516
+ const located = locationCategory(socket.command);
517
+ const project = projectHint(socket.command);
518
+ const build = (label, source, category, hint) => {
519
+ const info = CATEGORY_INFO[category];
520
+ return {
521
+ label,
522
+ source,
523
+ category,
524
+ // "Docker Desktop (Docker)" is one fact wearing two hats. A hint only
525
+ // earns its brackets by saying something the label did not.
526
+ hint: hint !== null && redundantHint(hint, label) ? null : hint,
527
+ summary: signature?.summary ?? info.summary,
528
+ restart: signature?.restart ?? null,
529
+ };
530
+ };
531
+ // A named framework or daemon beats everything: it is the one source that
532
+ // says what the program does rather than where it came from.
533
+ if (signature && !signature.runtime) {
534
+ return build(signature.label, 'signature', signature.category, project ?? application);
535
+ }
536
+ if (registered && !registered.generic && !signature) {
537
+ return build(registered.label, 'registry', registered.category, project ?? application);
538
+ }
539
+ // An application name outranks a bare runtime: "Visual Studio Code (Node.js)"
540
+ // is the useful way round, not "Node.js (Visual Studio Code)". But a runtime
541
+ // installed at `Program Files\nodejs` is not an application that happens to
542
+ // use Node - it is Node - so a directory that only spells the runtime out
543
+ // again loses to the signature, which at least knows how to capitalise it.
544
+ if (application &&
545
+ !sameName(application, socket.processName) &&
546
+ !sameName(application, signature?.label ?? null)) {
547
+ return build(application, 'application',
548
+ // Where it is installed beats what it is written in: once the label is
549
+ // the application, "Visual Studio Code - a desktop app" says more than
550
+ // "Visual Studio Code - a program", and the runtime moves to the hint.
551
+ located ?? signature?.category ?? registered?.category ?? 'desktop', project ?? signature?.label ?? null);
552
+ }
553
+ if (signature) {
554
+ return build(signature.label, 'signature', signature.category, project);
555
+ }
556
+ if (registered) {
557
+ return build(registered.label, 'registry', registered.category, project);
558
+ }
559
+ if (located) {
560
+ return build(LOCATION_LABEL[located] ?? null, 'location', located, project);
561
+ }
562
+ // Nothing matched. Saying nothing is the honest answer; the process name is
563
+ // already in its own column, and repeating it there would be a lie about how
564
+ // much slash-port worked out.
565
+ return build(null, 'none', 'unknown', project);
566
+ }
567
+ /**
568
+ * A name reduced to what it is, for comparing two of them. The extension goes
569
+ * first: stripping separators first turns `node.exe` into `nodeexe`, which
570
+ * matches nothing, and Windows then gets the duplicated columns the whole
571
+ * never-repeat-the-process-name rule exists to prevent.
572
+ */
573
+ const bareName = (value) => value.toLowerCase().replace(/\.exe$/, '').replace(/[\s._-]/g, '');
574
+ /** Whether two names would only say the same thing twice. */
575
+ function sameName(candidate, other) {
576
+ if (!other)
577
+ return false;
578
+ return bareName(candidate) === bareName(other);
579
+ }
580
+ /**
581
+ * Whether a hint is already contained in its label. "Docker Desktop (Docker)"
582
+ * passes an equality test and still says one thing twice.
583
+ */
584
+ export function redundantHint(hint, label) {
585
+ if (!label)
586
+ return false;
587
+ const [a, b] = [bareName(hint), bareName(label)];
588
+ return a.includes(b) || b.includes(a);
589
+ }
590
+ /** Loopback and wildcard binds are the ones a browser here can actually reach. */
591
+ function reachableLocally(addresses) {
592
+ return addresses.some((address) => address === '*' || address === '127.0.0.1' || address === '::1' || address === 'localhost');
593
+ }
594
+ /**
595
+ * Where to point a browser, for the ports that answer one. Offered only for
596
+ * TCP that is reachable from this machine, because a URL you cannot open is
597
+ * worse than no URL at all.
598
+ */
599
+ export function browserUrl(entry) {
600
+ if (entry.protocol !== 'tcp')
601
+ return null;
602
+ if (!reachableLocally(entry.addresses))
603
+ return null;
604
+ // The port has the last word on this. A container publishing 8080 is almost
605
+ // certainly HTTP whatever its image is called, and mailpit's 1025 is almost
606
+ // certainly not, whatever the web UI on 8025 might suggest.
607
+ const browsable = CATEGORY_INFO[entry.category].browsable === true || PORT_REGISTRY[entry.port]?.category === 'web';
608
+ if (!browsable)
609
+ return null;
610
+ const scheme = entry.port === 443 || entry.port === 8443 ? 'https' : 'http';
611
+ return `${scheme}://localhost:${entry.port}`;
612
+ }
613
+ /**
614
+ * Whether this can be closed, folding together what it is, who owns it, and
615
+ * whether a guardrail already refuses it.
616
+ *
617
+ * Guards and ownership outrank the category, because they are facts about this
618
+ * machine rather than guesses about the software: a Postgres you do not own is
619
+ * blocked whatever anybody thinks of closing databases.
620
+ */
621
+ export function assessRisk(entry) {
622
+ if (entry.guard) {
623
+ return { risk: 'protected', reason: `slash-port will not kill ${entry.guard}` };
624
+ }
625
+ if (entry.elevation) {
626
+ return {
627
+ risk: 'blocked',
628
+ reason: `not yours to signal - ${entry.elevation}`,
629
+ };
630
+ }
631
+ if (entry.pid === null) {
632
+ return { risk: 'blocked', reason: 'there is no owner to signal' };
633
+ }
634
+ const signature = findSignature(entry.command?.trim() || entry.processName?.trim() || '');
635
+ const risk = signature?.risk ?? CATEGORY_INFO[entry.category].risk;
636
+ switch (risk) {
637
+ case 'safe':
638
+ return { risk, reason: 'yours, and as easy to start again as it was to start' };
639
+ case 'caution':
640
+ return { risk, reason: 'yours, but something may be relying on it' };
641
+ default:
642
+ return { risk: 'risky', reason: 'the system’s, not part of your work' };
643
+ }
221
644
  }
222
645
  /**
223
646
  * Processes that are never worth killing from here. Compared on the bare
@@ -228,8 +651,14 @@ const PROTECTED_NAMES = {
228
651
  systemd: 'the init process',
229
652
  launchd: 'the init process',
230
653
  kernel_task: 'the kernel',
231
- sshd: 'the SSH daemon — killing it locks you out of a remote machine',
654
+ sshd: 'the SSH daemon - killing it locks you out of a remote machine',
232
655
  'ssh-agent': 'the SSH agent',
656
+ 'dbus-daemon': 'the D-Bus message bus - the desktop session is built on it',
657
+ // `/proc/[pid]/comm` is capped at fifteen characters, so the resolver
658
+ // arrives with its last letter missing. Both spellings are the same daemon.
659
+ 'systemd-resolve': 'the systemd DNS resolver - killing it takes DNS down for everything',
660
+ 'systemd-resolved': 'the systemd DNS resolver - killing it takes DNS down for everything',
661
+ 'systemd-logind': 'the systemd login manager - killing it ends every session on the machine',
233
662
  loginwindow: 'the macOS session',
234
663
  windowserver: 'the macOS window server',
235
664
  systemuiserver: 'the macOS session',
@@ -241,7 +670,7 @@ const PROTECTED_NAMES = {
241
670
  services: 'the Windows service controller',
242
671
  lsass: 'the Windows security subsystem',
243
672
  smss: 'a Windows session process',
244
- svchost: 'a Windows service host — it runs many unrelated services',
673
+ svchost: 'a Windows service host - it runs many unrelated services',
245
674
  system: 'the Windows kernel',
246
675
  };
247
676
  /**
@@ -262,3 +691,68 @@ export function guardReason(socket, context = { self: process.pid, parent: proce
262
691
  return PROTECTED_NAMES[name];
263
692
  return null;
264
693
  }
694
+ function currentOwner() {
695
+ return {
696
+ uid: typeof process.getuid === 'function' ? process.getuid() : null,
697
+ // A uid with no passwd entry - a container running as a bare number -
698
+ // makes this throw rather than return anything useful.
699
+ user: (() => {
700
+ try {
701
+ return userInfo().username;
702
+ }
703
+ catch {
704
+ return null;
705
+ }
706
+ })(),
707
+ platform: process.platform,
708
+ };
709
+ }
710
+ /** Windows writes `MACHINE\amin`, and is not case sensitive about either half. */
711
+ function sameUser(a, b, platform) {
712
+ if (platform !== 'win32')
713
+ return a === b;
714
+ const bare = (name) => (name.split('\\').pop() ?? name).toLowerCase();
715
+ return bare(a) === bare(b);
716
+ }
717
+ /** Whether the process belongs to whoever is running slash-port. */
718
+ export function ownedByCurrentUser(socket, context = currentOwner()) {
719
+ if (socket.user === null || context.user === null)
720
+ return false;
721
+ return sameUser(socket.user, context.user, context.platform);
722
+ }
723
+ /**
724
+ * What to do about a process you cannot signal. `sudo` is the answer almost
725
+ * everywhere and the wrong word on Windows, so the remedy is named per
726
+ * platform while the badge stays the same in every terminal.
727
+ */
728
+ export function elevationRemedy(platform = process.platform) {
729
+ return platform === 'win32' ? 'an elevated terminal' : 'sudo';
730
+ }
731
+ /**
732
+ * Why a signal to this process is expected to be refused, or `null` when it
733
+ * should land.
734
+ *
735
+ * The scan already knows who owns every socket, so this is settled while the
736
+ * list is built rather than discovered after a confirmation. That is the whole
737
+ * point: "sudo" is worth knowing before you decide, not after. Nothing here
738
+ * refuses anything - `killEntry` still asks the kernel, which is the only
739
+ * authority on the answer.
740
+ */
741
+ export function elevationReason(socket, context = currentOwner()) {
742
+ // Root can signal anything.
743
+ if (context.uid === 0)
744
+ return null;
745
+ if (socket.pid === null) {
746
+ // Windows reports pid 0 for the idle process rather than hiding an owner,
747
+ // so an unresolved pid there is not a permission wall.
748
+ return context.platform === 'win32' ? null : 'its owner is not visible';
749
+ }
750
+ if (socket.user === null || context.user === null)
751
+ return null;
752
+ if (sameUser(socket.user, context.user, context.platform))
753
+ return null;
754
+ // The scanners fall back to the numeric uid when /etc/passwd has no name.
755
+ if (context.uid !== null && socket.user === String(context.uid))
756
+ return null;
757
+ return `it belongs to ${socket.user}`;
758
+ }