claude-bulkhead 0.2.2 → 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.
Files changed (66) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.es.md +142 -50
  3. package/README.ja.md +142 -50
  4. package/README.ko.md +143 -51
  5. package/README.md +142 -50
  6. package/README.pt-BR.md +142 -50
  7. package/README.zh-CN.md +154 -62
  8. package/hooks/hooks.json +68 -8
  9. package/package.json +5 -2
  10. package/src/agent-hook.js +162 -0
  11. package/src/agent-setup.js +256 -0
  12. package/src/agents.js +89 -0
  13. package/src/allow-cli.js +65 -0
  14. package/src/allow-hint.js +62 -0
  15. package/src/allow-once.js +152 -0
  16. package/src/auto-queue.js +93 -0
  17. package/src/bash-guards.js +36 -0
  18. package/src/basics.js +46 -27
  19. package/src/checkout-guard.js +342 -74
  20. package/src/cli-util.js +13 -1
  21. package/src/cli.js +40 -4
  22. package/src/config-check.js +21 -4
  23. package/src/config-cli.js +75 -10
  24. package/src/config.js +106 -9
  25. package/src/dev-servers.js +361 -61
  26. package/src/disk-guard.js +8 -8
  27. package/src/doctor-enforcement.js +273 -0
  28. package/src/doctor.js +19 -9
  29. package/src/edit-guard.js +196 -0
  30. package/src/events.js +30 -5
  31. package/src/explain.js +251 -0
  32. package/src/git-info.js +55 -0
  33. package/src/gitargs.js +82 -0
  34. package/src/hook.js +38 -6
  35. package/src/kill-extras.js +219 -0
  36. package/src/kill-guard.js +228 -77
  37. package/src/kill-owners.js +116 -0
  38. package/src/log-cli.js +15 -10
  39. package/src/mcp.js +131 -0
  40. package/src/memory-guard.js +126 -24
  41. package/src/memory.js +21 -2
  42. package/src/notices.js +95 -0
  43. package/src/orphan-reaper.js +58 -11
  44. package/src/port-cli.js +78 -3
  45. package/src/port-guard.js +80 -68
  46. package/src/port-kills.js +198 -0
  47. package/src/pressure.js +75 -0
  48. package/src/processes.js +44 -12
  49. package/src/scan-report.js +166 -0
  50. package/src/scan.js +87 -18
  51. package/src/secrets-guard.js +301 -0
  52. package/src/self-allow.js +73 -0
  53. package/src/session-info.js +121 -0
  54. package/src/session-state.js +176 -0
  55. package/src/sessions.js +109 -20
  56. package/src/shell.js +279 -19
  57. package/src/slot-cli.js +92 -21
  58. package/src/slots.js +61 -13
  59. package/src/stable-port.js +61 -0
  60. package/src/status.js +55 -17
  61. package/src/statusline.js +45 -16
  62. package/src/subagent-guard.js +24 -0
  63. package/src/top.js +36 -11
  64. package/src/wait-guard.js +96 -32
  65. package/src/worktree-cli.js +454 -0
  66. package/src/worktree-copy.js +82 -0
@@ -1,47 +1,183 @@
1
1
  'use strict'
2
2
  const fs = require('fs')
3
+ const os = require('os')
3
4
  const path = require('path')
5
+ const shell = require('./shell')
4
6
 
5
7
  // Dev servers and the port each one uses when none is given. `sub` lists the subcommands
