@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,171 @@
1
+ // The plan contributors the CLI adds beside the harness adapters and the node services (preview/plan.ts
2
+ // PlanContributor): pure, deterministic functions of (snapshot, profile) and what they were built with, so preview and
3
+ // apply plan the same digest.
4
+ //
5
+ // hubctlContributor the standalone hubctl this verified manifest pins for the host's exact target, into
6
+ // user-bin:hubctl (the stable authority services and MCP clients run), and the `hub` alias
7
+ // next to it (one command surface; the name grants nothing).
8
+ // enrollmentContributor inline enrollment: one enroll-machine for the hub the user named, only while this machine
9
+ // is not enrolled with it (the device code is approved by a person on the hub).
10
+ // refusing a contributor that can refuse: planSetup has no refusal channel for contributors, so a
11
+ // Result failure is thrown as a typed PlanRefusal the CLI catches and maps with exitFor.
12
+ import { ERROR_CODES, harnessCurrentRef, HARNESS_ORDER, selectArtifact, type Action, type ErrorCode, type HarnessID, type Issue, type PathRef, type PlanInput, type Result, type VerifiedManifest } from "../bootstrap/contracts/index.ts"
13
+ import type { PlanContributor } from "../bootstrap/preview/plan.ts"
14
+ import { userBinLinkDecision } from "./user-bin.ts"
15
+
16
+ export const HUBCTL_REF = { root: "user-bin", rel: "hubctl" } as const
17
+ export const HUB_ALIAS_REF = { root: "user-bin", rel: "hub" } as const
18
+
19
+ /** A plan that cannot be made, with the envelope error code it maps to. */
20
+ export class PlanRefusal extends Error {
21
+ override readonly name = "PlanRefusal"
22
+ constructor(
23
+ readonly code: ErrorCode,
24
+ message: string,
25
+ ) {
26
+ super(message)
27
+ }
28
+ }
29
+
30
+ /** The envelope code for a planner issue: the issue code when it is one, else `invalid`. */
31
+ export const errorCodeOf = (code: string): ErrorCode => ((ERROR_CODES as readonly string[]).includes(code) ? (code as ErrorCode) : "invalid")
32
+
33
+ export const issuesText = (issues: readonly Issue[]): string => issues.map((i) => `${i.path ? `${i.path}: ` : ""}${i.message}`).join("; ")
34
+
35
+ /** A contributor from a planner that returns a Result: a refusal is thrown as a PlanRefusal. */
36
+ export function refusing(plan: (input: PlanInput) => Result<readonly Action[]>): PlanContributor {
37
+ return (input) => {
38
+ const r = plan(input)
39
+ if (!r.ok) throw new PlanRefusal(errorCodeOf(r.issues[0]!.code), issuesText(r.issues))
40
+ return r.value
41
+ }
42
+ }
43
+
44
+ /**
45
+ * What is installed now (read-only): a file's digest (undefined when there is no regular file) and whether a path is
46
+ * a symlink to a target. The CLI reads the files the plan would write; tests inject fakes.
47
+ */
48
+ export interface InstalledInspector {
49
+ sha256(ref: PathRef): string | undefined
50
+ linksTo(ref: PathRef, target: PathRef): boolean
51
+ }
52
+
53
+ /**
54
+ * The pinned hubctl and its `hub` alias, each only when not already in place: an installed hubctl with the pinned
55
+ * artifact's digest is that artifact, whichever path the hubctl that plans runs from (HBS-12 E3: a converged host
56
+ * plans no install and no link).
57
+ */
58
+ export function hubctlContributor(options: { readonly manifest?: VerifiedManifest; readonly installed?: InstalledInspector }): PlanContributor {
59
+ return ({ snapshot }) => {
60
+ const manifest = options.manifest
61
+ if (!manifest) return []
62
+ const artifact = selectArtifact(manifest, snapshot.host.target)
63
+ if (!artifact.ok) return []
64
+ const installed = options.installed
65
+ const actions: Action[] = []
66
+ if (installed?.sha256(HUBCTL_REF) !== artifact.value.sha256)
67
+ actions.push({
68
+ id: "install-hubctl",
69
+ kind: "install-artifact",
70
+ escalation: "user",
71
+ summary: `Install hubctl ${manifest.release} for ${snapshot.host.target} as ~/.local/bin/hubctl (pinned by the signed release manifest)`,
72
+ artifact: "hubctl",
73
+ version: manifest.release,
74
+ target: snapshot.host.target,
75
+ sha256: artifact.value.sha256,
76
+ path: { ...HUBCTL_REF },
77
+ })
78
+ if (!installed?.linksTo(HUB_ALIAS_REF, HUBCTL_REF))
79
+ actions.push({ id: "link-hub", kind: "link-executable", escalation: "user", summary: "Link hub to hubctl (the same command surface)", path: { ...HUB_ALIAS_REF }, target: { ...HUBCTL_REF } })
80
+ return actions
81
+ }
82
+ }
83
+
84
+ /** Read-only facts about paths a plan would link, and the user's PATH (the CLI reads them through the probe). */
85
+ export interface PathInspector {
86
+ /** What is at `ref` now, not following a final symlink. */
87
+ entry(ref: PathRef): "missing" | "file" | "directory" | "symlink" | "other"
88
+ /** The absolute path a symlink at `ref` points to (resolved against its directory); undefined for anything else. */
89
+ linkTarget(ref: PathRef): string | undefined
90
+ /** The absolute path of `ref` on this host. */
91
+ where(ref: PathRef): string | undefined
92
+ /** The first `name` on the user's PATH, as found (not followed). */
93
+ onPath(name: string): string | undefined
94
+ }
95
+
96
+ /** Each harness's executable: its command name, and where it is inside an install root (the adapters' unpackers). */
97
+ export const HARNESS_EXECUTABLES: Readonly<Record<HarnessID, { readonly name: string; readonly rel: string; readonly label: string }>> = Object.freeze({
98
+ opencode: { name: "opencode", rel: "bin/opencode", label: "OpenCode" },
99
+ "claude-code": { name: "claude", rel: "claude", label: "Claude Code" },
100
+ codex: { name: "codex", rel: "bin/codex", label: "Codex" },
101
+ })
102
+
103
+ /**
104
+ * Contract amendment A6 (HBS-12 E4): a hub-installed harness goes on PATH through a typed link in user-bin
105
+ * (`~/.local/bin/opencode` → `data:hub/harnesses/opencode/current/bin/opencode`), journaled and rolled back like any
106
+ * link. Only for a harness hub makes current (this plan selects it, or its `current` is already there), and only when
107
+ * nothing else of that name is on PATH and user-bin holds nothing of that name but a link of hub's own (one into
108
+ * hub's harness directory): a user's binary is never shadowed or replaced.
109
+ */
110
+ export function harnessLinkContributor(inspector: PathInspector): PlanContributor {
111
+ return ({ profile, planned = [] }) => {
112
+ const actions: Action[] = []
113
+ for (const harness of HARNESS_ORDER) {
114
+ if (!profile.harnesses.includes(harness)) continue
115
+ const exe = HARNESS_EXECUTABLES[harness]
116
+ const current = harnessCurrentRef(harness)
117
+ const target: PathRef = { root: current.root, rel: `${current.rel}/${exe.rel}` }
118
+ const selected = planned.some((a) => a.kind === "select-harness-version" && a.harness === harness)
119
+ if (!selected && inspector.entry(target) !== "file") continue
120
+ const link: PathRef = { root: "user-bin", rel: exe.name }
121
+ const linkPath = inspector.where(link)
122
+ const targetPath = inspector.where(target)
123
+ const ownDir = inspector.where({ root: current.root, rel: `hub/harnesses/${harness}` })
124
+ if (!linkPath || !targetPath || !ownDir) continue
125
+ const pointsTo = inspector.linkTarget(link)
126
+ const found = inspector.onPath(exe.name)
127
+ const decision = userBinLinkDecision({
128
+ entry: inspector.entry(link),
129
+ ...(pointsTo !== undefined ? { pointsTo } : {}),
130
+ target: targetPath,
131
+ linkPath,
132
+ hubOwned: (to) => to.startsWith(`${ownDir}/`),
133
+ ...(found !== undefined ? { foundOnPath: found } : {}),
134
+ })
135
+ if (decision !== "create" && decision !== "replace") continue
136
+ actions.push({
137
+ id: `link-${harness}`,
138
+ kind: "link-executable",
139
+ escalation: "user",
140
+ summary: `Put the hub-installed ${exe.label} on PATH: link ~/.local/bin/${exe.name} to its current version`,
141
+ path: link,
142
+ target,
143
+ })
144
+ }
145
+ return actions
146
+ }
147
+ }
148
+
149
+ export interface EnrollmentChoices {
150
+ /** The hub to enroll with (inline enrollment); without it the plan does not enroll. */
151
+ readonly server?: string
152
+ readonly machineName: string
153
+ /** The hub this machine is enrolled with now, if any (read from the credential store before planning). */
154
+ readonly enrolledWith: string | undefined
155
+ }
156
+
157
+ export function enrollmentContributor(choices: EnrollmentChoices): PlanContributor {
158
+ return () => {
159
+ if (!choices.server || choices.enrolledWith === choices.server) return []
160
+ return [
161
+ {
162
+ id: "enroll-machine",
163
+ kind: "enroll-machine",
164
+ escalation: "user",
165
+ summary: `Enroll this machine with ${choices.server} (device code; you approve it on the hub)`,
166
+ server: choices.server,
167
+ machineName: choices.machineName,
168
+ },
169
+ ]
170
+ }
171
+ }
@@ -0,0 +1,74 @@
1
+ // Per-harness dispatch (HBS-13 integration item 1, adapter amendment A2). The engine holds one executor per action
2
+ // kind (ExecutorMap) and builtinExecutors one HarnessSource, but OpenCode, Claude Code and Codex each supply their own
3
+ // install-harness source and verify-harness (and OpenCode its register-mcp-server) executor. These route each step
4
+ // by `action.harness`. A harness nothing was registered for is refused with a typed failure at preflight, never
5
+ // thrown and never handed to another harness's executor.
6
+ import type { HarnessID, InstallHarness } from "../bootstrap/contracts/index.ts"
7
+ import type { HarnessSource } from "../bootstrap/transaction/builtin.ts"
8
+ import type { ActionExecutor, ActionOf, StepPlan } from "../bootstrap/transaction/executor.ts"
9
+ import { TxError, txFail, txOk } from "../bootstrap/transaction/result.ts"
10
+
11
+ /** The action kinds that name a harness. */
12
+ export type HarnessScopedKind = "install-harness" | "verify-harness" | "register-mcp-server"
13
+
14
+ const LABEL: Readonly<Record<HarnessScopedKind, string>> = {
15
+ "install-harness": "installs",
16
+ "verify-harness": "verifies",
17
+ "register-mcp-server": "registers MCP servers with",
18
+ }
19
+
20
+ /** One executor of `kind` that hands each action to the executor registered for its harness. */
21
+ export function dispatchByHarness<K extends HarnessScopedKind>(kind: K, executors: Partial<Record<HarnessID, ActionExecutor<K>>>): ActionExecutor<K> {
22
+ const harnessOf = (action: ActionOf<K>) => (action as { readonly harness: HarnessID }).harness
23
+ const pick = (action: ActionOf<K>) => executors[harnessOf(action)]
24
+ const missing = (action: ActionOf<K>) => txFail("internal", `this hubctl ${LABEL[kind]} no ${harnessOf(action)} (no executor for ${kind} on ${harnessOf(action)})`)
25
+ // The journal class must not depend on what is registered: an unregistered harness keeps the kind's class.
26
+ const fallback = (action: ActionOf<K>): StepPlan =>
27
+ kind === "verify-harness" ? { class: "external" } : { class: kind === "install-harness" ? "harness" : "config", path: (action as { path: StepPlan["path"] }).path }
28
+ return {
29
+ kind,
30
+ plan: (action) => pick(action)?.plan(action) ?? fallback(action),
31
+ check: async (action, ctx) => {
32
+ const exec = pick(action)
33
+ if (!exec) return missing(action)
34
+ return exec.check ? exec.check(action, ctx) : txOk(undefined)
35
+ },
36
+ change: async (action, ctx) => {
37
+ const exec = pick(action)
38
+ return exec ? exec.change(action, ctx) : missing(action)
39
+ },
40
+ undo: async (action, ctx) => {
41
+ const exec = pick(action)
42
+ if (!exec) return missing(action)
43
+ return exec.undo ? exec.undo(action, ctx) : txOk(undefined)
44
+ },
45
+ }
46
+ }
47
+
48
+ export interface HarnessSources {
49
+ /** The verified hub manifest every source pins against (one per release). */
50
+ readonly manifest: HarnessSource["manifest"]
51
+ readonly sources: Partial<Record<HarnessID, HarnessSource>>
52
+ /** Explicit --allow-downgrade (for every harness). */
53
+ readonly allowDowngrade?: boolean
54
+ }
55
+
56
+ /** One HarnessSource (builtinExecutors({harness})) over the adapters' sources, by `action.harness`. */
57
+ export function dispatchHarnessSource(options: HarnessSources): HarnessSource {
58
+ const pick = (action: InstallHarness) => options.sources[action.harness]
59
+ return {
60
+ manifest: options.manifest,
61
+ ...(options.allowDowngrade ? { allowDowngrade: true } : {}),
62
+ fetch: async (action, staging) => {
63
+ const source = pick(action)
64
+ if (!source) throw new TxError("internal", `this hubctl has no source to install ${action.harness} from`)
65
+ return source.fetch(action, staging)
66
+ },
67
+ unpack: async (action, file, staging) => {
68
+ const source = pick(action)
69
+ return source ? source.unpack(action, file, staging) : txFail("internal", `this hubctl has no source to install ${action.harness} from`)
70
+ },
71
+ installed: (harness) => options.sources[harness]?.installed?.(harness),
72
+ floor: (harness) => options.sources[harness]?.floor?.(harness),
73
+ }
74
+ }
@@ -0,0 +1,109 @@
1
+ // The LaunchDriver of `hubctl setup`'s optional verification launch (launch-session; node-services/launch.ts),
2
+ // over the existing hub launch API (the SDK's hub.launches, as `hub launch` uses it; no new central capability):
3
+ //
4
+ // start one launch of the harness in the workspace (checkout mode: the repository as it is), pinned to
5
+ // this machine (placement require), ending when idle, cut off at the bound, cleaned up by the hub
6
+ // awaitResponse until the launch runs a session (or ended without one: failed)
7
+ // awaitIdle until it ended idle (any other outcome fails)
8
+ // inspect what the node reported it left in the workspace (uncommitted changes, commits)
9
+ // cleanup nothing more: cleanup "remove" makes the hub's node remove the launch's own workspace changes
10
+ // cancel stops the launch (an ended one is already gone)
11
+ // slotFree whether the launch still counts against the node (any state but ended)
12
+ //
13
+ // The launch carries no credential; the caller's own hub login (`hub login`, an app token allowed to launch) is
14
+ // what the SDK client authenticates with. Every wait polls with the verification's abort signal.
15
+ import { randomBytes } from "node:crypto"
16
+ import type { LaunchSpec, LaunchView, Launches } from "../../../sdk/src/index.ts"
17
+ import type { LaunchDriver } from "../bootstrap/adapters/node-services/launch.ts"
18
+ import { txFail, txOk } from "../bootstrap/transaction/result.ts"
19
+
20
+ export const VERIFY_PROMPT = "This is a hubctl setup check: reply with the single word OK and change nothing."
21
+
22
+ export interface HubLaunchDriverOptions {
23
+ readonly launches: Pick<Launches, "create" | "get" | "stop" | "list">
24
+ /** This machine's ID (the stored machine credential's); undefined: not enrolled. */
25
+ readonly machineID: () => Promise<string | undefined>
26
+ readonly pollMs?: number
27
+ readonly sleep?: (ms: number, signal: AbortSignal) => Promise<void>
28
+ }
29
+
30
+ const ACTIVE = ["queued", "placing", "provisioning", "running"] as const
31
+
32
+ const abortableSleep = (ms: number, signal: AbortSignal) =>
33
+ new Promise<void>((resolve, reject) => {
34
+ if (signal.aborted) return reject(new Error("aborted"))
35
+ const t = setTimeout(resolve, ms)
36
+ signal.addEventListener("abort", () => (clearTimeout(t), reject(new Error("aborted"))), { once: true })
37
+ })
38
+
39
+ const message = (error: unknown) => (error instanceof Error ? error.message : String(error))
40
+
41
+ export function hubLaunchDriver(options: HubLaunchDriverOptions): LaunchDriver {
42
+ const pollMs = options.pollMs ?? 1000
43
+ const sleep = options.sleep ?? abortableSleep
44
+ const get = async (id: string): Promise<LaunchView> => (await options.launches.get(id)).launch
45
+
46
+ /** Polls until `done` says so (true: ok; a string: failed with it). */
47
+ async function until(id: string, signal: AbortSignal, done: (l: LaunchView) => true | string | undefined) {
48
+ try {
49
+ for (;;) {
50
+ const r = done(await get(id))
51
+ if (r === true) return txOk(undefined)
52
+ if (typeof r === "string") return txFail("harness_not_ready", r)
53
+ await sleep(pollMs, signal)
54
+ }
55
+ } catch (error) {
56
+ return txFail(signal.aborted ? "cancelled" : "offline", signal.aborted ? "cancelled" : `the hub could not be asked about launch ${id} (${message(error)})`)
57
+ }
58
+ }
59
+
60
+ return {
61
+ async start(request, signal) {
62
+ const machine = await options.machineID().catch(() => undefined)
63
+ if (!machine) return txFail("unauthorized", "this machine is not enrolled (run `hubctl machine enroll`)")
64
+ const spec: LaunchSpec = {
65
+ key: `hubctl-verify-${randomBytes(8).toString("hex")}`,
66
+ harness: request.harness,
67
+ workspace: { repo: request.workspace, mode: "checkout" },
68
+ placement: { machine, mode: "require" },
69
+ prompt: VERIFY_PROMPT,
70
+ labels: { "hub.setup": "verify" },
71
+ end: { when: "idle", timeout: `${Math.max(1, Math.ceil(request.maxDurationS / 60))}m`, cleanup: "remove" },
72
+ }
73
+ try {
74
+ const created = await options.launches.create(spec, { signal })
75
+ return txOk({ launchID: created.launch.id })
76
+ } catch (error) {
77
+ return txFail("harness_not_ready", `the hub refused the verification launch (${message(error)}); it needs \`hub login --scope sessions:launch\` and a grant with verb launch`)
78
+ }
79
+ },
80
+ awaitResponse: (id, signal) => until(id, signal, (l) => (l.state === "running" && l.sessionID ? true : l.state === "ended" ? `the launch ended ${l.outcome ?? ""} before its session answered`.trim() : undefined)),
81
+ awaitIdle: (id, signal) => until(id, signal, (l) => (l.state !== "ended" ? undefined : l.outcome === "idle" ? true : `the launch ended ${l.outcome ?? "without an outcome"}, not idle`)),
82
+ async inspect(id) {
83
+ try {
84
+ const a = (await get(id)).artifacts
85
+ const leftovers = [...(a?.dirty ? ["uncommitted changes"] : []), ...(a?.commits ? [`${a.commits} commit(s) on the launch branch`] : [])]
86
+ return txOk({ leftovers })
87
+ } catch (error) {
88
+ return txFail("offline", `the hub could not be asked about launch ${id} (${message(error)})`)
89
+ }
90
+ },
91
+ cleanup: async () => txOk(undefined),
92
+ async cancel(id) {
93
+ try {
94
+ if ((await get(id)).state !== "ended") await options.launches.stop(id)
95
+ return txOk(undefined)
96
+ } catch (error) {
97
+ return txFail("offline", `launch ${id} could not be stopped (${message(error)})`)
98
+ }
99
+ },
100
+ async slotFree(id) {
101
+ try {
102
+ const { launches } = await options.launches.list({ state: [...ACTIVE] })
103
+ return txOk(!launches.some((l) => l.id === id))
104
+ } catch (error) {
105
+ return txFail("offline", `the hub could not list launches (${message(error)})`)
106
+ }
107
+ },
108
+ }
109
+ }