@huynle/hub 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/Dockerfile +3 -0
  3. package/README.md +148 -11
  4. package/package.json +3 -1
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/main.ts +4 -4
  7. package/packages/claude-code/src/wrapper.ts +3 -2
  8. package/packages/cli/dist/hub-cli.js +19334 -7849
  9. package/packages/cli/package.json +11 -0
  10. package/packages/cli/src/acting.ts +70 -0
  11. package/packages/cli/src/args.ts +91 -0
  12. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  13. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  14. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  15. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  16. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  17. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  18. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  19. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  20. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  21. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  22. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  23. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  24. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  25. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  26. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  27. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  28. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  29. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  30. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  31. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  32. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  33. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  34. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  35. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  36. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  37. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  38. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  39. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  40. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  41. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  42. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  43. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  44. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  45. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  46. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  47. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  48. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  49. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  50. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  51. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  52. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  53. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  54. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  55. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  56. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  57. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  58. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  59. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  60. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  61. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  62. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  63. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  64. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  65. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  66. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  67. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  68. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  69. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  70. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  71. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  72. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  73. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  74. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  75. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  76. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  77. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  78. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  79. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  80. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  81. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  82. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  83. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  84. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  85. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  86. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  87. package/packages/cli/src/bootstrap/release/README.md +115 -0
  88. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  89. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  90. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  91. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  92. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  93. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  94. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  95. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  96. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  97. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  98. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  99. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  100. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  101. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  102. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  103. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  104. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  105. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  106. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  107. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  108. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  109. package/packages/cli/src/cli.ts +814 -0
  110. package/packages/cli/src/credentials.ts +75 -0
  111. package/packages/cli/src/decisions.ts +106 -0
  112. package/packages/cli/src/format.ts +185 -0
  113. package/packages/cli/src/grants.ts +105 -0
  114. package/packages/cli/src/http.ts +86 -0
  115. package/packages/cli/src/launches.ts +157 -0
  116. package/packages/cli/src/login.ts +160 -0
  117. package/packages/cli/src/main.ts +63 -0
  118. package/packages/cli/src/mcp.ts +879 -0
  119. package/packages/cli/src/messaging.ts +108 -0
  120. package/packages/cli/src/node.ts +279 -0
  121. package/packages/cli/src/openshell.ts +166 -0
  122. package/packages/cli/src/setup/commands.ts +809 -0
  123. package/packages/cli/src/setup/contributors.ts +171 -0
  124. package/packages/cli/src/setup/dispatch.ts +74 -0
  125. package/packages/cli/src/setup/launch-driver.ts +109 -0
  126. package/packages/cli/src/setup/runtime.ts +456 -0
  127. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  128. package/packages/cli/src/setup/state.ts +244 -0
  129. package/packages/cli/src/setup/user-bin.ts +39 -0
  130. package/packages/cli/src/spoke-services.ts +109 -0
  131. package/packages/cli/src/watch.ts +28 -0
  132. package/packages/codex/package.json +1 -1
  133. package/packages/codex/src/main.ts +3 -2
  134. package/packages/codex/src/spoke.ts +4 -1
  135. package/packages/codex/src/wrapper.ts +7 -3
  136. package/packages/node/package.json +1 -1
  137. package/packages/node/src/harnesses.ts +39 -4
  138. package/packages/node/src/run.ts +1 -1
  139. package/packages/preset/package.json +1 -1
  140. package/packages/protocol/package.json +1 -1
  141. package/packages/runtime-openshell/package.json +1 -1
  142. package/packages/runtime-sdk/package.json +1 -1
  143. package/packages/sdk/package.json +1 -1
  144. package/packages/sdk/src/client.ts +55 -4
  145. package/packages/sdk/src/errors.ts +1 -0
  146. package/packages/sdk/src/sse.ts +4 -1
  147. package/packages/sdk/src/types.ts +59 -0
  148. package/packages/spoke-sdk/package.json +1 -1
  149. package/packages/spoke-sdk/src/enroll.ts +26 -4
  150. package/src/central/agent/app-tokens.ts +27 -17
  151. package/src/central/agent/archive-inactive.ts +66 -0
  152. package/src/central/agent/directory-routes.ts +155 -12
  153. package/src/central/agent/directory.ts +1 -1
  154. package/src/central/agent/errors.ts +2 -0
  155. package/src/central/agent/event-cursor.ts +49 -0
  156. package/src/central/agent/event-writers.ts +181 -0
  157. package/src/central/agent/events-routes.ts +148 -0
  158. package/src/central/agent/events.ts +277 -0
  159. package/src/central/agent/names.ts +1 -1
  160. package/src/central/agent/read-routes.ts +32 -36
  161. package/src/central/agent/routes.ts +14 -11
  162. package/src/central/agent/watch.ts +65 -174
  163. package/src/central/agent/webhook-routes.ts +10 -2
  164. package/src/central/agent/webhooks.ts +49 -5
  165. package/src/central/auth.ts +127 -1
  166. package/src/central/backend/index.ts +3 -0
  167. package/src/central/backend/types.ts +2 -0
  168. package/src/central/http.ts +115 -32
  169. package/src/central/mcp-http.ts +116 -0
  170. package/src/central/store.ts +53 -0
  171. package/src/config.ts +70 -7
  172. package/src/index.ts +90 -91
  173. package/src/spoke/launchers.ts +109 -3
  174. package/web/dist/apple-touch-icon.png +0 -0
  175. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  176. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  177. package/web/dist/favicon.ico +0 -0
  178. package/web/dist/icon-192.png +0 -0
  179. package/web/dist/icon-512.png +0 -0
  180. package/web/dist/icon-maskable-512.png +0 -0
  181. package/web/dist/icon.svg +5 -5
  182. package/web/dist/index.html +10 -4
  183. package/web/dist/manifest.webmanifest +2 -2
  184. package/web/dist/assets/index-D9zMbxSg.css +0 -1
  185. package/web/dist/assets/index-db1z4duA.js +0 -20