6
- // that start a server; '' means the bare command does, null means any arguments do.
8
+ // that start a server; '' means the bare command does, null means any arguments do. `notSub`
9
+ // is the reverse: every subcommand except these. `test(args)` is an extra gate on the arguments,
10
+ // `need` means the tool serves only when a port flag or environment variable says where, `scan`
11
+ // reads the port out of the joined arguments (`--args='--server.port=3000'`), `version` lists
12
+ // extra flags that only print a version.
7
13
  const TOOLS = [
8
- { bin: ['next'], sub: ['dev', 'start'], port: 3000, flags: ['-p', '--port'], env: ['PORT'] },
9
- { bin: ['vite'], sub: ['', 'dev', 'serve'], port: 5173, flags: ['--port'] },
10
- { bin: ['vite'], sub: ['preview'], port: 4173, flags: ['--port'] },
14
+ { bin: ['next'], sub: ['dev', 'start'], port: 3000, flags: ['-p', '--port'], env: ['PORT'], version: ['-v'] },
15
+ { bin: ['vite'], sub: ['preview'], port: 4173, flags: ['--port'], version: ['-v'] },
16
+ // vite [root] and vite dev|serve start the server; build and optimize do not
17
+ { bin: ['vite'], notSub: ['build', 'optimize', 'preview'], port: 5173, flags: ['--port'], version: ['-v'] },
11
18
  { bin: ['astro'], sub: ['dev', 'preview'], port: 4321, flags: ['--port'] },
12
19
  { bin: ['nuxt', 'nuxi'], sub: ['dev'], port: 3000, flags: ['-p', '--port'], env: ['NUXT_PORT', 'PORT'] },
13
20
  { bin: ['remix'], sub: ['dev'], port: 3000, flags: ['--port'], env: ['PORT'] },
14
21
  { bin: ['remix'], sub: ['vite:dev'], port: 5173, flags: ['--port'] },
15
22
  { bin: ['expo'], sub: ['start'], port: 8081, flags: ['-p', '--port'], env: ['RCT_METRO_PORT'] },
23
+ { bin: ['react-native', 'metro'], sub: ['start', 'serve'], port: 8081, flags: ['--port'], env: ['RCT_METRO_PORT'] },
16
24
  { bin: ['uvicorn'], sub: null, port: 8000, flags: ['--port'] },
25
+ { bin: ['hypercorn'], sub: null, port: 8000, flags: ['-b', '--bind'], arg: n => `--bind :${n}` },
26
+ { bin: ['gunicorn'], sub: null, port: 8000, flags: ['-b', '--bind'], env: ['PORT'], arg: n => `--bind :${n}` },
27
+ { bin: ['fastapi'], sub: ['dev', 'run'], port: 8000, flags: ['--port'] },
17
28
  { bin: ['flask'], sub: ['run'], port: 5000, flags: ['-p', '--port'], env: ['FLASK_RUN_PORT'] },
18
29
  { bin: ['rails'], sub: ['s', 'server'], port: 3000, flags: ['-p', '--port'], env: ['PORT'] },
30
+ { bin: ['puma'], sub: null, port: 9292, flags: ['-p', '--port', '-b', '--bind'], env: ['PORT'], arg: n => `-p ${n}` },
31
+ { bin: ['rackup'], sub: null, port: 9292, flags: ['-p', '--port'] },
19
32
  { bin: ['gatsby'], sub: ['develop'], port: 8000, flags: ['-p', '--port'] },
20
- { bin: ['ng'], sub: ['serve'], port: 4200, flags: ['--port'] },
33
+ { bin: ['gatsby'], sub: ['serve'], port: 9000, flags: ['-p', '--port'] },
34
+ { bin: ['ng', 'nx', 'ember'], sub: ['serve', 's'], port: 4200, flags: ['--port'] },
21
35
  { bin: ['webpack'], sub: ['serve'], port: 8080, flags: ['--port'] },
22
36
  { bin: ['webpack-dev-server'], sub: null, port: 8080, flags: ['--port'] },
37
+ { bin: ['vue-cli-service'], sub: ['serve'], port: 8080, flags: ['--port'] },
38
+ { bin: ['rspack'], sub: ['serve', 'dev', 's'], port: 8080, flags: ['--port'] },
39
+ { bin: ['rsbuild'], sub: ['dev'], port: 3000, flags: ['--port'] },
40
+ { bin: ['react-scripts', 'craco'], sub: ['start'], port: 3000, flags: [], env: ['PORT'] },
41
+ { bin: ['parcel'], notSub: ['build', 'watch'], port: 1234, flags: ['-p', '--port'] },
23
42
  { bin: ['hugo'], sub: ['server', 'serve'], port: 1313, flags: ['-p', '--port'] },
24
43
  { bin: ['jekyll'], sub: ['serve', 's'], port: 4000, flags: ['-P', '--port'] },
44
+ { bin: ['hexo'], sub: ['server', 's'], port: 4000, flags: ['-p', '--port'] },
45
+ { bin: ['zola'], sub: ['serve'], port: 1111, flags: ['-p', '--port'] },
46
+ { bin: ['mdbook'], sub: ['serve'], port: 3000, flags: ['-p', '--port'] },
47
+ { bin: ['mkdocs'], sub: ['serve'], port: 8000, flags: ['-a', '--dev-addr'], arg: n => `-a localhost:${n}` },
48
+ { bin: ['trunk'], sub: ['serve'], port: 8080, flags: ['--port'] },
49
+ { bin: ['eleventy', '11ty'], sub: null, port: 8080, flags: ['--port'], test: a => a.includes('--serve') },
50
+ { bin: ['vuepress', 'vuepress-vite', 'vuepress-webpack'], sub: ['dev'], port: 8080, flags: ['-p', '--port'] },
51
+ { bin: ['vitepress'], sub: ['dev'], port: 5173, flags: ['--port'] },
52
+ { bin: ['vitepress'], sub: ['preview', 'serve'], port: 4173, flags: ['--port'] },
25
53
  { bin: ['storybook'], sub: ['dev'], port: 6006, flags: ['-p', '--port'] },
26
54
  { bin: ['wrangler'], sub: ['dev'], port: 8787, flags: ['--port'] },
55
+ { bin: ['netlify'], sub: ['dev'], port: 8888, flags: ['-p', '--port'] },
56
+ { bin: ['vercel', 'vc'], sub: ['dev'], port: 3000, flags: ['-l', '--listen'] },
27
57
  { bin: ['docusaurus'], sub: ['start'], port: 3000, flags: ['-p', '--port'] },
28
- { bin: ['http-server'], sub: null, port: 8080, flags: ['-p', '--port'] },
58
+ { bin: ['http-server'], sub: null, port: 8080, flags: ['-p', '--port'], env: ['PORT'] },
59
+ { bin: ['live-server'], sub: null, port: 8080, flags: ['--port'], env: ['PORT'] },
60
+ { bin: ['serve'], sub: null, port: 3000, flags: ['-l', '--listen', '-p'], arg: n => `-l ${n}` },
61
+ { bin: ['json-server'], sub: null, port: 3000, flags: ['-p', '--port'], env: ['PORT'] },
62
+ { bin: ['lite-server'], sub: null, port: 3000, flags: ['--port'] },
63
+ { bin: ['browser-sync'], sub: null, port: 3000, flags: ['-p', '--port'] },
64
+ { bin: ['streamlit'], sub: ['run'], port: 8501, flags: ['--server.port'], env: ['STREAMLIT_SERVER_PORT'] },
65
+ { bin: ['jupyter'], sub: ['notebook', 'lab', 'server', 'nbclassic'], port: 8888, flags: ['--port', '--NotebookApp.port', '--ServerApp.port'], arg: n => `--port ${n}` },
66
+ { bin: ['jupyter-lab', 'jupyter-notebook'], sub: null, port: 8888, flags: ['--port', '--NotebookApp.port', '--ServerApp.port'], arg: n => `--port ${n}` },
67
+ { bin: ['mix'], sub: ['phx.server'], port: 4000, flags: [], env: ['PORT'] },
68
+ // Spring Boot: 8080 unless a server.port says otherwise
69
+ { bin: ['gradle', 'gradlew'], sub: null, port: 8080, flags: ['--server.port', '-Dserver.port'], env: ['SERVER_PORT'], scan: /server\.port[=\s]+(\d+)/, arg: n => `--args='--server.port=${n}'`, test: a => a.includes('bootRun') },
70
+ { bin: ['mvn', 'mvnw'], sub: null, port: 8080, flags: ['--server.port', '-Dserver.port'], env: ['SERVER_PORT'], scan: /server\.port[=\s]+(\d+)/, arg: n => `-Dspring-boot.run.arguments=--server.port=${n}`, test: a => a.includes('spring-boot:run') },
71
+ // these serve only where an explicit port says so: php -S host:port, java -jar app.jar --server.port=N, dotnet run --urls ...
72
+ { bin: ['php'], sub: null, flags: ['-S'], need: true, arg: n => `-S localhost:${n}` },
73
+ { bin: ['ruby'], sub: null, port: 8080, flags: ['-p'], test: a => a.includes('-run') && a.some(x => /^(?:-e)?httpd\b/.test(x)) },
74
+ { bin: ['java'], sub: null, flags: ['--server.port', '-Dserver.port', '--port'], env: ['SERVER_PORT', 'PORT'], need: true, arg: n => `--server.port=${n}`, test: a => a.includes('-jar') },
75
+ { bin: ['dotnet'], sub: ['run', 'watch'], flags: ['--urls'], env: ['ASPNETCORE_HTTP_PORTS'], need: true, arg: n => `--urls http://localhost:${n}` },
29
76
  // positional ports
30
77
  { bin: ['http.server'], sub: null, port: 8000, positional: /^(\d+)$/ },
31
- { bin: ['manage.py'], sub: ['runserver'], port: 8000, positional: /^(?:[\w.[\]]*:)?(\d+)$/ },
78
+ { bin: ['manage.py', 'django-admin', 'django'], sub: ['runserver', 'runserver_plus'], port: 8000, positional: /^(?:[\w.[\]]*:)?(\d+)$/ },
32
79
  { bin: ['artisan'], sub: ['serve'], port: 8000, flags: ['--port'] },
33
80
  ]
