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/remotes.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
|
|
5
|
+
// The "remotes" section of the global config file: machines bulkhead remote may run jobs on.
|
|
6
|
+
// It is read from the global file only. A project's .claude/bulkhead.json comes with whatever
|
|
7
|
+
// repository you clone, and a cloned repository must not be able to make you ssh somewhere
|
|
8
|
+
// or pass ssh options such as ProxyCommand.
|
|
9
|
+
|
|
10
|
+
const DEFAULTS = { shell: 'bash', dir: '~/bulkhead-remote', copies: 4, maxJobs: 4, minFreeGb: 0, sshOptions: [] }
|
|
11
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/
|
|
12
|
+
const MAX_COPIES = 8
|
|
13
|
+
|
|
14
|
+
const FIELDS = {
|
|
15
|
+
host: v => typeof v === 'string' && /^[^\s-][^\s]*$/.test(v) ? null : 'should be user@host or an ssh config host name (no spaces, not starting with -)',
|
|
16
|
+
shell: v => typeof v === 'string' && v.trim() && !/[\r\n]/.test(v) ? null : 'should be the remote shell command, such as bash',
|
|
17
|
+
dir: v => typeof v === 'string' && v.trim() && !/[\r\n\0]/.test(v) ? null : 'should be a folder path on the remote',
|
|
18
|
+
copies: v => Number.isInteger(v) && v >= 1 && v <= MAX_COPIES ? null : `should be a whole number from 1 to ${MAX_COPIES}`,
|
|
19
|
+
maxJobs: v => Number.isInteger(v) && v >= 1 && v <= 64 ? null : 'should be a whole number from 1 to 64',
|
|
20
|
+
minFreeGb: v => typeof v === 'number' && Number.isFinite(v) && v >= 0 ? null : 'should be a number of GB, 0 or more',
|
|
21
|
+
// -o options only, written Key=value. scp, sftp and ssh all take them the same way, while
|
|
22
|
+
// flags differ (-p is the port for ssh but "preserve times" for scp).
|
|
23
|
+
sshOptions: v => Array.isArray(v) && v.every(o => typeof o === 'string' && /^[A-Za-z][A-Za-z0-9]*[= ]\S/.test(o) && !/[\r\n]/.test(o))
|
|
24
|
+
? null : 'should be a list of ssh -o options such as "IdentityFile=~/.ssh/buildbox" or "Port=2222"',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// One entry with defaults filled in, or the problems that keep it out.
|
|
28
|
+
function normalize(name, entry) {
|
|
29
|
+
const problems = []
|
|
30
|
+
if (!NAME.test(name)) problems.push(`remotes: "${name}" is not a valid name (letters, digits, . _ -)`)
|
|
31
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return { problems: [...problems, `remotes.${name} should be an object with at least "host"`] }
|
|
32
|
+
if (entry.host === undefined) problems.push(`remotes.${name}.host is missing`)
|
|
33
|
+
for (const [k, v] of Object.entries(entry)) {
|
|
34
|
+
if (!FIELDS[k]) problems.push(`unknown key "remotes.${name}.${k}"`)
|
|
35
|
+
else {
|
|
36
|
+
const why = FIELDS[k](v)
|
|
37
|
+
if (why) problems.push(`"remotes.${name}.${k}" ${why}`)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (problems.length) return { problems }
|
|
41
|
+
return { remote: { name, ...DEFAULTS, ...entry, sshOptions: [...(entry.sshOptions || [])] }, problems }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// { remotes: { name: remote }, problems: [] }. A bad entry is dropped whole, so a typo in
|
|
45
|
+
// sshOptions never means connecting with the wrong key.
|
|
46
|
+
function normalizeAll(body) {
|
|
47
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return { remotes: {}, problems: ['"remotes" should be an object of name: { host, ... }'] }
|
|
48
|
+
const remotes = {}
|
|
49
|
+
const problems = []
|
|
50
|
+
for (const [name, entry] of Object.entries(body)) {
|
|
51
|
+
const r = normalize(name, entry)
|
|
52
|
+
if (r.remote) remotes[name] = r.remote
|
|
53
|
+
problems.push(...r.problems)
|
|
54
|
+
}
|
|
55
|
+
return { remotes, problems }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readJson(file) {
|
|
59
|
+
let text
|
|
60
|
+
try {
|
|
61
|
+
text = fs.readFileSync(file, 'utf8')
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if (e.code === 'ENOENT') return {}
|
|
64
|
+
throw new Error(`cannot read ${file}: ${e.message}`)
|
|
65
|
+
}
|
|
66
|
+
if (!text.trim()) return {}
|
|
67
|
+
let obj
|
|
68
|
+
try {
|
|
69
|
+
obj = JSON.parse(text.replace(/^\uFEFF/, ''))
|
|
70
|
+
} catch (e) {
|
|
71
|
+
throw new Error(`${file} is not valid JSON (${e.message}); fix it first, it was not changed`)
|
|
72
|
+
}
|
|
73
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) throw new Error(`${file} should hold a JSON object; it was not changed`)
|
|
74
|
+
return obj
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Reads the global file, lets `change` edit the object, and writes it back through a temporary
|
|
78
|
+
// file and a rename, so a crash never leaves half a file. Other keys keep their order.
|
|
79
|
+
// `change` returns false to leave the file untouched.
|
|
80
|
+
function editFile(file, change) {
|
|
81
|
+
const obj = readJson(file)
|
|
82
|
+
if (change(obj) === false) return obj
|
|
83
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
84
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
85
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n')
|
|
86
|
+
try {
|
|
87
|
+
fs.renameSync(tmp, file)
|
|
88
|
+
} catch (e) {
|
|
89
|
+
try { fs.unlinkSync(tmp) } catch {}
|
|
90
|
+
throw e
|
|
91
|
+
}
|
|
92
|
+
return obj
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function addRemote(file, name, entry, { makeDefault = false } = {}) {
|
|
96
|
+
const check = normalize(name, entry)
|
|
97
|
+
if (!check.remote) throw new Error(check.problems.join('; '))
|
|
98
|
+
let existed = false
|
|
99
|
+
editFile(file, obj => {
|
|
100
|
+
if (obj.remotes !== undefined && (!obj.remotes || typeof obj.remotes !== 'object' || Array.isArray(obj.remotes))) {
|
|
101
|
+
throw new Error(`"remotes" in ${file} is not an object; fix it first, the file was not changed`)
|
|
102
|
+
}
|
|
103
|
+
obj.remotes = obj.remotes || {}
|
|
104
|
+
existed = name in obj.remotes
|
|
105
|
+
obj.remotes[name] = entry
|
|
106
|
+
if (makeDefault) obj.remote = { ...(obj.remote || {}), default: name }
|
|
107
|
+
})
|
|
108
|
+
return { remote: check.remote, existed }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function removeRemote(file, name) {
|
|
112
|
+
let found = false
|
|
113
|
+
editFile(file, obj => {
|
|
114
|
+
found = Boolean(obj.remotes && typeof obj.remotes === 'object' && name in obj.remotes)
|
|
115
|
+
if (!found) return false
|
|
116
|
+
delete obj.remotes[name]
|
|
117
|
+
if (!Object.keys(obj.remotes).length) delete obj.remotes
|
|
118
|
+
if (obj.remote && obj.remote.default === name) {
|
|
119
|
+
delete obj.remote.default
|
|
120
|
+
if (!Object.keys(obj.remote).length) delete obj.remote
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
return found
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { normalize, normalizeAll, addRemote, removeRemote, readJson, DEFAULTS, MAX_COPIES, NAME }
|