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
package/src/basics.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const os = require('os')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const { execFileSync } = require('child_process')
|
|
5
|
+
const { runHook, enabled, setting, blocked, oneLine, ASK_USER } = require('./hook')
|
|
6
|
+
const shell = require('./shell')
|
|
7
|
+
const { realPath } = require('./paths')
|
|
8
|
+
|
|
9
|
+
// Narrow rules for mistakes that cannot be undone. Each can be turned off on its own:
|
|
10
|
+
// rm-root, force-push-main, discard-changes, agent-author.
|
|
11
|
+
|
|
12
|
+
// ~, $HOME and %USERPROFILE% become the home directory; /c/... becomes C:/... on Windows.
|
|
13
|
+
// Returns null for anything with a variable we cannot expand.
|
|
14
|
+
function expand(arg, home, platform) {
|
|
15
|
+
let a = arg
|
|
16
|
+
.replace(/^~(?=[\\/]|$)/, home)
|
|
17
|
+
.replace(/^(?:\$HOME|\$\{HOME\}|\$env:USERPROFILE|\$env:HOME|%USERPROFILE%|\$USERPROFILE)(?=[\\/]|$)/i, home)
|
|
18
|
+
if (/[$%]/.test(a)) return null
|
|
19
|
+
if (platform === 'win32') a = a.replace(/^\/([a-z])(?=\/|$)/i, '$1:')
|
|
20
|
+
return a
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const pathFor = platform => (platform === 'win32' ? path.win32 : path.posix)
|
|
24
|
+
|
|
25
|
+
const norm = (p, platform) => {
|
|
26
|
+
const n = pathFor(platform).resolve(p).replace(/\\/g, '/').replace(/\/+$/, '') || '/'
|
|
27
|
+
return platform === 'win32' ? n.toLowerCase() : n
|
|
28
|
+
}
|
|
29
|
+
// git prints the repository's real path; the cwd and home may be spelled through a link or a
|
|
30
|
+
// Windows short name (C:\Users\RUNNER~1). Only those two folders are resolved, never a target:
|
|
31
|
+
// a target can be on an automounted or network path, and a hook must not wait on it. A hook
|
|
32
|
+
// process asks for the same two folders again and again, so the answers are kept.
|
|
33
|
+
const realCache = new Map()
|
|
34
|
+
function realOnce(p) {
|
|
35
|
+
if (!realCache.has(p)) {
|
|
36
|
+
if (realCache.size > 64) realCache.clear()
|
|
37
|
+
realCache.set(p, realPath(p))
|
|
38
|
+
}
|
|
39
|
+
return realCache.get(p)
|
|
40
|
+
}
|
|
41
|
+
const isAncestorOrSame = (a, b) => b === a || b.startsWith(a.endsWith('/') ? a : a + '/')
|
|
42
|
+
|
|
43
|
+
function rmTargets(t) {
|
|
44
|
+
const cmd = path.basename(t[0] || '').replace(/\.exe$/i, '').toLowerCase()
|
|
45
|
+
const args = t.slice(1)
|
|
46
|
+
if (cmd === 'rm') {
|
|
47
|
+
// options end at `--`; everything after it is a path, even one that starts with a dash
|
|
48
|
+
const dash = args.indexOf('--')
|
|
49
|
+
const opts = (dash < 0 ? args : args.slice(0, dash)).filter(a => a.startsWith('-'))
|
|
50
|
+
const operands = dash < 0 ? args.filter(a => !a.startsWith('-')) : [...args.slice(0, dash).filter(a => !a.startsWith('-')), ...args.slice(dash + 1)]
|
|
51
|
+
const recursive = opts.some(a => /^-[a-zA-Z]*[rR]/.test(a) || a === '--recursive')
|
|
52
|
+
const force = opts.some(a => /^-[a-zA-Z]*f/.test(a) || a === '--force')
|
|
53
|
+
return recursive && force ? operands : []
|
|
54
|
+
}
|
|
55
|
+
if (/^(?:remove-item|ri|rmdir|rd|del|erase)$/.test(cmd) && args.some(a => /^-r(?:ecurse)?$/i.test(a)) && args.some(a => /^-fo(?:rce)?$/i.test(a))) {
|
|
56
|
+
return args.filter(a => !a.startsWith('-'))
|
|
57
|
+
}
|
|
58
|
+
if (/^(?:rd|rmdir)$/.test(cmd) && args.some(a => /^\/s$/i.test(a))) return args.filter(a => !/^\/[sq]$/i.test(a))
|
|
59
|
+
return []
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Folders directly in the home directory that are only caches and can always be rebuilt.
|
|
63
|
+
const HOME_CACHES = /^\.?(?:cache|caches|tmp|temp|npm|pnpm-store|yarn|bun|gradle|m2|nuget|cargo|rustup|deno|expo|Trash|node-gyp|pyenv|bundle|cocoapods)$/i
|
|
64
|
+
|
|
65
|
+
// The dangerous targets: a filesystem or drive root, a top-level folder such as /usr or /srv,
|
|
66
|
+
// the home directory or anything above it, a folder directly in it (~/Documents, ~/backups;
|
|
67
|
+
// caches excepted), and any directory above the repository the command runs in.
|
|
68
|
+
function dangerousTarget(arg, { cwd, home, platform, repoRoot }) {
|
|
69
|
+
const expanded = expand(arg, home, platform)
|
|
70
|
+
if (expanded === null) return null
|
|
71
|
+
const dir = expanded.replace(/[\\/]\*$/, '') || '/'
|
|
72
|
+
const rel = expanded === '*' ? '.' : dir
|
|
73
|
+
// as written, and on this machine also from the real cwd and home: rm resolves .. from the
|
|
74
|
+
// folder the cwd really is, not from the link it was entered through
|
|
75
|
+
const views = [{ cwd, home }]
|
|
76
|
+
if (platform === process.platform) views.push({ cwd: realOnce(cwd), home: realOnce(home) })
|
|
77
|
+
for (const v of views) {
|
|
78
|
+
const abs = norm(pathFor(platform).resolve(v.cwd, rel), platform)
|
|
79
|
+
if (/^(?:\/|[a-z]:\/?)$/i.test(abs) || /^[a-z]:$/i.test(abs)) return 'a filesystem root'
|
|
80
|
+
const homeKey = norm(v.home, platform)
|
|
81
|
+
if (isAncestorOrSame(abs, homeKey)) return 'your home directory or a folder above it'
|
|
82
|
+
const parent = pathFor('linux').dirname(abs)
|
|
83
|
+
if (parent === homeKey && !HOME_CACHES.test(pathFor('linux').basename(abs))) return 'a folder directly in your home directory'
|
|
84
|
+
if ((parent === '/' || /^[a-z]:\/?$/i.test(parent)) && !isAncestorOrSame(abs, norm(v.cwd, platform))) return 'a top-level folder of the drive'
|
|
85
|
+
// a folder inside the current directory cannot be above the repository
|
|
86
|
+
if (abs !== norm(v.cwd, platform) && isAncestorOrSame(norm(v.cwd, platform), abs)) continue
|
|
87
|
+
const root = repoRoot()
|
|
88
|
+
if (root && abs !== norm(root, platform) && isAncestorOrSame(abs, norm(root, platform))) return 'a folder above this repository'
|
|
89
|
+
}
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function git(args, cwd) {
|
|
94
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function gitCwd(t, cwd) {
|
|
98
|
+
const i = t.indexOf('-C')
|
|
99
|
+
return i > 0 && t[i + 1] ? path.resolve(cwd, t[i + 1]) : cwd
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function gitSub(t) {
|
|
103
|
+
const rest = []
|
|
104
|
+
for (let i = 1; i < t.length; i++) {
|
|
105
|
+
if (t[i] === '-C' || t[i] === '-c') i++
|
|
106
|
+
else rest.push(t[i])
|
|
107
|
+
}
|
|
108
|
+
return rest
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function forcePushToMain(rest, branch, protectedBranches = ['main', 'master']) {
|
|
112
|
+
if (rest[0] !== 'push') return false
|
|
113
|
+
const args = rest.slice(1)
|
|
114
|
+
// --force-with-lease and --force-if-includes are the safe forms and do not count
|
|
115
|
+
const force = args.some(a => a === '--force' || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(a) || a.startsWith('+'))
|
|
116
|
+
if (!force) return false
|
|
117
|
+
const refs = args.filter(a => !a.startsWith('-')).slice(1).map(r => r.replace(/^\+/, '').split(':').pop())
|
|
118
|
+
const targets = refs.length ? refs.map(r => (r === 'HEAD' ? branch() : r)) : [branch()]
|
|
119
|
+
return targets.some(r => protectedBranches.includes(String(r || '').replace(/^refs\/heads\//, '')))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// An identity that is the agent, not the person: Claude <noreply@anthropic.com> and the like.
|
|
123
|
+
const AGENT_ID = /\bclaude\b|anthropic/i
|
|
124
|
+
|
|
125
|
+
// The --author value of git commit, the value git config sets for user.name or user.email, or a
|
|
126
|
+
// -c user.name=... before the subcommand.
|
|
127
|
+
function agentAuthor(t, rest) {
|
|
128
|
+
for (let i = 1; i < t.length; i++) {
|
|
129
|
+
if (t[i] === '-c' && /^user\.(?:name|email)=/i.test(t[i + 1] || '') && AGENT_ID.test(t[i + 1].split('=').slice(1).join('='))) return t[i + 1]
|
|
130
|
+
}
|
|
131
|
+
if (rest[0] === 'commit' || rest[0] === 'merge' || rest[0] === 'cherry-pick' || rest[0] === 'revert') {
|
|
132
|
+
for (let i = 1; i < rest.length; i++) {
|
|
133
|
+
const a = rest[i]
|
|
134
|
+
const v = a.startsWith('--author=') ? a.slice(9) : a === '--author' ? rest[i + 1] : null
|
|
135
|
+
if (v && AGENT_ID.test(v)) return `--author ${v}`
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (rest[0] === 'config') {
|
|
139
|
+
const args = rest.slice(1).filter(a => !/^--(?:global|local|system|worktree|replace-all|add)$/.test(a))
|
|
140
|
+
if (/^user\.(?:name|email)$/i.test(args[0] || '') && args[1] && AGENT_ID.test(args[1])) return `${args[0]} ${args[1]}`
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// GIT_AUTHOR_NAME=Claude git commit, export GIT_COMMITTER_EMAIL=noreply@anthropic.com, $env:GIT_AUTHOR_NAME = "Claude"
|
|
146
|
+
const AGENT_ENV = /\bGIT_(?:AUTHOR|COMMITTER)_(?:NAME|EMAIL)\s*=\s*["']?([^"'\s;&|]*)/gi
|
|
147
|
+
function agentEnv(text) {
|
|
148
|
+
for (const m of text.matchAll(AGENT_ENV)) if (AGENT_ID.test(m[1])) return m[0]
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function agentMessage(what) {
|
|
153
|
+
return `[bulkhead] basics: \`${oneLine(what, 120)}\` makes the agent the author of the commit.\n` +
|
|
154
|
+
'Commits authored as Claude or Anthropic do not count as the user\'s work on GitHub (contribution graph, achievements), and later blame points at a bot.\n' +
|
|
155
|
+
'Commit with the git identity already configured: leave user.name and user.email alone and drop --author. A Co-Authored-By line in the message is fine.'
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function check(cmd, {
|
|
159
|
+
cwd = process.cwd(), home = os.homedir(), platform = process.platform, run = git,
|
|
160
|
+
protectedBranches = setting('basics.protectedBranches', ['main', 'master']), mode = 'sh',
|
|
161
|
+
} = {}) {
|
|
162
|
+
// each rule's switch and the repository root are read once, however many commands the line has
|
|
163
|
+
const once = fn => { let v; let done = false; return () => (done ? v : ((done = true), (v = fn()))) }
|
|
164
|
+
const rmRoot = once(() => enabled('rm-root'))
|
|
165
|
+
const forcePush = once(() => enabled('force-push-main'))
|
|
166
|
+
const discard = once(() => enabled('discard-changes'))
|
|
167
|
+
const author = once(() => enabled('agent-author'))
|
|
168
|
+
const prepared = shell.prepare(String(cmd || ''), mode)
|
|
169
|
+
const cmds = shell.commands(prepared, mode).filter(x => !x.remote)
|
|
170
|
+
if (author() && /GIT_(?:AUTHOR|COMMITTER)_/i.test(prepared)) {
|
|
171
|
+
// only the shell's own words: a heredoc body handed to python or node is code, not a variable
|
|
172
|
+
const runsGit = cmds.some(c => shell.bin(c.argv[0]) === 'git')
|
|
173
|
+
for (const c of cmds) {
|
|
174
|
+
const own = c.depth === 0 ? prepared.slice(c.s, c.e) : c.argv.join(' ')
|
|
175
|
+
const env = agentEnv(own)
|
|
176
|
+
if (env && (runsGit || /^(?:export|set|setx|\$env:)/i.test(own.trim()))) return agentMessage(env)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const repoRoot = once(() => { try { return run(['rev-parse', '--show-toplevel'], cwd) } catch { return null } })
|
|
180
|
+
for (const c of cmds) {
|
|
181
|
+
const t = c.argv
|
|
182
|
+
if (!t.length) continue
|
|
183
|
+
const seg = t.join(' ')
|
|
184
|
+
const targets = rmTargets(t)
|
|
185
|
+
if (targets.length && rmRoot()) {
|
|
186
|
+
for (const target of targets) {
|
|
187
|
+
const what = dangerousTarget(target, { cwd, home, platform, repoRoot })
|
|
188
|
+
if (what) {
|
|
189
|
+
return `[bulkhead] basics: \`${oneLine(seg, 120)}\` deletes ${what} (${target}).\n` +
|
|
190
|
+
'Name the folder you mean inside the project instead, for example rm -rf ./dist.'
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (path.basename(t[0]).replace(/\.exe$/i, '') !== 'git') continue
|
|
195
|
+
const dir = gitCwd(t, cwd)
|
|
196
|
+
const rest = gitSub(t)
|
|
197
|
+
const who = author() && agentAuthor(t, rest)
|
|
198
|
+
if (who) return agentMessage(who)
|
|
199
|
+
if (rest[0] === 'push' && forcePush() && forcePushToMain(rest, () => { try { return run(['rev-parse', '--abbrev-ref', 'HEAD'], dir) } catch { return '' } }, protectedBranches)) {
|
|
200
|
+
return `[bulkhead] basics: force-pushing to ${protectedBranches.join(' or ')} rewrites history other people and sessions have pulled.\n` +
|
|
201
|
+
'Push to a branch, or use --force-with-lease if you are sure.'
|
|
202
|
+
}
|
|
203
|
+
const reset = rest[0] === 'reset' && rest.includes('--hard')
|
|
204
|
+
const clean = rest[0] === 'clean' && rest.some(a => /^-[a-z]*f/.test(a) || a === '--force') && rest.some(a => /^-[a-z]*[dx]/.test(a))
|
|
205
|
+
if ((reset || clean) && discard()) {
|
|
206
|
+
let status
|
|
207
|
+
try { status = run(['status', '--porcelain'], dir) } catch { continue }
|
|
208
|
+
const lines = status.split('\n').filter(Boolean)
|
|
209
|
+
const lost = reset ? lines.filter(l => !l.startsWith('??')) : lines.filter(l => l.startsWith('??'))
|
|
210
|
+
if (lost.length) {
|
|
211
|
+
return `[bulkhead] basics: \`${oneLine(seg, 120)}\` would throw away ${lost.length} ${reset ? 'uncommitted change' : 'untracked file'}${lost.length === 1 ? '' : 's'}` +
|
|
212
|
+
` (${lost.slice(0, 3).map(l => l.slice(3)).join(', ')}${lost.length > 3 ? ', ...' : ''}).\n` +
|
|
213
|
+
'Commit them or run `git stash push -u` first, so they can be brought back.'
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (require.main === module) {
|
|
221
|
+
runHook('basics', input => {
|
|
222
|
+
const cmd = input.tool_input && input.tool_input.command
|
|
223
|
+
const why = typeof cmd === 'string' && cmd && check(cmd, { cwd: input.cwd || process.cwd(), mode: shell.modeFor(input) })
|
|
224
|
+
if (!why) return
|
|
225
|
+
return blocked('basics', ruleOf(why), input, `${why}\n${ASK_USER}`)
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function ruleOf(message) {
|
|
230
|
+
if (/makes the agent the author/.test(message)) return 'agent-author'
|
|
231
|
+
if (/force-pushing/.test(message)) return 'force-push-main'
|
|
232
|
+
if (/would throw away/.test(message)) return 'discard-changes'
|
|
233
|
+
return 'rm-root'
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = { check, rmTargets, dangerousTarget, forcePushToMain, ruleOf, agentAuthor }
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const { execFileSync } = require('child_process')
|
|
5
|
+
const os = require('os')
|
|
6
|
+
const { runHook, blocked, oneLine, enabled, ASK_USER } = require('./hook')
|
|
7
|
+
const { readSessionFiles } = require('./sessions')
|
|
8
|
+
const shell = require('./shell')
|
|
9
|
+
const { rmTargets } = require('./basics')
|
|
10
|
+
|
|
11
|
+
// The git commands a line runs on this machine, wrappers (bash -c, env, VAR=value) opened.
|
|
12
|
+
function gitCommands(cmd, mode = 'sh') {
|
|
13
|
+
return shell.commands(shell.prepare(String(cmd || ''), mode), mode).filter(c => !c.remote && shell.bin(c.argv[0]) === 'git')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// git <sub> args, with -C <dir> pulled out. Returns null for anything that is not git.
|
|
17
|
+
// Takes the words of one command, or a command line (read as its first command).
|
|
18
|
+
function gitCall(seg, cwd) {
|
|
19
|
+
const t = Array.isArray(seg) ? seg : ((shell.commands(seg)[0] || {}).argv || [])
|
|
20
|
+
if (shell.bin(t[0] || '') !== 'git') return null
|
|
21
|
+
let dir = cwd
|
|
22
|
+
const rest = []
|
|
23
|
+
for (let i = 1; i < t.length; i++) {
|
|
24
|
+
if (t[i] === '-C' && t[i + 1]) dir = path.resolve(dir, t[++i])
|
|
25
|
+
else if (t[i] === '-c') i++
|
|
26
|
+
else rest.push(t[i])
|
|
27
|
+
}
|
|
28
|
+
return { dir, sub: rest[0], args: rest.slice(1) }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DONE = /^--(?:abort|continue|skip|quit)$/
|
|
32
|
+
|
|
33
|
+
// What the command does to a shared working tree, or null when it leaves it alone.
|
|
34
|
+
// 'dirty-only' means it matters only when there are uncommitted changes.
|
|
35
|
+
function changesTree({ sub, args, dir = '.' }, exists = fs.existsSync) {
|
|
36
|
+
const paths = args.indexOf('--') >= 0 ? args.slice(args.indexOf('--') + 1) : []
|
|
37
|
+
switch (sub) {
|
|
38
|
+
case 'checkout': {
|
|
39
|
+
if (paths.length) return paths.includes('.') ? 'restores every file' : null
|
|
40
|
+
if (args.includes('-')) return 'switches the branch'
|
|
41
|
+
const names = args.filter(a => !a.startsWith('-'))
|
|
42
|
+
if (names.includes('.')) return 'restores every file'
|
|
43
|
+
// git checkout src/app.js restores a file; only a branch or commit moves the tree
|
|
44
|
+
if (!args.some(a => /^-[bB]$/.test(a)) && names.length && names.every(n => exists(path.resolve(dir, n)))) return null
|
|
45
|
+
return names.length ? 'switches the branch' : null
|
|
46
|
+
}
|
|
47
|
+
case 'switch':
|
|
48
|
+
return 'switches the branch'
|
|
49
|
+
case 'restore': {
|
|
50
|
+
// --staged alone only unstages; the files on disk stay as they are
|
|
51
|
+
const staged = args.some(a => a === '--staged' || /^-[a-zA-Z]*S/.test(a))
|
|
52
|
+
const worktree = args.some(a => a === '--worktree' || /^-[a-zA-Z]*W/.test(a))
|
|
53
|
+
if (staged && !worktree) return null
|
|
54
|
+
return args.includes('.') ? 'restores every file' : null
|
|
55
|
+
}
|
|
56
|
+
case 'stash':
|
|
57
|
+
return !args[0] || /^(?:push|save|pop|apply|-u|--include-untracked|-k|--keep-index|-m)$/.test(args[0]) ? 'stashes or unstashes changes' : null
|
|
58
|
+
case 'reset':
|
|
59
|
+
return args.includes('--hard') ? 'resets the files' : null
|
|
60
|
+
case 'clean':
|
|
61
|
+
if (args.some(a => /^-[a-z]*n/.test(a) || a === '--dry-run')) return null
|
|
62
|
+
return args.some(a => /^-[a-z]*f/.test(a) || a === '--force') ? 'deletes untracked files' : null
|
|
63
|
+
case 'rebase':
|
|
64
|
+
return args.some(a => DONE.test(a)) ? null : 'rebases the branch'
|
|
65
|
+
case 'merge':
|
|
66
|
+
return args.some(a => DONE.test(a)) ? null : 'merges into the branch'
|
|
67
|
+
case 'pull':
|
|
68
|
+
return 'dirty-only'
|
|
69
|
+
// git add -A, git add ., git commit -a: everything changed in the tree goes into one commit,
|
|
70
|
+
// including the files the other session is in the middle of editing
|
|
71
|
+
case 'add':
|
|
72
|
+
return args.some(a => /^(?:-A|--all|-u|--update|\.|:\/|:)$/.test(a) || /^-[a-zA-Z]*[Au]$/.test(a) || a === './') ? 'stage-all' : null
|
|
73
|
+
case 'commit':
|
|
74
|
+
return args.some(a => a === '--all' || /^-[a-zA-Z]*a[a-zA-Z]*$/.test(a)) ? 'stage-all' : null
|
|
75
|
+
default:
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function git(args, cwd) {
|
|
81
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function alive(pid) {
|
|
85
|
+
try {
|
|
86
|
+
process.kill(pid, 0)
|
|
87
|
+
return true
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return e.code === 'EPERM'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// git prints the real path of the tree; a session may have started through a symlink, a junction
|
|
94
|
+
// or a Windows short name (C:\Users\RUNNER~1), so both sides are resolved before comparing.
|
|
95
|
+
const real = p => { try { return fs.realpathSync.native(p) } catch { return path.resolve(p) } }
|
|
96
|
+
const key = (p, platform) => {
|
|
97
|
+
const n = real(p).replace(/\\/g, '/').replace(/\/+$/, '')
|
|
98
|
+
return platform === 'win32' ? n.toLowerCase() : n
|
|
99
|
+
}
|
|
100
|
+
const inside = (child, parent) => child === parent || child.startsWith(parent + '/')
|
|
101
|
+
|
|
102
|
+
// Other live sessions whose project directory is in the same working tree. A session started in
|
|
103
|
+
// a subfolder of this tree could still be in a nested worktree or submodule, so git decides those.
|
|
104
|
+
function sharers(top, sessionId, { files, isAlive, run, platform }) {
|
|
105
|
+
const topKey = key(top, platform)
|
|
106
|
+
const out = []
|
|
107
|
+
for (const [pid, f] of files) {
|
|
108
|
+
if (!f.cwd || f.sessionId === sessionId || !isAlive(pid)) continue
|
|
109
|
+
const cwdKey = key(f.cwd, platform)
|
|
110
|
+
if (!inside(cwdKey, topKey)) continue
|
|
111
|
+
if (cwdKey !== topKey) {
|
|
112
|
+
let theirs
|
|
113
|
+
try { theirs = run(['rev-parse', '--show-toplevel'], f.cwd) } catch { continue }
|
|
114
|
+
if (key(theirs, platform) !== topKey) continue
|
|
115
|
+
}
|
|
116
|
+
out.push({ pid, ...f })
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function when(ms) {
|
|
122
|
+
if (!ms) return 'unknown time'
|
|
123
|
+
const d = new Date(ms)
|
|
124
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ` +
|
|
125
|
+
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// git worktree remove <path> and rm -rf <path>: the folders the command deletes.
|
|
129
|
+
function removedDirs(c, cwd, home) {
|
|
130
|
+
const call = gitCall(c.argv, cwd)
|
|
131
|
+
if (call && call.sub === 'worktree' && call.args[0] === 'remove') {
|
|
132
|
+
const rest = call.args.slice(1)
|
|
133
|
+
const target = rest.find(a => !a.startsWith('-'))
|
|
134
|
+
return target ? [{ dir: path.resolve(call.dir, expandHome(target, home)), how: 'git worktree remove', force: rest.some(a => a === '-f' || a === '--force') }] : []
|
|
135
|
+
}
|
|
136
|
+
return rmTargets(c.argv)
|
|
137
|
+
.map(t => expandHome(t, home))
|
|
138
|
+
.map(t => expandPwd(t, cwd))
|
|
139
|
+
.filter(t => t && !/[$%*?`]/.test(t))
|
|
140
|
+
.map(t => ({ dir: path.resolve(cwd, t), how: 'deleting', force: true }))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// $PWD, ${PWD}, $(pwd) and `pwd` are the folder the command runs in.
|
|
144
|
+
function expandPwd(arg, cwd) {
|
|
145
|
+
return String(arg).replace(/^(?:\$PWD|\$\{PWD\}|\$\(\s*pwd\s*\)|`\s*pwd\s*`)(?=[\\/]|$)/, cwd.replace(/\\/g, '/'))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function expandHome(arg, home) {
|
|
149
|
+
return String(arg)
|
|
150
|
+
.replace(/^~(?=[\\/]|$)/, home)
|
|
151
|
+
.replace(/^(?:\$HOME|\$\{HOME\}|\$env:USERPROFILE|%USERPROFILE%)(?=[\\/]|$)/i, home)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Links in the first two levels of a folder (node_modules linked to the main checkout is the
|
|
155
|
+
// usual one). On Windows, git worktree remove --force has followed a junction and deleted what
|
|
156
|
+
// it pointed to.
|
|
157
|
+
function linksIn(dir) {
|
|
158
|
+
const out = []
|
|
159
|
+
const walk = (d, depth) => {
|
|
160
|
+
let entries
|
|
161
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { return }
|
|
162
|
+
for (const e of entries) {
|
|
163
|
+
if (e.name === '.git') continue
|
|
164
|
+
const p = path.join(d, e.name)
|
|
165
|
+
if (e.isSymbolicLink()) out.push(p)
|
|
166
|
+
else if (e.isDirectory() && depth < 2) walk(p, depth + 1)
|
|
167
|
+
if (out.length >= 3) return
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
walk(dir, 1)
|
|
171
|
+
return out
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function classify(cmd, {
|
|
175
|
+
cwd = process.cwd(), sessionId = null, files = () => readSessionFiles(), isAlive = alive, run = git, platform = process.platform, mode = 'sh',
|
|
176
|
+
home = os.homedir(), links = linksIn, removeRule = () => enabled('remove-session-dir'), stageRule = () => enabled('stage-all'),
|
|
177
|
+
} = {}) {
|
|
178
|
+
const text = shell.prepare(String(cmd || ''), mode)
|
|
179
|
+
const all = shell.commands(text, mode).filter(c => !c.remote)
|
|
180
|
+
let live = null
|
|
181
|
+
const liveOthers = () => live || (live = new Map([...files()].filter(([pid, f]) => f.cwd && f.sessionId !== sessionId && isAlive(pid))))
|
|
182
|
+
|
|
183
|
+
for (const c of all) {
|
|
184
|
+
const removals = removedDirs(c, cwd, home)
|
|
185
|
+
if (!removals.length) continue
|
|
186
|
+
const seg = oneLine(c.argv.join(' '), 120)
|
|
187
|
+
for (const r of removals) {
|
|
188
|
+
if (r.how === 'git worktree remove' && r.force && platform === 'win32') {
|
|
189
|
+
const found = links(r.dir)
|
|
190
|
+
if (found.length) {
|
|
191
|
+
return {
|
|
192
|
+
rule: 'worktree-force-links',
|
|
193
|
+
why: `[bulkhead] checkout-guard: \`${seg}\` force-removes a worktree that holds links (${found.map(f => path.relative(r.dir, f)).join(', ')}).\n` +
|
|
194
|
+
'On Windows, git worktree remove --force can follow a junction and delete what it points to, such as the main checkout\'s node_modules.\n' +
|
|
195
|
+
'Remove the links first (rmdir <link> removes a junction, not its target), check none are left, then run git worktree remove without --force.',
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!sessionId || !removeRule()) continue
|
|
200
|
+
const others = liveOthers()
|
|
201
|
+
if (!others.size) continue
|
|
202
|
+
const target = key(r.dir, platform)
|
|
203
|
+
// a deletion of the folder a session works in, or of a folder above it
|
|
204
|
+
const hit = [...others].find(([, f]) => inside(key(f.cwd, platform), target))
|
|
205
|
+
if (hit) {
|
|
206
|
+
const [pid, f] = hit
|
|
207
|
+
return {
|
|
208
|
+
rule: 'remove-session-dir',
|
|
209
|
+
why: `[bulkhead] checkout-guard: \`${seg}\` removes ${r.dir}, and another Claude session works in it (pid ${pid}, started ${when(f.startedAt)}, in ${f.cwd}).\n` +
|
|
210
|
+
'Its files, its dev server and its next commit would disappear under it.\n' +
|
|
211
|
+
'Leave this folder to that session. If it is really finished, wait until it exits (`bulkhead status` lists the live sessions), then remove it.',
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!sessionId) return null
|
|
218
|
+
for (const c of all) {
|
|
219
|
+
if (shell.bin(c.argv[0]) !== 'git') continue
|
|
220
|
+
const call = gitCall(c.argv, cwd)
|
|
221
|
+
const seg = c.argv.join(' ')
|
|
222
|
+
if (!call) continue
|
|
223
|
+
let what = changesTree(call)
|
|
224
|
+
if (!what || (what === 'stage-all' && !stageRule())) continue
|
|
225
|
+
if (!liveOthers().size) continue
|
|
226
|
+
let top
|
|
227
|
+
try { top = run(['rev-parse', '--show-toplevel'], call.dir) } catch { continue }
|
|
228
|
+
const others = sharers(top, sessionId, { files: liveOthers(), isAlive, run, platform })
|
|
229
|
+
if (!others.length) continue
|
|
230
|
+
const o = others[0]
|
|
231
|
+
const who = `another Claude session works in the same tree (pid ${o.pid}, started ${when(o.startedAt)}` +
|
|
232
|
+
(others.length > 1 ? `, and ${others.length - 1} more` : '') + ')'
|
|
233
|
+
if (what === 'stage-all') {
|
|
234
|
+
let status = ''
|
|
235
|
+
try { status = run(['status', '--porcelain'], call.dir) } catch { continue }
|
|
236
|
+
const changed = status.split('\n').filter(Boolean)
|
|
237
|
+
if (!changed.length) continue
|
|
238
|
+
return {
|
|
239
|
+
rule: 'stage-all',
|
|
240
|
+
why: `[bulkhead] checkout-guard: \`${oneLine(seg, 120)}\` stages every change in ${top} (${changed.length} file${changed.length === 1 ? '' : 's'}), ` +
|
|
241
|
+
`and ${who}.\n` +
|
|
242
|
+
'Some of those changes are probably its half-finished work, and they would go into your commit.\n' +
|
|
243
|
+
'Stage only the files you changed, by name: git add src/a.ts src/b.ts, or commit them directly with git commit -m "..." -- src/a.ts src/b.ts.',
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (what === 'dirty-only') {
|
|
247
|
+
let status = ''
|
|
248
|
+
try { status = run(['status', '--porcelain', '--untracked-files=no'], call.dir) } catch { continue }
|
|
249
|
+
if (!status) continue
|
|
250
|
+
what = 'merges into a tree with uncommitted changes'
|
|
251
|
+
}
|
|
252
|
+
const name = path.basename(top)
|
|
253
|
+
if (call.sub === 'pull') {
|
|
254
|
+
return {
|
|
255
|
+
rule: 'git-pull',
|
|
256
|
+
why: `[bulkhead] checkout-guard: \`${oneLine(seg, 120)}\` ${what} in ${top}, and ${who}.\n` +
|
|
257
|
+
'A pull rewrites the files that session has open, and a conflict would stop halfway through its edits.\n' +
|
|
258
|
+
'Wait until it has committed or stashed its changes, or pull from a clone no other session is using.',
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
rule: `git-${call.sub}`,
|
|
263
|
+
why: `[bulkhead] checkout-guard: \`${oneLine(seg, 120)}\` ${what} in ${top}, and ${who}.\n` +
|
|
264
|
+
'Its files and branch would change under it. Work in your own worktree instead:\n' +
|
|
265
|
+
` git worktree add ../${name}-<task> -b <branch>`,
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return null
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function check(cmd, opts) {
|
|
272
|
+
const hit = classify(cmd, opts)
|
|
273
|
+
return hit ? hit.why : null
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (require.main === module) {
|
|
277
|
+
runHook('checkout-guard', input => {
|
|
278
|
+
const cmd = input.tool_input && input.tool_input.command
|
|
279
|
+
if (!cmd) return
|
|
280
|
+
const hit = classify(cmd, { cwd: input.cwd || process.cwd(), sessionId: input.session_id || null, mode: shell.modeFor(input) })
|
|
281
|
+
if (!hit) return
|
|
282
|
+
return blocked('checkout-guard', hit.rule, input, `${hit.why}\n${ASK_USER}`)
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// `git-checkout`, `git-stash` and so on: the first git command that moves the tree.
|
|
287
|
+
function ruleOf(cmd, cwd = '.', mode = 'sh') {
|
|
288
|
+
for (const c of gitCommands(cmd, mode)) {
|
|
289
|
+
const call = gitCall(c.argv, cwd)
|
|
290
|
+
const what = call && changesTree(call)
|
|
291
|
+
if (what) return what === 'stage-all' ? 'stage-all' : `git-${call.sub}`
|
|
292
|
+
}
|
|
293
|
+
return 'git'
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
module.exports = { check, classify, changesTree, gitCall, ruleOf, linksIn }
|
package/src/cli-util.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const os = require('os')
|
|
3
|
+
|
|
4
|
+
// Small helpers shared by the bulkhead subcommands.
|
|
5
|
+
|
|
6
|
+
function useColor(stream = process.stdout, env = process.env) {
|
|
7
|
+
return Boolean(stream && stream.isTTY) && !('NO_COLOR' in env) && env.TERM !== 'dumb'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const CODES = { bold: 1, dim: 2, red: 31, green: 32, yellow: 33, blue: 34, cyan: 36, gray: 90 }
|
|
11
|
+
function paint(on, style, s) {
|
|
12
|
+
return on ? `\x1b[${CODES[style]}m${s}\x1b[0m` : String(s)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function shortPath(p, home = os.homedir()) {
|
|
16
|
+
if (!p) return '?'
|
|
17
|
+
const s = String(p)
|
|
18
|
+
return home && s.toLowerCase().startsWith(home.toLowerCase()) ? '~' + s.slice(home.length).replace(/\\/g, '/') : s.replace(/\\/g, '/')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// --name value or --name=value. Returns undefined when absent, true for a bare flag at the end.
|
|
22
|
+
function option(args, name) {
|
|
23
|
+
for (let i = 0; i < args.length; i++) {
|
|
24
|
+
if (args[i] === `--${name}`) return args[i + 1] !== undefined && !args[i + 1].startsWith('--') ? args[i + 1] : true
|
|
25
|
+
if (args[i].startsWith(`--${name}=`)) return args[i].slice(name.length + 3)
|
|
26
|
+
}
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Cut to `width` visible characters, ending in … when anything was dropped.
|
|
31
|
+
function fit(s, width) {
|
|
32
|
+
s = String(s == null ? '' : s)
|
|
33
|
+
if (width <= 0) return ''
|
|
34
|
+
return s.length > width ? s.slice(0, Math.max(0, width - 1)) + '…' : s
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Columns padded to their widest cell; the last column is not padded.
|
|
38
|
+
function table(rows, gap = ' ') {
|
|
39
|
+
const widths = []
|
|
40
|
+
for (const r of rows) r.forEach((c, i) => (widths[i] = Math.max(widths[i] || 0, String(c).length)))
|
|
41
|
+
return rows.map(r => r.map((c, i) => (i === r.length - 1 ? String(c) : String(c).padEnd(widths[i]))).join(gap).trimEnd())
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function bytes(n) {
|
|
45
|
+
if (n >= 2 ** 30) return `${(n / 2 ** 30).toFixed(1)} GB`
|
|
46
|
+
if (n >= 2 ** 20) return `${Math.round(n / 2 ** 20)} MB`
|
|
47
|
+
if (n >= 1024) return `${Math.round(n / 1024)} KB`
|
|
48
|
+
return `${n} B`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The first argument a subcommand does not know, as a message, or null. flags take no value,
|
|
52
|
+
// valued ones take `--name value` or `--name=value`, shorts are like { n: true } for `-n 5`.
|
|
53
|
+
function unknownArg(args, { flags = [], valued = [], shorts = [] } = {}) {
|
|
54
|
+
for (let i = 0; i < args.length; i++) {
|
|
55
|
+
const a = args[i]
|
|
56
|
+
if (a === '--help' || a === '-h') continue
|
|
57
|
+
if (a.startsWith('--')) {
|
|
58
|
+
const name = a.slice(2).split('=')[0]
|
|
59
|
+
if (flags.includes(name)) continue
|
|
60
|
+
if (valued.includes(name)) { if (!a.includes('=') && args[i + 1] !== undefined && !args[i + 1].startsWith('--')) i++; continue }
|
|
61
|
+
return `unknown option ${a}`
|
|
62
|
+
}
|
|
63
|
+
if (a.length === 2 && a[0] === '-' && shorts.includes(a[1])) { i++; continue }
|
|
64
|
+
return a.startsWith('-') ? `unknown option ${a}` : `unexpected argument ${a}`
|
|
65
|
+
}
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Writes the complaint and the usage text and sets the exit code; true when args were rejected.
|
|
70
|
+
function rejectArgs(cmd, args, spec, stderr, usage) {
|
|
71
|
+
const bad = unknownArg(args, spec)
|
|
72
|
+
if (!bad) return false
|
|
73
|
+
const line = usage || `usage: bulkhead ${cmd} ${[...spec.flags, ...spec.valued].map(f => `[--${f}${spec.valued.includes(f) ? ' X' : ''}]`).join(' ')}`
|
|
74
|
+
stderr.write(`bulkhead ${cmd}: ${bad}\n${line}\n`)
|
|
75
|
+
process.exitCode = 1
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { unknownArg, rejectArgs, useColor, paint, shortPath, option, fit, table, bytes }
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const USAGE = `usage:
|
|
4
|
+
bulkhead status [--json] sessions, their ports and memory, slots, free RAM
|
|
5
|
+
bulkhead top [--interval=SECONDS] the same, live, full screen (q quits, r refreshes)
|
|
6
|
+
bulkhead log [-n N] [--since 7d] [--guard NAME] [--summary] [--json]
|
|
7
|
+
what the guards blocked
|
|
8
|
+
bulkhead scan [--since 30d] [--project TEXT] [-n N] [--json]
|
|
9
|
+
replay your Claude Code transcripts against the guards
|
|
10
|
+
bulkhead doctor [--json] [--no-remote] check the install, settings and this machine
|
|
11
|
+
bulkhead config [--json] effective settings and where each one came from
|
|
12
|
+
bulkhead port [--near PORT] [--json] a port no process is listening on
|
|
13
|
+
bulkhead reap [--dry-run] [--json] stop what exited sessions left running
|
|
14
|
+
bulkhead slot run [--gb=N] [--wait=MINUTES] [--label=TEXT] -- <command...>
|
|
15
|
+
bulkhead slot status [--gb=N] same as bulkhead-slot
|
|
16
|
+
bulkhead remote run [--remote=NAME] [--gb=N] [--tail=N] -- <command...>
|
|
17
|
+
run a heavy command on another machine over ssh
|
|
18
|
+
bulkhead remote status|list|add|remove|fetch see bulkhead remote --help`
|
|
19
|
+
|
|
20
|
+
const COMMANDS = {
|
|
21
|
+
status: './status',
|
|
22
|
+
top: './top',
|
|
23
|
+
log: './log-cli',
|
|
24
|
+
scan: './scan',
|
|
25
|
+
doctor: './doctor',
|
|
26
|
+
config: './config-cli',
|
|
27
|
+
port: './port-cli',
|
|
28
|
+
reap: './reap-cli',
|
|
29
|
+
slot: './slot-cli',
|
|
30
|
+
remote: './remote',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function main(argv) {
|
|
34
|
+
const [cmd, ...rest] = argv
|
|
35
|
+
if (COMMANDS[cmd]) return require(COMMANDS[cmd]).main(rest)
|
|
36
|
+
const help = cmd === '--help' || cmd === '-h' || cmd === 'help'
|
|
37
|
+
;(help ? console.log : console.error)(USAGE)
|
|
38
|
+
process.exit(help ? 0 : 1)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { main, USAGE }
|