34
81
 
35
- const WRAPPERS = new Set(['npx', 'bunx', 'pnpx', 'nohup', 'exec', 'time', 'env', 'cross-env', 'dotenv', 'sudo', 'nice'])
82
+ // Programs that run a file and read the port from PORT; the port must be named in the command.
83
+ const RUNTIMES = [
84
+ { bin: /^(?:node|nodemon|tsx|ts-node|bun|deno)$/, ok: a => a.some(x => !x.startsWith('-') && /\.(?:[cm]?[jt]sx?)$/.test(x)) },
85
+ { bin: /^(?:python[\d.]*|py)$/, ok: a => a.some(x => !x.startsWith('-') && /\.py$/.test(x)) },
86
+ { bin: /^ruby$/, ok: a => a.some(x => !x.startsWith('-') && /\.rb$/.test(x)) },
87
+ { bin: /^go$/, ok: a => a[0] === 'run' },
88
+ { bin: /^cargo$/, ok: a => /^(?:run|watch)$/.test(a[0]) },
89
+ ]
90
+
36
91
  const RUNNERS = new Set(['npm', 'pnpm', 'yarn', 'bun'])
92
+ const OPT_WITH_VALUE = /^(?:--prefix|-C|--cwd|--dir|--filter|-F|--workspace|-w|--workspace-root)$/
93
+
94
+ // A token after which the wrapped command starts, for each wrapper: how many tokens it takes.
95
+ // `skipFlags` passes options, the ones in `valued` also take the next token.
96
+ const skipFlags = (t, i, valued = []) => {
97
+ while (i < t.length && /^-/.test(t[i]) && t[i] !== '--') i += valued.includes(t[i]) ? 2 : 1
98
+ return t[i] === '--' ? i + 1 : i
99
+ }
100
+ const afterDashes = (t, i) => {
101
+ const j = t.indexOf('--', i)
102
+ return j >= 0 ? j + 1 : -1
103
+ }
104
+ const PKG_FLAGS = ['-p', '--package', '-c', '--call', '-w', '--workspace', '--prefix', '--registry', '--node-options']
105
+ const WRAPPERS = {
106
+ npx: (t, i) => skipFlags(t, i + 1, PKG_FLAGS),
107
+ bunx: (t, i) => skipFlags(t, i + 1, PKG_FLAGS),
108
+ pnpx: (t, i) => skipFlags(t, i + 1, PKG_FLAGS),
109
+ uvx: (t, i) => skipFlags(t, i + 1, ['--from', '--with', '-p', '--python']),
110
+ nohup: (t, i) => i + 1,
111
+ exec: (t, i) => skipFlags(t, i + 1, ['-a']),
112
+ time: (t, i) => skipFlags(t, i + 1, ['-f', '-o', '--format', '--output']),
113
+ command: (t, i) => i + 1,
114
+ builtin: (t, i) => i + 1,
115
+ setsid: (t, i) => skipFlags(t, i + 1),
116
+ nice: (t, i) => skipFlags(t, i + 1, ['-n', '--adjustment']),
117
+ ionice: (t, i) => skipFlags(t, i + 1, ['-c', '-n', '-p']),
118
+ stdbuf: (t, i) => skipFlags(t, i + 1),
119
+ unbuffer: (t, i) => skipFlags(t, i + 1),
120
+ caffeinate: (t, i) => skipFlags(t, i + 1, ['-t', '-w']),
121
+ sudo: (t, i) => skipFlags(t, i + 1, ['-u', '-g', '-h', '-p', '-C', '-D', '-R', '-T', '-U']),
122
+ doas: (t, i) => skipFlags(t, i + 1, ['-u', '-C']),
123
+ env: (t, i) => skipFlags(t, i + 1, ['-u', '-C', '-S', '-P']),
124
+ 'cross-env': (t, i) => skipFlags(t, i + 1),
125
+ 'env-cmd': (t, i) => {
126
+ const d = afterDashes(t, i + 1)
127
+ return d >= 0 ? d : skipFlags(t, i + 1, ['-f', '-e', '-r', '--file', '--environments', '--rc-file'])
128
+ },
129
+ dotenv: (t, i) => {
130
+ const d = afterDashes(t, i + 1)
131
+ return d >= 0 ? d : skipFlags(t, i + 1, ['-e', '-p', '-v', '-c'])
132
+ },
133
+ dotenvx: (t, i) => {
134
+ const d = t[i + 1] === 'run' ? afterDashes(t, i + 2) : -1
135
+ return d >= 0 ? d : i + 1
136
+ },
137
+ timeout: (t, i) => {
138
+ let j = skipFlags(t, i + 1, ['-s', '--signal', '-k', '--kill-after'])
139
+ if (/^\d+(?:\.\d+)?[smhd]?$/.test(t[j] || '')) j++
140
+ return j
141
+ },
142
+ }
143
+ // `<tool> <verb>` wrappers: npm exec, pnpm dlx, uv run, bundle exec, poetry run ...
144
+ const SUBWRAPPERS = {
145
+ npm: /^(?:exec|x)$/,
146
+ pnpm: /^(?:exec|dlx)$/,
147
+ yarn: /^(?:exec|dlx)$/,
148
+ bun: /^x$/,
149
+ bundle: /^exec$/,
150
+ uv: /^run$/,
151
+ poetry: /^run$/,
152
+ pipenv: /^run$/,
153
+ pdm: /^run$/,
154
+ rye: /^run$/,
155
+ hatch: /^run$/,
156
+ pixi: /^run$/,
157
+ pipx: /^run$/,
158
+ mise: /^(?:exec|x)$/,
159
+ conda: /^run$/,
160
+ direnv: /^exec$/,
161
+ }
37
162
 
