slash-port 0.2.1 → 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,98 +1,242 @@
1
1
  import { userInfo } from 'node:os';
2
- import { basename } from 'node:path';
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`.';
3
59
  export const SIGNATURES = [
4
60
  // Node frameworks and dev servers, ahead of the runtime that hosts them.
5
- { pattern: /\bnext(-server)?\b|[/\\]next[/\\]dist[/\\]/i, label: 'Next.js' },
6
- { pattern: /\bnuxt\b/i, label: 'Nuxt' },
7
- { pattern: /[/\\]vite[/\\]|\bvite\b/i, label: 'Vite dev server' },
8
- { pattern: /\bastro\b/i, label: 'Astro' },
9
- { pattern: /\bremix\b/i, label: 'Remix' },
10
- { pattern: /\bsvelte-kit\b|\bsveltekit\b/i, label: 'SvelteKit' },
11
- { pattern: /\bgatsby\b/i, label: 'Gatsby' },
12
- { pattern: /\bnest\b|\bnestjs\b/i, label: 'NestJS' },
13
- { pattern: /\breact-scripts\b/i, label: 'Create React App' },
14
- { pattern: /\bwebpack(-dev-server)?\b/i, label: 'webpack dev server' },
15
- { pattern: /\bparcel\b/i, label: 'Parcel' },
16
- { pattern: /\bstorybook\b/i, label: 'Storybook' },
17
- { pattern: /\bexpo\b/i, label: 'Expo' },
18
- { pattern: /\bmetro\b/i, label: 'Metro bundler' },
19
- { pattern: /\bvitest\b/i, label: 'Vitest' },
20
- { pattern: /\bjest\b/i, label: 'Jest' },
21
- { pattern: /\bnodemon\b/i, label: 'nodemon' },
22
- { 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 },
23
97
  // Deliberately narrow: a bare "serve" appears in half the daemons on a
24
98
  // machine, so only the npm package's own paths count.
25
- { pattern: /\bhttp-server\b|npx\s+serve\b|node_modules[/\\]\.bin[/\\]serve\b/i, label: 'static file server' },
26
- { 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
+ },
27
113
  // Python.
28
- { pattern: /\bgunicorn\b/i, label: 'Gunicorn' },
29
- { pattern: /\buvicorn\b/i, label: 'Uvicorn' },
30
- { pattern: /\bhypercorn\b/i, label: 'Hypercorn' },
31
- { pattern: /manage\.py\s+runserver/i, label: 'Django dev server' },
32
- { pattern: /\bdjango\b/i, label: 'Django' },
33
- { pattern: /\bflask\b/i, label: 'Flask' },
34
- { pattern: /\bfastapi\b/i, label: 'FastAPI' },
35
- { pattern: /\bstreamlit\b/i, label: 'Streamlit' },
36
- { pattern: /\bjupyter\b|\bipykernel\b/i, label: 'Jupyter' },
37
- { pattern: /\bcelery\b/i, label: 'Celery' },
38
- { 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' },
39
137
  // Ruby, PHP, Java, Go, .NET.
40
- { pattern: /\bpuma\b/i, label: 'Puma' },
41
- { pattern: /\bunicorn\b/i, label: 'Unicorn' },
42
- { pattern: /\bsidekiq\b/i, label: 'Sidekiq' },
43
- { pattern: /\brails\b/i, label: 'Rails' },
44
- { pattern: /\bjekyll\b/i, label: 'Jekyll' },
45
- { pattern: /artisan\s+serve/i, label: 'Laravel dev server' },
46
- { pattern: /\bphp\b.*\s-S\s/i, label: 'PHP built-in server' },
47
- { pattern: /\bspring-boot\b|\borg\.springframework\b/i, label: 'Spring Boot' },
48
- { pattern: /\bgradle\b/i, label: 'Gradle' },
49
- { pattern: /\btomcat\b|\bcatalina\b/i, label: 'Tomcat' },
50
- { pattern: /(?:^|[/\\])air(?:\s|$)/i, label: 'Air (Go live reload)' },
51
- { 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' },
52
150
  // Datastores and queues, matched on the binary name.
53
- { pattern: /\bpostgres(ql)?\b/i, label: 'PostgreSQL' },
54
- { pattern: /\bmysqld\b|\bmariadbd\b/i, label: 'MySQL / MariaDB' },
55
- { pattern: /\bmongod\b/i, label: 'MongoDB' },
56
- { pattern: /\bredis-(server|stack)\b/i, label: 'Redis' },
57
- { pattern: /\bvalkey\b/i, label: 'Valkey' },
58
- { pattern: /\bmemcached\b/i, label: 'Memcached' },
59
- { pattern: /\belasticsearch\b|\bopensearch\b/i, label: 'Elasticsearch / OpenSearch' },
60
- { pattern: /\bclickhouse\b/i, label: 'ClickHouse' },
61
- { pattern: /\brabbitmq\b|\bbeam\.smp\b/i, label: 'RabbitMQ' },
62
- { pattern: /\bkafka\b/i, label: 'Kafka' },
63
- { pattern: /\bzookeeper\b/i, label: 'ZooKeeper' },
64
- { pattern: /\betcd\b/i, label: 'etcd' },
65
- { pattern: /\bminio\b/i, label: 'MinIO' },
66
- { pattern: /\blocalstack\b/i, label: 'LocalStack' },
67
- { pattern: /\bollama\b/i, label: 'Ollama' },
68
- { pattern: /\blm-?studio\b/i, label: 'LM Studio' },
69
- { 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
+ },
70
195
  // Containers, proxies, and system services.
71
- { pattern: /\bdocker-proxy\b/i, label: 'Docker published port' },
72
- { pattern: /com\.docker\./i, label: 'Docker Desktop' },
73
- { pattern: /\bcontainerd\b/i, label: 'containerd' },
74
- { pattern: /\bkubelet\b|\bk3s\b|\bminikube\b/i, label: 'Kubernetes' },
75
- { pattern: /\bnginx\b/i, label: 'nginx' },
76
- { pattern: /\bcaddy\b/i, label: 'Caddy' },
77
- { pattern: /\btraefik\b/i, label: 'Traefik' },
78
- { pattern: /\bhttpd\b|\bapache2\b/i, label: 'Apache' },
79
- { pattern: /\bngrok\b|\bcloudflared\b/i, label: 'tunnel client' },
80
- { pattern: /\bsshd\b/i, label: 'OpenSSH server' },
81
- { pattern: /\bmDNSResponder\b|\bavahi\b/i, label: 'mDNS / Bonjour' },
82
- { pattern: /\brapportd\b/i, label: 'macOS Handoff' },
83
- { pattern: /\bControlCe(nter|ntre)\b/i, label: 'macOS Control Center' },
84
- { pattern: /\bcupsd\b/i, label: 'CUPS printing' },
85
- { pattern: /\bsystemd-resolve/i, label: 'systemd-resolved' },
86
- { pattern: /\bdnsmasq\b/i, label: 'dnsmasq' },
87
- // Runtimes last: they only win when nothing more specific matched.
88
- { pattern: /\bdeno\b/i, label: 'Deno' },
89
- { pattern: /\bbun\b/i, label: 'Bun' },
90
- { pattern: /\bnode(js)?\b/i, label: 'Node.js' },
91
- { pattern: /\bpython[\d.]*\b/i, label: 'Python' },
92
- { pattern: /\bruby\b/i, label: 'Ruby' },
93
- { pattern: /\bjava\b/i, label: 'Java' },
94
- { pattern: /\bphp\b/i, label: 'PHP' },
95
- { 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 },
96
240
  ];
97
241
  /**
98
242
  * Well-known ports, biased towards the ones a developer actually meets rather
@@ -100,125 +244,403 @@ export const SIGNATURES = [
100
244
  * identified, which is mostly other users' processes.
101
245
  */
102
246
  export const PORT_REGISTRY = {
103
- 20: { label: 'FTP data' },
104
- 21: { label: 'FTP' },
105
- 22: { label: 'SSH' },
106
- 25: { label: 'SMTP' },
107
- 53: { label: 'DNS' },
108
- 80: { label: 'HTTP' },
109
- 110: { label: 'POP3' },
110
- 111: { label: 'rpcbind' },
111
- 123: { label: 'NTP' },
112
- 143: { label: 'IMAP' },
113
- 389: { label: 'LDAP' },
114
- 443: { label: 'HTTPS' },
115
- 445: { label: 'SMB' },
116
- 465: { label: 'SMTPS' },
117
- 514: { label: 'syslog' },
118
- 548: { label: 'Apple Filing Protocol' },
119
- 587: { label: 'SMTP submission' },
120
- 631: { label: 'CUPS printing' },
121
- 853: { label: 'DNS over TLS' },
122
- 993: { label: 'IMAPS' },
123
- 1080: { label: 'SOCKS proxy' },
124
- 1433: { label: 'SQL Server' },
125
- 1521: { label: 'Oracle database' },
126
- 1883: { label: 'MQTT' },
127
- 1900: { label: 'SSDP / UPnP' },
128
- 2049: { label: 'NFS' },
129
- 2375: { label: 'Docker daemon (unencrypted)' },
130
- 2376: { label: 'Docker daemon (TLS)' },
131
- 3000: { label: 'dev server', generic: true },
132
- 3001: { label: 'dev server', generic: true },
133
- 3306: { label: 'MySQL / MariaDB' },
134
- 3478: { label: 'STUN / TURN' },
135
- 4200: { label: 'Angular dev server' },
136
- 4321: { label: 'Astro dev server' },
137
- 4873: { label: 'Verdaccio npm registry' },
138
- 5000: { label: 'AirPlay receiver or Flask' },
139
- 5173: { label: 'Vite dev server' },
140
- 5432: { label: 'PostgreSQL' },
141
- 5601: { label: 'Kibana' },
142
- 5672: { label: 'RabbitMQ (AMQP)' },
143
- 5900: { label: 'VNC / screen sharing' },
144
- 6006: { label: 'Storybook or TensorBoard' },
145
- 6379: { label: 'Redis' },
146
- 7000: { label: 'AirPlay' },
147
- 8000: { label: 'dev server', generic: true },
148
- 8025: { label: 'Mailpit / MailHog' },
149
- 8080: { label: 'HTTP alternate', generic: true },
150
- 8081: { label: 'HTTP alternate', generic: true },
151
- 8443: { label: 'HTTPS alternate' },
152
- 8888: { label: 'Jupyter' },
153
- 9000: { label: 'PHP-FPM or MinIO' },
154
- 9090: { label: 'Prometheus' },
155
- 9092: { label: 'Kafka' },
156
- 9200: { label: 'Elasticsearch' },
157
- 9229: { label: 'Node.js inspector' },
158
- 11211: { label: 'Memcached' },
159
- 15672: { label: 'RabbitMQ management' },
160
- 27017: { label: 'MongoDB' },
161
- 50000: { label: 'DB2 or SIP' },
162
- 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' },
163
307
  };
164
308
  /** The text the heuristics run against: full command line, else process name. */
165
309
  function searchable(socket) {
166
310
  return socket.command?.trim() || socket.processName?.trim() || '';
167
311
  }
168
- export function matchSignature(text) {
312
+ function findSignature(text) {
169
313
  if (!text)
170
314
  return null;
171
315
  for (const signature of SIGNATURES) {
172
316
  if (signature.pattern.test(text))
173
- return signature.label;
317
+ return signature;
174
318
  }
175
319
  return null;
176
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
+ }
177
385
  /**
178
386
  * The project a dev server belongs to, taken as the directory above
179
387
  * `node_modules` in its command line. Without it two Vite servers on 5173 and
180
388
  * 5174 are indistinguishable, which is exactly when you need to tell them
181
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.
182
394
  */
183
395
  export function projectHint(command) {
184
396
  if (!command)
185
397
  return null;
186
- const modules = /([^\s"']+)[/\\]node_modules[/\\]/.exec(command);
187
- if (modules?.[1]) {
188
- const name = basename(modules[1]);
189
- 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)
190
405
  return name;
191
406
  }
192
407
  // Python and Ruby have no node_modules; fall back to the directory of the
193
- // script argument when the command line names one.
194
- 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);
195
411
  if (script?.[1]) {
196
- const directory = script[1].replace(/[/\\][^/\\]+$/, '');
197
- const name = basename(directory);
198
- 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)
199
470
  return name;
200
471
  }
201
472
  return null;
202
473
  }
203
474
  /**
204
- * Describe a port from three sources, in priority order: what the command line
205
- * says it is, then the project it belongs to, then the well-known port
206
- * 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.
207
510
  */
208
511
  export function describe(socket) {
209
512
  const text = searchable(socket);
210
- const signature = matchSignature(text);
211
- const hint = projectHint(socket.command);
212
- if (signature)
213
- return { label: signature, hint };
513
+ const signature = findSignature(text);
514
+ const application = applicationName(socket.command);
214
515
  const registered = PORT_REGISTRY[socket.port];
215
- if (registered && !registered.generic)
216
- return { label: registered.label, hint };
217
- if (socket.processName)
218
- return { label: socket.processName, hint };
219
- if (socket.pid === null)
220
- return { label: 'owner not visible', hint };
221
- 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
+ }
222
644
  }
223
645
  /**
224
646
  * Processes that are never worth killing from here. Compared on the bare
@@ -292,6 +714,20 @@ function sameUser(a, b, platform) {
292
714
  const bare = (name) => (name.split('\\').pop() ?? name).toLowerCase();
293
715
  return bare(a) === bare(b);
294
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
+ }
295
731
  /**
296
732
  * Why a signal to this process is expected to be refused, or `null` when it
297
733
  * should land.