claude-bulkhead 0.0.0-stage → 0.2.2

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 (48) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/LICENSE +21 -0
  3. package/README.es.md +498 -0
  4. package/README.ja.md +498 -0
  5. package/README.ko.md +498 -0
  6. package/README.md +497 -2
  7. package/README.pt-BR.md +498 -0
  8. package/README.zh-CN.md +498 -0
  9. package/bin/bulkhead +3 -0
  10. package/bin/bulkhead-slot +3 -0
  11. package/bin/bulkhead-statusline +3 -0
  12. package/hooks/hooks.json +39 -0
  13. package/package.json +52 -4
  14. package/src/basics.js +236 -0
  15. package/src/checkout-guard.js +296 -0
  16. package/src/cli-util.js +79 -0
  17. package/src/cli.js +41 -0
  18. package/src/config-check.js +29 -0
  19. package/src/config-cli.js +51 -0
  20. package/src/config.js +236 -0
  21. package/src/dev-servers.js +223 -0
  22. package/src/disk-guard.js +200 -0
  23. package/src/doctor.js +255 -0
  24. package/src/events.js +108 -0
  25. package/src/hook.js +94 -0
  26. package/src/kill-guard.js +435 -0
  27. package/src/log-cli.js +146 -0
  28. package/src/memory-guard.js +145 -0
  29. package/src/memory.js +76 -0
  30. package/src/orphan-reaper.js +527 -0
  31. package/src/paths.js +31 -0
  32. package/src/port-cli.js +59 -0
  33. package/src/port-guard.js +142 -0
  34. package/src/processes.js +181 -0
  35. package/src/reap-cli.js +56 -0
  36. package/src/remote-sh.js +478 -0
  37. package/src/remote.js +1034 -0
  38. package/src/remotes.js +126 -0
  39. package/src/scan.js +604 -0
  40. package/src/sessions.js +147 -0
  41. package/src/shell.js +597 -0
  42. package/src/slot-cli.js +150 -0
  43. package/src/slots.js +168 -0
  44. package/src/status.js +111 -0
  45. package/src/statusline.js +69 -0
  46. package/src/top-worker.js +14 -0
  47. package/src/top.js +305 -0
  48. package/src/wait-guard.js +263 -0
