@huynle/hub 0.7.1 → 0.7.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 (185) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/Dockerfile +3 -0
  3. package/README.md +148 -11
  4. package/package.json +3 -1
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/main.ts +4 -4
  7. package/packages/claude-code/src/wrapper.ts +3 -2
  8. package/packages/cli/dist/hub-cli.js +19334 -7849
  9. package/packages/cli/package.json +11 -0
  10. package/packages/cli/src/acting.ts +70 -0
  11. package/packages/cli/src/args.ts +91 -0
  12. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  13. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  14. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  15. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  16. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  17. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  18. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  19. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  20. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  21. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  22. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  23. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  24. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  25. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  26. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  27. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  28. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  29. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  30. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  31. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  32. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  33. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  34. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  35. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  36. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  37. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  38. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  39. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  40. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  41. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  42. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  43. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  44. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  45. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  46. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  47. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  48. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  49. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  50. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  51. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  52. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  53. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  54. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  55. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  56. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  57. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  58. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  59. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  60. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  61. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  62. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  63. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  64. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  65. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  66. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  67. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  68. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  69. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  70. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  71. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  72. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  73. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  74. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  75. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  76. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  77. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  78. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  79. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  80. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  81. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  82. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  83. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  84. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  85. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  86. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  87. package/packages/cli/src/bootstrap/release/README.md +115 -0
  88. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  89. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  90. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  91. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  92. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  93. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  94. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  95. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  96. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  97. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  98. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  99. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  100. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  101. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  102. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  103. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  104. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  105. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  106. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  107. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  108. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  109. package/packages/cli/src/cli.ts +814 -0
  110. package/packages/cli/src/credentials.ts +75 -0
  111. package/packages/cli/src/decisions.ts +106 -0
  112. package/packages/cli/src/format.ts +185 -0
  113. package/packages/cli/src/grants.ts +105 -0
  114. package/packages/cli/src/http.ts +86 -0
  115. package/packages/cli/src/launches.ts +157 -0
  116. package/packages/cli/src/login.ts +160 -0
  117. package/packages/cli/src/main.ts +63 -0
  118. package/packages/cli/src/mcp.ts +879 -0
  119. package/packages/cli/src/messaging.ts +108 -0
  120. package/packages/cli/src/node.ts +279 -0
  121. package/packages/cli/src/openshell.ts +166 -0
  122. package/packages/cli/src/setup/commands.ts +809 -0
  123. package/packages/cli/src/setup/contributors.ts +171 -0
  124. package/packages/cli/src/setup/dispatch.ts +74 -0
  125. package/packages/cli/src/setup/launch-driver.ts +109 -0
  126. package/packages/cli/src/setup/runtime.ts +456 -0
  127. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  128. package/packages/cli/src/setup/state.ts +244 -0
  129. package/packages/cli/src/setup/user-bin.ts +39 -0
  130. package/packages/cli/src/spoke-services.ts +109 -0
  131. package/packages/cli/src/watch.ts +28 -0
  132. package/packages/codex/package.json +1 -1
  133. package/packages/codex/src/main.ts +3 -2
  134. package/packages/codex/src/spoke.ts +4 -1
  135. package/packages/codex/src/wrapper.ts +7 -3
  136. package/packages/node/package.json +1 -1
  137. package/packages/node/src/harnesses.ts +39 -4
  138. package/packages/node/src/run.ts +1 -1
  139. package/packages/preset/package.json +1 -1
  140. package/packages/protocol/package.json +1 -1
  141. package/packages/runtime-openshell/package.json +1 -1
  142. package/packages/runtime-sdk/package.json +1 -1
  143. package/packages/sdk/package.json +1 -1
  144. package/packages/sdk/src/client.ts +55 -4
  145. package/packages/sdk/src/errors.ts +1 -0
  146. package/packages/sdk/src/sse.ts +4 -1
  147. package/packages/sdk/src/types.ts +59 -0
  148. package/packages/spoke-sdk/package.json +1 -1
  149. package/packages/spoke-sdk/src/enroll.ts +26 -4
  150. package/src/central/agent/app-tokens.ts +27 -17
  151. package/src/central/agent/archive-inactive.ts +66 -0
  152. package/src/central/agent/directory-routes.ts +155 -12
  153. package/src/central/agent/directory.ts +1 -1
  154. package/src/central/agent/errors.ts +2 -0
  155. package/src/central/agent/event-cursor.ts +49 -0
  156. package/src/central/agent/event-writers.ts +181 -0
  157. package/src/central/agent/events-routes.ts +148 -0
  158. package/src/central/agent/events.ts +277 -0
  159. package/src/central/agent/names.ts +1 -1
  160. package/src/central/agent/read-routes.ts +32 -36
  161. package/src/central/agent/routes.ts +14 -11
  162. package/src/central/agent/watch.ts +65 -174
  163. package/src/central/agent/webhook-routes.ts +10 -2
  164. package/src/central/agent/webhooks.ts +49 -5
  165. package/src/central/auth.ts +127 -1
  166. package/src/central/backend/index.ts +3 -0
  167. package/src/central/backend/types.ts +2 -0
  168. package/src/central/http.ts +115 -32
  169. package/src/central/mcp-http.ts +116 -0
  170. package/src/central/store.ts +53 -0
  171. package/src/config.ts +70 -7
  172. package/src/index.ts +90 -91
  173. package/src/spoke/launchers.ts +109 -3
  174. package/web/dist/apple-touch-icon.png +0 -0
  175. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  176. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  177. package/web/dist/favicon.ico +0 -0
  178. package/web/dist/icon-192.png +0 -0
  179. package/web/dist/icon-512.png +0 -0
  180. package/web/dist/icon-maskable-512.png +0 -0
  181. package/web/dist/icon.svg +5 -5
  182. package/web/dist/index.html +10 -4
  183. package/web/dist/manifest.webmanifest +2 -2
  184. package/web/dist/assets/index-D9zMbxSg.css +0 -1
  185. package/web/dist/assets/index-db1z4duA.js +0 -20
