@huynle/hub 0.6.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 (355) hide show
  1. package/CHANGELOG.md +870 -0
  2. package/Dockerfile +33 -0
  3. package/README.md +1007 -66
  4. package/package.json +47 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +156 -0
  21. package/packages/cli/dist/hub-cli.js +21647 -0
  22. package/packages/cli/package.json +11 -0
  23. package/packages/cli/src/acting.ts +70 -0
  24. package/packages/cli/src/args.ts +91 -0
  25. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  26. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  27. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  28. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  29. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  30. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  31. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  32. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  33. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  34. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  35. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  36. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  37. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  38. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  39. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  40. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  41. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  42. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  43. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  44. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  45. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  46. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  47. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  48. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  49. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  50. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  51. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  52. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  53. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  54. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  55. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  56. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  57. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  58. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  59. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  60. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  61. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  62. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  63. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  64. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  65. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  66. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  67. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  68. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  69. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  70. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  71. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  72. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  73. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  74. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  75. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  76. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  77. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  78. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  79. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  80. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  81. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  82. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  83. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  84. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  85. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  86. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  87. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  88. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  89. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  90. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  91. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  92. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  93. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  94. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  95. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  96. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  97. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  98. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  99. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  100. package/packages/cli/src/bootstrap/release/README.md +115 -0
  101. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  102. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  103. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  104. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  105. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  106. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  107. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  108. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  109. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  110. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  111. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  112. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  113. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  114. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  115. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  116. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  117. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  118. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  119. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  120. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  121. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  122. package/packages/cli/src/cli.ts +814 -0
  123. package/packages/cli/src/credentials.ts +75 -0
  124. package/packages/cli/src/decisions.ts +106 -0
  125. package/packages/cli/src/format.ts +185 -0
  126. package/packages/cli/src/grants.ts +105 -0
  127. package/packages/cli/src/http.ts +86 -0
  128. package/packages/cli/src/launches.ts +157 -0
  129. package/packages/cli/src/login.ts +160 -0
  130. package/packages/cli/src/main.ts +63 -0
  131. package/packages/cli/src/mcp.ts +879 -0
  132. package/packages/cli/src/messaging.ts +108 -0
  133. package/packages/cli/src/node.ts +279 -0
  134. package/packages/cli/src/openshell.ts +166 -0
  135. package/packages/cli/src/setup/commands.ts +809 -0
  136. package/packages/cli/src/setup/contributors.ts +171 -0
  137. package/packages/cli/src/setup/dispatch.ts +74 -0
  138. package/packages/cli/src/setup/launch-driver.ts +109 -0
  139. package/packages/cli/src/setup/runtime.ts +456 -0
  140. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  141. package/packages/cli/src/setup/state.ts +244 -0
  142. package/packages/cli/src/setup/user-bin.ts +39 -0
  143. package/packages/cli/src/spoke-services.ts +109 -0
  144. package/packages/cli/src/watch.ts +28 -0
  145. package/packages/codex/package.json +12 -0
  146. package/packages/codex/src/app-server.ts +44 -0
  147. package/packages/codex/src/ids.ts +28 -0
  148. package/packages/codex/src/index.ts +11 -0
  149. package/packages/codex/src/jsonrpc.ts +168 -0
  150. package/packages/codex/src/main.ts +123 -0
  151. package/packages/codex/src/paths.ts +65 -0
  152. package/packages/codex/src/sessions.ts +578 -0
  153. package/packages/codex/src/spoke.ts +252 -0
  154. package/packages/codex/src/stdio.ts +101 -0
  155. package/packages/codex/src/websocket.ts +206 -0
  156. package/packages/codex/src/wrapper.ts +104 -0
  157. package/packages/node/package.json +12 -0
  158. package/packages/node/src/config.ts +184 -0
  159. package/packages/node/src/connection.ts +283 -0
  160. package/packages/node/src/daemon.ts +281 -0
  161. package/packages/node/src/git.ts +107 -0
  162. package/packages/node/src/harnesses.ts +95 -0
  163. package/packages/node/src/host.ts +319 -0
  164. package/packages/node/src/index.ts +17 -0
  165. package/packages/node/src/main.ts +11 -0
  166. package/packages/node/src/openshell.ts +73 -0
  167. package/packages/node/src/run.ts +106 -0
  168. package/packages/preset/package.json +12 -0
  169. package/packages/preset/presets/aider.json +8 -0
  170. package/packages/preset/presets/goose.json +8 -0
  171. package/packages/preset/src/index.ts +2 -0
  172. package/packages/preset/src/main.ts +195 -0
  173. package/packages/preset/src/presets.ts +87 -0
  174. package/packages/preset/src/spoke.ts +251 -0
  175. package/packages/preset/src/tmux.ts +160 -0
  176. package/packages/protocol/package.json +12 -0
  177. package/packages/protocol/src/harness-events.ts +207 -0
  178. package/packages/protocol/src/index.ts +1215 -0
  179. package/packages/protocol/src/launch.ts +172 -0
  180. package/packages/protocol/src/node.ts +409 -0
  181. package/packages/protocol/src/preset.ts +144 -0
  182. package/packages/protocol/src/webhook.ts +62 -0
  183. package/packages/protocol/src/wire.ts +54 -0
  184. package/packages/runtime-openshell/image/Dockerfile +36 -0
  185. package/packages/runtime-openshell/image/README.md +78 -0
  186. package/packages/runtime-openshell/image/build.ts +203 -0
  187. package/packages/runtime-openshell/image/entry.sh +163 -0
  188. package/packages/runtime-openshell/image/image.ts +119 -0
  189. package/packages/runtime-openshell/image/start.sh +24 -0
  190. package/packages/runtime-openshell/package.json +12 -0
  191. package/packages/runtime-openshell/src/cli.ts +584 -0
  192. package/packages/runtime-openshell/src/index.ts +6 -0
  193. package/packages/runtime-openshell/src/policy.ts +155 -0
  194. package/packages/runtime-openshell/src/profiles.ts +181 -0
  195. package/packages/runtime-openshell/src/runtime.ts +475 -0
  196. package/packages/runtime-sdk/package.json +12 -0
  197. package/packages/runtime-sdk/src/index.ts +87 -0
  198. package/packages/sdk/package.json +12 -0
  199. package/packages/sdk/src/address.ts +25 -0
  200. package/packages/sdk/src/client.ts +553 -0
  201. package/packages/sdk/src/envelope.ts +108 -0
  202. package/packages/sdk/src/errors.ts +107 -0
  203. package/packages/sdk/src/index.ts +10 -0
  204. package/packages/sdk/src/sse.ts +70 -0
  205. package/packages/sdk/src/types.ts +517 -0
  206. package/packages/sdk/src/webhook.ts +102 -0
  207. package/packages/spoke-sdk/package.json +12 -0
  208. package/packages/spoke-sdk/src/activity.ts +3 -0
  209. package/packages/spoke-sdk/src/connection.ts +295 -0
  210. package/packages/spoke-sdk/src/delivery.ts +119 -0
  211. package/packages/spoke-sdk/src/enroll.ts +101 -0
  212. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  213. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  214. package/packages/spoke-sdk/src/index.ts +12 -0
  215. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  216. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  217. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  218. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  219. package/scripts/hub-claude-code.service +34 -0
  220. package/scripts/hub-codex-spoke.service +34 -0
  221. package/scripts/hub-node.service +35 -0
  222. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  223. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  224. package/scripts/io.github.huynle.hub-node.plist +52 -0
  225. package/src/central/actions.ts +233 -0
  226. package/src/central/agent/acting-routes.ts +133 -0
  227. package/src/central/agent/acting-switch.ts +93 -0
  228. package/src/central/agent/acting.ts +599 -0
  229. package/src/central/agent/actor.ts +193 -0
  230. package/src/central/agent/app-tokens.ts +152 -0
  231. package/src/central/agent/archive-inactive.ts +66 -0
  232. package/src/central/agent/audit.ts +185 -0
  233. package/src/central/agent/decisions.ts +530 -0
  234. package/src/central/agent/delivery.ts +678 -0
  235. package/src/central/agent/directory-routes.ts +328 -0
  236. package/src/central/agent/directory.ts +473 -0
  237. package/src/central/agent/envelope.ts +51 -0
  238. package/src/central/agent/errors.ts +74 -0
  239. package/src/central/agent/escalation.ts +219 -0
  240. package/src/central/agent/event-cursor.ts +49 -0
  241. package/src/central/agent/event-writers.ts +181 -0
  242. package/src/central/agent/events-routes.ts +148 -0
  243. package/src/central/agent/events.ts +277 -0
  244. package/src/central/agent/grant-requests.ts +221 -0
  245. package/src/central/agent/grant-routes.ts +70 -0
  246. package/src/central/agent/grants.ts +235 -0
  247. package/src/central/agent/launch-engine.ts +1025 -0
  248. package/src/central/agent/launch-routes.ts +252 -0
  249. package/src/central/agent/launch-spec.ts +289 -0
  250. package/src/central/agent/launch-store.ts +264 -0
  251. package/src/central/agent/launch-tokens.ts +206 -0
  252. package/src/central/agent/message-limits.ts +123 -0
  253. package/src/central/agent/message-routes.ts +385 -0
  254. package/src/central/agent/messages.ts +442 -0
  255. package/src/central/agent/names.ts +86 -0
  256. package/src/central/agent/placement.ts +419 -0
  257. package/src/central/agent/policy.ts +378 -0
  258. package/src/central/agent/read-routes.ts +385 -0
  259. package/src/central/agent/routes.ts +281 -0
  260. package/src/central/agent/session-tokens.ts +227 -0
  261. package/src/central/agent/versioned.ts +26 -0
  262. package/src/central/agent/watch.ts +112 -0
  263. package/src/central/agent/webhook-routes.ts +86 -0
  264. package/src/central/agent/webhooks.ts +493 -0
  265. package/src/central/agent-data.ts +177 -0
  266. package/src/central/auth.ts +350 -121
  267. package/src/central/backend/file-state.ts +240 -0
  268. package/src/central/backend/index.ts +116 -0
  269. package/src/central/backend/memory-bus.ts +93 -0
  270. package/src/central/backend/memory-catalog.ts +90 -0
  271. package/src/central/backend/memory-counters.ts +102 -0
  272. package/src/central/backend/memory-events.ts +45 -0
  273. package/src/central/backend/memory-kv.ts +38 -0
  274. package/src/central/backend/memory-state.ts +77 -0
  275. package/src/central/backend/postgres-migrate.ts +100 -0
  276. package/src/central/backend/postgres-state.ts +197 -0
  277. package/src/central/backend/types.ts +246 -0
  278. package/src/central/backend/valkey-bus.ts +425 -0
  279. package/src/central/backend/valkey-catalog.ts +190 -0
  280. package/src/central/backend/valkey-client.ts +36 -0
  281. package/src/central/backend/valkey-counters.ts +38 -0
  282. package/src/central/backend/valkey-events.ts +168 -0
  283. package/src/central/backend/valkey-kv.ts +54 -0
  284. package/src/central/backend/valkey-locks.ts +92 -0
  285. package/src/central/cluster/directory.ts +141 -0
  286. package/src/central/cluster/feed.ts +84 -0
  287. package/src/central/cluster/leader.ts +232 -0
  288. package/src/central/cluster/live.ts +151 -0
  289. package/src/central/cluster/recommend.ts +59 -0
  290. package/src/central/cluster/rpc.ts +150 -0
  291. package/src/central/cluster/view.ts +232 -0
  292. package/src/central/config.ts +197 -3
  293. package/src/central/done.ts +96 -0
  294. package/src/central/events-log.ts +100 -0
  295. package/src/central/http.ts +1149 -170
  296. package/src/central/import.ts +318 -0
  297. package/src/central/main.ts +211 -12
  298. package/src/central/mcp-http.ts +116 -0
  299. package/src/central/nodes.ts +314 -0
  300. package/src/central/oidc.ts +34 -14
  301. package/src/central/push-target.ts +118 -18
  302. package/src/central/push.ts +256 -82
  303. package/src/central/registry.ts +902 -92
  304. package/src/central/say-stream.ts +128 -0
  305. package/src/central/session-token-push.ts +233 -0
  306. package/src/central/speech/openai.ts +21 -14
  307. package/src/central/speech/openrouter.ts +27 -10
  308. package/src/central/speech/provider.ts +54 -0
  309. package/src/central/speech/service.ts +22 -7
  310. package/src/central/spoke-ws.ts +522 -44
  311. package/src/central/store-shared.ts +518 -0
  312. package/src/central/store.ts +625 -189
  313. package/src/central/token-keys.ts +165 -0
  314. package/src/config.ts +118 -7
  315. package/src/index.ts +195 -43
  316. package/src/protocol.ts +3 -650
  317. package/src/rpc.ts +27 -2
  318. package/src/spoke/client.ts +3 -272
  319. package/src/spoke/conversation.ts +16 -12
  320. package/src/spoke/deliver.ts +75 -0
  321. package/src/spoke/enroll.ts +2 -79
  322. package/src/spoke/env-inject.ts +160 -0
  323. package/src/spoke/hub-rpc.ts +115 -0
  324. package/src/spoke/hub-tool.ts +409 -0
  325. package/src/spoke/launch-source.ts +22 -0
  326. package/src/spoke/launchers.ts +174 -0
  327. package/src/spoke/locations.ts +3 -2
  328. package/src/spoke/node-roots.ts +61 -0
  329. package/src/spoke/prompt-refs.ts +116 -0
  330. package/src/spoke/session-tokens.ts +2 -0
  331. package/src/spoke/spoke.ts +465 -28
  332. package/src/spoke/taskboard.ts +243 -0
  333. package/src/spoke/timeline.ts +184 -6
  334. package/src/spoke/tracker.ts +79 -4
  335. package/src/status.ts +203 -10
  336. package/src/tui-commands.ts +13 -6
  337. package/src/tui-messaging.ts +93 -0
  338. package/src/tui.ts +15 -0
  339. package/web/dist/apple-touch-icon.png +0 -0
  340. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  341. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  342. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  343. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  344. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  345. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  346. package/web/dist/favicon.ico +0 -0
  347. package/web/dist/icon-192.png +0 -0
  348. package/web/dist/icon-512.png +0 -0
  349. package/web/dist/icon-maskable-512.png +0 -0
  350. package/web/dist/icon.svg +5 -5
  351. package/web/dist/index.html +17 -4
  352. package/web/dist/manifest.webmanifest +2 -2
  353. package/web/dist/sw.js +79 -15
  354. package/web/dist/assets/index-DClBvfLL.css +0 -1
  355. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,198 @@