@@ -0,0 +1,276 @@
1
+ // Upgrade and downgrade planning, under the contract's downgrade policy.
2
+ //
3
+ // Profiles. planProfileChange compares two profiles as sets of capabilities (harnesses, runtimes, services,
4
+ // launching, escalation). Adding is an upgrade. Removing anything is a downgrade and needs explicit consent
5
+ // (downgrade_rejected otherwise, like --allow-downgrade for artifacts). v1.1: given the host's service manager and
6
+ // the units hub installed (installedServiceUnits), a removed service is planned as typed deactivate-service and
7
+ // remove-service actions (the unit only while it is exactly what hub installed). Everything else removed (harnesses,
8
+ // runtimes, launching, escalation) has no typed uninstall and is reported as retained: left in place until a
9
+ // rollback (history.ts) or the user removes it.
10
+ //
11
+ // Catalog revisions. rebaseProfile moves a profile from the defaults it was made from (base) to a newer catalog's
12
+ // (next), field by field: a field still equal to the old default follows the new one; a field the user changed is
13
+ // kept and reported. New defaults never raise escalation or opt in to a runtime (OpenShell is opt-in): such changes
14
+ // are refused and reported. A rebase that would produce an invalid profile fails instead of guessing. v1.1: the base
15
+ // is found by the profile's own `revision` in the catalogs given (or checked against it when passed explicitly), a
16
+ // rebase never goes to an older revision, and the result records the new revision.
17
+ //
18
+ // Harness versions. planHarnessVersions decides, per desired harness, against the pins of a verified manifest and
19
+ // the versions hub installed (from the history), with decideHarnessInstall: nothing below the security floor,
20
+ // older pins only with explicit consent, the same version with other bytes is tampering. A harness the user
21
+ // installed another way is never replaced. Each install, upgrade or downgrade carries its install-harness action,
22
+ // recording the version it replaces so a rollback can restore it.
23
+ import {
24
+ checkHarnessPin,
25
+ Checker,
26
+ canonicalJSON,
27
+ decideHarnessInstall,
28
+ EMBEDDED_ASSETS,
29
+ fail,
30
+ HARNESS_ORDER,
31
+ ok,
32
+ validateProfile,
33
+ type HarnessID,
34
+ type Action,
35
+ type InstallHarness,
36
+ type InstallKind,
37
+ type Profile,
38
+ type Result,
39
+ type ServiceID,
40
+ type ServiceManagerID,
41
+ type VerifiedManifest,
42
+ } from "../contracts/index.ts"
43
+ import { PROFILE_CATALOG, type ProfileCatalog } from "./catalog.ts"
44
+ import type { DesiredState } from "./desired.ts"
45
+ import type { InstalledHarness } from "./history.ts"
46
+
47
+ /** "harness:<id>", "runtime:<id>", "service:<id>", "launch", "escalation:explicit". */
48
+ export type Capability = string
49
+
50
+ export interface ProfileChange {
51
+ readonly from: Profile["id"]
52
+ readonly to: Profile["id"]
53
+ readonly direction: "same" | "upgrade" | "downgrade"
54
+ readonly adds: readonly Capability[]
55
+ readonly removes: readonly Capability[]
56
+ /** Removed capabilities hub leaves in place (no typed uninstall): undo them with a rollback. */
57
+ readonly retained: readonly Capability[]
58
+ /** v1.1: the typed steps that remove what can be removed (services), in execution order. */
59
+ readonly actions: readonly Action[]
60
+ }
61
+
62
+ /** v1.1: what planProfileChange needs to plan removing services. */
63
+ export interface ServiceFacts {
64
+ /** The host's user service manager. */
65
+ readonly manager: ServiceManagerID
66
+ /** The digest of each unit hub installed and still owns (installedServiceUnits). */
67
+ readonly installed: Partial<Record<ServiceID, string>>
68
+ }
69
+
70
+ const UNIT_ROOT: Readonly<Record<ServiceManagerID, "launch-agents" | "systemd-user">> = { launchd: "launch-agents", "systemd-user": "systemd-user" }
71
+
72
+ /** Deactivates, then removes (when hub knows the unit it installed), one service of a removed component. */
73
+ function removeServiceActions(service: ServiceID, facts: ServiceFacts): Action[] {
74
+ const actions: Action[] = [
75
+ { id: `deactivate-${service}`, kind: "deactivate-service", escalation: "user", summary: `Unload the hub ${service} service (profile downgrade)`, service, manager: facts.manager },
76
+ ]
77
+ const asset = EMBEDDED_ASSETS[`service/${facts.manager}/${service}`]
78
+ const sha256 = facts.installed[service]
79
+ if (asset && sha256)
80
+ actions.push({
81
+ id: `remove-${service}`,
82
+ kind: "remove-service",
83
+ escalation: "user",
84
+ summary: `Remove the hub ${service} ${facts.manager} unit (profile downgrade)`,
85
+ service,
86
+ manager: facts.manager,
87
+ path: { root: UNIT_ROOT[facts.manager], rel: asset.installAs },
88
+ sha256,
89
+ })
90
+ return actions
91
+ }
92
+
93
+ function capabilities(p: Profile): Capability[] {
94
+ return [
95
+ ...HARNESS_ORDER.filter((h) => p.harnesses.includes(h)).map((h) => `harness:${h}`),
96
+ ...p.runtimes.map((r) => `runtime:${r}`),
97
+ ...p.services.map((s) => `service:${s}`),
98
+ ...(p.launch.enabled ? ["launch"] : []),
99
+ ...(p.escalation === "explicit" ? ["escalation:explicit"] : []),
100
+ ]
101
+ }
102
+
103
+ export function planProfileChange(fromDoc: unknown, toDoc: unknown, options: { readonly allowDowngrade?: boolean; readonly services?: ServiceFacts } = {}): Result<ProfileChange> {
104
+ const c = new Checker()
105
+ const from = validateProfile(fromDoc)
106
+ const to = validateProfile(toDoc)
107
+ c.merge("/from", from)
108
+ c.merge("/to", to)
109
+ if (!from.ok || !to.ok) return c.result(undefined)
110
+ const before = capabilities(from.value)
111
+ const after = capabilities(to.value)
112
+ const adds = after.filter((x) => !before.includes(x))
113
+ const removes = before.filter((x) => !after.includes(x))
114
+ const direction = removes.length > 0 ? "downgrade" : adds.length > 0 ? "upgrade" : "same"
115
+ if (direction === "downgrade" && !options.allowDowngrade)
116
+ return fail("/to", "downgrade_rejected", `${to.value.id} removes ${removes.join(", ")}; pass --allow-downgrade to change to it`)
117
+ const actions: Action[] = []
118
+ const removed = new Set<Capability>()
119
+ if (options.services)
120
+ for (const capability of removes) {
121
+ if (!capability.startsWith("service:")) continue
122
+ const service = capability.slice("service:".length) as ServiceID
123
+ const planned = removeServiceActions(service, options.services)
124
+ actions.push(...planned)
125
+ // A service is gone only once its unit is removed too; a deactivated unit hub never recorded stays.
126
+ if (planned.some((a) => a.kind === "remove-service")) removed.add(capability)
127
+ }
128
+ // Deactivate everything first, then remove the units (the plan rule: a service is deactivated before it is removed).
129
+ actions.sort((a, b) => Number(a.kind === "remove-service") - Number(b.kind === "remove-service"))
130
+ return ok({ from: from.value.id, to: to.value.id, direction, adds, removes, retained: removes.filter((x) => !removed.has(x)), actions })
131
+ }
132
+
133
+ const REBASED_FIELDS = ["harnesses", "runtimes", "services", "launch", "escalation", "enrollment"] as const
134
+ export type RebasedField = (typeof REBASED_FIELDS)[number]
135
+
136
+ export interface RebasedProfile {
137
+ readonly profile: Profile
138
+ /** Fields the user had changed from the old defaults: kept. */
139
+ readonly kept: readonly RebasedField[]
140
+ /** Fields that followed the new defaults. */
141
+ readonly updated: readonly RebasedField[]
142
+ /** New defaults refused because they would raise privilege or opt in to a runtime. */
143
+ readonly refused: readonly RebasedField[]
144
+ }
145
+
146
+ // An absent optional field (enrollment) compares as null.
147
+ const same = (a: unknown, b: unknown) => canonicalJSON(a ?? null) === canonicalJSON(b ?? null)
148
+
149
+ export interface RebaseInput {
150
+ readonly current: unknown
151
+ /** The defaults `current` was made from. Optional when `current.revision` names one of `catalogs`. */
152
+ readonly base?: unknown
153
+ readonly next: unknown
154
+ /** v1.1: catalogs (any revisions) to find the base in by `current.revision`; default: this hubctl's catalog. */
155
+ readonly catalogs?: readonly ProfileCatalog[]
156
+ }
157
+
158
+ export function rebaseProfile(input: RebaseInput): Result<RebasedProfile> {
159
+ const c = new Checker()
160
+ const current = validateProfile(input.current)
161
+ const next = validateProfile(input.next)
162
+ c.merge("/current", current)
163
+ c.merge("/next", next)
164
+ if (!current.ok || !next.ok) return c.result(undefined)
165
+ let baseDoc = input.base
166
+ const revision = current.value.revision
167
+ if (baseDoc === undefined) {
168
+ if (revision === undefined) return fail("/current/revision", "missing_field", "the profile records no catalog revision; pass the defaults it was made from as base")
169
+ const catalog = (input.catalogs ?? [PROFILE_CATALOG]).find((k) => k.revision === revision)
170
+ if (!catalog) return fail("/current/revision", "invalid_value", `this hubctl does not know catalog revision ${revision}`)
171
+ baseDoc = catalog.profiles[current.value.id]
172
+ }
173
+ const base = validateProfile(baseDoc)
174
+ c.merge("/base", base)
175
+ if (!base.ok) return c.result(undefined)
176
+ const [cur, old, neu] = [current.value, base.value, next.value]
177
+ if (cur.id !== old.id || cur.id !== neu.id) return fail("/next/id", "invalid_value", "a rebase keeps the profile; change profiles with planProfileChange")
178
+ if (revision !== undefined && old.revision !== undefined && old.revision !== revision)
179
+ return fail("/base/revision", "inconsistent", `the profile was made from catalog revision ${revision}, not ${old.revision}`)
180
+ if (revision !== undefined && neu.revision !== undefined && neu.revision < revision)
181
+ return fail("/next/revision", "downgrade_rejected", `catalog revision ${neu.revision} is older than the profile's ${revision}`)
182
+
183
+ const result: Record<string, unknown> = { ...cur }
184
+ const kept: RebasedField[] = []
185
+ const updated: RebasedField[] = []
186
+ const refused: RebasedField[] = []
187
+ for (const field of REBASED_FIELDS) {
188
+ if (!same(cur[field], old[field])) {
189
+ kept.push(field)
190
+ continue
191
+ }
192
+ if (same(cur[field], neu[field])) continue
193
+ const raises = (field === "escalation" && neu.escalation === "explicit") || (field === "runtimes" && neu.runtimes.some((r) => !cur.runtimes.includes(r)))
194
+ if (raises) refused.push(field)
195
+ else {
196
+ result[field] = neu[field]
197
+ updated.push(field)
198
+ }
199
+ }
200
+ if (neu.revision !== undefined) result.revision = neu.revision
201
+ const profile = validateProfile(result)
202
+ if (!profile.ok) return profile
203
+ return ok({ profile: profile.value, kept, updated, refused })
204
+ }
205
+
206
+ export type HarnessVersionDecision = "install" | "upgrade" | "downgrade" | "keep" | "keep-user-managed"
207
+
208
+ export interface HarnessVersionStep {
209
+ readonly harness: HarnessID
210
+ readonly decision: HarnessVersionDecision
211
+ /** The version there now (when known). */
212
+ readonly from?: string
213
+ /** The pinned version. */
214
+ readonly to?: string
215
+ /** For install, upgrade and downgrade. */
216
+ readonly action?: InstallHarness
217
+ }
218
+
219
+ export interface HarnessVersionInput {
220
+ readonly desired: DesiredState
221
+ readonly manifest: VerifiedManifest
222
+ /** What hub installed, from the history (installedHarnesses). */
223
+ readonly installed: Partial<Record<HarnessID, InstalledHarness>>
224
+ /** The highest harness floors this machine has seen. */
225
+ readonly floors?: Partial<Record<HarnessID, string>>
226
+ readonly allowDowngrade?: boolean
227
+ }
228
+
229
+ /** Where hub installs a pinned harness version (a versioned directory under the data root). */
230
+ export const harnessInstallPath = (harness: HarnessID, version: string) => ({ root: "data", rel: `hub/harnesses/${harness}/${version}` }) as const
231
+
232
+ const VERB: Readonly<Record<Exclude<InstallKind, "reinstall">, string>> = { install: "Install", upgrade: "Upgrade", downgrade: "Downgrade" }
233
+
234
+ export function planHarnessVersions(input: HarnessVersionInput): Result<HarnessVersionStep[]> {
235
+ const c = new Checker()
236
+ const { desired, manifest } = input
237
+ const steps: HarnessVersionStep[] = []
238
+ desired.harnesses.forEach((h, i) => {
239
+ const at = `/harnesses/${i}`
240
+ if (h.owner === "user") return void steps.push({ harness: h.id, decision: "keep-user-managed", ...(h.version ? { from: h.version } : {}) })
241
+ const inst = h.presence === "installed" ? input.installed[h.id] : undefined
242
+ if (h.presence === "installed") {
243
+ if (!inst) return c.add(at, "inconsistent", `${h.id} was installed by hub, but no committed transaction records it; recover or reinstall`)
244
+ if (h.version !== undefined && h.version !== inst.version) return c.add(at, "inconsistent", `${h.id} is ${h.version}, not the ${inst.version} hub installed; verify it or roll back`)
245
+ }
246
+ const pin = manifest.harnesses.find((p) => p.harness === h.id && p.target === desired.target)
247
+ if (!pin) {
248
+ if (inst) return void steps.push({ harness: h.id, decision: "keep", from: inst.version })
249
+ return c.add(at, "unpinned_artifact", `hub ${manifest.release} pins no ${h.id} for ${desired.target}`)
250
+ }
251
+ const decided = decideHarnessInstall(pin, { ...(inst ? { installed: { version: inst.version, sha256: inst.sha256 } } : {}), ...(input.floors?.[h.id] ? { floor: input.floors[h.id] } : {}), allowDowngrade: input.allowDowngrade })
252
+ if (!decided.ok) return c.merge(at, decided)
253
+ if (decided.value === "reinstall") return void steps.push({ harness: h.id, decision: "keep", from: pin.version, to: pin.version })
254
+ const action: InstallHarness = {
255
+ id: `install-${h.id}`,
256
+ kind: "install-harness",
257
+ escalation: "user",
258
+ summary: `${VERB[decided.value]} ${h.id} ${pin.version} for ${pin.target} (pinned by hub ${manifest.release})`,
259
+ harness: h.id,
260
+ version: pin.version,
261
+ target: pin.target,
262
+ source: { manifest: manifest.release, url: pin.url, file: pin.file },
263
+ size: pin.size,
264
+ sha256: pin.sha256,
265
+ evidence: [...pin.evidence],
266
+ path: harnessInstallPath(h.id, pin.version),
267
+ owner: "hub",
268
+ rollback: "restore-previous",
269
+ ...(inst ? { previous: { version: inst.version, sha256: inst.sha256 } } : {}),
270
+ }
271
+ const pinned = checkHarnessPin(manifest, action)
272
+ if (!pinned.ok) return c.merge(at, pinned)
273
+ steps.push({ harness: h.id, decision: decided.value, ...(inst ? { from: inst.version } : {}), to: pin.version, action })
274
+ })
275
+ return c.result(steps)
276
+ }
@@ -0,0 +1,50 @@
1
+ // Profile document versions. A persisted profile is read through the contract's migration hook (migrateProfile),
2
+ // one version at a time, and the steps taken are reported so the caller can say "migrated from v0". A document from
3
+ // a newer hubctl is refused (unsupported_version), never read as if it were current; a version no chain of
4
+ // migrations reaches is unmigratable. Negotiation picks the highest version both sides understand.
5
+ import { fail, isRecord, migrateProfile, ok, PROFILE_MIGRATIONS, PROFILE_VERSION, type Profile, type ProfileMigrations, type Result } from "../contracts/index.ts"
6
+
7
+ export interface MigrationStep {
8
+ readonly from: number
9
+ readonly to: number
10
+ }
11
+
12
+ export interface LoadedProfile {
13
+ readonly profile: Profile
14
+ /** The version the document had on disk. */
15
+ readonly from: number
16
+ /** The migrations applied, oldest first (empty for a current document). */
17
+ readonly steps: readonly MigrationStep[]
18
+ }
19
+
20
+ /** Reads a profile document of any readable version; the input is never modified. */
21
+ export function loadProfile(raw: unknown, migrations: ProfileMigrations = PROFILE_MIGRATIONS): Result<LoadedProfile> {
22
+ const steps: MigrationStep[] = []
23
+ const recording: Record<number, (doc: Record<string, unknown>) => unknown> = {}
24
+ for (const [key, step] of Object.entries(migrations))
25
+ recording[Number(key)] = (doc) => {
26
+ const next = step(structuredClone(doc))
27
+ if (isRecord(next) && Number.isInteger(next.version)) steps.push({ from: doc.version as number, to: next.version as number })
28
+ return next
29
+ }
30
+ const result = migrateProfile(raw, recording)
31
+ if (!result.ok) return result
32
+ return ok({ profile: result.value, from: (raw as { version: number }).version, steps })
33
+ }
34
+
35
+ /** Every profile version this hubctl can read, ascending: the current one and each older one with an unbroken chain. */
36
+ export function supportedProfileVersions(migrations: ProfileMigrations = PROFILE_MIGRATIONS): number[] {
37
+ const versions = [PROFILE_VERSION]
38
+ for (let v = PROFILE_VERSION - 1; typeof migrations[v] === "function"; v--) versions.unshift(v)
39
+ return versions
40
+ }
41
+
42
+ /** The highest offered version this hubctl can read. */
43
+ export function negotiateProfileVersion(offered: readonly number[], migrations: ProfileMigrations = PROFILE_MIGRATIONS): Result<number> {
44
+ if (offered.length === 0 || !offered.every((v) => Number.isInteger(v))) return fail("", "invalid_value", "offer at least one integer profile version")
45
+ const supported = new Set(supportedProfileVersions(migrations))
46
+ const common = offered.filter((v) => supported.has(v))
47
+ if (common.length > 0) return ok(Math.max(...common))
48
+ if (offered.some((v) => v > PROFILE_VERSION)) return fail("", "unsupported_version", `profile versions ${offered.join(", ")} are newer than this hubctl understands (${PROFILE_VERSION}); upgrade hubctl`)
49
+ return fail("", "unmigratable", `no migration reaches profile version ${PROFILE_VERSION} from ${offered.join(", ")}`)
50
+ }
@@ -0,0 +1,115 @@
1
+ # Standalone hubctl releases
2
+
3
+ This directory builds, signs and verifies the standalone `hubctl` (contracts §3.6, §3.7, I5, I6 in
4
+ `docs/bootstrap-contracts.md`). The central hub server binaries (`scripts/build-binaries.sh`) are separate and
5
+ unchanged.
6
+
7
+ | File | Role |
8
+ |---|---|
9
+ | `verify.ts` | The installer's checks on a downloaded hubctl: signature (trust root), exact target, size and digest, downgrade policy. |
10
+ | `manifest.ts` | Builds the manifest body (every supported target, or an explicit `targets` subset; matrix order; harness pins) and signs it (ed25519, detached, over `manifestPayload`). |
11
+ | `pins.ts` | The reviewed harness pin list (`harness-pins.json`): format, validation, re-download check. |
12
+ | `trust-root.ts` | The trust root embedded at build time (`--define HUBCTL_TRUST_ROOT=…`), never read at run time. |
13
+ | `assets.ts` | The six service templates of `EMBEDDED_ASSETS`, imported as text so the binary carries them. |
14
+ | `cli.ts` | The release tool behind `bun scripts/hubctl-release.ts build\|manifest\|sign\|verify\|trust-root`. |
15
+
16
+ Everything except `build` needs only bun and node's built-ins, so the signing job installs no npm packages.
17
+
18
+ ## Building
19
+
20
+ ```sh
21
+ scripts/build-hubctl.sh # dev build: every supported target, no trust root
22
+ scripts/build-hubctl.sh linux-x64 # one target
23
+ scripts/build-hubctl.sh --require-trust-root # release build: refuses without trust-root.json
24
+ scripts/build-hubctl.sh linux-x64 --outdir /tmp/out # binaries and checksums in /tmp/out
25
+ ```
26
+
27
+ Outputs: `dist/hubctl-<target>` and `dist/HUBCTL_SHA256SUMS` (in `--outdir` instead, when given). x64 targets are
28
+ compiled against Bun's `-baseline` runtime, which needs no AVX2 (older CPUs, QEMU's default `kvm64` model). The supported targets are `darwin-arm64`,
29
+ `darwin-x64`, `linux-x64` and `linux-arm64` (glibc). Windows and musl are refused with their reasons. The
30
+ binary is the whole program: it runs without bun, node or the repository, and a `hub` link to it is the same
31
+ command surface. A build without a trust root cannot verify any release (`shippedTrustRoot` fails closed).
32
+
33
+ ## The harness pin list (`harness-pins.json`)
34
+
35
+ ```json
36
+ { "schema": "hub.harness-pin-list", "version": 1,
37
+ "pins": [ { "pin": { "harness": "opencode", "version": "…", "target": "darwin-arm64", "url": "https://…/file.zip",
38
+ "file": "file.zip", "size": 123, "sha256": "sha256:…", "evidence": [] },
39
+ "review": { "by": "github-handle", "on": "2026-10-28",
40
+ "checked": ["upstream-release", "digest-redownloaded", "evidence-listed"] } } ] }
41
+ ```
42
+
43
+ `pin` is exactly a manifest `HarnessPin`. Each pin is checked with the manifest's own rules: a supported
44
+ target, an exact version, https, a plain file name, one pin per harness and target, and only evidence that
45
+ harness can offer. On top of that, the file must be the URL's last path segment, the URL may not carry
46
+ credentials, a query or a fragment, and nothing secret-shaped may appear anywhere in the list.
47
+
48
+ The list is empty until the harness adapter tasks confirm the upstream sources (HBS-06 for OpenCode 2.x).
49
+
50
+ **Review process**
51
+
52
+ 1. A pin changes only through a pull request that touches `harness-pins.json`. The reviewer is not the author.
53
+ 2. The reviewer fills in `review`:
54
+ - `by`: their GitHub handle.
55
+ - `on`: the review date.
56
+ - `checked`: all three checks.
57
+ - `upstream-release`: the URL is the upstream's own release asset for exactly this version and target.
58
+ - `digest-redownloaded`: they downloaded the file themselves and computed its size and sha256, for example
59
+ with `curl -fsSL <url> | shasum -a 256`.
60
+ - `evidence-listed`: `evidence` lists exactly the upstream evidence published for that file (§5 of the
61
+ contracts doc).
62
+ 3. CI validates the file (`bootstrap-release-pins.test.ts`).
63
+ 4. At release time, the `hubctl-sign` job downloads every pinned file again and refuses to sign on any size or
64
+ digest mismatch. The signature never vouches for a file that nobody fetched during that release.
65
+
66
+ ## The release key (decision Q5)
67
+
68
+ There is one ed25519 key, with threshold 1. The private key is the `HUB_RELEASE_SIGNING_KEY` secret of the
69
+ `hubctl-release` GitHub environment. Only the `hubctl-sign` job uses that environment, and only its Sign step
70
+ sees the secret. The key is PKCS#8, either PEM or base64 DER. No key is ever committed.
71
+
72
+ A maintainer sets the key up once, on their own machine. This has not been done yet:
73
+
74
+ ```sh
75
+ openssl genpkey -algorithm ed25519 -out release-key.pem # keep it offline
76
+ HUB_RELEASE_SIGNING_KEY="$(cat release-key.pem)" bun scripts/hubctl-release.ts trust-root --key-id hub-release-1 \
77
+ > packages/cli/src/bootstrap/release/trust-root.json # public key only; commit through a reviewed PR
78
+ gh secret set HUB_RELEASE_SIGNING_KEY --env hubctl-release < release-key.pem
79
+ gh variable set HUBCTL_RELEASE --body true # enables the hubctl release jobs
80
+ gh variable set HUBCTL_MINIMUM_VERSION --body 0.0.0 # the security floor (optional, default 0.0.0)
81
+ ```
82
+
83
+ To rotate the key, publish a version-2 trust root signed by the current key (`rotateTrustRoot`), with the old key
84
+ `retired`, or `revoked` if it was compromised. Then update `trust-root.json` and the secret.
85
+
86
+ ## The release workflow (`.github/workflows/release.yml`)
87
+
88
+ The hubctl jobs are added alongside the existing jobs. They run only when `HUBCTL_RELEASE` is `true`.
89
+
90
+ 1. `hubctl` (needs `test`): `scripts/build-hubctl.sh --require-trust-root` cross-compiles all four targets.
91
+ Then it smoke-tests `linux-x64` with an empty `PATH` (no bun or node): `--version` must equal the CLI
92
+ version, and `hub --help` must equal `hubctl --help`.
93
+ 2. `hubctl-sign` (needs `hubctl`, environment `hubctl-release`, no npm install):
94
+ - `manifest` re-downloads the pins and writes the body. `issuedAt` is the tagged commit's time.
95
+ - `sign` signs the body and verifies it against `trust-root.json`. A wrong secret writes nothing.
96
+ - `verify --dist dist` runs the installer's checks on every artifact.
97
+ 3. `hubctl-publish` (needs the existing `release` job and `hubctl-sign`): it verifies again, then attaches
98
+ `hubctl-*`, `hubctl-manifest.json` and `HUBCTL_SHA256SUMS` to the GitHub release.
99
+
100
+ ## Verifying by hand
101
+
102
+ ```sh
103
+ bun scripts/hubctl-release.ts verify --manifest hubctl-manifest.json --target linux-x64 --file hubctl-linux-x64
104
+ ```
105
+
106
+ The exit code is 0 when the artifact is accepted and 11 when it is rejected: `signature_invalid`, `wrong_platform`,
107
+ `integrity_mismatch`, `downgrade_rejected` or `below_security_floor`. Pass `--installed V --installed-sha256 D`,
108
+ `--floor V` and `--allow-downgrade` to apply the downgrade policy.
109
+
110
+ ## A partial release (some targets only)
111
+
112
+ `manifest`, `sign` and `verify --dist` take `--targets T,…` (e.g. `--targets linux-x64`) to list exactly the
113
+ targets a test or partial release ships. Each listed target must be built and is checked in full; an artifact for
114
+ any other target is refused. `sign` refuses a partial body unless `--targets` names the same list, so a partial
115
+ release is never signed by accident. Without `--targets`, every supported target is required.
@@ -0,0 +1,39 @@
1
+ // The embedded-asset bundle: the six service templates of EMBEDDED_ASSETS (contracts §3.7, I2), imported as text so
2
+ // `bun build --compile` (and the npm bundle) carry them inside the program. A standalone hubctl never reads
3
+ // scripts/ at run time; node.ts / spoke-services.ts render from embeddedAsset(id).text (wired by HBS-13).
4
+ //
5
+ // Each import must match EMBEDDED_ASSETS[id].source; the release tests check every entry byte for byte.
6
+ import { EMBEDDED_ASSETS, type EmbeddedAsset } from "../contracts/index.ts"
7
+ import { digestBytes } from "./verify.ts"
8
+ import launchdNode from "../../../../../scripts/io.github.huynle.hub-node.plist" with { type: "text" }
9
+ import systemdNode from "../../../../../scripts/hub-node.service" with { type: "text" }
10
+ import launchdClaude from "../../../../../scripts/io.github.huynle.hub-claude-code.plist" with { type: "text" }
11
+ import systemdClaude from "../../../../../scripts/hub-claude-code.service" with { type: "text" }
12
+ import launchdCodex from "../../../../../scripts/io.github.huynle.hub-codex-spoke.plist" with { type: "text" }
13
+ import systemdCodex from "../../../../../scripts/hub-codex-spoke.service" with { type: "text" }
14
+
15
+ export type AssetID = keyof typeof EMBEDDED_ASSETS
16
+
17
+ /** The template text of every embedded asset, by EMBEDDED_ASSETS id. */
18
+ export const EMBEDDED_TEXT: Readonly<Record<string, string>> = Object.freeze({
19
+ "service/launchd/node": launchdNode,
20
+ "service/systemd-user/node": systemdNode,
21
+ "service/launchd/claude-code": launchdClaude,
22
+ "service/systemd-user/claude-code": systemdClaude,
23
+ "service/launchd/codex-spoke": launchdCodex,
24
+ "service/systemd-user/codex-spoke": systemdCodex,
25
+ })
26
+
27
+ export interface EmbeddedText {
28
+ readonly asset: EmbeddedAsset
29
+ readonly text: string
30
+ /** "sha256:<hex>" of the template's UTF-8 bytes (a write-file action pins the rendered result instead). */
31
+ readonly sha256: string
32
+ }
33
+
34
+ /** An embedded template with its contract entry and digest; undefined for any other id. */
35
+ export function embeddedAsset(id: string): EmbeddedText | undefined {
36
+ if (!Object.hasOwn(EMBEDDED_ASSETS, id) || !Object.hasOwn(EMBEDDED_TEXT, id)) return undefined
37
+ const text = EMBEDDED_TEXT[id]!
38
+ return { asset: EMBEDDED_ASSETS[id]!, text, sha256: digestBytes(new TextEncoder().encode(text)).sha256 }
39
+ }