@@ -0,0 +1,403 @@
1
+ // Lossless config merging for merge-config (v1.1: JSONC, comment- and formatting-preserving).
2
+ //
3
+ // OpenCode configs are often JSONC: comments, trailing commas, the user's own layout. A merge must keep all of it,
4
+ // so it never re-serializes the document. It parses the text into a concrete syntax tree that remembers where every
5
+ // value starts and ends, decides what changes on the plain JSON value (the same pointer semantics as always: "-"
6
+ // appends unless the value is already there, a missing container is created, a scalar in the way is a conflict),
7
+ // and then splices only the text of the value it sets or the member/item it inserts. Every other byte (comments,
8
+ // trailing commas, key order, whitespace, numbers and strings exactly as written) is carried through untouched,
9
+ // and a document that needs no change is returned byte for byte. After each edit the result is parsed again and must
10
+ // equal the intended value exactly, or the merge is refused.
11
+ //
12
+ // What cannot be edited this way is refused with manual_edit_required (exit 10; the planner turns a known
13
+ // unmergeable format into an unmergeable-format ManualEdit): TOML, text that is not JSON/JSONC, duplicate keys (which
14
+ // one counts is ambiguous). A structural mismatch (the root is not an object, a scalar in the pointer's way, an
15
+ // index out of range) stays a conflict; a prototype key is invalid. No dependency: a small scanner below.
16
+ import { canonicalJSON, type ConfigFormat, type ConfigSet, type Json } from "../contracts/index.ts"
17
+ import { txFail, txOk, type TxResult } from "./result.ts"
18
+
19
+ type Value = null | boolean | number | string | Value[] | { [key: string]: Value }
20
+
21
+ const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"])
22
+ const MAX_DEPTH = 512
23
+
24
+ // ---------- concrete syntax tree ----------
25
+
26
+ interface Span {
27
+ /** Offset of the first character. */
28
+ readonly start: number
29
+ /** Offset just past the last character. */
30
+ readonly end: number
31
+ }
32
+
33
+ interface Member {
34
+ readonly key: string
35
+ readonly keyStart: number
36
+ readonly keyEnd: number
37
+ readonly value: CstNode
38
+ }
39
+
40
+ type CstNode =
41
+ | (Span & { readonly kind: "object"; readonly members: readonly Member[] })
42
+ | (Span & { readonly kind: "array"; readonly items: readonly CstNode[] })
43
+ | (Span & { readonly kind: "scalar" })
44
+
45
+ class ParseError extends Error {}
46
+
47
+ /** Skips whitespace and comments from `i`; returns the offset of the next significant character. */
48
+ function skipTrivia(text: string, i: number): number {
49
+ for (;;) {
50
+ while (i < text.length && " \t\n\r\uFEFF".includes(text[i]!)) i++
51
+ if (text.startsWith("//", i)) {
52
+ const nl = text.indexOf("\n", i)
53
+ i = nl < 0 ? text.length : nl
54
+ } else if (text.startsWith("/*", i)) {
55
+ const close = text.indexOf("*/", i + 2)
56
+ if (close < 0) throw new ParseError("an unterminated /* comment")
57
+ i = close + 2
58
+ } else return i
59
+ }
60
+ }
61
+
62
+ function parseCst(text: string): CstNode {
63
+ let i = 0
64
+ const value = (depth: number): CstNode => {
65
+ if (depth > MAX_DEPTH) throw new ParseError("nested too deeply")
66
+ i = skipTrivia(text, i)
67
+ const start = i
68
+ const ch = text[i]
69
+ if (ch === "{") {
70
+ i++
71
+ const members: Member[] = []
72
+ const keys = new Set<string>()
73
+ for (;;) {
74
+ i = skipTrivia(text, i)
75
+ if (text[i] === "}") break
76
+ if (text[i] !== '"') throw new ParseError("expected a key")
77
+ const keyStart = i
78
+ const key = str()
79
+ if (keys.has(key)) throw new ParseError(`duplicate key ${JSON.stringify(key)}`)
80
+ keys.add(key)
81
+ const keyEnd = i
82
+ i = skipTrivia(text, i)
83
+ if (text[i++] !== ":") throw new ParseError("expected ':'")
84
+ members.push({ key, keyStart, keyEnd, value: value(depth + 1) })
85
+ i = skipTrivia(text, i)
86
+ if (text[i] === ",") i++
87
+ else if (text[i] !== "}") throw new ParseError("expected ',' or '}'")
88
+ }
89
+ i++
90
+ return { kind: "object", start, end: i, members }
91
+ }
92
+ if (ch === "[") {
93
+ i++
94
+ const items: CstNode[] = []
95
+ for (;;) {
96
+ i = skipTrivia(text, i)
97
+ if (text[i] === "]") break
98
+ items.push(value(depth + 1))
99
+ i = skipTrivia(text, i)
100
+ if (text[i] === ",") i++
101
+ else if (text[i] !== "]") throw new ParseError("expected ',' or ']'")
102
+ }
103
+ i++
104
+ return { kind: "array", start, end: i, items }
105
+ }
106
+ if (ch === '"') {
107
+ str()
108
+ return { kind: "scalar", start, end: i }
109
+ }
110
+ const m = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i, i + 400))
111
+ if (m) {
112
+ i += m[0].length
113
+ return { kind: "scalar", start, end: i }
114
+ }
115
+ for (const word of ["true", "false", "null"])
116
+ if (text.startsWith(word, i)) {
117
+ i += word.length
118
+ return { kind: "scalar", start, end: i }
119
+ }
120
+ throw new ParseError("not valid JSON or JSONC")
121
+ }
122
+ const str = (): string => {
123
+ const start = i
124
+ i++
125
+ while (i < text.length && text[i] !== '"') i += text[i] === "\\" ? 2 : 1
126
+ if (i >= text.length) throw new ParseError("an unterminated string")
127
+ i++
128
+ try {
129
+ return JSON.parse(text.slice(start, i)) as string
130
+ } catch {
131
+ throw new ParseError("an invalid string")
132
+ }
133
+ }
134
+ const root = value(0)
135
+ if (skipTrivia(text, i) !== text.length) throw new ParseError("unexpected text after the document")
136
+ return root
137
+ }
138
+
139
+ function toValue(text: string, node: CstNode): Value {
140
+ if (node.kind === "scalar") return JSON.parse(text.slice(node.start, node.end)) as Value
141
+ if (node.kind === "array") return node.items.map((item) => toValue(text, item))
142
+ const out: { [key: string]: Value } = {}
143
+ for (const m of node.members) Object.defineProperty(out, m.key, { value: toValue(text, m.value), enumerable: true, writable: true, configurable: true })
144
+ return out
145
+ }
146
+
147
+ /** Parses JSON or JSONC (comments, trailing commas) into its JSON value; duplicate keys are refused. */
148
+ export function parseJsonc(text: string): { readonly ok: true; readonly value: Value } | { readonly ok: false; readonly message: string } {
149
+ try {
150
+ return { ok: true, value: toValue(text, parseCst(text)) }
151
+ } catch (error) {
152
+ return { ok: false, message: error instanceof ParseError ? error.message : "not valid JSON or JSONC" }
153
+ }
154
+ }
155
+
156
+ // ---------- the merge ----------
157
+
158
+ /** Merges `sets` into the config text (undefined: the file does not exist yet). */
159
+ export function mergeConfigText(text: string | undefined, format: ConfigFormat, sets: readonly ConfigSet[]): TxResult<string> {
160
+ if (format === "toml") return txFail("manual_edit_required", "TOML configs cannot be edited losslessly by this hubctl; make the edit by hand")
161
+ if (text === undefined) {
162
+ const root: Value = {}
163
+ for (const set of sets) {
164
+ const r = applySet(root, set)
165
+ if (!r.ok) return r
166
+ }
167
+ return txOk(`${JSON.stringify(root, null, 2)}\n`)
168
+ }
169
+ let current = text
170
+ for (const set of sets) {
171
+ let cst: CstNode
172
+ try {
173
+ cst = parseCst(current)
174
+ } catch (error) {
175
+ return txFail("manual_edit_required", `the config cannot be edited losslessly (${error instanceof Error ? error.message : "unreadable"}); make the edit by hand`)
176
+ }
177
+ const tree = toValue(current, cst)
178
+ if (!isObject(tree)) return txFail("conflict", "the config is not a JSON object")
179
+ const changed = applySet(tree, set)
180
+ if (!changed.ok) return changed
181
+ if (!changed.value) continue
182
+ const next = editText(current, cst, segments(set.pointer), set.value)
183
+ const check = parseJsonc(next)
184
+ if (!check.ok || canonicalJSON(check.value) !== canonicalJSON(tree))
185
+ return txFail("manual_edit_required", `${set.pointer} could not be edited losslessly in this config; make the edit by hand`)
186
+ current = next
187
+ }
188
+ return txOk(current)
189
+ }
190
+
191
+ const isObject = (v: unknown): v is { [key: string]: Value } => typeof v === "object" && v !== null && !Array.isArray(v)
192
+ const same = (a: unknown, b: unknown) => canonicalJSON(a) === canonicalJSON(b)
193
+ const clone = (v: Json): Value => JSON.parse(JSON.stringify(v)) as Value
194
+
195
+ function segments(pointer: string): string[] {
196
+ return pointer
197
+ .slice(1)
198
+ .split("/")
199
+ .map((s) => s.replaceAll("~1", "/").replaceAll("~0", "~"))
200
+ }
201
+
202
+ function setKey(obj: { [key: string]: Value }, key: string, value: Value): void {
203
+ Object.defineProperty(obj, key, { value, enumerable: true, writable: true, configurable: true })
204
+ }
205
+
206
+ /** Applies one set to the plain value (the semantics of a merge); ok(true) when the document changed. */
207
+ function applySet(root: Value, set: ConfigSet): TxResult<boolean> {
208
+ if (!set.pointer.startsWith("/")) return txFail("invalid", `${set.pointer} is not a JSON pointer`)
209
+ const segs = segments(set.pointer)
210
+ if (segs.some((s) => FORBIDDEN_KEYS.has(s))) return txFail("invalid", `${set.pointer} names a prototype key`)
211
+ let current: Value = root
212
+ for (let i = 0; i < segs.length - 1; i++) {
213
+ const seg = segs[i]!
214
+ let next: Value | undefined
215
+ if (Array.isArray(current)) {
216
+ const index = arrayIndex(seg, current.length - 1)
217
+ if (index === undefined) return txFail("conflict", `${set.pointer}: no array element ${seg}`)
218
+ next = current[index]
219
+ } else if (isObject(current)) {
220
+ next = Object.hasOwn(current, seg) ? current[seg] : undefined
221
+ if (next === undefined) {
222
+ // A missing container is created as an array when the pointer appends to it ("-"), else an object.
223
+ next = segs[i + 1] === "-" ? [] : {}
224
+ setKey(current, seg, next)
225
+ }
226
+ }
227
+ if (typeof next !== "object" || next === null) return txFail("conflict", `${set.pointer}: ${segs.slice(0, i + 1).join("/")} is not an object or array`)
228
+ current = next
229
+ }
230
+ const last = segs[segs.length - 1]!
231
+ const value = clone(set.value)
232
+ if (Array.isArray(current)) {
233
+ if (last === "-") {
234
+ if (current.some((item) => same(item, value))) return txOk(false)
235
+ current.push(value)
236
+ return txOk(true)
237
+ }
238
+ const index = arrayIndex(last, current.length)
239
+ if (index === undefined) return txFail("conflict", `${set.pointer}: index ${last} is out of range`)
240
+ if (index < current.length && same(current[index], value)) return txOk(false)
241
+ current[index] = value
242
+ return txOk(true)
243
+ }
244
+ if (!isObject(current)) return txFail("conflict", `${set.pointer}: the parent is not an object or array`)
245
+ if (Object.hasOwn(current, last) && same(current[last], value)) return txOk(false)
246
+ setKey(current, last, value)
247
+ return txOk(true)
248
+ }
249
+
250
+ function arrayIndex(seg: string, max: number): number | undefined {
251
+ if (!/^(0|[1-9]\d*)$/.test(seg)) return undefined
252
+ const n = Number(seg)
253
+ return n <= max ? n : undefined
254
+ }
255
+
256
+ // ---------- text edits ----------
257
+
258
+ interface Splice {
259
+ readonly at: number
260
+ readonly remove: number
261
+ readonly insert: string
262
+ }
263
+
264
+ /** Applies splices given in text order (at equal offsets, the earlier one ends up first). */
265
+ function applySplices(text: string, splices: readonly Splice[]): string {
266
+ let out = text
267
+ const ordered = splices.map((s, i) => ({ s, i })).sort((a, b) => b.s.at - a.s.at || b.i - a.i)
268
+ for (const { s } of ordered) out = out.slice(0, s.at) + s.insert + out.slice(s.at + s.remove)
269
+ return out
270
+ }
271
+
272
+ /** The whitespace that starts the line holding offset `pos`. */
273
+ function lineIndent(text: string, pos: number): string {
274
+ const lineStart = text.lastIndexOf("\n", pos - 1) + 1
275
+ return /^[ \t]*/.exec(text.slice(lineStart))![0]
276
+ }
277
+
278
+ /** The document's indentation unit: the first indented line's indentation, else two spaces. */
279
+ function indentUnit(text: string): string {
280
+ return /\n([ \t]+)\S/.exec(text)?.[1] ?? " "
281
+ }
282
+
283
+ /**
284
+ * Where a new line may be inserted after offset `from` without separating a value from the comment that follows it
285
+ * on the same line: past spaces and same-line comments, before the newline (or before the next token).
286
+ */
287
+ function lineEnd(text: string, from: number): number {
288
+ let i = from
289
+ for (;;) {
290
+ while (i < text.length && (text[i] === " " || text[i] === "\t")) i++
291
+ if (text.startsWith("//", i)) {
292
+ const nl = text.indexOf("\n", i)
293
+ return nl < 0 ? text.length : nl
294
+ }
295
+ if (text.startsWith("/*", i)) {
296
+ const close = text.indexOf("*/", i + 2)
297
+ if (close < 0 || text.slice(i, close).includes("\n")) return i
298
+ i = close + 2
299
+ continue
300
+ }
301
+ return i
302
+ }
303
+ }
304
+
305
+ const isScalar = (v: unknown) => v === null || typeof v !== "object"
306
+
307
+ /** A value as text: compact when inline; otherwise objects (and arrays holding containers) one entry per line. */
308
+ function render(value: Json, indent: string, unit: string, inline: boolean, colon: string): string {
309
+ if (isScalar(value)) return JSON.stringify(value)
310
+ if (Array.isArray(value)) {
311
+ if (value.length === 0) return "[]"
312
+ if (inline || value.every(isScalar)) return `[${value.map((v) => render(v, indent, unit, true, colon)).join(", ")}]`
313
+ const inner = indent + unit
314
+ return `[\n${value.map((v) => inner + render(v, inner, unit, false, colon)).join(",\n")}\n${indent}]`
315
+ }
316
+ const entries = Object.entries(value as { [key: string]: Json })
317
+ if (entries.length === 0) return "{}"
318
+ if (inline) return `{${entries.map(([k, v]) => `${JSON.stringify(k)}${colon}${render(v, indent, unit, true, colon)}`).join(", ")}}`
319
+ const inner = indent + unit
320
+ return `{\n${entries.map(([k, v]) => `${inner}${JSON.stringify(k)}${colon}${render(v, inner, unit, false, colon)}`).join(",\n")}\n${indent}}`
321
+ }
322
+
323
+ /** The value a missing pointer tail creates ("-" makes an array, any other segment an object). */
324
+ function nested(segs: readonly string[], value: Json): Json {
325
+ if (segs.length === 0) return value
326
+ const inner = nested(segs.slice(1), value)
327
+ return segs[0] === "-" ? [inner] : { [segs[0]!]: inner }
328
+ }
329
+
330
+ /** The key/value separator a container already uses (": " unless its members are written tight). */
331
+ function colonOf(text: string, node: CstNode): string {
332
+ if (node.kind !== "object" || node.members.length === 0) return ": "
333
+ const m = node.members[0]!
334
+ return /:\s/.test(text.slice(m.keyEnd, m.value.start)) ? ": " : ":"
335
+ }
336
+
337
+ /** The text after editing one pointer of the (already validated) set. */
338
+ function editText(text: string, root: CstNode, segs: readonly string[], value: Json): string {
339
+ const unit = indentUnit(text)
340
+ let colon = colonOf(text, root)
341
+ let node = root
342
+ for (let i = 0; i < segs.length; i++) {
343
+ const seg = segs[i]!
344
+ const last = i === segs.length - 1
345
+ if (node.kind === "object") {
346
+ colon = colonOf(text, node) === ":" ? ":" : colon
347
+ const member = node.members.find((m) => m.key === seg)
348
+ if (!member) return insertEntry(text, node, `${JSON.stringify(seg)}${colon}`, nested(segs.slice(i + 1), value), unit, colon)
349
+ if (last) return replaceValue(text, member.value, value, lineIndent(text, member.keyStart), unit, colon)
350
+ node = member.value
351
+ } else if (node.kind === "array") {
352
+ if (seg === "-" || Number(seg) === node.items.length) return insertEntry(text, node, "", value, unit, colon)
353
+ const item = node.items[Number(seg)]!
354
+ if (last) return replaceValue(text, item, value, lineIndent(text, item.start), unit, colon)
355
+ node = item
356
+ } else throw new Error("unreachable: the merge already refused a scalar in the way")
357
+ }
358
+ throw new Error("unreachable: an empty pointer")
359
+ }
360
+
361
+ function replaceValue(text: string, node: CstNode, value: Json, indent: string, unit: string, colon: string): string {
362
+ const inline = !text.slice(node.start, node.end).includes("\n") && !isScalar(value) && !text.includes("\n")
363
+ return applySplices(text, [{ at: node.start, remove: node.end - node.start, insert: render(value, indent, unit, inline, colon) }])
364
+ }
365
+
366
+ /** Inserts a member (`prefix` is `"key": `) or an array item (no prefix) as the container's last entry. */
367
+ function insertEntry(text: string, container: CstNode, prefix: string, value: Json, unit: string, colon: string): string {
368
+ const entries: { start: number; end: number }[] =
369
+ container.kind === "object" ? container.members.map((m) => ({ start: m.keyStart, end: m.value.end })) : container.kind === "array" ? container.items.map((n) => ({ start: n.start, end: n.end })) : []
370
+ const close = container.end - 1
371
+ const multiline = text.slice(container.start, container.end).includes("\n")
372
+ if (entries.length === 0) {
373
+ const inner = text.slice(container.start + 1, close)
374
+ const commented = /\/[/*]/.test(inner)
375
+ const baseIndent = lineIndent(text, container.start)
376
+ // An empty array, or an empty object nested in a one-line document, gets an inline entry; an empty root object
377
+ // (a fresh `{}` config) or a commented one gets an entry on its own line.
378
+ const singleLineDoc = !text.trimEnd().includes("\n")
379
+ const isRoot = container.start === skipTrivia(text, 0)
380
+ if ((container.kind === "array" || (singleLineDoc && !isRoot)) && !commented) {
381
+ return applySplices(text, [{ at: container.start + 1, remove: inner.length, insert: prefix + render(value, baseIndent, unit, singleLineDoc, colon) }])
382
+ }
383
+ const keep = inner.trimEnd().length
384
+ const entryIndent = baseIndent + unit
385
+ return applySplices(text, [{ at: container.start + 1 + keep, remove: inner.length - keep, insert: `\n${entryIndent}${prefix}${render(value, entryIndent, unit, false, colon)}\n${baseIndent}` }])
386
+ }
387
+ const lastEntry = entries[entries.length - 1]!
388
+ const after = skipTrivia(text, lastEntry.end)
389
+ const trailingComma = text[after] === ","
390
+ if (!multiline) {
391
+ const entry = prefix + render(value, "", unit, true, colon)
392
+ const sep = entries.length > 1 && !/,\s/.test(text.slice(entries[0]!.end, entries[1]!.start)) ? "," : ", "
393
+ if (trailingComma) return applySplices(text, [{ at: after + 1, remove: 0, insert: `${sep === "," ? "" : " "}${entry},` }])
394
+ return applySplices(text, [{ at: lastEntry.end, remove: 0, insert: `${sep}${entry}` }])
395
+ }
396
+ const indent = lineIndent(text, lastEntry.start)
397
+ const entry = prefix + render(value, indent, unit, false, colon)
398
+ if (trailingComma) return applySplices(text, [{ at: lineEnd(text, after + 1), remove: 0, insert: `\n${indent}${entry},` }])
399
+ return applySplices(text, [
400
+ { at: lastEntry.end, remove: 0, insert: "," },
401
+ { at: lineEnd(text, lastEntry.end), remove: 0, insert: `\n${indent}${entry}` },
402
+ ])
403
+ }
@@ -0,0 +1,132 @@
1
+ // The apply lock (contract: journal.ts ApplyLock, lockState, LOCK_PATH). One apply or recovery at a time per
2
+ // state directory. The lock is created atomically with complete content: the document is written to a private
3
+ // temporary file and hard-linked into place, which fails if a lock exists (the O_EXCL guarantee without a window
4
+ // where another process could read a half-written lock).
5
+ //
6
+ // held a live apply on this host: refuse (exit locked).
7
+ // foreign another host's lock on a shared home: never broken automatically.
8
+ // stale this host, holder dead or overran: the caller recovers its journal first, then breaks it.
9
+ // corrupt unreadable: never broken automatically (the user removes it after checking).
10
+ import { canonicalJSON, LOCK_MAX_MS, LOCK_SCHEMA, lockState, validateApplyLock, type ApplyLock } from "../contracts/index.ts"
11
+ import type { TxFs } from "./fs.ts"
12
+
13
+ export interface LockIO {
14
+ readonly fs: TxFs
15
+ /** Absolute path of the lock (LOCK_PATH resolved). */
16
+ readonly path: string
17
+ readonly hostFingerprint: string
18
+ readonly pid: number
19
+ readonly now: () => number
20
+ readonly isAlive: (pid: number) => boolean
21
+ }
22
+
23
+ export type LockAttempt =
24
+ | { readonly state: "acquired"; readonly lock: ApplyLock }
25
+ | { readonly state: "held" | "foreign" | "stale"; readonly lock: ApplyLock }
26
+ | { readonly state: "corrupt" }
27
+
28
+ const errno = (error: unknown) => (error as NodeJS.ErrnoException | undefined)?.code
29
+
30
+ function removeQuietly(fs: TxFs, path: string) {
31
+ try {
32
+ if (fs.lstat(path)) fs.unlink(path)
33
+ } catch (error) {
34
+ if (!errno(error)) throw error
35
+ }
36
+ }
37
+
38
+ /** The lock on disk: undefined when there is none, "corrupt" when it cannot be read as a valid lock. */
39
+ export function readLock(io: LockIO): ApplyLock | "corrupt" | undefined {
40
+ if (!io.fs.lstat(io.path)) return undefined
41
+ try {
42
+ const parsed: unknown = JSON.parse(new TextDecoder().decode(io.fs.readFile(io.path)))
43
+ const checked = validateApplyLock(parsed)
44
+ return checked.ok ? checked.value : "corrupt"
45
+ } catch (error) {
46
+ if (errno(error) === "ENOENT") return undefined
47
+ return "corrupt"
48
+ }
49
+ }
50
+
51
+ function newLock(io: LockIO, txID: string): ApplyLock {
52
+ const now = io.now()
53
+ return { schema: LOCK_SCHEMA, version: 1, pid: io.pid, hostFingerprint: io.hostFingerprint, txID, acquiredAt: now, expiresAt: now + LOCK_MAX_MS }
54
+ }
55
+
56
+ function writeTemp(io: LockIO, lock: ApplyLock): string {
57
+ const tmp = `${io.path}.${lock.txID}.tmp`
58
+ removeQuietly(io.fs, tmp)
59
+ io.fs.writeExclusive(tmp, `${canonicalJSON(lock)}\n`, 0o600)
60
+ return tmp
61
+ }
62
+
63
+ export function tryAcquireLock(io: LockIO, txID: string): LockAttempt {
64
+ for (let attempt = 0; attempt < 3; attempt++) {
65
+ const lock = newLock(io, txID)
66
+ const tmp = writeTemp(io, lock)
67
+ try {
68
+ io.fs.link(tmp, io.path)
69
+ return { state: "acquired", lock }
70
+ } catch (error) {
71
+ // ENOENT: our temporary file was swept by the holder; treat like an existing lock and look again.
72
+ if (errno(error) !== "EEXIST" && errno(error) !== "ENOENT") throw error
73
+ } finally {
74
+ removeQuietly(io.fs, tmp)
75
+ }
76
+ const existing = readLock(io)
77
+ if (existing === undefined) continue // released between our attempt and the read
78
+ if (existing === "corrupt") return { state: "corrupt" }
79
+ const state = lockState(existing, { now: io.now(), hostFingerprint: io.hostFingerprint, isAlive: io.isAlive })
80
+ if (state === "free") continue
81
+ return { state, lock: existing }
82
+ }
83
+ const existing = readLock(io)
84
+ return existing && existing !== "corrupt" ? { state: "held", lock: existing } : { state: "corrupt" }
85
+ }
86
+
87
+ /**
88
+ * Removes a lock judged stale, but only if it is still exactly the lock that was judged: it is moved aside first
89
+ * (atomic), compared, and put back if another process replaced it meanwhile. True when the lock is gone.
90
+ */
91
+ export function breakStaleLock(io: LockIO, observed: ApplyLock): boolean {
92
+ const aside = `${io.path}.broken-${observed.txID}-${io.pid}`
93
+ removeQuietly(io.fs, aside)
94
+ try {
95
+ io.fs.rename(io.path, aside)
96
+ } catch (error) {
97
+ if (errno(error) === "ENOENT") return true
98
+ throw error
99
+ }
100
+ let same = false
101
+ try {
102
+ same = canonicalJSON(JSON.parse(new TextDecoder().decode(io.fs.readFile(aside)))) === canonicalJSON(observed)
103
+ } catch {}
104
+ if (!same) {
105
+ try {
106
+ io.fs.link(aside, io.path)
107
+ } catch {}
108
+ }
109
+ removeQuietly(io.fs, aside)
110
+ return same
111
+ }
112
+
113
+ /** Extends our lock by LOCK_MAX_MS from now (atomic replace); refuses if the lock is no longer ours. */
114
+ export function renewLock(io: LockIO, lock: ApplyLock): ApplyLock {
115
+ const current = readLock(io)
116
+ if (!current || current === "corrupt" || current.txID !== lock.txID || current.pid !== lock.pid) throw new Error("the apply lock is no longer held by this transaction")
117
+ const renewed = newLock(io, lock.txID)
118
+ const tmp = writeTemp(io, renewed)
119
+ try {
120
+ io.fs.rename(tmp, io.path)
121
+ } catch (error) {
122
+ removeQuietly(io.fs, tmp)
123
+ throw error
124
+ }
125
+ return renewed
126
+ }
127
+
128
+ /** Removes the lock if it is ours. */
129
+ export function releaseLock(io: LockIO, txID: string): void {
130
+ const current = readLock(io)
131
+ if (current && current !== "corrupt" && current.txID === txID) io.fs.unlink(io.path)
132
+ }
@@ -0,0 +1,48 @@
1
+ // PathRef → absolute path on this host, refusing anything a planted link or a crafted reference could redirect:
2
+ // traversal and unsafe relative paths (the contract's PathRef rules), unmapped roots, any symlink between the root
3
+ // and the target (checkNoSymlinkEscape), and a regular file where a directory should be. The root itself is the
4
+ // mapping discovery reported and is not checked (contract: paths.ts). v1.1: paths resolve for the execution context
5
+ // apply runs in (contract rootPathIn: inside a container, a root is containerPath ?? path; on the host, path).
6
+ import { checkNoSymlinkEscape, resolvePathRef, rootPathIn, type ExecutionContext, type PathRef, type RootMapping } from "../contracts/index.ts"
7
+ import type { TxFs } from "./fs.ts"
8
+ import { txFail, txOk, type TxResult } from "./result.ts"
9
+
10
+ export interface ResolveOptions {
11
+ /** link-executable manages a symlink at the final component; every component above it is still checked. */
12
+ readonly finalMayBeSymlink?: boolean
13
+ /**
14
+ * A symlink's target: mapped and checked like any PathRef (no traversal, a known root) but never written through,
15
+ * so links on the way (a harness's `current`) are allowed.
16
+ */
17
+ readonly targetOnly?: boolean
18
+ /** Where apply runs (default: the host). */
19
+ readonly context?: ExecutionContext
20
+ }
21
+
22
+ /** The absolute directory of a root for the execution context, if the host has it. */
23
+ export function rootDir(id: PathRef["root"], roots: readonly RootMapping[], context: ExecutionContext = "host"): string | undefined {
24
+ const root = roots.find((r) => r.id === id)
25
+ return root ? rootPathIn(root, context) : undefined
26
+ }
27
+
28
+ export function resolveTarget(ref: PathRef, roots: readonly RootMapping[], fs: TxFs, options: ResolveOptions = {}): TxResult<string> {
29
+ const resolved = resolvePathRef(ref, roots, options.context)
30
+ if (!resolved.ok) return txFail("invalid", `${ref.root}:${String(ref.rel)}: ${resolved.issues[0]!.message}`)
31
+ if (options.targetOnly) return txOk(resolved.value)
32
+ const root = rootDir(ref.root, roots, options.context)!
33
+ const parts = ref.rel.split("/")
34
+ const checked = options.finalMayBeSymlink ? parts.slice(0, -1).join("/") : ref.rel
35
+ if (checked) {
36
+ const walk = checkNoSymlinkEscape(root, checked, (p) => fs.lstat(p)?.kind ?? "missing")
37
+ if (!walk.ok) return txFail("conflict", `${walk.issues[0]!.message} (make this change by hand)`)
38
+ }
39
+ // Every component above the target that exists must be a directory.
40
+ let current = root === "/" ? "" : root
41
+ for (const part of parts.slice(0, -1)) {
42
+ current = `${current}/${part}`
43
+ const info = fs.lstat(current)
44
+ if (!info) break
45
+ if (info.kind !== "directory") return txFail("conflict", `${current} is not a directory`)
46
+ }
47
+ return txOk(resolved.value)
48
+ }
@@ -0,0 +1,60 @@
1
+ // Outcomes of the transaction layer. A failure carries one contract ErrorCode (envelope.ts), so the CLI maps it to
2
+ // an exit code with exitFor, and one printable message. Messages name paths and reasons, never file contents or
3
+ // credentials.
4
+ import type { ErrorCode, Result } from "../contracts/index.ts"
5
+
6
+ export interface TxFailure {
7
+ readonly ok: false
8
+ readonly code: ErrorCode
9
+ readonly message: string
10
+ }
11
+
12
+ export type TxResult<T> = { readonly ok: true; readonly value: T } | TxFailure
13
+
14
+ export const txOk = <T>(value: T): TxResult<T> => ({ ok: true, value })
15
+ export const txFail = (code: ErrorCode, message: string): TxFailure => ({ ok: false, code, message })
16
+
17
+ /** Contract issue codes that are also error codes keep their name; the rest map to the nearest error code. */
18
+ const ISSUE_TO_ERROR: Readonly<Record<string, ErrorCode>> = {
19
+ symlink: "conflict",
20
+ invalid_path: "invalid",
21
+ unmapped_root: "invalid",
22
+ invalid_value: "invalid",
23
+ missing_field: "invalid",
24
+ unknown_field: "invalid",
25
+ unsupported_version: "invalid",
26
+ inconsistent: "invalid",
27
+ duplicate: "invalid",
28
+ exec_field: "invalid",
29
+ secret: "invalid",
30
+ unmigratable: "invalid",
31
+ }
32
+
33
+ /** The first issue of a failed contract Result as a TxFailure. */
34
+ export function fromResult<T>(result: Result<T>, context?: string): TxResult<T> {
35
+ if (result.ok) return txOk(result.value)
36
+ const issue = result.issues[0]!
37
+ const code = ISSUE_TO_ERROR[issue.code] ?? (issue.code as ErrorCode)
38
+ return txFail(code, context ? `${context}: ${issue.message}` : issue.message)
39
+ }
40
+
41
+ /** A failure thrown from deep inside a step; the engine turns it back into a TxFailure. */
42
+ export class TxError extends Error {
43
+ constructor(
44
+ readonly code: ErrorCode,
45
+ message: string,
46
+ ) {
47
+ super(message)
48
+ this.name = "TxError"
49
+ }
50
+
51
+ get failure(): TxFailure {
52
+ return txFail(this.code, this.message)
53
+ }
54
+ }
55
+
56
+ /** The errno code of a filesystem error (ENOSPC, EEXIST, …), or undefined for anything else. */
57
+ export const errnoOf = (error: unknown): string | undefined => {
58
+ const code = (error as NodeJS.ErrnoException | undefined)?.code
59
+ return typeof code === "string" ? code : undefined
60
+ }