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.
- package/CHANGELOG.md +90 -0
- package/LICENSE +21 -0
- package/README.es.md +498 -0
- package/README.ja.md +498 -0
- package/README.ko.md +498 -0
- package/README.md +497 -2
- package/README.pt-BR.md +498 -0
- package/README.zh-CN.md +498 -0
- package/bin/bulkhead +3 -0
- package/bin/bulkhead-slot +3 -0
- package/bin/bulkhead-statusline +3 -0
- package/hooks/hooks.json +39 -0
- package/package.json +52 -4
- package/src/basics.js +236 -0
- package/src/checkout-guard.js +296 -0
- package/src/cli-util.js +79 -0
- package/src/cli.js +41 -0
- package/src/config-check.js +29 -0
- package/src/config-cli.js +51 -0
- package/src/config.js +236 -0
- package/src/dev-servers.js +223 -0
- package/src/disk-guard.js +200 -0
- package/src/doctor.js +255 -0
- package/src/events.js +108 -0
- package/src/hook.js +94 -0
- package/src/kill-guard.js +435 -0
- package/src/log-cli.js +146 -0
- package/src/memory-guard.js +145 -0
- package/src/memory.js +76 -0
- package/src/orphan-reaper.js +527 -0
- package/src/paths.js +31 -0
- package/src/port-cli.js +59 -0
- package/src/port-guard.js +142 -0
- package/src/processes.js +181 -0
- package/src/reap-cli.js +56 -0
- package/src/remote-sh.js +478 -0
- package/src/remote.js +1034 -0
- package/src/remotes.js +126 -0
- package/src/scan.js +604 -0
- package/src/sessions.js +147 -0
- package/src/shell.js +597 -0
- package/src/slot-cli.js +150 -0
- package/src/slots.js +168 -0
- package/src/status.js +111 -0
- package/src/statusline.js +69 -0
- package/src/top-worker.js +14 -0
- package/src/top.js +305 -0
- 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 }
|