1
+ // The transaction journal, ownership records, recovery rules and the apply lock.
2
+ //
3
+ // apply takes the lock (O_EXCL create of state:hub/bootstrap/apply.lock), writes the journal before each action
4
+ // ("pending", with the file's state before and a backup reference), performs the action, then marks it "done"
5
+ // with the state after. The journal holds digests and modes, never contents (backups are separate owner-only
6
+ // files) and never credentials. After a crash, recovery walks the entries newest first and decides per entry
7
+ // (recoveryDecision): undo what is still exactly as we left it, keep anything the user changed since, and never
8
+ // restore a stale credential (a rolled-back enrollment is cleared, not reverted to the previous token).
9
+ import { ACTION_KINDS, type ActionKind } from "./actions.ts"
10
+ import { checkPathRef, type PathRef } from "./paths.ts"
11
+ import { findSecrets } from "./secrets.ts"
12
+ import { bool, Checker, digest, header, instant, int, isRecord, list, oneOf, pointer, shape, text, type Result } from "./validate.ts"
13
+
14
+ export const JOURNAL_SCHEMA = "hub.transaction-journal"
15
+ export const JOURNAL_STATES = ["prepared", "applying", "committed", "rolling-back", "rolled-back", "failed"] as const
16
+ export type JournalState = (typeof JOURNAL_STATES)[number]
17
+ export const ENTRY_CLASSES = ["file", "config", "credential", "service", "artifact", "harness", "link", "directory", "external"] as const
18
+ /** harness: an installed harness binary/version (rollback restores the previous one). external: a step with no local file (verify, launch, service activation); nothing to undo on disk. */
19
+ export type EntryClass = (typeof ENTRY_CLASSES)[number]
20
+
21
+ export interface FileState {
22
+ readonly exists: boolean
23
+ readonly sha256?: string
24
+ readonly mode?: number
25
+ readonly symlink?: boolean
26
+ }
27
+
28
+ /** One owned change. `before`/`after` are what the file was and what we made it; ownership is "we wrote `after`". */
29
+ export interface JournalEntry {
30
+ readonly seq: number
31
+ readonly actionID: string
32
+ readonly kind: ActionKind
33
+ readonly class: EntryClass
34
+ readonly path?: PathRef
35
+ readonly before: FileState
36
+ readonly after?: FileState
37
+ /** The owner-only copy of `before` (never for credentials). */
38
+ readonly backup?: { readonly id: string }
39
+ readonly status: "pending" | "done" | "undone" | "skipped"
40
+ }
41
+
42
+ export interface TransactionJournal {
43
+ readonly schema: typeof JOURNAL_SCHEMA
44
+ readonly version: 1
45
+ readonly txID: string
46
+ readonly planDigest: string
47
+ readonly hostFingerprint: string
48
+ /** The preview nonce this apply consumed (replays are refused). */
49
+ readonly nonce: string
50
+ readonly state: JournalState
51
+ readonly startedAt: number
52
+ readonly updatedAt: number
53
+ readonly entries: readonly JournalEntry[]
54
+ }
55
+
56
+ /** Classes whose previous content must be backed up before they are changed. */
57
+ const BACKED_UP: readonly EntryClass[] = ["file", "config", "service", "artifact", "harness", "link"]
58
+ const TX_ID = /^tx_[A-Za-z0-9]{1,64}$/
59
+ const BACKUP_ID = /^bk_[A-Za-z0-9]{1,64}$/
60
+ const NONCE = /^[A-Za-z0-9_-]{16,128}$/
61
+
62
+ function checkFileState(c: Checker, path: string, v: unknown) {
63
+ const r = shape(c, path, v, ["exists"], ["sha256", "mode", "symlink"])
64
+ if (!r) return
65
+ bool(c, pointer(path, "exists"), r.exists)
66
+ if (r.sha256 !== undefined) digest(c, pointer(path, "sha256"), r.sha256)
67
+ if (r.mode !== undefined) int(c, pointer(path, "mode"), r.mode, 0, 0o7777)
68
+ if (r.symlink !== undefined) bool(c, pointer(path, "symlink"), r.symlink)
69
+ if (r.exists === false && (r.sha256 !== undefined || r.mode !== undefined)) c.add(path, "inconsistent", "a missing file has no digest or mode")
70
+ }
71
+
72
+ export function validateJournal(value: unknown): Result<TransactionJournal> {
73
+ const c = new Checker()
74
+ const r = shape(c, "", value, ["schema", "version", "txID", "planDigest", "hostFingerprint", "nonce", "state", "startedAt", "updatedAt", "entries"])
75
+ if (!r) return c.result(value)
76
+ header(c, "", r, JOURNAL_SCHEMA, 1)
77
+ text(c, "/txID", r.txID, 67, TX_ID)
78
+ digest(c, "/planDigest", r.planDigest)
79
+ digest(c, "/hostFingerprint", r.hostFingerprint)
80
+ text(c, "/nonce", r.nonce, 128, NONCE)
81
+ oneOf(c, "/state", r.state, JOURNAL_STATES)
82
+ if (instant(c, "/startedAt", r.startedAt) && instant(c, "/updatedAt", r.updatedAt) && (r.updatedAt as number) < (r.startedAt as number))
83
+ c.add("/updatedAt", "inconsistent", "updated before it started")
84
+ list(c, "/entries", r.entries, 256, (p, v) => {
85
+ const e = shape(c, p, v, ["seq", "actionID", "kind", "class", "before", "status"], ["path", "after", "backup"])
86
+ if (!e) return
87
+ const index = Number(p.slice(p.lastIndexOf("/") + 1))
88
+ if (int(c, pointer(p, "seq"), e.seq, 1, 256) && e.seq !== index + 1) c.add(pointer(p, "seq"), "inconsistent", "entries are numbered 1, 2, 3, … in order")
89
+ text(c, pointer(p, "actionID"), e.actionID, 64, /^[a-z0-9][a-z0-9-]{0,63}$/)
90
+ oneOf(c, pointer(p, "kind"), e.kind, ACTION_KINDS)
91
+ oneOf(c, pointer(p, "class"), e.class, ENTRY_CLASSES)
92
+ oneOf(c, pointer(p, "status"), e.status, ["pending", "done", "undone", "skipped"] as const)
93
+ if (e.path !== undefined) checkPathRef(c, pointer(p, "path"), e.path)
94
+ else if (e.class !== "external") c.add(pointer(p, "path"), "missing_field", "a local change names its path")
95
+ checkFileState(c, pointer(p, "before"), e.before)
96
+ if (e.after !== undefined) checkFileState(c, pointer(p, "after"), e.after)
97
+ if (e.backup !== undefined) {
98
+ const b = shape(c, pointer(p, "backup"), e.backup, ["id"])
99
+ if (b) text(c, pointer(pointer(p, "backup"), "id"), b.id, 67, BACKUP_ID)
100
+ }
101
+ const before = isRecord(e.before) ? e.before : {}
102
+ if (e.class === "credential" && e.backup !== undefined) c.add(pointer(p, "backup"), "inconsistent", "credentials are never backed up (a stale credential is never restored)")
103
+ // v1.1: a skipped entry changed nothing, so it has nothing to back up.
104
+ if (BACKED_UP.includes(e.class as EntryClass) && before.exists === true && e.backup === undefined && e.status !== "skipped")
105
+ c.add(pointer(p, "backup"), "inconsistent", "an existing file is backed up before it is changed")
106
+ if ((e.status === "done" || e.status === "undone") && e.after === undefined && e.class !== "external") c.add(pointer(p, "after"), "inconsistent", "a done entry records the state it left")
107
+ })
108
+ for (const p of findSecrets(value)) c.add(p, "secret", "journals never carry credentials")
109
+ return c.result(value)
110
+ }
111
+
112
+ export type RecoveryDecision = "nothing-to-do" | "remove-created" | "restore-backup" | "clear-credential" | "keep-user-edit"
113
+
114
+ const sameState = (a: FileState, b: FileState) =>
115
+ a.exists === b.exists && (a.sha256 ?? null) === (b.sha256 ?? null) && (a.mode === undefined || b.mode === undefined || a.mode === b.mode) && Boolean(a.symlink) === Boolean(b.symlink)
116
+
117
+ /**
118
+ * What recovery does for one entry, given the file's state now. Anything not exactly as we left it is a user edit
119
+ * and is kept (reported as a conflict), never overwritten.
120
+ */
121
+ export function recoveryDecision(entry: JournalEntry, current: FileState): RecoveryDecision {
122
+ if (entry.status === "undone" || entry.status === "skipped" || entry.class === "external") return "nothing-to-do"
123
+ const ours = entry.after !== undefined && sameState(current, entry.after)
124
+ if (entry.status === "pending" && sameState(current, entry.before)) return "nothing-to-do"
125
+ // v1.1: an enrollment interrupted before it was journaled done records no `after`. The credential store writes
126
+ // atomically, so a changed file means the interrupted enrollment stored its credential: clear it (never restore).
127
+ if (entry.class === "credential" && entry.status === "pending" && entry.after === undefined) return "clear-credential"
128
+ if (!ours) return "keep-user-edit"
129
+ if (entry.class === "credential") return "clear-credential"
130
+ if (entry.before.exists) return entry.backup ? "restore-backup" : "keep-user-edit"
131
+ return "remove-created"
132
+ }
133
+
134
+ export interface RecoveryStep {
135
+ readonly seq: number
136
+ readonly actionID: string
137
+ readonly decision: RecoveryDecision
138
+ }
139
+
140
+ /** Recovery for an interrupted transaction, newest entry first; finished transactions need none. */
141
+ export function recoveryPlan(journal: TransactionJournal, stateOf: (entry: JournalEntry) => FileState): RecoveryStep[] {
142
+ if (journal.state === "committed" || journal.state === "rolled-back") return []
143
+ return [...journal.entries].reverse().map((entry) => ({ seq: entry.seq, actionID: entry.actionID, decision: recoveryDecision(entry, stateOf(entry)) }))
144
+ }
145
+
146
+ /** Hooks the applier calls around each step (logging, progress, tests). They observe; they cannot change actions. */
147
+ export interface JournalHooks {
148
+ beforeAction?(entry: JournalEntry, journal: TransactionJournal): void | Promise<void>
149
+ afterAction?(entry: JournalEntry, journal: TransactionJournal): void | Promise<void>
150
+ onConflict?(entry: JournalEntry, current: FileState): void | Promise<void>
151
+ onRecover?(journal: TransactionJournal, steps: readonly RecoveryStep[]): void | Promise<void>
152
+ }
153
+
154
+ // ---------- apply lock ----------
155
+
156
+ export const LOCK_SCHEMA = "hub.apply-lock"
157
+ /** The longest an apply may hold the lock before it is considered stale. */
158
+ export const LOCK_MAX_MS = 60 * 60_000
159
+ /** Where the lock lives (state root). */
160
+ export const LOCK_PATH: PathRef = { root: "state", rel: "hub/bootstrap/apply.lock" }
161
+
162
+ export interface ApplyLock {
163
+ readonly schema: typeof LOCK_SCHEMA
164
+ readonly version: 1
165
+ readonly pid: number
166
+ readonly hostFingerprint: string
167
+ readonly txID: string
168
+ readonly acquiredAt: number
169
+ readonly expiresAt: number
170
+ }
171
+
172
+ export function validateApplyLock(value: unknown): Result<ApplyLock> {
173
+ const c = new Checker()
174
+ const r = shape(c, "", value, ["schema", "version", "pid", "hostFingerprint", "txID", "acquiredAt", "expiresAt"])
175
+ if (!r) return c.result(value)
176
+ header(c, "", r, LOCK_SCHEMA, 1)
177
+ int(c, "/pid", r.pid, 1, 2 ** 31)
178
+ digest(c, "/hostFingerprint", r.hostFingerprint)
179
+ text(c, "/txID", r.txID, 67, TX_ID)
180
+ if (instant(c, "/acquiredAt", r.acquiredAt) && instant(c, "/expiresAt", r.expiresAt)) {
181
+ const span = (r.expiresAt as number) - (r.acquiredAt as number)
182
+ if (span <= 0 || span > LOCK_MAX_MS) c.add("/expiresAt", "invalid_value", `a lock is held for 1 ms..${LOCK_MAX_MS / 60_000} minutes`)
183
+ }
184
+ return c.result(value)
185
+ }
186
+
187
+ export type LockState = "free" | "held" | "stale" | "foreign"
188
+
189
+ /**
190
+ * free: take it. held: another apply runs (refuse: exit locked). stale: same host, the holder died or overran
191
+ * (recover its journal, then take it). foreign: another host's lock (a shared home): never broken automatically.
192
+ */
193
+ export function lockState(lock: ApplyLock | undefined, context: { readonly now: number; readonly hostFingerprint: string; readonly isAlive: (pid: number) => boolean }): LockState {
194
+ if (!lock) return "free"
195
+ if (lock.hostFingerprint !== context.hostFingerprint) return "foreign"
196
+ if (context.now >= lock.expiresAt || !context.isAlive(lock.pid)) return "stale"
197
+ return "held"
198
+ }
@@ -0,0 +1,91 @@
1
+ // Where the bootstrap may touch the filesystem. Plans never carry absolute paths: every path is a PathRef, a named
2
+ // root plus a relative path inside it. Discovery maps each root to this host's absolute directory; a relative path
3
+ // can never climb out of its root ("..", absolute, backslashes, NUL), and the applier refuses to write through any
4
+ // symlink between the root and the target (checkNoSymlinkEscape), so a planted link cannot redirect a write.
5
+ import { Checker, fail, isAbsolutePath, ok, oneOf, pointer, shape, type Result } from "./validate.ts"
6
+
7
+ export const ROOT_IDS = ["home", "config", "state", "data", "user-bin", "launch-agents", "systemd-user", "system-bin"] as const
8
+ export type RootID = (typeof ROOT_IDS)[number]
9
+
10
+ /**
11
+ * Roots that need explicit escalation to write. Everything else is the user's own directories; the default
12
+ * install is unprivileged.
13
+ */
14
+ export const ELEVATED_ROOTS: readonly RootID[] = ["system-bin"]
15
+
16
+ export interface PathRef {
17
+ readonly root: RootID
18
+ /** Relative, "/"-separated, inside the root. */
19
+ readonly rel: string
20
+ }
21
+
22
+ /** A root as discovery found it on this host (and where a container sees it, when the host is a container). */
23
+ export interface RootMapping {
24
+ readonly id: RootID
25
+ readonly path: string
26
+ readonly containerPath?: string
27
+ }
28
+
29
+ // eslint-disable-next-line no-control-regex
30
+ const BAD_REL = /[\u0000-\u001f\u007f\\]/
31
+
32
+ export function isSafeRel(rel: unknown): rel is string {
33
+ if (typeof rel !== "string" || rel.length === 0 || rel.length > 1024 || rel.startsWith("/") || BAD_REL.test(rel)) return false
34
+ return rel.split("/").every((s) => s !== "" && s !== "." && s !== ".." && s.length <= 255)
35
+ }
36
+
37
+ export function checkPathRef(c: Checker, path: string, value: unknown): value is PathRef {
38
+ const r = shape(c, path, value, ["root", "rel"])
39
+ if (!r) return false
40
+ const rootOK = oneOf(c, pointer(path, "root"), r.root, ROOT_IDS)
41
+ const relOK = isSafeRel(r.rel)
42
+ if (!relOK && r.rel !== undefined) c.add(pointer(path, "rel"), "invalid_path", "expected a relative path inside the root (no '..', '/', '\\' or control characters)")
43
+ return rootOK && relOK
44
+ }
45
+
46
+ export function validatePathRef(value: unknown): Result<PathRef> {
47
+ const c = new Checker()
48
+ checkPathRef(c, "", value)
49
+ return c.result(value)
50
+ }
51
+
52
+ /**
53
+ * v1.1: where the code that resolves a path runs. `path` is always the host's view of a root; `containerPath` is
54
+ * what a process inside the container sees (discovery records it only when it ran inside one). hubctl resolves
55
+ * paths for the context it runs in: inside the container, a root is `containerPath ?? path`; on the host, `path`.
56
+ */
57
+ export const EXECUTION_CONTEXTS = ["host", "container"] as const
58
+ export type ExecutionContext = (typeof EXECUTION_CONTEXTS)[number]
59
+
60
+ /** The absolute directory of `root` as seen from `context`. */
61
+ export const rootPathIn = (root: RootMapping, context: ExecutionContext): string => (context === "container" ? (root.containerPath ?? root.path) : root.path)
62
+
63
+ /** The absolute path of `ref` on a host with these roots, as seen from `context` (default: the host). */
64
+ export function resolvePathRef(ref: PathRef, roots: readonly RootMapping[], context: ExecutionContext = "host"): Result<string> {
65
+ const checked = validatePathRef(ref)
66
+ if (!checked.ok) return checked as Result<string>
67
+ const root = roots.find((r) => r.id === ref.root)
68
+ const base = root ? rootPathIn(root, context) : undefined
69
+ if (!base || !isAbsolutePath(base)) return fail("/root", "unmapped_root", `this host has no ${ref.root} root`)
70
+ return ok(base === "/" ? `/${ref.rel}` : `${base}/${ref.rel}`)
71
+ }
72
+
73
+ export type EntryKind = "file" | "directory" | "symlink" | "missing" | "other"
74
+
75
+ /**
76
+ * Walks from `rootAbs` to `rootAbs/rel` with `lstat` (which must not follow links) and fails at the first symlink. A missing component ends the walk: the rest would be created. The root itself is the
77
+ * mapping discovery reported and is not checked here.
78
+ */
79
+ export function checkNoSymlinkEscape(rootAbs: string, rel: string, lstat: (path: string) => EntryKind): Result<string> {
80
+ if (!isAbsolutePath(rootAbs)) return fail("", "invalid_path", "the root must be an absolute, normalized path")
81
+ if (!isSafeRel(rel)) return fail("", "invalid_path", "expected a relative path inside the root")
82
+ const parts = rel.split("/")
83
+ let current = rootAbs === "/" ? "" : rootAbs
84
+ for (const part of parts) {
85
+ current = `${current}/${part}`
86
+ const kind = lstat(current)
87
+ if (kind === "symlink") return fail("", "symlink", `${current} is a symbolic link; refusing to write through it`)
88
+ if (kind === "missing") break
89
+ }
90
+ return ok(rootAbs === "/" ? `/${rel}` : `${rootAbs}/${rel}`)
91
+ }
@@ -0,0 +1,155 @@
1
+ // SetupPlan and PreviewToken. A plan is a pure function of (discovery snapshot, profile): no timestamps, no
2
+ // randomness, so the same host and choices always produce the same plan and the same digest. `hubctl setup
3
+ // preview` shows the plan and issues a preview token bound to the host fingerprint, the snapshot digest and the
4
+ // plan digest, with a short expiry and a single-use nonce. `apply` never trusts the token's content: it rediscovers,
5
+ // re-plans, and applies only when the fresh plan's digest equals the previewed one (revalidatePreview), so a stale,
6
+ // foreign, expired or replayed preview applies nothing.
7
+ import { checkAction, type Action } from "./actions.ts"
8
+ import { digestOf } from "./canonical.ts"
9
+ import { PLAN_PROFILE_VERSIONS, PROFILE_IDS, type PlanProfileVersion, type ProfileID } from "./profile.ts"
10
+ import { checkTarget } from "./platform.ts"
11
+ import { bool, Checker, digest, fail, header, instant, isRecord, list, ok, oneOf, shape, text, type Result } from "./validate.ts"
12
+
13
+ export const PLAN_SCHEMA = "hub.setup-plan"
14
+ export const PLAN_VERSION = 1
15
+ export const PLAN_MAX_ACTIONS = 256
16
+
17
+ export interface SetupPlan {
18
+ readonly schema: typeof PLAN_SCHEMA
19
+ readonly version: typeof PLAN_VERSION
20
+ readonly hostFingerprint: string
21
+ /** digestOf the DiscoverySnapshot the plan was made from. */
22
+ readonly snapshotDigest: string
23
+ readonly target: string
24
+ /** v1.1: the profile version it was planned from (1 for plans made by a v1.0 hubctl). */
25
+ readonly profile: { readonly id: ProfileID; readonly version: PlanProfileVersion }
26
+ /** True iff some action is elevated; apply then needs explicit consent (--escalate). */
27
+ readonly requiresEscalation: boolean
28
+ /** True iff the plan activates a service; apply then needs explicit consent. */
29
+ readonly activatesServices: boolean
30
+ /** In execution order. */
31
+ readonly actions: readonly Action[]
32
+ }
33
+
34
+ export function validateSetupPlan(value: unknown): Result<SetupPlan> {
35
+ const c = new Checker()
36
+ const r = shape(c, "", value, ["schema", "version", "hostFingerprint", "snapshotDigest", "target", "profile", "requiresEscalation", "activatesServices", "actions"])
37
+ if (!r) return c.result(value)
38
+ header(c, "", r, PLAN_SCHEMA, PLAN_VERSION)
39
+ digest(c, "/hostFingerprint", r.hostFingerprint)
40
+ digest(c, "/snapshotDigest", r.snapshotDigest)
41
+ if (typeof r.target !== "string" || !checkTarget(r.target).ok) c.add("/target", "unsupported_platform", `cannot plan for ${String(r.target)}`)
42
+ const profile = shape(c, "/profile", r.profile, ["id", "version"])
43
+ if (profile) {
44
+ oneOf(c, "/profile/id", profile.id, PROFILE_IDS)
45
+ if (!(PLAN_PROFILE_VERSIONS as readonly unknown[]).includes(profile.version)) c.add("/profile/version", "unsupported_version", `profile version must be one of ${PLAN_PROFILE_VERSIONS.join(", ")}`)
46
+ }
47
+ bool(c, "/requiresEscalation", r.requiresEscalation)
48
+ bool(c, "/activatesServices", r.activatesServices)
49
+ if (!list(c, "/actions", r.actions, PLAN_MAX_ACTIONS, (p, v) => checkAction(c, p, v), (v) => (isRecord(v) && typeof v.id === "string" ? v.id : undefined))) return c.result(value)
50
+
51
+ const actions = (r.actions as unknown[]).filter(isRecord)
52
+ const elevated = actions.some((a) => a.escalation === "elevated")
53
+ if (r.requiresEscalation !== elevated) c.add("/requiresEscalation", "inconsistent", "requiresEscalation must say exactly whether an action is elevated")
54
+ const activates = actions.some((a) => a.kind === "activate-service")
55
+ if (r.activatesServices !== activates) c.add("/activatesServices", "inconsistent", "activatesServices must say exactly whether a service is activated")
56
+ const installed = new Set<string>()
57
+ const verified = new Set<string>()
58
+ const unverifiedInstalls = new Map<string, number>()
59
+ // v1.1: an installed version is made current (select-harness-version) before it is verified.
60
+ const awaitingSelect = new Map<string, { version: string; index: number }>()
61
+ const removed = new Set<string>()
62
+ const installAt = (harness: string, version: string) => actions.findIndex((a) => a.kind === "install-harness" && a.harness === harness && a.version === version)
63
+ let enrollments = 0
64
+ actions.forEach((a, i) => {
65
+ const at = `/actions/${i}`
66
+ const service = `${String(a.service)}@${String(a.manager)}`
67
+ if (a.kind === "install-service") installed.add(service)
68
+ if (a.kind === "install-harness") {
69
+ unverifiedInstalls.set(String(a.harness), i)
70
+ awaitingSelect.set(String(a.harness), { version: String(a.version), index: i })
71
+ }
72
+ if (a.kind === "select-harness-version") {
73
+ const pending = awaitingSelect.get(String(a.harness))
74
+ if (pending?.version === a.version) awaitingSelect.delete(String(a.harness))
75
+ else if (installAt(String(a.harness), String(a.version)) > i) c.add(at, "inconsistent", "a harness version is installed before it is made current")
76
+ unverifiedInstalls.set(String(a.harness), i)
77
+ }
78
+ if (a.kind === "verify-harness") {
79
+ if (awaitingSelect.has(String(a.harness))) c.add(at, "inconsistent", "a harness is verified after its installed version is made current")
80
+ verified.add(String(a.harness))
81
+ unverifiedInstalls.delete(String(a.harness))
82
+ }
83
+ if (a.kind === "remove-service") removed.add(service)
84
+ if (a.kind === "deactivate-service" && removed.has(service)) c.add(at, "inconsistent", "a service is deactivated before it is removed")
85
+ if (a.kind === "activate-service" && !installed.has(service)) c.add(at, "inconsistent", "a service is installed before it is activated")
86
+ if (a.kind === "launch-session" && !verified.has(String(a.harness))) c.add(at, "inconsistent", "a harness is verified before a session is launched on it")
87
+ if (a.kind === "enroll-machine" && ++enrollments > 1) c.add(at, "duplicate", "a plan enrolls the machine at most once")
88
+ })
89
+ for (const i of unverifiedInstalls.values()) c.add(`/actions/${i}`, "inconsistent", "an installed harness is verified afterwards (install is not readiness)")
90
+ for (const { index } of awaitingSelect.values()) c.add(`/actions/${index}`, "inconsistent", "an installed harness version is made current afterwards (select-harness-version)")
91
+ return c.result(value)
92
+ }
93
+
94
+ /** The digest previews and journals pin. */
95
+ export const planDigest = (plan: SetupPlan): string => digestOf(plan)
96
+
97
+ export const PREVIEW_SCHEMA = "hub.preview-token"
98
+ export const PREVIEW_VERSION = 1
99
+ export const PREVIEW_MAX_TTL_MS = 15 * 60_000
100
+ /** How far a preview's issue time may be ahead of this clock. */
101
+ export const PREVIEW_CLOCK_SKEW_MS = 60_000
102
+ const NONCE = /^[A-Za-z0-9_-]{16,128}$/
103
+
104
+ export interface PreviewToken {
105
+ readonly schema: typeof PREVIEW_SCHEMA
106
+ readonly version: typeof PREVIEW_VERSION
107
+ readonly planDigest: string
108
+ readonly hostFingerprint: string
109
+ readonly snapshotDigest: string
110
+ readonly issuedAt: number
111
+ readonly expiresAt: number
112
+ /** Single use: apply records it in the journal and refuses it again. */
113
+ readonly nonce: string
114
+ }
115
+
116
+ export function validatePreviewToken(value: unknown): Result<PreviewToken> {
117
+ const c = new Checker()
118
+ const r = shape(c, "", value, ["schema", "version", "planDigest", "hostFingerprint", "snapshotDigest", "issuedAt", "expiresAt", "nonce"])
119
+ if (!r) return c.result(value)
120
+ header(c, "", r, PREVIEW_SCHEMA, PREVIEW_VERSION)
121
+ digest(c, "/planDigest", r.planDigest)
122
+ digest(c, "/hostFingerprint", r.hostFingerprint)
123
+ digest(c, "/snapshotDigest", r.snapshotDigest)
124
+ if (instant(c, "/issuedAt", r.issuedAt) && instant(c, "/expiresAt", r.expiresAt)) {
125
+ const ttl = (r.expiresAt as number) - (r.issuedAt as number)
126
+ if (ttl <= 0 || ttl > PREVIEW_MAX_TTL_MS) c.add("/expiresAt", "invalid_value", `a preview lives 1 ms..${PREVIEW_MAX_TTL_MS / 60_000} minutes`)
127
+ }
128
+ text(c, "/nonce", r.nonce, 128, NONCE)
129
+ return c.result(value)
130
+ }
131
+
132
+ export interface RevalidationContext {
133
+ readonly now: number
134
+ /** This host's fingerprint, computed fresh. */
135
+ readonly hostFingerprint: string
136
+ /** The plan made fresh from a new discovery. */
137
+ readonly plan: SetupPlan
138
+ /** Nonces already consumed by earlier applies (from the journals). */
139
+ readonly usedNonces: ReadonlySet<string>
140
+ }
141
+
142
+ /** Whether `apply` may proceed with this preview. Checked in this order; the first failure is the answer. */
143
+ export function revalidatePreview(token: unknown, context: RevalidationContext): Result<PreviewToken> {
144
+ const checked = validatePreviewToken(token)
145
+ if (!checked.ok) return checked
146
+ const t = checked.value
147
+ if (t.hostFingerprint !== context.hostFingerprint || context.plan.hostFingerprint !== context.hostFingerprint)
148
+ return fail("/hostFingerprint", "preview_foreign", "this preview was made for another host or account")
149
+ if (context.now >= t.expiresAt) return fail("/expiresAt", "preview_expired", "the preview expired; run the preview again")
150
+ if (t.issuedAt > context.now + PREVIEW_CLOCK_SKEW_MS) return fail("/issuedAt", "preview_not_yet_valid", "the preview is from the future (clock skew?)")
151
+ if (context.usedNonces.has(t.nonce)) return fail("/nonce", "preview_replayed", "this preview was already applied")
152
+ if (t.snapshotDigest !== context.plan.snapshotDigest || t.planDigest !== planDigest(context.plan))
153
+ return fail("/planDigest", "preview_stale", "the host or the plan changed since the preview; run the preview again")
154
+ return ok(t)
155
+ }
@@ -0,0 +1,62 @@
1
+ // The supported platform matrix: the only hosts a hubctl artifact is built for and a setup plan may target. Every
2
+ // other host is named explicitly with the reason it is refused, so "unsupported" is a clear answer rather than a
3
+ // guess at a nearby target.
4
+ import { fail, ok, type Result } from "./validate.ts"
5
+
6
+ export type Platform = "darwin" | "linux"
7
+ export type Arch = "arm64" | "x64"
8
+ /** "none" on macOS; "unknown" when a Linux host's C library could not be identified. */
9
+ export type Libc = "none" | "glibc" | "musl" | "unknown"
10
+ export type ServiceManager = "launchd" | "systemd-user"
11
+
12
+ export interface SupportedTarget {
13
+ readonly target: string
14
+ readonly platform: Platform
15
+ readonly arch: Arch
16
+ readonly libc: Libc
17
+ /** The per-user service manager services are installed for on this target. */
18
+ readonly serviceManager: ServiceManager
19
+ /**
20
+ * The `bun build --compile --target` the release builds this target's hubctl with. x64 targets use Bun's
21
+ * `-baseline` runtime: the default x64 runtime needs AVX2 and dies with SIGILL (exit 132) on CPUs without it,
22
+ * including QEMU's default kvm64 model, before hubctl can even print --version.
23
+ */
24
+ readonly bunTarget: string
25
+ }
26
+
27
+ export const SUPPORTED_TARGETS: readonly SupportedTarget[] = Object.freeze([
28
+ { target: "darwin-arm64", platform: "darwin", arch: "arm64", libc: "none", serviceManager: "launchd", bunTarget: "bun-darwin-arm64" },
29
+ { target: "darwin-x64", platform: "darwin", arch: "x64", libc: "none", serviceManager: "launchd", bunTarget: "bun-darwin-x64-baseline" },
30
+ { target: "linux-x64", platform: "linux", arch: "x64", libc: "glibc", serviceManager: "systemd-user", bunTarget: "bun-linux-x64-baseline" },
31
+ { target: "linux-arm64", platform: "linux", arch: "arm64", libc: "glibc", serviceManager: "systemd-user", bunTarget: "bun-linux-arm64" },
32
+ ] as const)
33
+
34
+ export interface UnsupportedTarget {
35
+ readonly target: string
36
+ readonly reason: string
37
+ }
38
+
39
+ export const UNSUPPORTED_TARGETS: readonly UnsupportedTarget[] = Object.freeze([
40
+ { target: "windows-x64", reason: "Windows bootstrap is out of scope (no launchd/systemd user services, different credential store)" },
41
+ { target: "windows-arm64", reason: "Windows bootstrap is out of scope (no launchd/systemd user services, different credential store)" },
42
+ { target: "linux-x64-musl", reason: "musl Linux (e.g. Alpine) has no hubctl build yet; the glibc build does not run there" },
43
+ { target: "linux-arm64-musl", reason: "musl Linux (e.g. Alpine) has no hubctl build yet; the glibc build does not run there" },
44
+ ] as const)
45
+
46
+ const PLATFORM_NAME: Record<string, string> = { darwin: "darwin", linux: "linux", win32: "windows" }
47
+
48
+ /** The target name of a host as Node reports it (process.platform, process.arch) plus its C library. */
49
+ export function targetOf(platform: string, arch: string, libc: Libc): string {
50
+ const name = PLATFORM_NAME[platform] ?? platform
51
+ return `${name}-${arch}${name === "linux" && libc === "musl" ? "-musl" : ""}`
52
+ }
53
+
54
+ export const supportedTarget = (target: string): SupportedTarget | undefined => SUPPORTED_TARGETS.find((t) => t.target === target)
55
+
56
+ /** The target's entry in the matrix, or `unsupported_platform` with the documented reason. */
57
+ export function checkTarget(target: string): Result<SupportedTarget> {
58
+ const supported = supportedTarget(target)
59
+ if (supported) return ok(supported)
60
+ const known = UNSUPPORTED_TARGETS.find((t) => t.target === target)
61
+ return fail("", "unsupported_platform", known ? `${target} is not supported: ${known.reason}` : `${target} is not a supported target (supported: ${SUPPORTED_TARGETS.map((t) => t.target).join(", ")})`)
62
+ }