@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,215 @@
1
+ // DiscoverySnapshot: what `hubctl setup` found on this host, read-only, before anything is planned. It separates
2
+ // presence from health (an installed harness stays "unverified" until real probes pass), records the container and
3
+ // shim topology (a `claude` on PATH may be a shim into another environment), the package/runtime/service managers,
4
+ // who owns each config file it would touch, and where each path root lives. It can describe an unsupported host
5
+ // (supported: false) so the refusal is explicit; planning for such a host fails.
6
+ import { CONFIG_OWNERS, type ConfigOwner } from "./actions.ts"
7
+ import { HARNESS_ORDER, PROBE_KINDS, RUNTIME_IDS, type HarnessID, type Probe, type RuntimeID } from "./harness.ts"
8
+ import { digestOf } from "./canonical.ts"
9
+ import { ROOT_IDS, type ExecutionContext, type RootMapping } from "./paths.ts"
10
+ import { checkTarget, targetOf, type Libc, type ServiceManager } from "./platform.ts"
11
+ import { findSecrets } from "./secrets.ts"
12
+ import { absPath, bool, Checker, digest, header, instant, list, oneOf, pointer, semver, shape, subset, text, type Result } from "./validate.ts"
13
+
14
+ export const DISCOVERY_SCHEMA = "hub.discovery-snapshot"
15
+ export const DISCOVERY_VERSION = 1
16
+
17
+ export type ContainerKind = "none" | "docker" | "podman" | "lxc" | "wsl" | "unknown"
18
+ export type ShimKind = "host" | "container-shim" | "wrapper" | "unknown"
19
+ export const PACKAGE_MANAGERS = ["brew", "npm", "bun", "apt", "dnf", "apk", "pacman", "nix"] as const
20
+ export type PackageManager = (typeof PACKAGE_MANAGERS)[number]
21
+ /** hub: installed by hubctl from a pinned artifact. */
22
+ export type InstallMethod = "native" | "npm" | "brew" | "package-manager" | "hub" | "unknown"
23
+
24
+ export interface HostInfo {
25
+ readonly fingerprint: string
26
+ /** process.platform as found (may be unsupported). */
27
+ readonly platform: string
28
+ readonly arch: string
29
+ readonly libc: Libc
30
+ readonly target: string
31
+ /** Exactly whether `target` is in SUPPORTED_TARGETS. */
32
+ readonly supported: boolean
33
+ readonly os: { readonly name: string; readonly version: string }
34
+ }
35
+
36
+ export interface Shim {
37
+ readonly name: string
38
+ readonly path: string
39
+ readonly kind: ShimKind
40
+ /** For a shim: what it forwards to, when known. */
41
+ readonly resolvesTo?: string
42
+ }
43
+
44
+ export interface RuntimeInfo {
45
+ readonly id: "bun" | "node"
46
+ readonly version: string
47
+ readonly path: string
48
+ }
49
+
50
+ export interface HarnessHealth {
51
+ readonly state: "unverified" | "verified" | "failed"
52
+ readonly probes: readonly Probe[]
53
+ }
54
+
55
+ /** A harness as discovery sees it. `installed` is presence only; `health` is what probes proved. */
56
+ export interface HarnessPresence {
57
+ readonly id: HarnessID
58
+ readonly installed: boolean
59
+ readonly path?: string
60
+ readonly version?: string
61
+ readonly installMethod?: InstallMethod
62
+ readonly health: HarnessHealth
63
+ }
64
+
65
+ export interface RuntimePresence {
66
+ readonly id: RuntimeID
67
+ readonly present: boolean
68
+ readonly version?: string
69
+ }
70
+
71
+ export interface ConfigFile {
72
+ readonly path: string
73
+ readonly owner: ConfigOwner | "package-manager"
74
+ readonly format: "json" | "jsonc" | "toml" | "plist" | "unit"
75
+ /** Digest of its bytes now; null when it does not exist. Contents are never recorded. */
76
+ readonly sha256: string | null
77
+ }
78
+
79
+ export interface DiscoverySnapshot {
80
+ readonly schema: typeof DISCOVERY_SCHEMA
81
+ readonly version: typeof DISCOVERY_VERSION
82
+ readonly capturedAt: number
83
+ readonly host: HostInfo
84
+ readonly topology: { readonly container: ContainerKind; readonly shims: readonly Shim[] }
85
+ readonly managers: { readonly package: readonly PackageManager[]; readonly runtime: readonly RuntimeInfo[]; readonly service: ServiceManager | "none" }
86
+ readonly harnesses: readonly HarnessPresence[]
87
+ readonly runtimes: readonly RuntimePresence[]
88
+ readonly configs: readonly ConfigFile[]
89
+ readonly roots: readonly RootMapping[]
90
+ }
91
+
92
+ const NAME = /^[A-Za-z0-9._+-]{1,64}$/
93
+ const VERSION_TEXT = /^[0-9A-Za-z.+_-]{1,64}$/
94
+
95
+ function checkProbe(c: Checker, p: string, v: unknown) {
96
+ const r = shape(c, p, v, ["kind", "ok", "observedAt"], ["detail"])
97
+ if (!r) return
98
+ oneOf(c, pointer(p, "kind"), r.kind, PROBE_KINDS)
99
+ bool(c, pointer(p, "ok"), r.ok)
100
+ instant(c, pointer(p, "observedAt"), r.observedAt)
101
+ if (r.detail !== undefined) text(c, pointer(p, "detail"), r.detail)
102
+ }
103
+
104
+ function checkHarness(c: Checker, p: string, v: unknown) {
105
+ const r = shape(c, p, v, ["id", "installed", "health"], ["path", "version", "installMethod"])
106
+ if (!r) return
107
+ oneOf(c, pointer(p, "id"), r.id, HARNESS_ORDER)
108
+ bool(c, pointer(p, "installed"), r.installed)
109
+ if (r.path !== undefined) absPath(c, pointer(p, "path"), r.path)
110
+ if (r.version !== undefined) text(c, pointer(p, "version"), r.version, 64, VERSION_TEXT)
111
+ if (r.installMethod !== undefined) oneOf(c, pointer(p, "installMethod"), r.installMethod, ["native", "npm", "brew", "package-manager", "hub", "unknown"] as const)
112
+ if (r.installed === false && (r.path !== undefined || r.version !== undefined)) c.add(p, "inconsistent", "a harness that is not installed has no path or version")
113
+ const h = shape(c, pointer(p, "health"), r.health, ["state", "probes"])
114
+ if (!h) return
115
+ oneOf(c, pointer(pointer(p, "health"), "state"), h.state, ["unverified", "verified", "failed"] as const)
116
+ list(c, pointer(pointer(p, "health"), "probes"), h.probes, 32, (pp, probe) => checkProbe(c, pp, probe))
117
+ const probes = Array.isArray(h.probes) ? (h.probes as Partial<Probe>[]) : []
118
+ if (h.state === "verified") {
119
+ const real = probes.filter((pr) => pr.kind !== "presence")
120
+ if (real.length === 0 || !probes.every((pr) => pr.ok === true)) c.add(pointer(p, "health"), "inconsistent", "verified needs passing real probes, not presence")
121
+ }
122
+ if (h.state === "failed" && !probes.some((pr) => pr.ok === false)) c.add(pointer(p, "health"), "inconsistent", "failed needs a failing probe")
123
+ if (r.installed === false && h.state !== "unverified") c.add(pointer(p, "health"), "inconsistent", "a harness that is not installed cannot be verified")
124
+ }
125
+
126
+ /**
127
+ * v1.1: the one canonical digest of a snapshot that plans, preview tokens, journals, audits and the Ansible vars
128
+ * pin: the canonical JSON without `capturedAt`, so rediscovering an unchanged host gives the same digest.
129
+ */
130
+ export const snapshotDigest = (snapshot: DiscoverySnapshot): string => digestOf({ ...snapshot, capturedAt: undefined })
131
+
132
+ /** v1.1: the context a plan made from this snapshot resolves paths in: where discovery (and so apply) ran. */
133
+ export const executionContextOf = (snapshot: Pick<DiscoverySnapshot, "topology">): ExecutionContext => (snapshot.topology.container === "none" ? "host" : "container")
134
+
135
+ export function validateDiscoverySnapshot(value: unknown): Result<DiscoverySnapshot> {
136
+ const c = new Checker()
137
+ const r = shape(c, "", value, ["schema", "version", "capturedAt", "host", "topology", "managers", "harnesses", "runtimes", "configs", "roots"])
138
+ if (!r) return c.result(value)
139
+ header(c, "", r, DISCOVERY_SCHEMA, DISCOVERY_VERSION)
140
+ instant(c, "/capturedAt", r.capturedAt)
141
+
142
+ const host = shape(c, "/host", r.host, ["fingerprint", "platform", "arch", "libc", "target", "supported", "os"])
143
+ if (host) {
144
+ digest(c, "/host/fingerprint", host.fingerprint)
145
+ text(c, "/host/platform", host.platform, 32, NAME)
146
+ text(c, "/host/arch", host.arch, 32, NAME)
147
+ oneOf(c, "/host/libc", host.libc, ["none", "glibc", "musl", "unknown"] as const)
148
+ text(c, "/host/target", host.target, 64, NAME)
149
+ bool(c, "/host/supported", host.supported)
150
+ const os = shape(c, "/host/os", host.os, ["name", "version"])
151
+ if (os) {
152
+ text(c, "/host/os/name", os.name, 64)
153
+ text(c, "/host/os/version", os.version, 64)
154
+ }
155
+ if (typeof host.platform === "string" && typeof host.arch === "string" && typeof host.libc === "string" && host.target !== targetOf(host.platform, host.arch, host.libc as Libc))
156
+ c.add("/host/target", "inconsistent", "target does not match platform, arch and libc")
157
+ if (typeof host.target === "string" && host.supported !== checkTarget(host.target).ok) c.add("/host/supported", "inconsistent", "supported must say exactly whether the target is in the support matrix")
158
+ }
159
+
160
+ const topology = shape(c, "/topology", r.topology, ["container", "shims"])
161
+ if (topology) {
162
+ oneOf(c, "/topology/container", topology.container, ["none", "docker", "podman", "lxc", "wsl", "unknown"] as const)
163
+ list(c, "/topology/shims", topology.shims, 64, (p, v) => {
164
+ const s = shape(c, p, v, ["name", "path", "kind"], ["resolvesTo"])
165
+ if (!s) return
166
+ text(c, pointer(p, "name"), s.name, 64, NAME)
167
+ absPath(c, pointer(p, "path"), s.path)
168
+ oneOf(c, pointer(p, "kind"), s.kind, ["host", "container-shim", "wrapper", "unknown"] as const)
169
+ if (s.resolvesTo !== undefined) absPath(c, pointer(p, "resolvesTo"), s.resolvesTo)
170
+ })
171
+ }
172
+
173
+ const managers = shape(c, "/managers", r.managers, ["package", "runtime", "service"])
174
+ if (managers) {
175
+ subset(c, "/managers/package", managers.package, PACKAGE_MANAGERS)
176
+ list(c, "/managers/runtime", managers.runtime, 8, (p, v) => {
177
+ const rt = shape(c, p, v, ["id", "version", "path"])
178
+ if (!rt) return
179
+ oneOf(c, pointer(p, "id"), rt.id, ["bun", "node"] as const)
180
+ semver(c, pointer(p, "version"), rt.version)
181
+ absPath(c, pointer(p, "path"), rt.path)
182
+ })
183
+ oneOf(c, "/managers/service", managers.service, ["launchd", "systemd-user", "none"] as const)
184
+ const platform = host?.platform
185
+ if (managers.service === "launchd" && platform !== "darwin") c.add("/managers/service", "inconsistent", "launchd is macOS only")
186
+ if (managers.service === "systemd-user" && platform !== "linux") c.add("/managers/service", "inconsistent", "systemd is Linux only")
187
+ }
188
+
189
+ list(c, "/harnesses", r.harnesses, HARNESS_ORDER.length, (p, v) => checkHarness(c, p, v), (v) => (v as { id?: string })?.id)
190
+ list(c, "/runtimes", r.runtimes, RUNTIME_IDS.length, (p, v) => {
191
+ const rt = shape(c, p, v, ["id", "present"], ["version"])
192
+ if (!rt) return
193
+ oneOf(c, pointer(p, "id"), rt.id, RUNTIME_IDS)
194
+ bool(c, pointer(p, "present"), rt.present)
195
+ if (rt.version !== undefined) text(c, pointer(p, "version"), rt.version, 64, VERSION_TEXT)
196
+ }, (v) => (v as { id?: string })?.id)
197
+ list(c, "/configs", r.configs, 128, (p, v) => {
198
+ const cf = shape(c, p, v, ["path", "owner", "format", "sha256"])
199
+ if (!cf) return
200
+ absPath(c, pointer(p, "path"), cf.path)
201
+ oneOf(c, pointer(p, "owner"), cf.owner, [...CONFIG_OWNERS, "package-manager"])
202
+ oneOf(c, pointer(p, "format"), cf.format, ["json", "jsonc", "toml", "plist", "unit"] as const)
203
+ if (cf.sha256 !== null) digest(c, pointer(p, "sha256"), cf.sha256)
204
+ }, (v) => (v as { path?: string })?.path)
205
+ list(c, "/roots", r.roots, ROOT_IDS.length, (p, v) => {
206
+ const root = shape(c, p, v, ["id", "path"], ["containerPath"])
207
+ if (!root) return
208
+ oneOf(c, pointer(p, "id"), root.id, ROOT_IDS)
209
+ absPath(c, pointer(p, "path"), root.path)
210
+ if (root.containerPath !== undefined) absPath(c, pointer(p, "containerPath"), root.containerPath)
211
+ }, (v) => (v as { id?: string })?.id)
212
+
213
+ for (const p of findSecrets(value)) c.add(p, "secret", "discovery never records credentials")
214
+ return c.result(value)
215
+ }
@@ -0,0 +1,143 @@
1
+ // The JSON output envelope and exit codes of the bootstrap commands (`hubctl machine …`, `hubctl setup …`). With
2
+ // --json, stdout carries exactly one envelope; without it, people get text and the same exit code. Exit codes 0–9
3
+ // are the CLI's existing ones (http.ts EXIT) with unchanged meanings; the bootstrap adds 10 and up.
4
+ import { EXIT } from "../../http.ts"
5
+ import { findSecrets } from "./secrets.ts"
6
+ import { bool, Checker, header, isRecord, list, oneOf, shape, text, type Result } from "./validate.ts"
7
+
8
+ export const BOOTSTRAP_EXIT = Object.freeze({
9
+ ...EXIT,
10
+ /** A file the plan would change was edited by someone else, is not ours, or is a symlink the user must edit themselves: nothing was overwritten. */
11
+ conflict: 10,
12
+ /** A hubctl or harness artifact failed authentication: unpinned, tampered, wrong platform, bad signature or evidence, or a refused downgrade. */
13
+ artifactRejected: 11,
14
+ /** The preview is stale, foreign, expired, replayed or malformed: preview again. */
15
+ previewInvalid: 12,
16
+ /** Another apply holds the lock. */
17
+ locked: 13,
18
+ /** An interrupted transaction must be recovered (`hubctl setup recover`) before anything else applies. */
19
+ recoveryRequired: 14,
20
+ /** A harness or provider failed its real probes. */
21
+ notReady: 15,
22
+ } as const)
23
+
24
+ export const ERROR_CODES = [
25
+ "usage",
26
+ "invalid",
27
+ "unsupported_platform",
28
+ "wrong_platform",
29
+ "integrity_mismatch",
30
+ "signature_invalid",
31
+ "downgrade_rejected",
32
+ "below_security_floor",
33
+ "trust_root_rollback",
34
+ "unpinned_artifact",
35
+ "evidence_failed",
36
+ "preview_invalid",
37
+ "preview_foreign",
38
+ "preview_expired",
39
+ "preview_not_yet_valid",
40
+ "preview_replayed",
41
+ "preview_stale",
42
+ "locked",
43
+ "conflict",
44
+ "manual_edit_required",
45
+ "recovery_required",
46
+ "escalation_required",
47
+ "needs_user",
48
+ "forbidden",
49
+ "unauthorized",
50
+ "revoked",
51
+ "offline",
52
+ "not_found",
53
+ "harness_not_ready",
54
+ // v1.1
55
+ "no_service_manager",
56
+ "enrollment_denied",
57
+ "enrollment_expired",
58
+ "cancelled",
59
+ // v1.3
60
+ "service_failed",
61
+ "internal",
62
+ ] as const
63
+ export type ErrorCode = (typeof ERROR_CODES)[number]
64
+
65
+ const EXIT_OF: Readonly<Record<ErrorCode, number>> = {
66
+ usage: EXIT.usage,
67
+ invalid: EXIT.usage,
68
+ unsupported_platform: EXIT.unavailable,
69
+ wrong_platform: BOOTSTRAP_EXIT.artifactRejected,
70
+ integrity_mismatch: BOOTSTRAP_EXIT.artifactRejected,
71
+ signature_invalid: BOOTSTRAP_EXIT.artifactRejected,
72
+ downgrade_rejected: BOOTSTRAP_EXIT.artifactRejected,
73
+ below_security_floor: BOOTSTRAP_EXIT.artifactRejected,
74
+ trust_root_rollback: BOOTSTRAP_EXIT.artifactRejected,
75
+ unpinned_artifact: BOOTSTRAP_EXIT.artifactRejected,
76
+ evidence_failed: BOOTSTRAP_EXIT.artifactRejected,
77
+ preview_invalid: BOOTSTRAP_EXIT.previewInvalid,
78
+ preview_foreign: BOOTSTRAP_EXIT.previewInvalid,
79
+ preview_expired: BOOTSTRAP_EXIT.previewInvalid,
80
+ preview_not_yet_valid: BOOTSTRAP_EXIT.previewInvalid,
81
+ preview_replayed: BOOTSTRAP_EXIT.previewInvalid,
82
+ preview_stale: BOOTSTRAP_EXIT.previewInvalid,
83
+ locked: BOOTSTRAP_EXIT.locked,
84
+ conflict: BOOTSTRAP_EXIT.conflict,
85
+ manual_edit_required: BOOTSTRAP_EXIT.conflict,
86
+ recovery_required: BOOTSTRAP_EXIT.recoveryRequired,
87
+ escalation_required: EXIT.needsUser,
88
+ needs_user: EXIT.needsUser,
89
+ forbidden: EXIT.forbidden,
90
+ unauthorized: EXIT.unauthorized,
91
+ revoked: EXIT.unauthorized,
92
+ offline: EXIT.unavailable,
93
+ not_found: EXIT.notFound,
94
+ harness_not_ready: BOOTSTRAP_EXIT.notReady,
95
+ // v1.1: specific codes for outcomes that already had these exits (the exits are unchanged).
96
+ no_service_manager: EXIT.unavailable,
97
+ enrollment_denied: EXIT.forbidden,
98
+ enrollment_expired: EXIT.needsUser,
99
+ cancelled: EXIT.needsUser,
100
+ // v1.3: an activated service that does not stay running (failed, crash-looping, never settles) is not ready.
101
+ service_failed: BOOTSTRAP_EXIT.notReady,
102
+ internal: EXIT.other,
103
+ }
104
+
105
+ export const exitFor = (code: ErrorCode | string): number => EXIT_OF[code as ErrorCode] ?? EXIT.other
106
+
107
+ export const ENVELOPE_SCHEMA = "hub.bootstrap.result"
108
+
109
+ export interface ResultEnvelope<T = unknown> {
110
+ readonly schema: typeof ENVELOPE_SCHEMA
111
+ readonly version: 1
112
+ /** The command words ("machine status", "setup apply"). */
113
+ readonly command: string
114
+ readonly ok: boolean
115
+ readonly data?: T
116
+ readonly error?: { readonly code: ErrorCode; readonly message: string; readonly hint?: string }
117
+ readonly warnings?: readonly string[]
118
+ }
119
+
120
+ export function validateEnvelope(value: unknown): Result<ResultEnvelope> {
121
+ const c = new Checker()
122
+ const r = shape(c, "", value, ["schema", "version", "command", "ok"], ["data", "error", "warnings"])
123
+ if (!r) return c.result(value)
124
+ header(c, "", r, ENVELOPE_SCHEMA, 1)
125
+ text(c, "/command", r.command, 64, /^[a-z][a-z-]*(?: [a-z][a-z-]*)*$/)
126
+ bool(c, "/ok", r.ok)
127
+ if (r.error !== undefined) {
128
+ const e = shape(c, "/error", r.error, ["code", "message"], ["hint"])
129
+ if (e) {
130
+ oneOf(c, "/error/code", e.code, ERROR_CODES)
131
+ text(c, "/error/message", e.message, 500)
132
+ if (e.hint !== undefined) text(c, "/error/hint", e.hint, 500)
133
+ }
134
+ }
135
+ if (r.ok === true && r.error !== undefined) c.add("/error", "inconsistent", "a success has no error")
136
+ if (r.ok === false && r.error === undefined) c.add("/error", "inconsistent", "a failure names its error")
137
+ if (r.warnings !== undefined) list(c, "/warnings", r.warnings, 64, (p, w) => text(c, p, w, 500))
138
+ // `setup preview --json` prints the SetupPreview, whose /token is a preview token (no authority, contract §3.4),
139
+ // not a credential; its values are still scanned.
140
+ const allow = isRecord(r.data) && r.data.schema === "hub.setup-preview" ? new Set(["/data/token"]) : new Set<string>()
141
+ for (const p of findSecrets(value, "", allow)) c.add(p, "secret", "output never carries credentials")
142
+ return c.result(value)
143
+ }
@@ -0,0 +1,74 @@
1
+ // The executable contract. `hub` and `hubctl` are two names for one command surface: the name never changes what a
2
+ // caller may do (credentials, scopes and grants do). The CLI runs either as a standalone compiled binary (a signed
3
+ // hubctl with the Bun runtime and its assets embedded) or as a script under a runtime (the npm package, or the
4
+ // OpenCode binary acting as bun). Services always start it with an argv word list built here, never a shell string,
5
+ // and their unit templates are embedded assets so a standalone binary needs nothing beside it on disk.
6
+ import path from "node:path"
7
+ import { isAbsolutePath } from "./validate.ts"
8
+
9
+ export const COMMAND_ALIASES = ["hub", "hubctl"] as const
10
+ export type CommandAlias = (typeof COMMAND_ALIASES)[number]
11
+
12
+ /** The single command surface both names resolve to; undefined for any other program name. */
13
+ export function commandSurface(argv0: string): "hub-cli" | undefined {
14
+ return (COMMAND_ALIASES as readonly string[]).includes(path.basename(argv0)) ? "hub-cli" : undefined
15
+ }
16
+
17
+ export type ProgramLayout =
18
+ /** A compiled hubctl: the executable is the whole program. */
19
+ | { readonly kind: "standalone"; readonly executable: string }
20
+ /** A script (hub-cli.js) run by a runtime (bun, node, or OpenCode with BUN_BE_BUN=1). */
21
+ | { readonly kind: "script"; readonly runtime: string; readonly entry: string }
22
+
23
+ /** Where `bun build --compile` mounts the embedded entry (POSIX and Windows forms). */
24
+ const EMBEDDED_PREFIXES = ["/$bunfs/", "B:/~BUN/", "B:\\~BUN\\"]
25
+
26
+ /** This process's layout from process.execPath and the (real path of) process.argv[1]. */
27
+ export function programLayout(program: { readonly execPath: string; readonly entry: string }): ProgramLayout {
28
+ if (EMBEDDED_PREFIXES.some((p) => program.entry.startsWith(p))) return { kind: "standalone", executable: program.execPath }
29
+ return { kind: "script", runtime: program.execPath, entry: program.entry }
30
+ }
31
+
32
+ export const SERVICE_IDS = ["node", "claude-code", "codex-spoke"] as const
33
+ export type ServiceID = (typeof SERVICE_IDS)[number]
34
+
35
+ /** The subcommand each long-running service runs (after the program words). */
36
+ export const SERVICE_COMMANDS: Readonly<Record<ServiceID, readonly string[]>> = Object.freeze({
37
+ node: ["node", "run"],
38
+ "claude-code": ["claude-code", "daemon"],
39
+ "codex-spoke": ["codex-spoke", "serve"],
40
+ })
41
+
42
+ // eslint-disable-next-line no-control-regex
43
+ const CONTROL = /[\u0000-\u001f\u007f]/
44
+
45
+ /** The argv a service unit starts: absolute program words, then the service's subcommand. Throws on unsafe paths. */
46
+ export function serviceArgv(layout: ProgramLayout, service: ServiceID): string[] {
47
+ const program = layout.kind === "standalone" ? [layout.executable] : [layout.runtime, layout.entry]
48
+ for (const word of program) if (!isAbsolutePath(word) || CONTROL.test(word)) throw new Error(`service argv needs absolute, single-line paths, not ${JSON.stringify(word)}`)
49
+ const command = SERVICE_COMMANDS[service]
50
+ if (!command) throw new Error(`unknown service ${String(service)}`)
51
+ return [...program, ...command]
52
+ }
53
+
54
+ export type AssetKind = "launchd-plist" | "systemd-unit"
55
+
56
+ export interface EmbeddedAsset {
57
+ readonly kind: AssetKind
58
+ readonly service: ServiceID
59
+ readonly manager: "launchd" | "systemd-user"
60
+ /** The repository file it is embedded from (the template the source install reads today). */
61
+ readonly source: string
62
+ /** The file name it is installed as. */
63
+ readonly installAs: string
64
+ }
65
+
66
+ /** Every service template a standalone hubctl must carry inside the binary. */
67
+ export const EMBEDDED_ASSETS: Readonly<Record<string, EmbeddedAsset>> = Object.freeze({
68
+ "service/launchd/node": { kind: "launchd-plist", service: "node", manager: "launchd", source: "scripts/io.github.huynle.hub-node.plist", installAs: "io.github.huynle.hub-node.plist" },
69
+ "service/systemd-user/node": { kind: "systemd-unit", service: "node", manager: "systemd-user", source: "scripts/hub-node.service", installAs: "hub-node.service" },
70
+ "service/launchd/claude-code": { kind: "launchd-plist", service: "claude-code", manager: "launchd", source: "scripts/io.github.huynle.hub-claude-code.plist", installAs: "io.github.huynle.hub-claude-code.plist" },
71
+ "service/systemd-user/claude-code": { kind: "systemd-unit", service: "claude-code", manager: "systemd-user", source: "scripts/hub-claude-code.service", installAs: "hub-claude-code.service" },
72
+ "service/launchd/codex-spoke": { kind: "launchd-plist", service: "codex-spoke", manager: "launchd", source: "scripts/io.github.huynle.hub-codex-spoke.plist", installAs: "io.github.huynle.hub-codex-spoke.plist" },
73
+ "service/systemd-user/codex-spoke": { kind: "systemd-unit", service: "codex-spoke", manager: "systemd-user", source: "scripts/hub-codex-spoke.service", installAs: "hub-codex-spoke.service" },
74
+ })
@@ -0,0 +1,202 @@
1
+ // Harness adapters: how the bootstrap discovers, plans for and verifies each agent harness. An installed binary is
2
+ // not a healthy harness: readiness needs real probes (the binary runs and reports its version on the host, not a
3
+ // container shim; its model provider answers; its spoke is connected to the hub), each fresh and passing. Presence
4
+ // alone never counts. Adapters are pure planners: they return typed Actions and run probes through the context the
5
+ // applier gives them; they never get a shell.
6
+ import type { Action } from "./actions.ts"
7
+ import type { DiscoverySnapshot, HarnessPresence } from "./discovery.ts"
8
+ import type { Profile } from "./profile.ts"
9
+
10
+ /** Supported harnesses, in the order the bootstrap offers, installs and verifies them. */
11
+ export const HARNESS_ORDER = ["opencode", "claude-code", "codex"] as const
12
+ export type HarnessID = (typeof HARNESS_ORDER)[number]
13
+
14
+ /** Sandboxed runtimes: opt-in, discovery and verification only (never installed or configured by the bootstrap). */
15
+ export const RUNTIME_IDS = ["openshell"] as const
16
+ export type RuntimeID = (typeof RUNTIME_IDS)[number]
17
+
18
+ /**
19
+ * Upstream authenticity evidence a pinned harness download may carry beyond hub's signed digest pin. It is always
20
+ * optional extra evidence (recorded as verified, unavailable or deferred); the pin itself is mandatory.
21
+ * gpg-manifest Claude Code's GPG-signed per-release manifest.json
22
+ * macos-notarization Apple notarization / codesign of the macOS binary
23
+ * sigstore a sigstore bundle published next to the asset (Codex, where available)
24
+ */
25
+ export const EVIDENCE_KINDS = ["gpg-manifest", "macos-notarization", "sigstore"] as const
26
+ export type EvidenceKind = (typeof EVIDENCE_KINDS)[number]
27
+
28
+ /** Which evidence each harness's upstream can offer. OpenCode offers none: only hub's pinned digest vouches for it. */
29
+ export const HARNESS_EVIDENCE: Readonly<Record<HarnessID, readonly EvidenceKind[]>> = Object.freeze({
30
+ opencode: [],
31
+ "claude-code": ["gpg-manifest", "macos-notarization"],
32
+ // v1.1 (HBS-13): Codex's macOS binaries are codesigned; the Codex source records only sigstore so far, so a pin may
33
+ // list macos-notarization only once the source checks it.
34
+ codex: ["sigstore", "macos-notarization"],
35
+ })
36
+
37
+ /**
38
+ * presence: found on PATH / at a known location (never sufficient). version-exec: executed on the host and printed
39
+ * a version. provider-auth: a real request to its model provider succeeded with the configured credentials.
40
+ * api-health: the harness's local server answered its health endpoint. spoke-connected: the hub sees its spoke.
41
+ */
42
+ export const PROBE_KINDS = ["presence", "version-exec", "provider-auth", "api-health", "spoke-connected"] as const
43
+ export type ProbeKind = (typeof PROBE_KINDS)[number]
44
+ /** Probes that exercise the harness for real; a verify-harness action may only ask for these. */
45
+ export const REAL_PROBES: readonly Exclude<ProbeKind, "presence">[] = ["version-exec", "provider-auth", "api-health", "spoke-connected"]
46
+
47
+ export interface Probe {
48
+ readonly kind: ProbeKind
49
+ readonly ok: boolean
50
+ readonly observedAt: number
51
+ /** One line for people (a version string, an HTTP status); never a credential. */
52
+ readonly detail?: string
53
+ }
54
+
55
+ export interface HarnessVerification {
56
+ readonly harness: HarnessID
57
+ readonly probes: readonly Probe[]
58
+ }
59
+
60
+ /** What each harness must pass before it is ready. */
61
+ export const REQUIRED_PROBES: Readonly<Record<HarnessID, readonly ProbeKind[]>> = Object.freeze({
62
+ opencode: ["version-exec", "provider-auth", "spoke-connected"],
63
+ "claude-code": ["version-exec", "provider-auth", "spoke-connected"],
64
+ codex: ["version-exec", "provider-auth", "spoke-connected"],
65
+ })
66
+
67
+ /** How old a probe may be and still count. */
68
+ export const PROBE_MAX_AGE_MS = 15 * 60_000
69
+ const CLOCK_SKEW_MS = 60_000
70
+
71
+ /** Every required probe was run, passed, and is fresh; any failing or stale required probe means not ready. */
72
+ export function isHarnessReady(verification: HarnessVerification, now: number): boolean {
73
+ const required = REQUIRED_PROBES[verification.harness]
74
+ if (!required) return false
75
+ return required.every((kind) => {
76
+ const probes = verification.probes.filter((p) => p.kind === kind)
77
+ return probes.length > 0 && probes.every((p) => p.ok && p.observedAt <= now + CLOCK_SKEW_MS && now - p.observedAt <= PROBE_MAX_AGE_MS)
78
+ })
79
+ }
80
+
81
+ /**
82
+ * v1.2: what a probe that is not passing means. `integrity`: the install itself is wrong (the binary is missing, does
83
+ * not run, is not the pinned version, is a container shim, its local API does not answer), so apply rolls back.
84
+ * `user`: the install is fine but waits on the user (their provider login, a spoke they start), which only the
85
+ * installed harness lets them do, so apply commits it as "installed, not ready" unless --require-ready is given.
86
+ * The class belongs to the probe kind, never to a probe's detail, so it cannot be argued from harness output.
87
+ */
88
+ export const PROBE_CLASSES = ["integrity", "user"] as const
89
+ export type ProbeClass = (typeof PROBE_CLASSES)[number]
90
+ export const PROBE_CLASS: Readonly<Record<ProbeKind, ProbeClass>> = Object.freeze({
91
+ presence: "integrity",
92
+ "version-exec": "integrity",
93
+ "api-health": "integrity",
94
+ "provider-auth": "user",
95
+ "spoke-connected": "user",
96
+ })
97
+
98
+ /** v1.2: pass (present, passing, fresh); pending (a user probe that is not passing); fail (anything else). */
99
+ export type ProbeStatus = "pass" | "pending" | "fail"
100
+ /** v1.2: ready (every probe passes); pending (only user probes do not); failed (an integrity probe does not). */
101
+ export type ReadinessState = "ready" | "pending" | "failed"
102
+
103
+ export interface ProbeReadiness {
104
+ readonly kind: ProbeKind
105
+ readonly class: ProbeClass
106
+ readonly status: ProbeStatus
107
+ readonly detail?: string
108
+ }
109
+
110
+ export interface HarnessReadiness {
111
+ readonly harness: HarnessID
112
+ readonly state: ReadinessState
113
+ readonly probes: readonly ProbeReadiness[]
114
+ /** What the user does next, in order (empty when ready); never a command hubctl runs. */
115
+ readonly nextSteps: readonly string[]
116
+ }
117
+
118
+ /** v1.2: the user's next step for each pending user probe. */
119
+ export const PENDING_STEPS: Readonly<Record<HarnessID, Readonly<Partial<Record<ProbeKind, string>>>>> = Object.freeze({
120
+ opencode: {
121
+ "provider-auth": "sign in to a model provider on this machine: run `opencode auth login`",
122
+ "spoke-connected": "start OpenCode (`opencode`) so the hub plugin connects its spoke",
123
+ },
124
+ "claude-code": {
125
+ "provider-auth": "log in to Claude Code on this machine: run `claude auth login`",
126
+ "spoke-connected": "start the claude-code spoke (`hub claude-code daemon`, or activate the hub services)",
127
+ },
128
+ codex: {
129
+ "provider-auth": "log in to Codex on this machine: run `codex login`",
130
+ "spoke-connected": "start the Codex spoke (`hub codex-spoke serve`, or activate the hub services)",
131
+ },
132
+ })
133
+ export const VERIFY_STEP = "then check readiness with `hubctl setup verify`"
134
+
135
+ const fresh = (p: Probe, now: number) => p.ok && p.observedAt <= now + CLOCK_SKEW_MS && now - p.observedAt <= PROBE_MAX_AGE_MS
136
+
137
+ /**
138
+ * v1.2: classifies a verification. Every REQUIRED_PROBES kind and every kind in `also` (the probes a verify-harness
139
+ * action asks for) must be present; a missing one is a failure (an adapter reports every probe it owes). `ready`
140
+ * implies isHarnessReady.
141
+ */
142
+ export function harnessReadiness(verification: HarnessVerification, now: number, also: readonly ProbeKind[] = []): HarnessReadiness {
143
+ const required = REQUIRED_PROBES[verification.harness] ?? []
144
+ const kinds = [...required, ...also.filter((k) => !required.includes(k))]
145
+ const probes: ProbeReadiness[] = kinds.map((kind) => {
146
+ const found = verification.probes.filter((p) => p.kind === kind)
147
+ const cls = PROBE_CLASS[kind]
148
+ if (found.length === 0) return { kind, class: cls, status: "fail", detail: "not run" }
149
+ const bad = found.find((p) => !fresh(p, now))
150
+ const detail = (bad ?? found[found.length - 1]!).detail
151
+ const status: ProbeStatus = !bad ? "pass" : cls === "user" ? "pending" : "fail"
152
+ return { kind, class: cls, status, ...(detail !== undefined ? { detail } : {}) }
153
+ })
154
+ const state: ReadinessState = probes.some((p) => p.status === "fail") ? "failed" : probes.some((p) => p.status === "pending") ? "pending" : "ready"
155
+ const steps = state === "pending" ? probes.flatMap((p) => (p.status === "pending" ? (PENDING_STEPS[verification.harness]?.[p.kind] ?? []) : [])) : []
156
+ return { harness: verification.harness, state, probes, nextSteps: state === "pending" ? [...steps, VERIFY_STEP] : [] }
157
+ }
158
+
159
+ /** What an adapter may use: typed, injected capabilities only (the applier implements them). */
160
+ export interface AdapterContext {
161
+ readonly env: Readonly<Record<string, string | undefined>>
162
+ readonly home: string
163
+ readonly platform: string
164
+ readonly arch: string
165
+ readonly now: () => number
166
+ readonly signal?: AbortSignal
167
+ }
168
+
169
+ export interface PlanInput {
170
+ readonly snapshot: DiscoverySnapshot
171
+ readonly profile: Profile
172
+ }
173
+
174
+ export interface HarnessAdapter {
175
+ readonly id: HarnessID
176
+ /** Presence and configuration only; health stays "unverified" until verify runs real probes. */
177
+ discover(context: AdapterContext): Promise<HarnessPresence>
178
+ /** Deterministic: the same snapshot and profile always give the same actions. */
179
+ plan(input: PlanInput): readonly Action[]
180
+ /** Runs the real probes (REQUIRED_PROBES) and reports each, passing or not. */
181
+ verify(context: AdapterContext): Promise<HarnessVerification>
182
+ }
183
+
184
+ export interface AdapterRegistry {
185
+ register(adapter: HarnessAdapter): void
186
+ get(id: HarnessID): HarnessAdapter | undefined
187
+ /** Registered adapters in HARNESS_ORDER. */
188
+ list(): readonly HarnessAdapter[]
189
+ }
190
+
191
+ export function createAdapterRegistry(): AdapterRegistry {
192
+ const adapters = new Map<HarnessID, HarnessAdapter>()
193
+ return {
194
+ register(adapter) {
195
+ if (!(HARNESS_ORDER as readonly string[]).includes(adapter.id)) throw new Error(`unknown harness ${String(adapter.id)} (one of ${HARNESS_ORDER.join(", ")})`)
196
+ if (adapters.has(adapter.id)) throw new Error(`harness ${adapter.id} is already registered`)
197
+ adapters.set(adapter.id, adapter)
198
+ },
199
+ get: (id) => adapters.get(id),
200
+ list: () => HARNESS_ORDER.flatMap((id) => adapters.get(id) ?? []),
201
+ }
202
+ }
@@ -0,0 +1,28 @@
1
+ // Bootstrap contracts, version 1 (frozen). The types, validators and pure decision rules every bootstrap layer
2
+ // shares: discovery, planning and preview, profiles, artifact authentication, the executable contract, enrollment
3
+ // status, the transaction journal and lock, harness adapters, and the output envelope. See
4
+ // docs/bootstrap-contracts.md for the threat model and the requirement → check mapping.
5
+ //
6
+ // Changing a contract means a new schema version (and a migration where documents persist); never an in-place
7
+ // change of meaning.
8
+ export const CONTRACT_VERSION = 1
9
+ /** The amendment of the v1 contracts this code implements (docs/bootstrap-contracts.md, items marked "v1.1"/"v1.2"). */
10
+ export const CONTRACT_AMENDMENT = "1.2"
11
+
12
+ export * from "./validate.ts"
13
+ export * from "./canonical.ts"
14
+ export * from "./secrets.ts"
15
+ export * from "./platform.ts"
16
+ export * from "./paths.ts"
17
+ export * from "./exec.ts"
18
+ export * from "./harness.ts"
19
+ export * from "./actions.ts"
20
+ export * from "./discovery.ts"
21
+ export * from "./profile.ts"
22
+ export * from "./plan.ts"
23
+ export * from "./preview.ts"
24
+ export * from "./artifact.ts"
25
+ export * from "./credentials.ts"
26
+ export * from "./journal.ts"
27
+ export * from "./envelope.ts"
28
+