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.
- package/CHANGELOG.md +51 -0
- package/README.es.md +142 -50
- package/README.ja.md +142 -50
- package/README.ko.md +143 -51
- package/README.md +142 -50
- package/README.pt-BR.md +142 -50
- package/README.zh-CN.md +154 -62
- package/hooks/hooks.json +68 -8
- package/package.json +5 -2
- package/src/agent-hook.js +162 -0
- package/src/agent-setup.js +256 -0
- package/src/agents.js +89 -0
- package/src/allow-cli.js +65 -0
- package/src/allow-hint.js +62 -0
- package/src/allow-once.js +152 -0
- package/src/auto-queue.js +93 -0
- package/src/bash-guards.js +36 -0
- package/src/basics.js +46 -27
- package/src/checkout-guard.js +342 -74
- package/src/cli-util.js +13 -1
- package/src/cli.js +40 -4
- package/src/config-check.js +21 -4
- package/src/config-cli.js +75 -10
- package/src/config.js +106 -9
- package/src/dev-servers.js +361 -61
- package/src/disk-guard.js +8 -8
- package/src/doctor-enforcement.js +273 -0
- package/src/doctor.js +19 -9
- package/src/edit-guard.js +196 -0
- package/src/events.js +30 -5
- package/src/explain.js +251 -0
- package/src/git-info.js +55 -0
- package/src/gitargs.js +82 -0
- package/src/hook.js +38 -6
- package/src/kill-extras.js +219 -0
- package/src/kill-guard.js +228 -77
- package/src/kill-owners.js +116 -0
- package/src/log-cli.js +15 -10
- package/src/mcp.js +131 -0
- package/src/memory-guard.js +126 -24
- package/src/memory.js +21 -2
- package/src/notices.js +95 -0
- package/src/orphan-reaper.js +58 -11
- package/src/port-cli.js +78 -3
- package/src/port-guard.js +80 -68
- package/src/port-kills.js +198 -0
- package/src/pressure.js +75 -0
- package/src/processes.js +44 -12
- package/src/scan-report.js +166 -0
- package/src/scan.js +87 -18
- package/src/secrets-guard.js +301 -0
- package/src/self-allow.js +73 -0
- package/src/session-info.js +121 -0
- package/src/session-state.js +176 -0
- package/src/sessions.js +109 -20
- package/src/shell.js +279 -19
- package/src/slot-cli.js +92 -21
- package/src/slots.js +61 -13
- package/src/stable-port.js +61 -0
- package/src/status.js +55 -17
- package/src/statusline.js +45 -16
- package/src/subagent-guard.js +24 -0
- package/src/top.js +36 -11
- package/src/wait-guard.js +96 -32
- package/src/worktree-cli.js +454 -0
- package/src/worktree-copy.js +82 -0
package/src/dev-servers.js
CHANGED
|
@@ -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: [''
|
|
10
|
-
|
|
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: ['
|
|
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
|
-
|
|
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
|
|
39
|
-
function
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
89
|
-
if (
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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 =
|
|
131
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
203
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
+
if (require.main === module) runHook('disk-guard', handle)
|
|
199
|
+
|
|
200
|
+
module.exports = { handle, check, wholeDisk, isScan }
|