38
- // Words of one segment. Grouping parentheses and braces, `( npm run dev )`, are not words.
39
- function tokens(s) {
40
- return [...s.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map(m => m[1] ?? m[2] ?? m[3]).filter(t => !/^[(){}]$/.test(t))
163
+ // Words of one command without the keyword in front (`do npm run dev`) and without redirections.
164
+ function cleanWords(words, out = []) {
165
+ let i = 0
166
+ while (i < words.length && /^(?:do|then|else|elif|if|while|until|\{|!)$/.test(words[i])) i++
167
+ for (; i < words.length; i++) {
168
+ const w = words[i]
169
+ if (/^\d*(?:>>?|<)$/.test(w)) { i++; continue }
170
+ if (/^\d*[<>]/.test(w)) continue
171
+ out.push(w)
172
+ }
173
+ return out
41
174
  }
42
175
 
43
- function segments(cmd) {
44
- return cmd.replace(/\d?>&\d/g, ' ').split(/\n|;|&&|\|\||\||&/).map(s => s.trim()).filter(Boolean)
176
+ // Every simple command in text order, each as its words. Quotes are respected, so
177
+ // grep -E "next dev|vite" is one command with a pattern, not two servers.
178
+ function commandsOf(cmd) {
179
+ const { cmds } = shell.lex(String(cmd || ''))
180
+ return cmds.slice().sort((a, b) => a.s - b.s).map(c => cleanWords(c.words.map(w => w.v))).filter(t => t.length)
45
181
  }
46
182
 
47
183
  const binName = t => path.basename(String(t)).replace(/\.(?:cmd|exe|ps1)$/i, '').toLowerCase()
@@ -65,14 +201,18 @@ function takeEnv(t, env) {
65
201
  }
66
202
  }
67
203
 
68
- // Index of the real command after npx, nohup, python -m and similar.
69
- function commandStart(t) {
204
+ // Index of the real command after npx, nohup, timeout 60, cross-env PORT=1, python -m and similar.
205
+ // NAME=value words on the way are added to env.
206
+ function commandStart(t, env) {
70
207
  let i = 0
71
208
  while (i < t.length) {
72
209
  const b = binName(t[i])
73
- if (WRAPPERS.has(b) || /^-(?:y|-yes)$/.test(t[i]) || /^\w+=/.test(t[i])) i++
74
- else if ((b === 'pnpm' || b === 'yarn' || b === 'npm') && /^(?:exec|dlx)$/.test(t[i + 1])) i += 2
75
- else if (b === 'bundle' && t[i + 1] === 'exec') i += 2
210
+ const kv = /^(\w+)=(.*)$/.exec(t[i])
211
+ if (kv) { env[kv[1].toUpperCase()] = kv[2]; i++ } else if (Object.hasOwn(WRAPPERS, b)) {
212
+ const j = WRAPPERS[b](t, i)
213
+ if (!(j > i)) break
214
+ i = j
215
+ } else if (Object.hasOwn(SUBWRAPPERS, b) && SUBWRAPPERS[b].test(t[i + 1] || '')) i = skipFlags(t, i + 2, PKG_FLAGS.concat(['--with', '--python', '--from']))
76
216
  else if (/^python[\d.]*$|^py$/.test(b) && t[i + 1] === '-m') i += 2
77
217
  else if (/^python[\d.]*$|^py$|^php$/.test(b) && /^(?:manage\.py|artisan)$/.test(binName(t[i + 1] || ''))) i += 1
78
218
  else break
@@ -80,13 +220,24 @@ function commandStart(t) {
80
220
  return i
81
221
  }
82
222
 
223
+ // The port in a flag value: 3000, :3000, 0.0.0.0:3000, tcp://0.0.0.0:3000, http://localhost:3000;https://x:3001
224
+ function portOf(v) {
225
+ const s = String(v)
226
+ if (/^\d+$/.test(s)) return Number(s)
227
+ const m = /^(?:[\w+.-]+:\/\/)?[^/\s;,]*:(\d+)/.exec(s)
228
+ return m ? Number(m[1]) : null
229
+ }
230
+
83
231
  // The last port flag wins, as in `npm run dev -- -p 3001` over a script's `next dev -p 3000`.
84
232
  function flagPort(args, flags = []) {
85
233
  let port
86
234
  for (let i = 0; i < args.length; i++) {
87
235
  for (const f of flags) {
88
- const v = args[i] === f ? args[i + 1] : args[i].startsWith(f + '=') ? args[i].slice(f.length + 1) : undefined
89
- if (v !== undefined) port = /^\d+$/.test(v) ? Number(v) : null
236
+ let v
237
+ if (args[i] === f) v = args[i + 1]
238
+ else if (args[i].startsWith(f + '=')) v = args[i].slice(f.length + 1)
239
+ else if (/^-[a-zA-Z]$/.test(f) && args[i].startsWith(f) && /^\d+$/.test(args[i].slice(2))) v = args[i].slice(2)
240
+ if (v !== undefined) port = portOf(v)
90
241
  }
91
242
  }
92
243
  return port
@@ -99,36 +250,60 @@ function toolPort(tool, args, env) {
99
250
  }
100
251
  const flag = flagPort(args, tool.flags)
101
252
  if (flag !== undefined) return flag
253
+ if (tool.scan) {
254
+ const m = tool.scan.exec(args.join(' '))
255
+ if (m) return Number(m[1])
256
+ }
102
257
  for (const name of tool.env || []) {
103
- if (env[name] !== undefined) return /^\d+$/.test(env[name]) ? Number(env[name]) : null
258
+ if (env[name] !== undefined) return portOf(env[name])
104
259
  }
105
260
  return tool.port
106
261
  }
107
262
 
263
+ const INFO_FLAGS = new Set(['--help', '-h', '--version'])
264
+
108
265
  function matchTool(t, env) {
109
266
  const bin = binName(t[0] || '')
110
267
  const sub = t[1] && !t[1].startsWith('-') ? t[1] : ''
111
268
  for (const tool of TOOLS) {
112
269
  if (!tool.bin.includes(bin)) continue
113
270
  if (tool.sub && !tool.sub.includes(sub)) continue
114
- const argsAt = tool.sub && sub ? 2 : 1
115
- return { tool, argsAt, port: toolPort(tool, t.slice(argsAt), env) }
271
+ if (tool.notSub && tool.notSub.includes(sub)) continue
272
+ const args = t.slice(1)
273
+ if (args.some(a => INFO_FLAGS.has(a) || (tool.version || []).includes(a))) continue
274
+ if (tool.test && !tool.test(args)) continue
275
+ const argsAt = sub && tool.sub && tool.sub.includes(sub) ? 2 : 1
276
+ const port = toolPort(tool, t.slice(argsAt), env)
277
+ if (tool.need && port === undefined) continue
278
+ return { tool, argsAt, port }
116
279
  }
117
280
  return null
118
281
  }
119
282
 
120
- // npm run dev, npm start, pnpm dev, yarn dev, bun run dev -> { runner, script, extra }
283
+ // npm run dev, npm start, pnpm dev, yarn dev, bun run dev -> { runner, script, extra, at, workspace }
121
284
  function scriptCall(t) {
122
285
  const runner = binName(t[0] || '')
123
286
  if (!RUNNERS.has(runner)) return null
124
287
  let i = 1
125
- while (/^-/.test(t[i] || '')) i += /^(?:--prefix|-C|--cwd|--dir)$/.test(t[i]) ? 2 : 1
288
+ let workspace
289
+ while (/^-/.test(t[i] || '')) {
290
+ if (/^(?:--filter|-F|--workspace|-w)$/.test(t[i])) workspace = t[i + 1]
291
+ else if (/^--(?:filter|workspace)=/.test(t[i])) workspace = t[i].split('=')[1]
292
+ i += OPT_WITH_VALUE.test(t[i]) ? 2 : 1
293
+ }
126
294
  let script = t[i]
295
+ if (runner === 'yarn' && script === 'workspace') { workspace = t[i + 1]; i += 2; script = t[i] }
127
296
  if (script === 'run' || script === 'run-script') script = t[++i]
128
297
  else if (runner === 'npm' && script !== 'start') return null
129
298
  if (!script || script === 'install' || script === 'test') return null
130
- const extra = t.slice(i + 1).filter(a => a !== '--')
131
- return { runner, script, extra }
299
+ const extra = []
300
+ const after = t.slice(i + 1)
301
+ for (let k = 0; k < after.length; k++) {
302
+ if (/^(?:--workspace|-w|--filter|-F)$/.test(after[k]) && after[k + 1]) { workspace = after[++k]; continue }
303
+ if (/^--(?:workspace|filter)=/.test(after[k])) { workspace = after[k].split('=')[1]; continue }
304
+ if (after[k] !== '--') extra.push(after[k])
305
+ }
306
+ return { runner, script, extra, at: i, workspace }
132
307
  }
133
308
 
134
309
  function prefixDir(t, dir) {
@@ -137,6 +312,7 @@ function prefixDir(t, dir) {
137
312
  }
138
313
 
139
314
  function resolveDir(dir, to) {
315
+ if (/^~(?:\/|$)/.test(to)) to = path.join(os.homedir(), to.slice(1))
140
316
  const win = /^\/([a-z])(?:\/|$)/i.exec(to)
141
317
  const target = process.platform === 'win32' && win ? `${win[1]}:/${to.slice(3)}` : to
142
318
  return path.resolve(dir || '.', target)
@@ -150,69 +326,193 @@ function readScripts(dir, readFile) {
150
326
  }
151
327
  }
152
328
 
329
+ // `pnpm --filter web dev` runs web's package.json: look in the usual workspace folders.
330
+ function workspaceDir(dir, name, readFile) {
331
+ const leaf = String(name).split('/').pop()
332
+ for (const d of [`apps/${leaf}`, `packages/${leaf}`, `services/${leaf}`, leaf]) {
333
+ const full = path.resolve(dir, d)
334
+ try {
335
+ readFile(path.resolve(full, 'package.json'), 'utf8')
336
+ return full
337
+ } catch { /* not this one */ }
338
+ }
339
+ return dir
340
+ }
341
+
342
+ const quote = a => (/[\s"]/.test(a) ? `'${a}'` : a)
343
+
153
344
  // Returns null when no known dev server starts, otherwise { port, portArg, hint } where port
154
345
  // is null when it cannot be worked out and hint(n) shows how to start the same thing on port n.
346
+ // ports lists every port the command publishes when there is more than one (docker run -p ...).
155
347
  function detect(cmd, { cwd = process.cwd(), readFile = fs.readFileSync, depth = 0, env = {} } = {}) {
156
348
  env = { ...env }
157
349
  let dir = cwd
158
- for (const seg of segments(String(cmd || ''))) {
159
- let t = tokens(seg)
350
+ let unknown = null
351
+ for (let t of commandsOf(cmd)) {
160
352
  t = t.slice(takeEnv(t, env))
161
353
  if (!t.length) continue
162
354
  if (/^(?:cd|pushd|Set-Location|sl)$/i.test(t[0])) {
163
- const to = t.slice(1).find(a => !/^[/-]d$/i.test(a))
355
+ const to = t.slice(1).find(a => !/^[/-]d$/i.test(a) && a !== '--')
164
356
  if (to) dir = resolveDir(dir, to)
165
357
  continue
166
358
  }
167
- const skip = commandStart(t)
168
- const hit = matchTool(t.slice(skip), env)
169
- if (hit) {
170
- const flags = hit.tool.flags || []
171
- const portArg = n => (flags.length ? `${flags[flags.length - 1]} ${n}` : String(n))
172
- const kept = withoutPort(t, skip + hit.argsAt, hit.tool)
173
- return { port: hit.port, portArg, hint: n => [...kept.map(quote), portArg(n)].join(' ') }
174
- }
175
- const call = scriptCall(t.slice(skip))
176
- if (!call) {
177
- // PORT=3000 node server.js: a script that reads PORT from the environment
178
- const direct = envPortServer(t.slice(skip), env)
179
- if (direct) return direct
180
- continue
181
- }
182
- if (depth > 2) continue
183
- const runDir = prefixDir(t.slice(skip), dir)
184
- const body = readScripts(runDir, readFile)[call.script]
185
- if (!body) continue
186
- const inner = detect([body, ...call.extra].join(' '), { cwd: runDir, readFile, depth: depth + 1, env })
187
- if (!inner) continue
188
- if (inner.viaEnv && !SERVER_SCRIPT.test(call.script)) continue
189
- if (inner.viaEnv) return { port: inner.port, viaEnv: true, portArg: inner.portArg, hint: n => `PORT=${n} ${call.runner} run ${call.script}` }
190
- return { port: inner.port, portArg: inner.portArg, hint: n => `${call.runner} run ${call.script} -- ${inner.portArg(n)}` }
359
+ const skip = commandStart(t, env)
360
+ const rest = t.slice(skip)
361
+ const hit = serverHit(t, skip, env) || containerHit(rest, dir, readFile) || scriptHit(t, skip, { dir, readFile, depth, env }) || envPortServer(rest, env)
362
+ if (!hit) continue
363
+ if (hit.port) return hit
364
+ unknown = unknown || hit
191
365
  }
192
- return null
366
+ return unknown
367
+ }
368
+
369
+ function serverHit(t, skip, env) {
370
+ const hit = matchTool(t.slice(skip), env)
371
+ if (!hit) return null
372
+ const { tool } = hit
373
+ const flags = tool.flags || []
374
+ const kept = withoutPort(t, skip + hit.argsAt, tool).map(quote)
375
+ // tools that read the port from the environment only: PORT=n next to the command
376
+ if (!tool.arg && !flags.length && tool.env) {
377
+ const portArg = n => `${tool.env[0]}=${n}`
378
+ return { port: hit.port, portArg, hint: n => [portArg(n), ...kept].join(' ') }
379
+ }
380
+ const portArg = tool.arg || (n => (flags.length ? `${flags[flags.length - 1]} ${n}` : String(n)))
381
+ return { port: hit.port, portArg, hint: n => [...kept, portArg(n)].join(' ') }
382
+ }
383
+
384
+ function scriptHit(t, skip, { dir, readFile, depth, env }) {
385
+ const rest = t.slice(skip)
386
+ const call = scriptCall(rest)
387
+ if (!call) return null
388
+ if (depth > 2) return null
389
+ let runDir = prefixDir(rest, dir)
390
+ if (call.workspace) runDir = workspaceDir(runDir, call.workspace, readFile)
391
+ const body = readScripts(runDir, readFile)[call.script]
392
+ if (!body) {
393
+ // pnpm vite, yarn next dev, bun vite: no script of that name, so it is a program
394
+ return call.runner === 'npm' ? null : serverHit(t, skip + call.at, env)
395
+ }
396
+ const inner = detect([body, ...call.extra].join(' '), { cwd: runDir, readFile, depth: depth + 1, env })
397
+ if (!inner) return null
398
+ if (inner.viaEnv && !SERVER_SCRIPT.test(call.script)) return null
399
+ if (inner.viaEnv) return { port: inner.port, viaEnv: true, portArg: inner.portArg, hint: n => `PORT=${n} ${call.runner} run ${call.script}` }
400
+ return { port: inner.port, portArg: inner.portArg, hint: n => `${call.runner} run ${call.script} -- ${inner.portArg(n)}` }
193
401
  }
194
402
 
195
403
  const SERVER_SCRIPT = /^(?:start|dev|serve|server|develop|watch|(?:start|dev|serve):[\w:-]+)$/i
196
404
  const envPort = env => (/^\d+$/.test(env.PORT || '') && Number(env.PORT) > 0 && Number(env.PORT) < 65536 ? Number(env.PORT) : null)
197
405
 
198
- // node, nodemon, tsx, ts-node, bun or deno running a file, with PORT set in the command.
406
+ // A program running a file with PORT set in the command: node server.js, go run ., python app.py, cargo run.
199
407
  function envPortServer(t, env) {
200
408
  const bin = binName(t[0] || '')
201
409
  const port = envPort(env)
202
- if (port == null || !/^(?:node|nodemon|tsx|ts-node|bun|deno)$/.test(bin)) return null
203
- if (!t.slice(1).some(a => !a.startsWith('-') && /\.(?:[cm]?[jt]s)$/.test(a))) return null
410
+ if (port == null) return null
411
+ const rt = RUNTIMES.find(r => r.bin.test(bin))
412
+ if (!rt || !rt.ok(t.slice(1))) return null
204
413
  return { port, viaEnv: true, portArg: n => `PORT=${n}`, hint: n => `PORT=${n} ${t.map(quote).join(' ')}` }
205
414
  }
206
415
 
207
- const quote = a => (/[\s"]/.test(a) ? `'${a}'` : a)
416
+ // docker run -p 3000:3000 image, docker compose up (ports from the compose file in the directory)
417
+ function containerHit(t, dir, readFile) {
418
+ const bin = binName(t[0] || '')
419
+ if (!/^(?:docker|podman|nerdctl|docker-compose|podman-compose)$/.test(bin)) return null
420
+ const verb = containerVerb(t, bin)
421
+ if (/^(?:run|container run)$/.test(verb)) {
422
+ const hosts = publishedPorts(t)
423
+ if (!hosts.length) return null
424
+ const ports = hosts.map(h => h.port)
425
+ return { port: ports[0], ports, portArg: String, hint: n => hostHint(t, hosts[0], n) }
426
+ }
427
+ if (!/^(?:compose up|up)$/.test(verb)) return null
428
+ for (const f of composeFiles(t, dir)) {
429
+ let text
430
+ try { text = readFile(f, 'utf8') } catch { continue }
431
+ const ports = composePorts(String(text))
432
+ if (ports.length) return { port: ports[0], ports, portArg: String, hint: n => `${t.join(' ')} (publish ${n} instead of ${ports[0]} in ${path.basename(f)})` }
433
+ }
434
+ return null
435
+ }
436
+
437
+ // "run", "container run", "compose up" or "up": the words after the global options.
438
+ function containerVerb(t, bin) {
439
+ const words = []
440
+ for (let i = 1; i < t.length && words.length < 2; i++) {
441
+ if (/^(?:-f|--file|-H|--host|--context|--project-name|--project-directory|--profile|--env-file)$/.test(t[i]) || (t[i] === '-p' && bin !== 'docker')) i++
442
+ else if (!t[i].startsWith('-')) words.push(t[i])
443
+ if (words[0] === 'run' || words[0] === 'up') break
444
+ }
445
+ return words.join(' ')
446
+ }
447
+
448
+ function composeFiles(t, dir) {
449
+ const files = []
450
+ for (let i = 0; i < t.length; i++) {
451
+ if (t[i] === '-f' || t[i] === '--file') files.push(path.resolve(dir, t[i + 1] || ''))
452
+ else if (/^--file=/.test(t[i])) files.push(path.resolve(dir, t[i].slice(7)))
453
+ }
454
+ if (files.length) return files
455
+ return ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml'].map(n => path.resolve(dir, n))
456
+ }
457
+
458
+ // Host ports of every -p/--publish option: 3000:3000, 127.0.0.1:3000:80/tcp, 8000-8010:8000-8010.
459
+ function publishedPorts(t) {
460
+ const out = []
461
+ for (let i = 0; i < t.length; i++) {
462
+ const m = /^(?:-p|--publish)(?:=(.*))?$/.exec(t[i]) || /^-p(\d[^\s]*)$/.exec(t[i])
463
+ if (!m) continue
464
+ const spec = m[1] !== undefined ? m[1] : t[i + 1]
465
+ const port = hostPort(spec)
466
+ if (port) out.push({ port, spec, at: i, inline: m[1] !== undefined })
467
+ }
468
+ return out
469
+ }
470
+
471
+ function hostPort(spec) {
472
+ const parts = String(spec || '').replace(/\/(?:tcp|udp|sctp)$/i, '').split(':')
473
+ if (parts.length < 2) return null
474
+ const host = parts[parts.length - 2]
475
+ const m = /^(\d+)(?:-\d+)?$/.exec(host)
476
+ return m && Number(m[1]) > 0 && Number(m[1]) < 65536 ? Number(m[1]) : null
477
+ }
478
+
479
+ function hostHint(t, h, n) {
480
+ const parts = h.spec.split(':')
481
+ parts[parts.length - 2] = String(n)
482
+ const spec = parts.join(':')
483
+ const out = t.slice()
484
+ if (h.inline) out[h.at] = out[h.at].replace(h.spec, spec)
485
+ else out[h.at + 1] = spec
486
+ return out.map(quote).join(' ')
487
+ }
488
+
489
+ // The host ports in a compose file's `ports:` lists, read line by line so a missing YAML parser costs nothing.
490
+ function composePorts(text) {
491
+ const ports = []
492
+ let indent = -1
493
+ for (const raw of text.split(/\r?\n/)) {
494
+ const line = raw.replace(/\s+#.*$/, '')
495
+ if (!line.trim()) continue
496
+ const at = line.length - line.trimStart().length
497
+ if (/^\s*ports:\s*$/.test(line)) { indent = at; continue }
498
+ if (indent < 0) continue
499
+ if (at <= indent && !line.trim().startsWith('-')) { indent = -1; continue }
500
+ const item = line.trim().replace(/^-\s*/, '').replace(/\$\{[^}:]*:?-(\d+)\}/g, '$1')
501
+ const pub = /^(?:published|host_port):\s*["']?(\d+)/.exec(item)
502
+ const short = /^["']?([\d.:[\]a-fA-F-]+(?:\/\w+)?)["']?$/.exec(item)
503
+ const p = pub ? Number(pub[1]) : short ? hostPort(short[1]) : null
504
+ if (p) ports.push(p)
505
+ }
506
+ return ports
507
+ }
208
508
 
209
509
  // The command as typed, minus redirections and any port it already names.
210
510
  function withoutPort(t, argsAt, tool) {
211
511
  const out = t.slice(0, argsAt)
212
512
  for (let i = argsAt; i < t.length; i++) {
213
513
  const a = t[i]
214
- if (/^\d?>/.test(a)) { if (/^\d?>>?$/.test(a)) i++; continue }
215
514
  if (tool.positional && tool.positional.test(a)) continue
515
+ if (tool.scan && tool.scan.test(a)) continue
216
516
  const flag = (tool.flags || []).find(f => a === f || a.startsWith(f + '='))
217
517
  if (flag) { if (a === flag) i++; continue }
218
518
  out.push(a)
@@ -220,4 +520,4 @@ function withoutPort(t, argsAt, tool) {
220
520
  return out
221
521
  }
222
522
 
223
- module.exports = { detect, tokens, segments, TOOLS }
523
+ module.exports = { detect, commandsOf, TOOLS, portOf, composePorts }
package/src/disk-guard.js CHANGED
@@ -188,13 +188,13 @@ function parallelMessage(cmd, how) {
188
188
  'Run one du at a time, in the foreground: du -sh <one folder>.'
189
189
  }
190
190
 
191
- if (require.main === module) {
192
- runHook('disk-guard', input => {
193
- const cmd = input.tool_input && input.tool_input.command
194
- const hit = check(cmd, { mode: shell.modeFor(input) })
195
- if (!hit) return
196
- return blocked('disk-guard', hit.rule, input, `${hit.why}\n${ASK_USER}`)
197
- })
191
+ function handle(input) {
192
+ const cmd = input.tool_input && input.tool_input.command
193
+ const hit = check(cmd, { mode: shell.modeFor(input) })
194
+ if (!hit) return
195
+ return blocked('disk-guard', hit.rule, input, `${hit.why}\n${ASK_USER}`)
198
196
  }
199
197
 
200
- module.exports = { check, wholeDisk, isScan }
198
+ if (require.main === module) runHook('disk-guard', handle)
199
+
200
+ module.exports = { handle, check, wholeDisk, isScan }