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
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 }