@@ -0,0 +1,29 @@
1
+ 'use strict'
2
+ // SessionStart: one notice when a bulkhead config file is broken. The guards ignore a broken
3
+ // file and run on their defaults, so without this nobody would notice a typo.
4
+ const { load } = require('./config')
5
+ const { shortPath } = require('./cli-util')
6
+
7
+ function notice(cfg) {
8
+ if (!cfg.warnings.length) return null
9
+ const lines = cfg.warnings.slice(0, 3).map(w => ` ${w.replace(/^\S+/, m => shortPath(m))}`)
10
+ if (cfg.warnings.length > 3) lines.push(` and ${cfg.warnings.length - 3} more`)
11
+ return `[bulkhead] config problem, defaults are used where it applies:\n${lines.join('\n')}\nRun \`bulkhead config\` or \`bulkhead doctor\` to check.`
12
+ }
13
+
14
+ if (require.main === module) {
15
+ let raw = ''
16
+ process.stdin.setEncoding('utf8')
17
+ process.stdin.on('data', d => (raw += d))
18
+ process.stdin.on('end', () => {
19
+ try {
20
+ let input = {}
21
+ try { input = JSON.parse(raw || '{}') } catch {}
22
+ const msg = notice(load({ cwd: process.env.CLAUDE_PROJECT_DIR || input.cwd || process.cwd() }))
23
+ if (msg) process.stdout.write(JSON.stringify({ systemMessage: msg }))
24
+ } catch {}
25
+ process.exit(0)
26
+ })
27
+ }
28
+
29
+ module.exports = { notice }
@@ -0,0 +1,51 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const { load, KEYS } = require('./config')
4
+ const { useColor, paint, shortPath, table, rejectArgs } = require('./cli-util')
5
+
6
+ function fileState(file, warnings) {
7
+ if (!file) return 'none found'
8
+ if (!fs.existsSync(file)) return 'not found'
9
+ return warnings.some(w => w.startsWith(file) && /not valid JSON/.test(w)) ? 'INVALID, ignored' : 'loaded'
10
+ }
11
+
12
+ // The remotes map is long; the table shows the names and `bulkhead remote list` the rest.
13
+ const show = (v, key) => (v === null ? 'auto' : key === 'remotes' ? Object.keys(v).join(', ') || 'none' : JSON.stringify(v))
14
+
15
+ function render(cfg, { color = false, home } = {}) {
16
+ const out = []
17
+ out.push(paint(color, 'bold', 'Files (project wins over global, environment wins over both)'))
18
+ out.push(` global ${shortPath(cfg.files.global, home)} ${fileState(cfg.files.global, cfg.warnings)}`)
19
+ out.push(` project ${cfg.files.project ? shortPath(cfg.files.project, home) : '<repo>/.claude/bulkhead.json'} ${fileState(cfg.files.project, cfg.warnings)}`)
20
+ out.push('')
21
+ const rows = [['KEY', 'VALUE', 'FROM']]
22
+ for (const key of Object.keys(KEYS)) {
23
+ const src = cfg.sources[key]
24
+ rows.push([key, show(cfg.values[key], key), src === 'default' || src.startsWith('env ') ? src : shortPath(src, home)])
25
+ }
26
+ const lines = table(rows)
27
+ out.push(paint(color, 'bold', lines[0]))
28
+ lines.slice(1).forEach((l, i) => {
29
+ const key = rows[i + 1][0]
30
+ out.push(cfg.sources[key] === 'default' ? paint(color, 'gray', l) : l)
31
+ })
32
+ if (cfg.warnings.length) {
33
+ out.push('')
34
+ for (const w of cfg.warnings) out.push(paint(color, 'yellow', `WARN ${w}`))
35
+ }
36
+ return out.join('\n')
37
+ }
38
+
39
+ function main(args, { env = process.env, stdout = process.stdout } = {}) {
40
+ if (rejectArgs('config', args, { flags: ['json'], valued: [] }, process.stderr, null)) return
41
+ const cfg = load({ env })
42
+ if (args.includes('--json')) {
43
+ const settings = {}
44
+ for (const key of Object.keys(KEYS)) settings[key] = { value: cfg.values[key], from: cfg.sources[key] }
45
+ stdout.write(JSON.stringify({ files: cfg.files, settings, warnings: cfg.warnings }, null, 2) + '\n')
46
+ return
47
+ }
48
+ stdout.write(render(cfg, { color: useColor(stdout, env) }) + '\n')
49
+ }
50
+
51
+ module.exports = { main, render }
package/src/config.js ADDED
@@ -0,0 +1,236 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const os = require('os')
4
+ const path = require('path')
5
+ const { pathKey } = require('./paths')
6
+
7
+ // Settings come from three places, later ones winning:
8
+ // 1. defaults below
9
+ // 2. <state dir>/config.json (global, usually ~/.claude/bulkhead/config.json)
10
+ // 3. <project>/.claude/bulkhead.json (project, merged over global)
11
+ // 4. environment variables (always win)
12
+ // A file that is not valid JSON is ignored as a whole, so the guards keep their defaults.
13
+
14
+ const GUARDS = {
15
+ 'kill-guard': 'killGuard',
16
+ 'wait-guard': 'waitGuard',
17
+ 'memory-guard': 'memoryGuard',
18
+ 'port-guard': 'portGuard',
19
+ 'checkout-guard': 'checkoutGuard',
20
+ 'disk-guard': 'diskGuard',
21
+ basics: 'basics',
22
+ 'orphan-reaper': 'orphanReaper',
23
+ }
24
+ // rules that can be turned off one at a time, inside a guard that stays on
25
+ const RULES = {
26
+ 'rm-root': 'basics.rmRoot',
27
+ 'force-push-main': 'basics.forcePushMain',
28
+ 'discard-changes': 'basics.discardChanges',
29
+ 'agent-author': 'basics.agentAuthor',
30
+ 'stage-all': 'checkoutGuard.stageAll',
31
+ 'remove-session-dir': 'checkoutGuard.removeSessionDir',
32
+ }
33
+
34
+ const bool = (def, doc) => ({ type: 'boolean', default: def, doc })
35
+ const on = g => bool(true, `Turn ${g} on or off.`)
36
+ // In display order: each section's keys together.
37
+ const KEYS = {
38
+ 'killGuard.enabled': on('kill-guard'),
39
+ 'killGuard.allowNames': { type: 'string[]', default: [], doc: 'Program names to take off the shared list, so killing them by name passes.' },
40
+ 'killGuard.extraSharedNames': { type: 'string[]', default: [], doc: 'More program names that must not be killed by name.' },
41
+ 'waitGuard.enabled': on('wait-guard'),
42
+ 'waitGuard.limitSeconds': { type: 'number', default: 180, env: 'BULKHEAD_WAIT_MAX_SEC', doc: 'Longest foreground wait allowed, in seconds.' },
43
+ 'memoryGuard.enabled': on('memory-guard'),
44
+ 'memory.minFreeGb': { type: 'number', default: null, env: 'BULKHEAD_MIN_FREE_GB', doc: 'Free RAM below which memory-guard blocks heavy commands. Default: 3, or 15% of RAM if less.' },
45
+ 'memory.slotFloorGb': { type: 'number', default: null, env: 'BULKHEAD_SLOT_FLOOR_GB', doc: 'RAM bulkhead-slot keeps free when it starts a second job. Default: 5, or 25% of RAM if less.' },
46
+ 'memory.maxCommitPct': { type: 'number', default: 75, env: 'BULKHEAD_MAX_COMMIT_PCT', doc: 'Windows commit charge above which bulkhead-slot holds a second job.' },
47
+ 'portGuard.enabled': on('port-guard'),
48
+ 'portGuard.ignorePorts': { type: 'number[]', default: [], doc: 'Ports port-guard never checks, for starting servers or for kills by port.' },
49
+ 'checkoutGuard.enabled': on('checkout-guard'),
50
+ 'checkoutGuard.stageAll': bool(true, 'Block git add -A, git add . and git commit -a when another live session works in the same tree.'),
51
+ 'checkoutGuard.removeSessionDir': bool(true, 'Block rm -rf and git worktree remove on a folder another live session works in.'),
52
+ 'diskGuard.enabled': on('disk-guard'),
53
+ 'basics.enabled': on('basics'),
54
+ 'basics.protectedBranches': { type: 'string[]', default: ['main', 'master'], doc: 'Branches force-push-main protects.' },
55
+ 'basics.rmRoot': bool(true, 'Block rm -rf at roots, top-level system folders, the home directory, folders directly in it (caches excepted) and folders above the repository.'),
56
+ 'basics.forcePushMain': bool(true, 'Block force-pushes to the protected branches.'),
57
+ 'basics.discardChanges': bool(true, 'Block git reset --hard and git clean -fd that would lose work.'),
58
+ 'basics.agentAuthor': bool(true, 'Block commits authored as Claude or Anthropic (--author, git config user.*, GIT_AUTHOR_*), so the work counts as yours.'),
59
+ 'orphanReaper.enabled': on('orphan-reaper'),
60
+ 'orphanReaper.onSessionEnd': bool(true, 'Also reap right after a session exits, not only when the next session starts.'),
61
+ 'orphanReaper.extraNames': { type: 'string[]', default: [], doc: 'More program names orphan-reaper stops once the session that started them has exited.' },
62
+ 'orphanReaper.keepNames': { type: 'string[]', default: [], doc: 'Program names orphan-reaper never stops, nor anything they started.' },
63
+ 'log.enabled': bool(true, 'Write blocks to events.jsonl. BULKHEAD_NO_LOG=1 turns it off.'),
64
+ // Global file only (see src/remotes.js for why).
65
+ 'remote.default': { type: 'string', default: null, globalOnly: true, doc: 'The remote bulkhead remote uses when several are configured and --remote is not given.' },
66
+ remotes: { type: 'remotes', default: {}, globalOnly: true, doc: 'Machines bulkhead remote can run jobs on: { name: { host, shell, dir, copies, maxJobs, minFreeGb, sshOptions } }.' },
67
+ }
68
+
69
+ function stateDir(env = process.env) {
70
+ return env.BULKHEAD_STATE_DIR || path.join(os.homedir(), '.claude', 'bulkhead')
71
+ }
72
+
73
+ function globalFile(env = process.env) {
74
+ return path.join(stateDir(env), 'config.json')
75
+ }
76
+
77
+ // <dir>/.claude/bulkhead.json in the project or the nearest folder above it, stopping at the
78
+ // repository root (a .git entry) or the home directory.
79
+ function projectFile(start, { exists = fs.existsSync, home = os.homedir() } = {}) {
80
+ if (!start) return null
81
+ let dir = path.resolve(start)
82
+ // the cwd and the home directory may be spelled differently (a link, C:\Users\RUNNER~1); any case, as before
83
+ const stop = pathKey(home, 'win32')
84
+ for (let i = 0; i < 40; i++) {
85
+ const f = path.join(dir, '.claude', 'bulkhead.json')
86
+ if (exists(f)) return f
87
+ if (exists(path.join(dir, '.git')) || pathKey(dir, 'win32') === stop) return null
88
+ const up = path.dirname(dir)
89
+ if (up === dir) return null
90
+ dir = up
91
+ }
92
+ return null
93
+ }
94
+
95
+ function typeOk(type, v) {
96
+ if (type === 'boolean') return typeof v === 'boolean'
97
+ if (type === 'number') return typeof v === 'number' && Number.isFinite(v) && v >= 0
98
+ if (type === 'string') return typeof v === 'string'
99
+ if (type === 'string[]') return Array.isArray(v) && v.every(x => typeof x === 'string')
100
+ if (type === 'number[]') return Array.isArray(v) && v.every(x => Number.isInteger(x) && x > 0 && x < 65536)
101
+ return false
102
+ }
103
+
104
+ // Flat { 'section.key': value } from one parsed file, with a warning for every key we do not know
105
+ // or whose value has the wrong type. Bad keys are dropped, good ones kept. `remotes` is a map
106
+ // of its own, checked entry by entry, and kept whole under the key 'remotes'.
107
+ function flatten(obj, file, warnings, scope = 'global') {
108
+ const out = {}
109
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
110
+ warnings.push(`${file}: expected a JSON object`)
111
+ return out
112
+ }
113
+ for (const [section, body] of Object.entries(obj)) {
114
+ if (section === '$schema' || section === '//') continue
115
+ if (section === 'remotes') {
116
+ if (scope !== 'global') {
117
+ warnings.push(`${file}: "remotes" can only be set in the global config file; ignored here`)
118
+ continue
119
+ }
120
+ const { remotes, problems } = require('./remotes').normalizeAll(body)
121
+ for (const p of problems) warnings.push(`${file}: ${p}`)
122
+ out.remotes = remotes
123
+ continue
124
+ }
125
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
126
+ warnings.push(`${file}: "${section}" should be an object`)
127
+ continue
128
+ }
129
+ for (const [k, v] of Object.entries(body)) {
130
+ const key = `${section}.${k}`
131
+ const spec = KEYS[key]
132
+ if (!spec) warnings.push(`${file}: unknown key "${key}"`)
133
+ else if (spec.globalOnly && scope !== 'global') warnings.push(`${file}: "${key}" can only be set in the global config file; ignored here`)
134
+ else if (!typeOk(spec.type, v)) warnings.push(`${file}: "${key}" should be ${spec.type}`)
135
+ else out[key] = v
136
+ }
137
+ }
138
+ return out
139
+ }
140
+
141
+ function readFile(file, warnings, read = f => fs.readFileSync(f, 'utf8'), scope = 'global') {
142
+ let text
143
+ try { text = read(file) } catch { return null }
144
+ try {
145
+ return flatten(JSON.parse(text.replace(/^\uFEFF/, '')), file, warnings, scope)
146
+ } catch (e) {
147
+ warnings.push(`${file} is not valid JSON (${e.message}); it is ignored until fixed`)
148
+ return {}
149
+ }
150
+ }
151
+
152
+ function envValue(spec, env) {
153
+ const raw = env[spec.env]
154
+ if (raw === undefined || raw === '') return undefined
155
+ if (spec.type === 'number') {
156
+ const n = Number(raw)
157
+ return Number.isFinite(n) ? n : undefined
158
+ }
159
+ return raw
160
+ }
161
+
162
+ // Defaults with both files merged over them. Read once per process.
163
+ function fileLayer({ env = process.env, cwd = env.CLAUDE_PROJECT_DIR || process.cwd(), read } = {}) {
164
+ const warnings = []
165
+ const values = {}
166
+ const sources = {}
167
+ for (const [k, spec] of Object.entries(KEYS)) {
168
+ values[k] = spec.default
169
+ sources[k] = 'default'
170
+ }
171
+ const files = { global: globalFile(env), project: projectFile(cwd) }
172
+ for (const [scope, file] of [['global', files.global], ['project', files.project]]) {
173
+ if (!file) continue
174
+ const got = readFile(file, warnings, read, scope)
175
+ if (!got) continue
176
+ for (const [k, v] of Object.entries(got)) {
177
+ values[k] = v
178
+ sources[k] = file
179
+ }
180
+ }
181
+ return { values, sources, files, warnings }
182
+ }
183
+
184
+ // { values, sources, files, warnings }. sources[key] is 'default', a file path or 'env NAME'.
185
+ function load(opts = {}) {
186
+ return withEnv(fileLayer(opts), opts.env || process.env)
187
+ }
188
+
189
+ // Environment variables win over both files. Applied on every call, since tests and the
190
+ // slot CLI change them while running.
191
+ function withEnv(layer, env) {
192
+ const values = { ...layer.values }
193
+ const sources = { ...layer.sources }
194
+ for (const [k, spec] of Object.entries(KEYS)) {
195
+ const v = spec.env && envValue(spec, env)
196
+ if (v !== undefined) {
197
+ values[k] = v
198
+ sources[k] = `env ${spec.env}`
199
+ }
200
+ }
201
+ if (/^(?:1|true|yes)$/i.test(String(env.BULKHEAD_NO_LOG || '').trim())) {
202
+ values['log.enabled'] = false
203
+ sources['log.enabled'] = 'env BULKHEAD_NO_LOG'
204
+ }
205
+ const off = (env.BULKHEAD_DISABLE || '').split(/[\s,]+/).filter(Boolean)
206
+ for (const name of off) {
207
+ const key = GUARDS[name] ? `${GUARDS[name]}.enabled` : RULES[name]
208
+ const keys = name === 'all' ? Object.values(GUARDS).map(s => `${s}.enabled`) : key ? [key] : []
209
+ for (const k of keys) {
210
+ values[k] = false
211
+ sources[k] = 'env BULKHEAD_DISABLE'
212
+ }
213
+ }
214
+ return { values, sources, files: layer.files, warnings: layer.warnings }
215
+ }
216
+
217
+ let cached = null
218
+ function current() {
219
+ if (!cached) cached = fileLayer()
220
+ return withEnv(cached, process.env)
221
+ }
222
+ function reset() {
223
+ cached = null
224
+ }
225
+
226
+ function get(key, cfg = current()) {
227
+ return cfg.values[key]
228
+ }
229
+
230
+ // Whether a guard or a basics rule is on.
231
+ function isEnabled(name, cfg = current()) {
232
+ const key = GUARDS[name] ? `${GUARDS[name]}.enabled` : RULES[name]
233
+ return key ? cfg.values[key] !== false : true
234
+ }
235
+
236
+ module.exports = { load, current, reset, get, isEnabled, projectFile, globalFile, KEYS, GUARDS, RULES, flatten }
@@ -0,0 +1,223 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+
5
+ // 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.
7
+ 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'] },
11
+ { bin: ['astro'], sub: ['dev', 'preview'], port: 4321, flags: ['--port'] },
12
+ { bin: ['nuxt', 'nuxi'], sub: ['dev'], port: 3000, flags: ['-p', '--port'], env: ['NUXT_PORT', 'PORT'] },
13
+ { bin: ['remix'], sub: ['dev'], port: 3000, flags: ['--port'], env: ['PORT'] },
14
+ { bin: ['remix'], sub: ['vite:dev'], port: 5173, flags: ['--port'] },
15
+ { bin: ['expo'], sub: ['start'], port: 8081, flags: ['-p', '--port'], env: ['RCT_METRO_PORT'] },
16
+ { bin: ['uvicorn'], sub: null, port: 8000, flags: ['--port'] },
17
+ { bin: ['flask'], sub: ['run'], port: 5000, flags: ['-p', '--port'], env: ['FLASK_RUN_PORT'] },
18
+ { bin: ['rails'], sub: ['s', 'server'], port: 3000, flags: ['-p', '--port'], env: ['PORT'] },
19
+ { bin: ['gatsby'], sub: ['develop'], port: 8000, flags: ['-p', '--port'] },
20
+ { bin: ['ng'], sub: ['serve'], port: 4200, flags: ['--port'] },
21
+ { bin: ['webpack'], sub: ['serve'], port: 8080, flags: ['--port'] },
22
+ { bin: ['webpack-dev-server'], sub: null, port: 8080, flags: ['--port'] },
23
+ { bin: ['hugo'], sub: ['server', 'serve'], port: 1313, flags: ['-p', '--port'] },
24
+ { bin: ['jekyll'], sub: ['serve', 's'], port: 4000, flags: ['-P', '--port'] },
25
+ { bin: ['storybook'], sub: ['dev'], port: 6006, flags: ['-p', '--port'] },
26
+ { bin: ['wrangler'], sub: ['dev'], port: 8787, flags: ['--port'] },
27
+ { bin: ['docusaurus'], sub: ['start'], port: 3000, flags: ['-p', '--port'] },
28
+ { bin: ['http-server'], sub: null, port: 8080, flags: ['-p', '--port'] },
29
+ // positional ports
30
+ { bin: ['http.server'], sub: null, port: 8000, positional: /^(\d+)$/ },
31
+ { bin: ['manage.py'], sub: ['runserver'], port: 8000, positional: /^(?:[\w.[\]]*:)?(\d+)$/ },
32
+ { bin: ['artisan'], sub: ['serve'], port: 8000, flags: ['--port'] },
33
+ ]
34
+
35
+ const WRAPPERS = new Set(['npx', 'bunx', 'pnpx', 'nohup', 'exec', 'time', 'env', 'cross-env', 'dotenv', 'sudo', 'nice'])
36
+ const RUNNERS = new Set(['npm', 'pnpm', 'yarn', 'bun'])
37
+
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))
41
+ }
42
+
43
+ function segments(cmd) {
44
+ return cmd.replace(/\d?>&\d/g, ' ').split(/\n|;|&&|\|\||\||&/).map(s => s.trim()).filter(Boolean)
45
+ }
46
+
47
+ const binName = t => path.basename(String(t)).replace(/\.(?:cmd|exe|ps1)$/i, '').toLowerCase()
48
+
49
+ // Leading NAME=value pairs, `export NAME=value`, `set NAME=value` and `$env:NAME = value`.
50
+ function takeEnv(t, env) {
51
+ let i = 0
52
+ for (;;) {
53
+ if (/^(?:export|set|env)$/i.test(t[i]) && /^\w+=/.test(t[i + 1] || '')) i++
54
+ const ps = /^\$env:(\w+)(?:=(.*))?$/i.exec(t[i] || '')
55
+ if (ps) {
56
+ if (ps[2] !== undefined && ps[2] !== '') env[ps[1].toUpperCase()] = ps[2]
57
+ else if (t[i + 1] === '=') { env[ps[1].toUpperCase()] = t[i + 2]; i += 2 }
58
+ i++
59
+ continue
60
+ }
61
+ const m = /^(\w+)=(.*)$/.exec(t[i] || '')
62
+ if (!m) return i
63
+ env[m[1].toUpperCase()] = m[2]
64
+ i++
65
+ }
66
+ }
67
+
68
+ // Index of the real command after npx, nohup, python -m and similar.
69
+ function commandStart(t) {
70
+ let i = 0
71
+ while (i < t.length) {
72
+ 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
76
+ else if (/^python[\d.]*$|^py$/.test(b) && t[i + 1] === '-m') i += 2
77
+ else if (/^python[\d.]*$|^py$|^php$/.test(b) && /^(?:manage\.py|artisan)$/.test(binName(t[i + 1] || ''))) i += 1
78
+ else break
79
+ }
80
+ return i
81
+ }
82
+
83
+ // The last port flag wins, as in `npm run dev -- -p 3001` over a script's `next dev -p 3000`.
84
+ function flagPort(args, flags = []) {
85
+ let port
86
+ for (let i = 0; i < args.length; i++) {
87
+ 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
90
+ }
91
+ }
92
+ return port
93
+ }
94
+
95
+ function toolPort(tool, args, env) {
96
+ if (tool.positional) {
97
+ const hit = args.map(a => tool.positional.exec(a)).find(Boolean)
98
+ return hit ? Number(hit[1]) : tool.port
99
+ }
100
+ const flag = flagPort(args, tool.flags)
101
+ if (flag !== undefined) return flag
102
+ for (const name of tool.env || []) {
103
+ if (env[name] !== undefined) return /^\d+$/.test(env[name]) ? Number(env[name]) : null
104
+ }
105
+ return tool.port
106
+ }
107
+
108
+ function matchTool(t, env) {
109
+ const bin = binName(t[0] || '')
110
+ const sub = t[1] && !t[1].startsWith('-') ? t[1] : ''
111
+ for (const tool of TOOLS) {
112
+ if (!tool.bin.includes(bin)) continue
113
+ 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) }
116
+ }
117
+ return null
118
+ }
119
+
120
+ // npm run dev, npm start, pnpm dev, yarn dev, bun run dev -> { runner, script, extra }
121
+ function scriptCall(t) {
122
+ const runner = binName(t[0] || '')
123
+ if (!RUNNERS.has(runner)) return null
124
+ let i = 1
125
+ while (/^-/.test(t[i] || '')) i += /^(?:--prefix|-C|--cwd|--dir)$/.test(t[i]) ? 2 : 1
126
+ let script = t[i]
127
+ if (script === 'run' || script === 'run-script') script = t[++i]
128
+ else if (runner === 'npm' && script !== 'start') return null
129
+ if (!script || script === 'install' || script === 'test') return null
130
+ const extra = t.slice(i + 1).filter(a => a !== '--')
131
+ return { runner, script, extra }
132
+ }
133
+
134
+ function prefixDir(t, dir) {
135
+ const i = t.findIndex(a => /^(?:--prefix|-C|--cwd|--dir)$/.test(a))
136
+ return i > 0 && t[i + 1] ? resolveDir(dir, t[i + 1]) : dir
137
+ }
138
+
139
+ function resolveDir(dir, to) {
140
+ const win = /^\/([a-z])(?:\/|$)/i.exec(to)
141
+ const target = process.platform === 'win32' && win ? `${win[1]}:/${to.slice(3)}` : to
142
+ return path.resolve(dir || '.', target)
143
+ }
144
+
145
+ function readScripts(dir, readFile) {
146
+ try {
147
+ return JSON.parse(readFile(path.resolve(dir, 'package.json'), 'utf8')).scripts || {}
148
+ } catch {
149
+ return {}
150
+ }
151
+ }
152
+
153
+ // Returns null when no known dev server starts, otherwise { port, portArg, hint } where port
154
+ // is null when it cannot be worked out and hint(n) shows how to start the same thing on port n.
155
+ function detect(cmd, { cwd = process.cwd(), readFile = fs.readFileSync, depth = 0, env = {} } = {}) {
156
+ env = { ...env }
157
+ let dir = cwd
158
+ for (const seg of segments(String(cmd || ''))) {
159
+ let t = tokens(seg)
160
+ t = t.slice(takeEnv(t, env))
161
+ if (!t.length) continue
162
+ if (/^(?:cd|pushd|Set-Location|sl)$/i.test(t[0])) {
163
+ const to = t.slice(1).find(a => !/^[/-]d$/i.test(a))
164
+ if (to) dir = resolveDir(dir, to)
165
+ continue
166
+ }
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)}` }
191
+ }
192
+ return null
193
+ }
194
+
195
+ const SERVER_SCRIPT = /^(?:start|dev|serve|server|develop|watch|(?:start|dev|serve):[\w:-]+)$/i
196
+ const envPort = env => (/^\d+$/.test(env.PORT || '') && Number(env.PORT) > 0 && Number(env.PORT) < 65536 ? Number(env.PORT) : null)
197
+
198
+ // node, nodemon, tsx, ts-node, bun or deno running a file, with PORT set in the command.
199
+ function envPortServer(t, env) {
200
+ const bin = binName(t[0] || '')
201
+ 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
204
+ return { port, viaEnv: true, portArg: n => `PORT=${n}`, hint: n => `PORT=${n} ${t.map(quote).join(' ')}` }
205
+ }
206
+
207
+ const quote = a => (/[\s"]/.test(a) ? `'${a}'` : a)
208
+
209
+ // The command as typed, minus redirections and any port it already names.
210
+ function withoutPort(t, argsAt, tool) {
211
+ const out = t.slice(0, argsAt)
212
+ for (let i = argsAt; i < t.length; i++) {
213
+ const a = t[i]
214
+ if (/^\d?>/.test(a)) { if (/^\d?>>?$/.test(a)) i++; continue }
215
+ if (tool.positional && tool.positional.test(a)) continue
216
+ const flag = (tool.flags || []).find(f => a === f || a.startsWith(f + '='))
217
+ if (flag) { if (a === flag) i++; continue }
218
+ out.push(a)
219
+ }
220
+ return out
221
+ }
222
+
223
+ module.exports = { detect, tokens, segments, TOOLS }