@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,332 @@
1
+ // The hubctl release tool (`bun scripts/hubctl-release.ts <command>`), used by .github/workflows/release.yml and by
2
+ // maintainers. It is not part of the hub/hubctl command surface.
3
+ //
4
+ // build [TARGET…] [--outdir dist] [--trust-root FILE] [--require-trust-root]
5
+ // Compiles packages/cli/src/main.ts into dist/hubctl-<target> for the supported targets (all by default),
6
+ // embedding the trust root (public keys only). --require-trust-root refuses a build without one.
7
+ // manifest --minimum-version V --out FILE [--dist dist] [--pins FILE] [--release V] [--issued-at MS] [--targets T,…]
8
+ // Writes the unsigned manifest body: every dist/hubctl-<target> with its size and sha256, and the reviewed
9
+ // harness pins after re-downloading and digesting every pinned file.
10
+ // sign --in FILE --out FILE [--trust-root FILE] [--key-id ID] [--targets T,…]
11
+ // Signs the body with the key in $HUB_RELEASE_SIGNING_KEY (never printed) and verifies the result against
12
+ // the trust root the binaries embed. Writes nothing unless it verifies.
13
+ // verify --manifest FILE [--trust-root FILE] (--dist DIR [--targets T,…] | --target T --file BIN)
14
+ // [--installed V --installed-sha256 D] [--floor V] [--allow-downgrade]
15
+ // Runs the installer's checks (verify.ts) on one or every artifact.
16
+ //
17
+ // --targets (comma-separated) makes a partial release: exactly the listed targets, each still required and checked
18
+ // in full. Without it every supported target is required. sign needs the same list to sign a partial body, so a
19
+ // partial release is never signed by accident.
20
+ // trust-root [--key-id ID]
21
+ // Prints the v1 trust root (public key only) for the key in $HUB_RELEASE_SIGNING_KEY, for the maintainer
22
+ // who sets up the release key (README.md next to this file).
23
+ //
24
+ // Exit codes: 0 ok, 1 other, 2 usage, 11 artifact rejected (contracts §3.11).
25
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
26
+ import path from "node:path"
27
+ import pkg from "../../../package.json" with { type: "json" }
28
+ import {
29
+ BOOTSTRAP_EXIT,
30
+ SUPPORTED_TARGETS,
31
+ canonicalJSON,
32
+ checkTarget,
33
+ exitFor,
34
+ isRecord,
35
+ supportedTarget,
36
+ type Issue,
37
+ type Result,
38
+ type TrustRoot,
39
+ } from "../contracts/index.ts"
40
+ import { buildManifestBody, manifestJSON, parseSigningKey, signVerified, trustRootFor } from "./manifest.ts"
41
+ import { PIN_LIST_FILE, validatePinList, verifyPinDownloads, type Downloader } from "./pins.ts"
42
+ import { shippedTrustRoot, trustRootDefine } from "./trust-root.ts"
43
+ import { digestBytes, parseManifest, verifyHubctlRelease } from "./verify.ts"
44
+
45
+ /** The environment variable holding the release signing key (a GitHub Actions secret; decision Q5). */
46
+ export const SIGNING_KEY_ENV = "HUB_RELEASE_SIGNING_KEY"
47
+ /** The trust root the release binaries embed, relative to the repository root (checked in once the key exists). */
48
+ export const TRUST_ROOT_FILE = "packages/cli/src/bootstrap/release/trust-root.json"
49
+ /** The program compiled into hubctl (I3). */
50
+ export const HUBCTL_ENTRY = "packages/cli/src/main.ts"
51
+ export const DEFAULT_KEY_ID = "hub-release-1"
52
+ /** The version hubctl --version prints, and the release the manifest names. */
53
+ export const HUBCTL_VERSION: string = pkg.version
54
+
55
+ export interface ReleaseIo {
56
+ readonly env: Readonly<Record<string, string | undefined>>
57
+ /** The repository root; relative paths resolve against it. */
58
+ readonly cwd: string
59
+ readonly stdout: (s: string) => void
60
+ readonly stderr: (s: string) => void
61
+ /** Runs a word list (no shell) in `cwd`; resolves to its exit code. */
62
+ readonly run: (argv: string[]) => Promise<number>
63
+ readonly download: Downloader
64
+ readonly now: () => number
65
+ }
66
+
67
+ const EXIT_USAGE = BOOTSTRAP_EXIT.usage
68
+ const EXIT_OTHER = BOOTSTRAP_EXIT.other
69
+
70
+ class Usage extends Error {}
71
+ class Failure extends Error {
72
+ constructor(
73
+ message: string,
74
+ readonly code: number,
75
+ ) {
76
+ super(message)
77
+ }
78
+ }
79
+
80
+ interface Parsed {
81
+ readonly values: Map<string, string>
82
+ readonly flags: Set<string>
83
+ readonly positional: string[]
84
+ }
85
+
86
+ function parseArgs(argv: readonly string[], valued: readonly string[], booleans: readonly string[], positional = false): Parsed {
87
+ const values = new Map<string, string>()
88
+ const flags = new Set<string>()
89
+ const rest: string[] = []
90
+ for (let i = 0; i < argv.length; i++) {
91
+ const word = argv[i]!
92
+ if (valued.includes(word)) {
93
+ const value = argv[++i]
94
+ if (value === undefined || value.startsWith("--")) throw new Usage(`${word} needs a value`)
95
+ values.set(word, value)
96
+ } else if (booleans.includes(word)) flags.add(word)
97
+ else if (word.startsWith("-") || !positional) throw new Usage(`unknown argument ${word}`)
98
+ else rest.push(word)
99
+ }
100
+ return { values, flags, positional: rest }
101
+ }
102
+
103
+ const required = (p: Parsed, flag: string): string => {
104
+ const value = p.values.get(flag)
105
+ if (value === undefined) throw new Usage(`${flag} is required`)
106
+ return value
107
+ }
108
+
109
+ const describe = (issues: readonly Issue[]) => issues.map((i) => ` ${i.code} ${i.path || "/"}: ${i.message}`).join("\n")
110
+
111
+ /** Throws a Failure carrying the exit code of the first issue. */
112
+ function unwrap<T>(r: Result<T>, what: string): T {
113
+ if (r.ok) return r.value
114
+ throw new Failure(`${what}:\n${describe(r.issues)}`, exitFor(r.issues[0]?.code ?? "internal"))
115
+ }
116
+
117
+ function readJSON(file: string, what: string): unknown {
118
+ let text: string
119
+ try {
120
+ text = readFileSync(file, "utf8")
121
+ } catch {
122
+ throw new Failure(`cannot read ${what} ${file}`, EXIT_OTHER)
123
+ }
124
+ const r = parseManifest(text)
125
+ if (!r.ok) throw new Failure(`${what} ${file} is not JSON`, EXIT_OTHER)
126
+ return r.value
127
+ }
128
+
129
+ function loadTrustRoot(file: string): TrustRoot {
130
+ const value = readJSON(file, "trust root")
131
+ const r = shippedTrustRoot(JSON.stringify(value))
132
+ if (!r.ok) throw new Failure(`trust root ${file}:\n${describe(r.issues)}`, EXIT_OTHER)
133
+ return r.value
134
+ }
135
+
136
+ function signingKey(io: ReleaseIo) {
137
+ const secret = io.env[SIGNING_KEY_ENV]
138
+ if (!secret) throw new Usage(`${SIGNING_KEY_ENV} is not set (the release signing key; a GitHub Actions secret)`)
139
+ try {
140
+ return parseSigningKey(secret)
141
+ } catch (error) {
142
+ throw new Failure((error as Error).message, EXIT_OTHER)
143
+ }
144
+ }
145
+
146
+ /** The --targets list: supported targets, each named once; undefined when not given (every supported target). */
147
+ function targetsArg(p: Parsed): string[] | undefined {
148
+ const text = p.values.get("--targets")
149
+ if (text === undefined) return undefined
150
+ const list = text.split(",").map((t) => t.trim())
151
+ if (list.some((t) => t === "")) throw new Usage("--targets takes a comma-separated list of targets (e.g. linux-x64,darwin-arm64)")
152
+ for (const target of list) {
153
+ const r = checkTarget(target)
154
+ if (!r.ok) throw new Usage(r.issues[0]!.message)
155
+ }
156
+ if (new Set(list).size !== list.length) throw new Usage("--targets names a target twice")
157
+ return SUPPORTED_TARGETS.map((t) => t.target).filter((t) => list.includes(t))
158
+ }
159
+
160
+ /** The `bun build --compile` words for one target's hubctl. */
161
+ export function hubctlBuildArgv(target: string, options: { readonly bun: string; readonly outdir: string; readonly root?: TrustRoot }): string[] {
162
+ const t = supportedTarget(target)
163
+ if (!t) throw new Error(`${target} is not a supported target`)
164
+ return [
165
+ options.bun,
166
+ "build",
167
+ "--compile",
168
+ "--minify",
169
+ "--sourcemap",
170
+ `--target=${t.bunTarget}`,
171
+ ...(options.root ? trustRootDefine(options.root) : []),
172
+ HUBCTL_ENTRY,
173
+ "--outfile",
174
+ path.join(options.outdir, `hubctl-${t.target}`),
175
+ ]
176
+ }
177
+
178
+ async function build(argv: readonly string[], io: ReleaseIo): Promise<number> {
179
+ const p = parseArgs(argv, ["--outdir", "--trust-root", "--bun"], ["--require-trust-root"], true)
180
+ const targets = p.positional.length > 0 ? p.positional : SUPPORTED_TARGETS.map((t) => t.target)
181
+ for (const target of targets) {
182
+ const r = checkTarget(target)
183
+ if (!r.ok) throw new Usage(r.issues[0]!.message)
184
+ }
185
+ const rootFile = path.resolve(io.cwd, p.values.get("--trust-root") ?? TRUST_ROOT_FILE)
186
+ let root: TrustRoot | undefined
187
+ if (existsSync(rootFile)) root = loadTrustRoot(rootFile)
188
+ else if (p.flags.has("--require-trust-root")) throw new Failure(`no trust root at ${rootFile}: a release build must embed one (see README.md next to ${TRUST_ROOT_FILE})`, EXIT_OTHER)
189
+ else io.stderr(`warning: no trust root at ${rootFile}; this dev build cannot verify releases\n`)
190
+ const outdir = path.resolve(io.cwd, p.values.get("--outdir") ?? "dist")
191
+ mkdirSync(outdir, { recursive: true })
192
+ const bun = p.values.get("--bun") ?? process.execPath
193
+ for (const target of targets) {
194
+ const words = hubctlBuildArgv(target, { bun, outdir, ...(root ? { root } : {}) })
195
+ io.stderr(`==> ${words.at(-1)}\n`)
196
+ const code = await io.run(words)
197
+ if (code !== 0) throw new Failure(`bun build failed for ${target} (exit ${code})`, EXIT_OTHER)
198
+ }
199
+ return 0
200
+ }
201
+
202
+ async function manifest(argv: readonly string[], io: ReleaseIo): Promise<number> {
203
+ const p = parseArgs(argv, ["--dist", "--pins", "--minimum-version", "--release", "--issued-at", "--out", "--targets"], [])
204
+ const listed = targetsArg(p)
205
+ const targets = listed ?? SUPPORTED_TARGETS.map((t) => t.target)
206
+ const minimumVersion = required(p, "--minimum-version")
207
+ const out = path.resolve(io.cwd, required(p, "--out"))
208
+ const dist = path.resolve(io.cwd, p.values.get("--dist") ?? "dist")
209
+ const issuedText = p.values.get("--issued-at")
210
+ const issuedAt = issuedText === undefined ? io.now() : Number(issuedText)
211
+ if (!Number.isSafeInteger(issuedAt) || issuedAt < 0) throw new Usage("--issued-at takes milliseconds since the epoch")
212
+ const pins = unwrap(validatePinList(readJSON(path.resolve(io.cwd, p.values.get("--pins") ?? PIN_LIST_FILE), "pin list")), "the pin list is invalid")
213
+ const artifacts = targets.map((target) => {
214
+ const file = `hubctl-${target}`
215
+ let bytes: Uint8Array
216
+ try {
217
+ bytes = readFileSync(path.join(dist, file))
218
+ } catch {
219
+ throw new Failure(`missing ${path.join(dist, file)}: build ${listed ? "every listed target" : "every supported target (or name a subset with --targets)"} first`, EXIT_OTHER)
220
+ }
221
+ return { target, file, ...digestBytes(bytes) }
222
+ })
223
+ io.stderr(`re-downloading ${pins.pins.length} pinned harness file(s)\n`)
224
+ const harnesses = unwrap(await verifyPinDownloads(pins, io.download), "a pinned harness file does not match its pin")
225
+ const body = unwrap(
226
+ buildManifestBody({ release: p.values.get("--release") ?? HUBCTL_VERSION, minimumVersion, issuedAt, artifacts, harnesses, ...(listed ? { targets: listed } : {}) }),
227
+ "the manifest is invalid",
228
+ )
229
+ writeFileSync(out, `${canonicalJSON(body)}\n`)
230
+ io.stdout(`${out}: hubctl ${body.release} (${body.channel}), ${body.artifacts.length} artifacts (${body.artifacts.map((a) => a.target).join(", ")}), ${body.harnesses.length} harness pins\n`)
231
+ return 0
232
+ }
233
+
234
+ async function sign(argv: readonly string[], io: ReleaseIo): Promise<number> {
235
+ const p = parseArgs(argv, ["--in", "--out", "--trust-root", "--key-id", "--targets"], [])
236
+ const listed = targetsArg(p)
237
+ const input = path.resolve(io.cwd, required(p, "--in"))
238
+ const out = path.resolve(io.cwd, required(p, "--out"))
239
+ const root = loadTrustRoot(path.resolve(io.cwd, p.values.get("--trust-root") ?? TRUST_ROOT_FILE))
240
+ const body = readJSON(input, "manifest body")
241
+ if (!isRecord(body) || "signatures" in body) throw new Failure(`${input} is not an unsigned manifest body (it has signatures, or is not an object)`, EXIT_OTHER)
242
+ const rebuilt = unwrap(
243
+ buildManifestBody({
244
+ release: body.release as string,
245
+ minimumVersion: body.minimumVersion as string,
246
+ issuedAt: body.issuedAt as number,
247
+ artifacts: body.artifacts as never,
248
+ harnesses: body.harnesses as never,
249
+ ...(listed ? { targets: listed } : {}),
250
+ }),
251
+ "the manifest body is invalid (a partial release needs --targets naming its targets)",
252
+ )
253
+ if (canonicalJSON(rebuilt) !== canonicalJSON(body)) throw new Failure(`${input} is not a manifest body as \`manifest\` writes it`, EXIT_OTHER)
254
+ const privateKey = signingKey(io)
255
+ const active = root.keys.filter((k) => k.status === "active")
256
+ const keyID = p.values.get("--key-id") ?? (active.length === 1 ? active[0]!.keyID : DEFAULT_KEY_ID)
257
+ const verified = unwrap(signVerified(rebuilt, { keyID, privateKey }, root), "the signed manifest does not verify against the trust root (is the secret the key the binaries embed?)")
258
+ writeFileSync(out, manifestJSON(verified))
259
+ io.stdout(`${out}: signed by ${keyID}, verified against trust root v${root.version}\n`)
260
+ return 0
261
+ }
262
+
263
+ async function verify(argv: readonly string[], io: ReleaseIo): Promise<number> {
264
+ const p = parseArgs(argv, ["--manifest", "--trust-root", "--dist", "--target", "--file", "--installed", "--installed-sha256", "--floor", "--targets"], ["--allow-downgrade"])
265
+ const listed = targetsArg(p)
266
+ const root = loadTrustRoot(path.resolve(io.cwd, p.values.get("--trust-root") ?? TRUST_ROOT_FILE))
267
+ const document = readJSON(path.resolve(io.cwd, required(p, "--manifest")), "manifest")
268
+ const dist = p.values.get("--dist")
269
+ if (listed && !dist) throw new Usage("--targets goes with --dist")
270
+ const jobs: { target: string; file: string }[] = dist
271
+ ? (listed ?? SUPPORTED_TARGETS.map((t) => t.target)).map((target) => ({ target, file: path.resolve(io.cwd, dist, `hubctl-${target}`) }))
272
+ : [{ target: required(p, "--target"), file: path.resolve(io.cwd, required(p, "--file")) }]
273
+ const installedVersion = p.values.get("--installed")
274
+ const installedSha = p.values.get("--installed-sha256")
275
+ if ((installedVersion === undefined) !== (installedSha === undefined)) throw new Usage("--installed and --installed-sha256 go together")
276
+ const floor = p.values.get("--floor")
277
+ for (const job of jobs) {
278
+ let bytes: Uint8Array
279
+ try {
280
+ bytes = readFileSync(job.file)
281
+ } catch {
282
+ throw new Failure(`cannot read ${job.file}`, EXIT_OTHER)
283
+ }
284
+ const accepted = unwrap(
285
+ verifyHubctlRelease({
286
+ manifest: document,
287
+ root,
288
+ target: job.target,
289
+ bytes: digestBytes(bytes),
290
+ ...(installedVersion !== undefined && installedSha !== undefined ? { installed: { version: installedVersion, sha256: installedSha } } : {}),
291
+ ...(floor !== undefined ? { floor } : {}),
292
+ allowDowngrade: p.flags.has("--allow-downgrade"),
293
+ }),
294
+ `${job.target}: rejected`,
295
+ )
296
+ io.stdout(`ok ${job.target} hubctl ${accepted.manifest.release} ${accepted.kind} ${accepted.artifact.sha256}\n`)
297
+ }
298
+ return 0
299
+ }
300
+
301
+ async function trustRoot(argv: readonly string[], io: ReleaseIo): Promise<number> {
302
+ const p = parseArgs(argv, ["--key-id"], [])
303
+ io.stdout(`${JSON.stringify(trustRootFor(signingKey(io), p.values.get("--key-id") ?? DEFAULT_KEY_ID), null, 2)}\n`)
304
+ return 0
305
+ }
306
+
307
+ const COMMANDS: Record<string, (argv: readonly string[], io: ReleaseIo) => Promise<number>> = { build, manifest, sign, verify, "trust-root": trustRoot }
308
+
309
+ const USAGE = "usage: hubctl-release <build|manifest|sign|verify|trust-root> [options] (see packages/cli/src/bootstrap/release/cli.ts)\n"
310
+
311
+ export async function runRelease(argv: readonly string[], io: ReleaseIo): Promise<number> {
312
+ const [name, ...rest] = argv
313
+ const command = name !== undefined && Object.hasOwn(COMMANDS, name) ? COMMANDS[name] : undefined
314
+ if (!command) {
315
+ io.stderr(USAGE)
316
+ return EXIT_USAGE
317
+ }
318
+ try {
319
+ return await command(rest, io)
320
+ } catch (error) {
321
+ if (error instanceof Usage) {
322
+ io.stderr(`${error.message}\n${USAGE}`)
323
+ return EXIT_USAGE
324
+ }
325
+ if (error instanceof Failure) {
326
+ io.stderr(`${error.message}\n`)
327
+ return error.code
328
+ }
329
+ io.stderr(`internal error: ${error instanceof Error ? error.message : String(error)}\n`)
330
+ return EXIT_OTHER
331
+ }
332
+ }
@@ -0,0 +1,94 @@
1
+ {
2
+ "schema": "hub.harness-pin-list",
3
+ "version": 1,
4
+ "pins": [
5
+ {
6
+ "pin": {
7
+ "harness": "opencode",
8
+ "version": "2.2.0",
9
+ "target": "darwin-arm64",
10
+ "url": "https://github.com/anomalyco/opencode/releases/download/v2.2.0/opencode-darwin-arm64.zip",
11
+ "file": "opencode-darwin-arm64.zip",
12
+ "size": 102507526,
13
+ "sha256": "sha256:c71b1cd96541539ad38e2a75377d2f43d2a27567289a14371a182e9a0800db3d",
14
+ "evidence": [],
15
+ "minimumVersion": "2.0.0"
16
+ },
17
+ "review": {
18
+ "by": "huynle",
19
+ "on": "2026-10-30",
20
+ "checked": [
21
+ "upstream-release",
22
+ "digest-redownloaded",
23
+ "evidence-listed"
24
+ ]
25
+ }
26
+ },
27
+ {
28
+ "pin": {
29
+ "harness": "opencode",
30
+ "version": "2.2.0",
31
+ "target": "darwin-x64",
32
+ "url": "https://github.com/anomalyco/opencode/releases/download/v2.2.0/opencode-darwin-x64-baseline.zip",
33
+ "file": "opencode-darwin-x64-baseline.zip",
34
+ "size": 107678258,
35
+ "sha256": "sha256:bda15562402475e891d39de5938de7ce052a245f9fd926ab72d32f55b1e26ac3",
36
+ "evidence": [],
37
+ "minimumVersion": "2.0.0"
38
+ },
39
+ "review": {
40
+ "by": "huynle",
41
+ "on": "2026-10-30",
42
+ "checked": [
43
+ "upstream-release",
44
+ "digest-redownloaded",
45
+ "evidence-listed"
46
+ ]
47
+ }
48
+ },
49
+ {
50
+ "pin": {
51
+ "harness": "opencode",
52
+ "version": "2.2.0",
53
+ "target": "linux-x64",
54
+ "url": "https://github.com/anomalyco/opencode/releases/download/v2.2.0/opencode-linux-x64-baseline.tar.gz",
55
+ "file": "opencode-linux-x64-baseline.tar.gz",
56
+ "size": 115841183,
57
+ "sha256": "sha256:6d597970a8cc448f1b9a9f645532584b339dd3ab9583bb5f063f13d83e54768f",
58
+ "evidence": [],
59
+ "minimumVersion": "2.0.0"
60
+ },
61
+ "review": {
62
+ "by": "huynle",
63
+ "on": "2026-10-30",
64
+ "checked": [
65
+ "upstream-release",
66
+ "digest-redownloaded",
67
+ "evidence-listed"
68
+ ]
69
+ }
70
+ },
71
+ {
72
+ "pin": {
73
+ "harness": "opencode",
74
+ "version": "2.2.0",
75
+ "target": "linux-arm64",
76
+ "url": "https://github.com/anomalyco/opencode/releases/download/v2.2.0/opencode-linux-arm64.tar.gz",
77
+ "file": "opencode-linux-arm64.tar.gz",
78
+ "size": 114522206,
79
+ "sha256": "sha256:1f1ce0fc07b9245a815ffa9d652dbb80e6b0f07e5bf74ea87d2b1c0339fbac52",
80
+ "evidence": [],
81
+ "minimumVersion": "2.0.0"
82
+ },
83
+ "review": {
84
+ "by": "huynle",
85
+ "on": "2026-10-30",
86
+ "checked": [
87
+ "upstream-release",
88
+ "digest-redownloaded",
89
+ "evidence-listed"
90
+ ]
91
+ }
92
+ }
93
+ ]
94
+ }
@@ -0,0 +1,118 @@
1
+ // Building and signing the hubctl release manifest (contracts §3.6, I5, decision Q5).
2
+ // - The body lists one hubctl artifact for every supported target (in matrix order) and the reviewed harness
3
+ // pins (pins.ts), so the same inputs always give the same bytes.
4
+ // - The signature is a detached ed25519 signature over manifestPayload (canonical JSON without `signatures`),
5
+ // by the single release key (threshold 1), held as a GitHub Actions secret.
6
+ // - signVerified signs and then verifies with the contract's verifyManifest against the trust root the hubctl
7
+ // binaries embed: a secret that does not match the shipped root never produces a publishable manifest.
8
+ // Only node:crypto and the contracts are used, so the signing job runs with no npm dependencies installed.
9
+ import { createPrivateKey, createPublicKey, sign, type KeyObject } from "node:crypto"
10
+ import {
11
+ Checker,
12
+ SEMVER,
13
+ SUPPORTED_TARGETS,
14
+ TRUST_ROOT_SCHEMA,
15
+ canonicalJSON,
16
+ manifestPayload,
17
+ validateArtifactManifest,
18
+ verifyManifest,
19
+ type ArtifactManifest,
20
+ type HarnessPin,
21
+ type Result,
22
+ type TrustRoot,
23
+ type VerifiedManifest,
24
+ } from "../contracts/index.ts"
25
+
26
+ export type ManifestBody = Omit<ArtifactManifest, "signatures">
27
+
28
+ export interface ManifestInput {
29
+ readonly release: string
30
+ /** The security floor (≤ release). */
31
+ readonly minimumVersion: string
32
+ readonly issuedAt: number
33
+ readonly artifacts: readonly { readonly target: string; readonly file: string; readonly size: number; readonly sha256: string }[]
34
+ /** The manifest's harness pins (verifyPinDownloads / harnessPins). */
35
+ readonly harnesses: readonly HarnessPin[]
36
+ /**
37
+ * The targets this release ships, listed explicitly (a partial release, e.g. one target for testing): each must
38
+ * be supported and have exactly one artifact, and no artifact may be for another target. Default: every
39
+ * supported target.
40
+ */
41
+ readonly targets?: readonly string[]
42
+ }
43
+
44
+ export interface SigningKey {
45
+ readonly keyID: string
46
+ readonly privateKey: KeyObject
47
+ }
48
+
49
+ /** A pre-release (x.y.z-…) goes to `next`, everything else to `stable`. */
50
+ export const channelFor = (release: string): "stable" | "next" => (SEMVER.exec(release)?.[4] ? "next" : "stable")
51
+
52
+ const ORDER = SUPPORTED_TARGETS.map((t) => t.target)
53
+ const rank = (target: string) => {
54
+ const i = ORDER.indexOf(target)
55
+ return i < 0 ? ORDER.length : i
56
+ }
57
+
58
+ /**
59
+ * The unsigned manifest for a release: validated, every supported target (or every listed target) present and
60
+ * nothing else, artifacts in matrix order.
61
+ */
62
+ export function buildManifestBody(input: ManifestInput): Result<ManifestBody> {
63
+ const body: ManifestBody = {
64
+ schema: "hub.artifact-manifest",
65
+ version: 1,
66
+ product: "hubctl",
67
+ release: input.release,
68
+ channel: channelFor(input.release),
69
+ minimumVersion: input.minimumVersion,
70
+ issuedAt: input.issuedAt,
71
+ artifacts: [...input.artifacts].map(({ target, file, size, sha256 }) => ({ target, file, size, sha256 })).sort((a, b) => rank(a.target) - rank(b.target)),
72
+ harnesses: [...input.harnesses],
73
+ }
74
+ const c = new Checker()
75
+ c.merge("", validateArtifactManifest({ ...body, signatures: [] }))
76
+ const targets = input.targets ?? ORDER
77
+ if (input.targets) {
78
+ if (targets.length === 0) c.add("/artifacts", "invalid_value", "a release lists at least one target")
79
+ if (new Set(targets).size !== targets.length) c.add("/artifacts", "duplicate", "a target is listed twice")
80
+ for (const target of targets) if (!ORDER.includes(target)) c.add("/artifacts", "unsupported_platform", `${target} is not a supported target`)
81
+ for (const a of body.artifacts) if (ORDER.includes(a.target) && !targets.includes(a.target)) c.add("/artifacts", "inconsistent", `the release has a hubctl for ${a.target}, which is not one of the listed targets (${targets.join(", ")})`)
82
+ }
83
+ for (const target of targets) if (!body.artifacts.some((a) => a.target === target)) c.add("/artifacts", "missing_field", `the release has no hubctl for ${target}`)
84
+ return c.result(body)
85
+ }
86
+
87
+ /** The release key from the secret's text: PKCS#8 PEM or base64 PKCS#8 DER, ed25519 only. Errors never quote it. */
88
+ export function parseSigningKey(secret: string): KeyObject {
89
+ const text = secret.trim()
90
+ let key: KeyObject
91
+ try {
92
+ key = text.startsWith("-----BEGIN") ? createPrivateKey({ key: text, format: "pem" }) : createPrivateKey({ key: Buffer.from(text, "base64"), format: "der", type: "pkcs8" })
93
+ } catch {
94
+ throw new Error("the release signing key is not a PKCS#8 ed25519 private key (PEM or base64 DER)")
95
+ }
96
+ if (key.asymmetricKeyType !== "ed25519") throw new Error(`the release signing key must be ed25519, not ${key.asymmetricKeyType ?? "unknown"}`)
97
+ return key
98
+ }
99
+
100
+ /** Adds the detached ed25519 signature over manifestPayload. */
101
+ export function signManifest(body: ManifestBody, key: SigningKey): ArtifactManifest {
102
+ const value = sign(null, Buffer.from(manifestPayload(body), "utf8"), key.privateKey).toString("base64")
103
+ return { ...body, signatures: [{ keyID: key.keyID, algorithm: "ed25519", value }] }
104
+ }
105
+
106
+ /** Signs, then verifies against the shipped trust root; a key the root does not list never yields a manifest. */
107
+ export function signVerified(body: ManifestBody, key: SigningKey, root: TrustRoot): Result<VerifiedManifest> {
108
+ return verifyManifest(signManifest(body, key), root)
109
+ }
110
+
111
+ /** The v1 trust root (one active key, threshold 1) for the public half of a release key. */
112
+ export function trustRootFor(privateKey: KeyObject, keyID: string): TrustRoot {
113
+ const publicKey = createPublicKey(privateKey).export({ format: "der", type: "spki" }).toString("base64")
114
+ return { schema: TRUST_ROOT_SCHEMA, version: 1, threshold: 1, keys: [{ keyID, algorithm: "ed25519", publicKey, status: "active" }] }
115
+ }
116
+
117
+ /** The bytes published as hubctl-manifest.json. */
118
+ export const manifestJSON = (manifest: ArtifactManifest): string => `${canonicalJSON(manifest)}\n`