@huynle/hub 0.7.1 → 0.7.3

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 (194) hide show
  1. package/CHANGELOG.md +87 -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/protocol/src/harness-events.ts +2 -2
  142. package/packages/protocol/src/index.ts +70 -7
  143. package/packages/runtime-openshell/package.json +1 -1
  144. package/packages/runtime-sdk/package.json +1 -1
  145. package/packages/sdk/package.json +1 -1
  146. package/packages/sdk/src/client.ts +55 -4
  147. package/packages/sdk/src/errors.ts +1 -0
  148. package/packages/sdk/src/sse.ts +4 -1
  149. package/packages/sdk/src/types.ts +59 -0
  150. package/packages/spoke-sdk/package.json +1 -1
  151. package/packages/spoke-sdk/src/enroll.ts +26 -4
  152. package/src/central/agent/app-tokens.ts +27 -17
  153. package/src/central/agent/archive-inactive.ts +66 -0
  154. package/src/central/agent/directory-routes.ts +155 -12
  155. package/src/central/agent/directory.ts +1 -1
  156. package/src/central/agent/errors.ts +2 -0
  157. package/src/central/agent/event-cursor.ts +49 -0
  158. package/src/central/agent/event-writers.ts +181 -0
  159. package/src/central/agent/events-routes.ts +148 -0
  160. package/src/central/agent/events.ts +277 -0
  161. package/src/central/agent/names.ts +1 -1
  162. package/src/central/agent/read-routes.ts +32 -36
  163. package/src/central/agent/routes.ts +14 -11
  164. package/src/central/agent/watch.ts +65 -174
  165. package/src/central/agent/webhook-routes.ts +10 -2
  166. package/src/central/agent/webhooks.ts +49 -5
  167. package/src/central/auth.ts +127 -1
  168. package/src/central/backend/index.ts +3 -0
  169. package/src/central/backend/types.ts +2 -0
  170. package/src/central/http.ts +116 -32
  171. package/src/central/mcp-http.ts +116 -0
  172. package/src/central/push.ts +14 -1
  173. package/src/central/registry.ts +34 -5
  174. package/src/central/store.ts +60 -0
  175. package/src/config.ts +70 -7
  176. package/src/index.ts +90 -91
  177. package/src/spoke/launchers.ts +109 -3
  178. package/src/spoke/spoke.ts +8 -4
  179. package/src/spoke/timeline.ts +59 -3
  180. package/src/spoke/tracker.ts +59 -6
  181. package/src/status.ts +97 -6
  182. package/web/dist/apple-touch-icon.png +0 -0
  183. package/web/dist/assets/index-Dm8OmkaO.css +1 -0
  184. package/web/dist/assets/index-FUJbj_Du.js +20 -0
  185. package/web/dist/favicon.ico +0 -0
  186. package/web/dist/icon-192.png +0 -0
  187. package/web/dist/icon-512.png +0 -0
  188. package/web/dist/icon-maskable-512.png +0 -0
  189. package/web/dist/icon.svg +5 -5
  190. package/web/dist/index.html +10 -4
  191. package/web/dist/manifest.webmanifest +2 -2
  192. package/web/dist/sw.js +4 -1
  193. package/web/dist/assets/index-D9zMbxSg.css +0 -1
  194. package/web/dist/assets/index-db1z4duA.js +0 -20
@@ -0,0 +1,994 @@
1
+ // The transactional apply engine. It applies a contract SetupPlan on this host, one typed action at a time,
2
+ // through per-action executors (executor.ts), and guarantees:
3
+ //
4
+ // - one apply at a time: the O_EXCL apply lock (lock.ts); a live holder is refused (locked), another host's lock
5
+ // is never broken, a stale one is only broken once no interrupted transaction is waiting for recovery;
6
+ // - nothing runs on a stale preview: apply re-plans through the caller's hook and revalidates the token
7
+ // (revalidatePreview: host, expiry, clock skew, single-use nonce, snapshot and plan digests), then refuses
8
+ // blockers and failed evidence (previewApplicable);
9
+ // - explicit consent: an elevated action needs `escalate`, a service activation `confirmActivation`;
10
+ // - preflight before any change: a valid plan for exactly this host's target, an executor for every action,
11
+ // every target path resolved without symlinks, and every executor's read-only check (preconditions, pinned
12
+ // artifacts, versions);
13
+ // - write-ahead ownership journal: each change is journaled ("pending", with the state before, the state it will
14
+ // leave and the backup) before it happens, and "done" after; contents and credentials never enter it;
15
+ // - atomic replacement: new content is staged beside the target and renamed over it;
16
+ // - bounded memory: large content (a harness, hubctl) reaches the engine as files of the step's staging area
17
+ // (stream.ts), never as bytes; it is moved or copied in chunks, digested in chunks, and backed up in chunks;
18
+ // - rollback on any failure and recovery after a crash (recover): newest entry first, undo only what is still
19
+ // exactly as we left it, keep anything changed since (reported as a conflict), clear (never restore) a
20
+ // credential, refuse a backup that does not match what it backed up;
21
+ // - resume after a crash: continue the stored plan from the interrupted step, when the host still agrees.
22
+ //
23
+ // The engine performs only the TxFs operations; it never spawns processes or runs commands.
24
+ import { randomBytes } from "node:crypto"
25
+ import {
26
+ isSecretValue,
27
+ JOURNAL_SCHEMA,
28
+ LOCK_MAX_MS,
29
+ planDigest,
30
+ previewApplicable,
31
+ recoveryDecision,
32
+ revalidatePreview,
33
+ SETUP_PREVIEW_SCHEMA,
34
+ snapshotDigest,
35
+ validateSetupPlan,
36
+ type Action,
37
+ type ApplyLock,
38
+ type DiscoverySnapshot,
39
+ type ExecutionContext,
40
+ type ErrorCode,
41
+ type EvidenceResult,
42
+ type FileState,
43
+ type JournalEntry,
44
+ type JournalHooks,
45
+ type JournalState,
46
+ type PathRef,
47
+ type PlanBlocker,
48
+ type PreviewEvidence,
49
+ type RecoveryStep,
50
+ type RootMapping,
51
+ type SetupPlan,
52
+ type TransactionJournal,
53
+ } from "../contracts/index.ts"
54
+ import type { ActionExecutor, Change, ExecutorMap, HostInfo, StepContext, StepPlan, TreeFile } from "./executor.ts"
55
+ import { nodeFs, type TxFs } from "./fs.ts"
56
+ import { breakStaleLock, releaseLock, renewLock, tryAcquireLock, type LockIO } from "./lock.ts"
57
+ import { resolveTarget, rootDir, type ResolveOptions } from "./resolve.ts"
58
+ import { errnoOf, fromResult, TxError, txFail, txOk, type TxFailure, type TxResult } from "./result.ts"
59
+ import { fileState, sameState, sha256Of, treeDigestOfFiles } from "./state.ts"
60
+ import { TxStore } from "./store.ts"
61
+ import { copyFile, fsStaging, type StagedFile, type StagingArea } from "./stream.ts"
62
+
63
+ export interface ApplierConfig {
64
+ /** The roots discovery mapped on this host. */
65
+ readonly roots: readonly RootMapping[]
66
+ /**
67
+ * v1.1: where this apply runs. "container": inside the container discovery found itself in, so every root
68
+ * resolves to containerPath ?? path (contract rootPathIn); "host" (default): to path.
69
+ */
70
+ readonly context?: ExecutionContext
71
+ /** This host, computed fresh (hostFingerprint, targetOf). */
72
+ readonly host: HostInfo
73
+ readonly executors: ExecutorMap
74
+ readonly fs?: TxFs
75
+ readonly now?: () => number
76
+ readonly pid?: number
77
+ /** Whether a process on this host is alive (default: signal 0). */
78
+ readonly isAlive?: (pid: number) => boolean
79
+ /** Transaction IDs (default: random). */
80
+ readonly newID?: () => string
81
+ /** Observers (logging, progress); they cannot change actions. */
82
+ readonly hooks?: JournalHooks
83
+ /**
84
+ * Keep a committed transaction's backups (`hubctl setup rollback` restores from them later). Default false: a
85
+ * commit drops them, so only an interrupted or failed transaction can be undone exactly.
86
+ */
87
+ readonly keepBackups?: boolean
88
+ }
89
+
90
+ /** Explicit consent flags (--escalate, --confirm-activation). Never defaults. */
91
+ export interface Consent {
92
+ readonly escalate?: boolean
93
+ readonly confirmActivation?: boolean
94
+ }
95
+
96
+ /** What a fresh discovery + plan produced: apply compares it against the token and refuses blockers. */
97
+ export interface FreshPlan {
98
+ readonly plan: SetupPlan
99
+ /** v1.1: the fresh snapshot the plan was made from; when given, its canonical digest must be the plan's. */
100
+ readonly snapshot?: DiscoverySnapshot
101
+ readonly blockers?: readonly PlanBlocker[]
102
+ readonly evidence?: readonly PreviewEvidence[]
103
+ }
104
+
105
+ export interface ApplyRequest {
106
+ /** The preview token (unknown: it is validated here). */
107
+ readonly token: unknown
108
+ /**
109
+ * The revalidation hook: rediscover and re-plan now. Must only read: apply calls it once before taking the lock
110
+ * (so a refused apply writes nothing) and again under the lock.
111
+ */
112
+ readonly replan: () => Promise<FreshPlan>
113
+ readonly consent?: Consent
114
+ }
115
+
116
+ export interface RecoverRequest {
117
+ /** rollback: undo the interrupted transaction. resume: finish its stored plan (needs the same consent again). */
118
+ readonly mode: "rollback" | "resume"
119
+ readonly consent?: Consent
120
+ }
121
+
122
+ /** `hubctl setup rollback`: undo committed transactions, newest first, back to just after `to` (or all of them). */
123
+ export interface RollbackRequest {
124
+ readonly to?: string
125
+ }
126
+
127
+ export interface Conflict {
128
+ readonly seq: number
129
+ readonly actionID: string
130
+ readonly path?: string
131
+ readonly reason: string
132
+ }
133
+
134
+ export interface StepOutcome {
135
+ readonly actionID: string
136
+ readonly kind: Action["kind"]
137
+ readonly outcome: "applied" | "skipped" | "failed" | "resumed"
138
+ }
139
+
140
+ export interface TxReport {
141
+ readonly txID: string
142
+ readonly state: JournalState
143
+ readonly steps: readonly StepOutcome[]
144
+ /** Files recovery left alone because they changed since hub wrote them (or could not be restored safely). */
145
+ readonly conflicts: readonly Conflict[]
146
+ /**
147
+ * Informational, not a conflict: directories hub created that a rollback left in place because they now also hold
148
+ * files hub did not write (a harness probe's cache, say). Nothing of the user's was touched.
149
+ */
150
+ readonly kept: readonly Conflict[]
151
+ /** Upstream evidence recorded for harness installs (never part of the journal). */
152
+ readonly evidence: readonly { readonly actionID: string; readonly results: readonly EvidenceResult[] }[]
153
+ }
154
+
155
+ export type TxOutcome = { readonly ok: true; readonly report: TxReport } | (TxFailure & { readonly report?: TxReport })
156
+
157
+ export interface Applier {
158
+ apply(request: ApplyRequest): Promise<TxOutcome>
159
+ /** Recovers an interrupted transaction; ok without a report when there was nothing to recover. */
160
+ recover(request: RecoverRequest): Promise<TxOutcome | { readonly ok: true; readonly report?: undefined }>
161
+ /**
162
+ * Undoes committed transactions of this host, newest first, with the recovery rules (restore backups, remove what
163
+ * hub created, clear a credential, keep anything changed since as a conflict). Ok without a report when there is
164
+ * nothing to undo. The report is the oldest transaction undone, with every transaction's steps and conflicts.
165
+ */
166
+ rollback(request: RollbackRequest): Promise<TxOutcome | { readonly ok: true; readonly report?: undefined }>
167
+ }
168
+
169
+ const FINISHED: readonly JournalState[] = ["committed", "rolled-back"]
170
+ const PARENT_MODE = 0o700
171
+
172
+ function defaultIsAlive(pid: number): boolean {
173
+ try {
174
+ process.kill(pid, 0)
175
+ return true
176
+ } catch (error) {
177
+ return errnoOf(error) === "EPERM"
178
+ }
179
+ }
180
+
181
+ /** Hooks observe: they get copies, so nothing they do reaches the journal or the plan. */
182
+ const copy = <T>(value: T): T => structuredClone(value)
183
+
184
+ /** Strings in anything the engine returns that look like a credential are withheld (defense in depth). */
185
+ function redact<T>(value: T): T {
186
+ const scrub = (v: unknown): unknown => {
187
+ if (typeof v === "string") {
188
+ if (!isSecretValue(v)) return v
189
+ const words = v.split(/(\s+)/).map((w) => (isSecretValue(w) ? "[redacted]" : w)).join("")
190
+ return isSecretValue(words) ? "(details withheld: the message looked like it carried a credential)" : words
191
+ }
192
+ if (Array.isArray(v)) return v.map(scrub)
193
+ if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, item]) => [k, scrub(item)]))
194
+ return v
195
+ }
196
+ return scrub(value) as T
197
+ }
198
+
199
+ const randomTxID = () => `tx_${randomBytes(12).toString("hex")}`
200
+ const samePath = (a?: PathRef, b?: PathRef) => (a === undefined && b === undefined) || (a !== undefined && b !== undefined && a.root === b.root && a.rel === b.rel)
201
+ const dirname = (p: string) => p.slice(0, p.lastIndexOf("/")) || "/"
202
+ const basename = (p: string) => p.slice(p.lastIndexOf("/") + 1)
203
+
204
+ /** Converts anything thrown inside a step into a failure; filesystem errors name their errno, never contents. */
205
+ function failureOf(error: unknown): TxFailure {
206
+ if (error instanceof TxError) return error.failure
207
+ const code = errnoOf(error)
208
+ if (code === "EACCES" || code === "EPERM") return txFail("forbidden", `permission denied: ${(error as Error).message}`)
209
+ return txFail("internal", code ? `filesystem error ${code}: ${(error as Error).message}` : `unexpected error: ${error instanceof Error ? error.message : String(error)}`)
210
+ }
211
+
212
+ export function createApplier(config: ApplierConfig): Applier {
213
+ const fs = config.fs ?? nodeFs
214
+ const now = config.now ?? Date.now
215
+ const pid = config.pid ?? process.pid
216
+ const isAlive = config.isAlive ?? defaultIsAlive
217
+ const newID = config.newID ?? randomTxID
218
+ const hooks = config.hooks ?? {}
219
+ const context: ExecutionContext = config.context ?? "host"
220
+
221
+ const lockIO = (store: TxStore): LockIO => ({ fs, path: store.lockPath, hostFingerprint: config.host.fingerprint, pid, now, isAlive })
222
+
223
+ /** Interrupted transactions of this host. Another host's (a shared home) are reported, never touched here. */
224
+ const unfinished = (store: TxStore) => {
225
+ const all = store.journals().filter((j) => !FINISHED.includes(j.state))
226
+ const foreign = all.find((j) => j.hostFingerprint !== config.host.fingerprint)
227
+ if (foreign) throw new TxError("recovery_required", `transaction ${foreign.txID} of another host was interrupted on this shared home; recover it on that host`)
228
+ return all
229
+ }
230
+
231
+ /** Runs `body` holding the apply lock. */
232
+ async function locked<T extends { ok: boolean }>(txID: string, forRecovery: boolean, body: (store: TxStore, lock: { current: ApplyLock }) => Promise<T | TxFailure>): Promise<T | TxFailure> {
233
+ let store: TxStore
234
+ try {
235
+ store = new TxStore(fs, config.roots, context)
236
+ store.init()
237
+ } catch (error) {
238
+ return failureOf(error)
239
+ }
240
+ const io = lockIO(store)
241
+ let attempt
242
+ try {
243
+ attempt = tryAcquireLock(io, txID)
244
+ if (attempt.state === "stale") {
245
+ if (!forRecovery && unfinished(store).length > 0)
246
+ return txFail("recovery_required", `apply ${attempt.lock.txID} was interrupted (its process is gone); run \`hubctl setup recover\` first`)
247
+ if (!breakStaleLock(io, attempt.lock)) return txFail("locked", "another apply took the lock")
248
+ attempt = tryAcquireLock(io, txID)
249
+ }
250
+ } catch (error) {
251
+ return failureOf(error)
252
+ }
253
+ if (attempt.state !== "acquired") {
254
+ const why =
255
+ attempt.state === "foreign"
256
+ ? "another host holds the apply lock on this shared home; it is never broken automatically"
257
+ : attempt.state === "corrupt"
258
+ ? `the apply lock ${io.path} is unreadable; remove it once no apply is running`
259
+ : `another apply (pid ${attempt.state === "held" || attempt.state === "stale" ? attempt.lock.pid : "?"}) is running`
260
+ return txFail("locked", why)
261
+ }
262
+ const lock = { current: attempt.lock }
263
+ try {
264
+ store.sweep()
265
+ return await body(store, lock)
266
+ } catch (error) {
267
+ return failureOf(error)
268
+ } finally {
269
+ try {
270
+ releaseLock(io, txID)
271
+ } catch {}
272
+ }
273
+ }
274
+
275
+ function consentProblem(plan: SetupPlan, consent: Consent = {}): TxFailure | undefined {
276
+ if (plan.requiresEscalation && !consent.escalate) return txFail("escalation_required", "this plan writes system locations; pass --escalate to allow it")
277
+ if (plan.activatesServices && !consent.confirmActivation) return txFail("needs_user", "this plan activates a background service; pass --confirm-activation to allow it")
278
+ if (plan.target !== config.host.target) return txFail("wrong_platform", `the plan is for ${plan.target}, but this host is ${config.host.target}`)
279
+ return undefined
280
+ }
281
+
282
+ /**
283
+ * Re-plans and revalidates the preview, with `store`'s journals as the used nonces: the plan to apply and the
284
+ * token's nonce, or why it is refused. Reads only; nothing is written.
285
+ */
286
+ async function admit(store: TxStore, request: ApplyRequest): Promise<{ ok: true; plan: SetupPlan; nonce: string } | TxFailure> {
287
+ const fresh = await request.replan()
288
+ const checked = fromResult(validateSetupPlan(fresh.plan), "the fresh plan is invalid")
289
+ if (!checked.ok) return checked
290
+ const plan = checked.value
291
+ if (fresh.snapshot && snapshotDigest(fresh.snapshot) !== plan.snapshotDigest) return txFail("preview_stale", "the fresh plan was not made from the fresh snapshot; run the preview again")
292
+ const usedNonces = new Set(store.journals().map((j) => j.nonce))
293
+ const token = revalidatePreview(request.token, { now: now(), hostFingerprint: config.host.fingerprint, plan, usedNonces })
294
+ if (!token.ok) {
295
+ const issue = token.issues[0]!
296
+ return txFail(issue.code.startsWith("preview_") ? (issue.code as ErrorCode) : "preview_invalid", issue.message)
297
+ }
298
+ const applicable = fromResult(
299
+ previewApplicable({ schema: SETUP_PREVIEW_SCHEMA, version: 1, plan, planDigest: planDigest(plan), token: token.value, blockers: fresh.blockers ?? [], evidence: fresh.evidence ?? [] }),
300
+ )
301
+ if (!applicable.ok) return applicable
302
+ const problem = consentProblem(plan, request.consent)
303
+ if (problem) return problem
304
+ return { ok: true, plan, nonce: token.value.nonce }
305
+ }
306
+
307
+ /**
308
+ * The admission check before the lock is taken, so a refused apply (expired, stale, replayed or invalid preview,
309
+ * missing consent) writes nothing: not even the state directories the lock lives in. Undefined when it passes
310
+ * or cannot decide (an interrupted transaction, an unusable state directory): the locked path decides then, and
311
+ * re-runs the whole check under the lock either way.
312
+ */
313
+ async function refusedBeforeLock(request: ApplyRequest): Promise<TxFailure | undefined> {
314
+ let store: TxStore
315
+ try {
316
+ store = new TxStore(fs, config.roots, context)
317
+ if (unfinished(store).length > 0) return undefined
318
+ } catch {
319
+ return undefined
320
+ }
321
+ try {
322
+ const admitted = await admit(store, request)
323
+ return admitted.ok ? undefined : admitted
324
+ } catch (error) {
325
+ return failureOf(error)
326
+ }
327
+ }
328
+
329
+ async function apply(request: ApplyRequest): Promise<TxOutcome> {
330
+ const refused = await refusedBeforeLock(request)
331
+ if (refused) return refused
332
+ const txID = newID()
333
+ return locked<TxOutcome>(txID, false, async (store, lock) => {
334
+ if (unfinished(store).length > 0) return txFail("recovery_required", "an interrupted apply must be recovered first (`hubctl setup recover`)")
335
+ const admitted = await admit(store, request)
336
+ if (!admitted.ok) return admitted
337
+ const { plan } = admitted
338
+ const run = new Run(store, lock, plan, newJournal(txID, plan, admitted.nonce))
339
+ const pre = await run.preflight()
340
+ if (!pre.ok) return pre
341
+ store.writePlan(txID, plan)
342
+ run.persist()
343
+ return run.forward()
344
+ })
345
+ }
346
+
347
+ function newJournal(txID: string, plan: SetupPlan, nonce: string): TransactionJournal {
348
+ const t = now()
349
+ return { schema: JOURNAL_SCHEMA, version: 1, txID, planDigest: planDigest(plan), hostFingerprint: config.host.fingerprint, nonce, state: "applying", startedAt: t, updatedAt: t, entries: [] }
350
+ }
351
+
352
+ async function recover(request: RecoverRequest): Promise<TxOutcome | { readonly ok: true; readonly report?: undefined }> {
353
+ return locked<TxOutcome | { ok: true; report?: undefined }>(newID(), true, async (store, lock) => {
354
+ const pending = unfinished(store)
355
+ if (pending.length === 0) return { ok: true }
356
+ let last: TxOutcome | undefined
357
+ for (const journal of pending) {
358
+ const plan = store.readPlan(journal)
359
+ const run = new Run(store, lock, plan, journal)
360
+ if (request.mode === "resume") {
361
+ if (!plan) return txFail("recovery_required", `the plan of ${journal.txID} is missing or does not match its journal; roll it back instead`)
362
+ if (journal.state !== "applying" && journal.state !== "prepared") return txFail("recovery_required", `${journal.txID} was already rolling back; it can only be rolled back`)
363
+ const problem = consentProblem(plan, request.consent)
364
+ if (problem) return problem
365
+ last = await run.resume()
366
+ } else last = await run.rollbackAll()
367
+ if (!last.ok) return last
368
+ }
369
+ return last!
370
+ })
371
+ }
372
+
373
+ async function rollback(request: RollbackRequest): Promise<TxOutcome | { readonly ok: true; readonly report?: undefined }> {
374
+ return locked<TxOutcome | { ok: true; report?: undefined }>(newID(), true, async (store, lock) => {
375
+ if (unfinished(store).length > 0) return txFail("recovery_required", "an interrupted apply must be recovered first (`hubctl setup recover`)")
376
+ const mine = store
377
+ .journals()
378
+ .filter((j) => j.hostFingerprint === config.host.fingerprint)
379
+ .sort((a, b) => a.startedAt - b.startedAt || (a.txID < b.txID ? -1 : a.txID > b.txID ? 1 : 0))
380
+ let start = 0
381
+ if (request.to !== undefined) {
382
+ const at = mine.findIndex((j) => j.txID === request.to)
383
+ if (at < 0) return txFail("invalid", `no transaction ${request.to} on this host`)
384
+ if (mine[at]!.state !== "committed") return txFail("invalid", `${request.to} did not commit; roll back to a committed transaction`)
385
+ start = at + 1
386
+ }
387
+ const undo = mine.slice(start).filter((j) => j.state === "committed").reverse()
388
+ if (undo.length === 0) return { ok: true }
389
+ const steps: StepOutcome[] = []
390
+ const conflicts: Conflict[] = []
391
+ const kept: Conflict[] = []
392
+ let last: TxReport | undefined
393
+ for (const journal of undo) {
394
+ const r = await new Run(store, lock, store.readPlan(journal), journal).rollbackAll()
395
+ if (!r.ok) return r
396
+ last = r.report
397
+ steps.push(...r.report.steps)
398
+ conflicts.push(...r.report.conflicts)
399
+ kept.push(...r.report.kept)
400
+ }
401
+ return { ok: true, report: { ...last!, steps, conflicts, kept } }
402
+ })
403
+ }
404
+
405
+ /** One transaction: its journal, the plan it applies, and its report. */
406
+ class Run {
407
+ private journal: TransactionJournal
408
+ private readonly steps: StepOutcome[] = []
409
+ private readonly conflicts: Conflict[] = []
410
+ private readonly kept: Conflict[] = []
411
+ private readonly evidence: { actionID: string; results: readonly EvidenceResult[] }[] = []
412
+ private scratch?: ReturnType<typeof fsStaging>
413
+
414
+ constructor(
415
+ private readonly store: TxStore,
416
+ private readonly lock: { current: ApplyLock },
417
+ private readonly plan: SetupPlan | undefined,
418
+ journal: TransactionJournal,
419
+ ) {
420
+ this.journal = journal
421
+ }
422
+
423
+ get txID() {
424
+ return this.journal.txID
425
+ }
426
+
427
+ report(): TxReport {
428
+ return { txID: this.txID, state: this.journal.state, steps: [...this.steps], conflicts: [...this.conflicts], kept: [...this.kept], evidence: [...this.evidence] }
429
+ }
430
+
431
+ persist(patch: Partial<Pick<TransactionJournal, "state" | "entries">> = {}): void {
432
+ const next: TransactionJournal = { ...this.journal, ...patch, updatedAt: Math.max(now(), this.journal.startedAt) }
433
+ this.store.writeJournal(next)
434
+ this.journal = next
435
+ }
436
+
437
+ private executor(action: Action): ActionExecutor | undefined {
438
+ return config.executors[action.kind] as ActionExecutor | undefined
439
+ }
440
+
441
+ /** The current step's staging area (its directory is only created once something is written). */
442
+ private staging(): StagingArea {
443
+ this.scratch ??= fsStaging(fs, this.store.stagingDir(this.txID))
444
+ return this.scratch
445
+ }
446
+
447
+ /** Removes the step's staging directory (what was not moved into place); a failure is left for the sweep. */
448
+ private dropStaging(): void {
449
+ const scratch = this.scratch
450
+ this.scratch = undefined
451
+ try {
452
+ scratch?.dispose()
453
+ } catch {}
454
+ }
455
+
456
+ private resolve(ref: PathRef, options?: ResolveOptions): TxResult<string> {
457
+ return resolveTarget(ref, config.roots, fs, { ...options, context })
458
+ }
459
+
460
+ // ---------- preflight ----------
461
+
462
+ async preflight(): Promise<TxResult<void>> {
463
+ for (const action of this.plan!.actions) {
464
+ const exec = this.executor(action)
465
+ if (!exec) return txFail("internal", `this hubctl cannot apply ${action.kind} steps`)
466
+ const step = exec.plan(action)
467
+ const ctx = this.context(action, step)
468
+ if (!ctx.ok) return ctx
469
+ if (exec.check) {
470
+ const r = await exec.check(action, ctx.value)
471
+ if (!r.ok) return txFail(r.code, `${action.id}: ${r.message}`)
472
+ }
473
+ }
474
+ return txOk(undefined)
475
+ }
476
+
477
+ private context(action: Action, step: StepPlan): TxResult<StepContext> {
478
+ let target: string | undefined
479
+ let current: FileState = { exists: false }
480
+ if (step.path) {
481
+ const r = this.resolve(step.path, step.resolve)
482
+ if (!r.ok) return txFail(r.code, `${action.id}: ${r.message}`)
483
+ target = r.value
484
+ current = fileState(fs, target, { tree: step.class === "harness" })
485
+ } else if (step.class !== "external") return txFail("internal", `${action.id}: a ${step.class} step names its path`)
486
+ return txOk({
487
+ fs,
488
+ roots: config.roots,
489
+ context,
490
+ host: config.host,
491
+ now,
492
+ target,
493
+ current,
494
+ original: this.original(step.path, current),
495
+ resolve: (ref, options) => this.resolve(ref, options),
496
+ recordEvidence: (results) => this.evidence.push({ actionID: action.id, results }),
497
+ staging: this.staging(),
498
+ })
499
+ }
500
+
501
+ /** The path's state before this transaction, if our own earlier steps are all that changed it. */
502
+ private original(path: PathRef | undefined, current: FileState): FileState {
503
+ if (!path) return current
504
+ const mine = this.journal.entries.filter((e) => samePath(e.path, path))
505
+ const latest = mine[mine.length - 1]
506
+ if (!latest || latest.status !== "done" || !latest.after || !sameState(current, latest.after)) return current
507
+ return mine[0]!.before
508
+ }
509
+
510
+ // ---------- forward ----------
511
+
512
+ async forward(): Promise<TxOutcome> {
513
+ let failure: TxFailure | undefined
514
+ try {
515
+ for (const action of this.plan!.actions) {
516
+ if (this.complete(action)) {
517
+ this.steps.push({ actionID: action.id, kind: action.kind, outcome: "resumed" })
518
+ continue
519
+ }
520
+ const r = await this.step(action)
521
+ if (!r.ok) {
522
+ this.steps.push({ actionID: action.id, kind: action.kind, outcome: "failed" })
523
+ failure = r
524
+ break
525
+ }
526
+ }
527
+ if (!failure) this.persist({ state: "committed" })
528
+ } catch (error) {
529
+ failure = failureOf(error)
530
+ }
531
+ if (!failure) {
532
+ if (!config.keepBackups) this.cleanup()
533
+ return { ok: true, report: this.report() }
534
+ }
535
+ const rolled = await this.rollbackAll()
536
+ if (!rolled.ok) return { ...rolled, message: `${failure.message}; then ${rolled.message}`, report: this.report() }
537
+ return { ...failure, report: this.report() }
538
+ }
539
+
540
+ /** Whether a step of the plan already finished in this transaction (resume). */
541
+ private complete(action: Action): boolean {
542
+ const exec = this.executor(action)
543
+ if (!exec) return false
544
+ const path = exec.plan(action).path
545
+ return this.journal.entries.some((e) => e.actionID === action.id && samePath(e.path, path) && (e.status === "done" || e.status === "skipped"))
546
+ }
547
+
548
+ /** Keeps the lock from looking overrun during a long apply (renewed once a quarter of its lifetime is used). */
549
+ private renew(): void {
550
+ if (now() - this.lock.current.acquiredAt < LOCK_MAX_MS / 4) return
551
+ this.lock.current = renewLock(lockIO(this.store), this.lock.current)
552
+ }
553
+
554
+ private async step(action: Action): Promise<TxResult<void>> {
555
+ try {
556
+ return await this.stepOnce(action)
557
+ } finally {
558
+ this.dropStaging()
559
+ }
560
+ }
561
+
562
+ private async stepOnce(action: Action): Promise<TxResult<void>> {
563
+ this.renew()
564
+ const exec = this.executor(action)
565
+ if (!exec) return txFail("internal", `this hubctl cannot apply ${action.kind} steps`)
566
+ const step = exec.plan(action)
567
+ const ctx = this.context(action, step)
568
+ if (!ctx.ok) return ctx
569
+ if (exec.check) {
570
+ const r = await exec.check(action, ctx.value)
571
+ if (!r.ok) return txFail(r.code, `${action.id}: ${r.message}`)
572
+ }
573
+ const change = await exec.change(action, ctx.value)
574
+ if (!change.ok) return txFail(change.code, `${action.id}: ${change.message}`)
575
+ const r = await this.perform(action, step, ctx.value, change.value)
576
+ if (r.ok) this.steps.push({ actionID: action.id, kind: action.kind, outcome: r.value })
577
+ return r.ok ? txOk(undefined) : r
578
+ }
579
+
580
+ private async append(entry: Omit<JournalEntry, "seq">): Promise<JournalEntry> {
581
+ const full: JournalEntry = { ...entry, seq: this.journal.entries.length + 1 }
582
+ this.persist({ entries: [...this.journal.entries, full] })
583
+ await hooks.beforeAction?.(copy(full), copy(this.journal))
584
+ return full
585
+ }
586
+
587
+ private async settle(entry: JournalEntry, patch: Partial<Pick<JournalEntry, "status" | "after">>, observe = true): Promise<JournalEntry> {
588
+ const next: JournalEntry = { ...entry, ...patch }
589
+ this.persist({ entries: this.journal.entries.map((e) => (e.seq === entry.seq ? next : e)) })
590
+ if (observe) await hooks.afterAction?.(copy(next), copy(this.journal))
591
+ return next
592
+ }
593
+
594
+ private stagingPath(target: string, seq: number): string {
595
+ return `${dirname(target)}/.${basename(target)}.hub-${this.txID}-${seq}.tmp`
596
+ }
597
+
598
+ /**
599
+ * A step that is already exactly as wanted changes nothing, so it gets no journal entry: there is nothing to
600
+ * own or undo, and a resume simply finds it unchanged again.
601
+ */
602
+ private skip(): TxResult<"skipped"> {
603
+ return txOk("skipped")
604
+ }
605
+
606
+ /** Re-checks the target right before it is written: a symlink planted since the step began is refused. */
607
+ private recheck(action: Action, step: StepPlan): void {
608
+ const r = this.resolve(step.path!, step.resolve)
609
+ if (!r.ok) throw new TxError(r.code, `${action.id}: ${r.message}`)
610
+ }
611
+
612
+ /** Creates the missing directories above `ref` (its root first), each journaled as its own owned change. */
613
+ private async parents(action: Action, ref: PathRef): Promise<void> {
614
+ const parts = ref.rel.split("/").slice(0, -1)
615
+ const root = rootDir(ref.root, config.roots, context)!
616
+ if (!fs.lstat(root)) await this.rootDirs(action, ref.root, root)
617
+ for (let i = 1; i <= parts.length; i++) await this.ownedDir(action, { root: ref.root, rel: parts.slice(0, i).join("/") }, `${root === "/" ? "" : root}/${parts.slice(0, i).join("/")}`)
618
+ }
619
+
620
+ /** One missing directory, journaled before it is made (rollback removes it while it is empty). */
621
+ private async ownedDir(action: Action, ref: PathRef, abs: string): Promise<void> {
622
+ const info = fs.lstat(abs)
623
+ if (info?.kind === "directory") return
624
+ if (info) throw new TxError("conflict", `${abs} is not a directory`)
625
+ const entry = await this.append({ actionID: action.id, kind: action.kind, class: "directory", path: ref, before: { exists: false }, after: { exists: true, mode: PARENT_MODE }, status: "pending" })
626
+ const again = this.resolve(ref)
627
+ if (!again.ok) throw new TxError(again.code, `${action.id}: ${again.message}`)
628
+ fs.mkdir(abs, PARENT_MODE)
629
+ fs.chmod(abs, PARENT_MODE)
630
+ await this.settle(entry, { status: "done", after: fileState(fs, abs) })
631
+ }
632
+
633
+ /**
634
+ * A missing root directory (a fresh home without ~/.local/bin). A root is not a PathRef of its own, but a root
635
+ * under the home root is one of the home root's: each missing level is journaled as a home directory, so a
636
+ * rollback removes what this transaction created (and only while it is empty). A root outside home, or below a
637
+ * symlink or a missing home, is created unjournaled as before (it cannot be owned without following the link).
638
+ */
639
+ private async rootDirs(action: Action, id: PathRef["root"], root: string): Promise<void> {
640
+ const home = id === "home" ? undefined : rootDir("home", config.roots, context)
641
+ const homeInfo = home ? fs.lstat(home) : undefined
642
+ const rel = home && homeInfo?.kind === "directory" && root.startsWith(`${home}/`) ? root.slice(home.length + 1) : undefined
643
+ const parts = rel?.split("/") ?? []
644
+ const levels = parts.map((_, i) => ({ rel: parts.slice(0, i + 1).join("/"), abs: `${home}/${parts.slice(0, i + 1).join("/")}` }))
645
+ if (!rel || levels.some((l) => fs.lstat(l.abs)?.kind === "symlink")) {
646
+ fs.mkdirp(root, PARENT_MODE)
647
+ return
648
+ }
649
+ for (const level of levels) await this.ownedDir(action, { root: "home", rel: level.rel }, level.abs)
650
+ }
651
+
652
+ private backup(seq: number, target: string, current: FileState): { id: string } | undefined {
653
+ if (!current.exists) return undefined
654
+ const id = `bk_${seq}`
655
+ if (current.symlink) this.store.writeBackup(this.txID, id, fs.readlink(target))
656
+ else this.store.writeBackupFrom(this.txID, id, target)
657
+ return { id }
658
+ }
659
+
660
+ /**
661
+ * Puts a staged file at `dest` with `mode`: a file this step's staging area wrote (with the digest it recorded) is
662
+ * moved (copied across filesystems); any other file is copied in chunks and must have the digest it claims.
663
+ */
664
+ private place(file: StagedFile, dest: string, mode: number, staging: StagingArea): void {
665
+ const written = staging.written(file)
666
+ if (written && written.sha256 === file.sha256 && fs.lstat(file.path)?.kind === "file") {
667
+ try {
668
+ fs.rename(file.path, dest)
669
+ fs.chmod(dest, mode)
670
+ return
671
+ } catch (error) {
672
+ if (errnoOf(error) !== "EXDEV") throw error
673
+ }
674
+ }
675
+ if (fs.lstat(file.path)?.kind !== "file") throw new TxError("not_found", `${file.path} is not a regular file`)
676
+ if (copyFile(fs, file.path, dest, mode) !== file.sha256) {
677
+ fs.unlink(dest)
678
+ throw new TxError("integrity_mismatch", `${file.path} changed after it was verified; nothing was installed`)
679
+ }
680
+ }
681
+
682
+ private async perform(action: Action, step: StepPlan, ctx: StepContext, change: Change): Promise<TxResult<"applied" | "skipped">> {
683
+ const { target, current } = ctx
684
+ const path = step.path
685
+ const conflict = (why: string) => txFail("conflict", `${action.id}: ${target} ${why}; nothing was overwritten`)
686
+ const kind = target ? fs.lstat(target)?.kind : undefined
687
+ switch (change.type) {
688
+ case "none":
689
+ return this.skip()
690
+ case "remove": {
691
+ if (!current.exists) return this.skip()
692
+ if (kind !== "file") return conflict("is not a file")
693
+ const seq = this.journal.entries.length + 1
694
+ const backup = this.backup(seq, target!, current)
695
+ const entry = await this.append({ actionID: action.id, kind: action.kind, class: step.class, path: path!, before: current, after: { exists: false }, ...(backup ? { backup } : {}), status: "pending" })
696
+ this.recheck(action, step)
697
+ fs.unlink(target!)
698
+ fs.fsyncDir(dirname(target!))
699
+ await this.settle(entry, { status: "done", after: fileState(fs, target!) })
700
+ return txOk("applied")
701
+ }
702
+ case "effect": {
703
+ const entry = await this.append({ actionID: action.id, kind: action.kind, class: step.class, ...(path ? { path } : {}), before: current, status: "pending" })
704
+ if (path) this.recheck(action, step)
705
+ const r = await change.perform()
706
+ if (!r.ok) return txFail(r.code, `${action.id}: ${r.message}`)
707
+ await this.settle(entry, { status: "done", ...(target ? { after: fileState(fs, target) } : {}) })
708
+ return txOk("applied")
709
+ }
710
+ case "directory": {
711
+ if (current.exists) return kind === "directory" ? this.skip() : conflict("exists and is not a directory")
712
+ await this.parents(action, path!)
713
+ const entry = await this.append({ actionID: action.id, kind: action.kind, class: step.class, path: path!, before: current, after: { exists: true, mode: change.mode }, status: "pending" })
714
+ this.recheck(action, step)
715
+ fs.mkdir(target!, change.mode)
716
+ fs.chmod(target!, change.mode)
717
+ await this.settle(entry, { status: "done", after: fileState(fs, target!) })
718
+ return txOk("applied")
719
+ }
720
+ case "file":
721
+ case "symlink": {
722
+ const desired: FileState =
723
+ change.type === "file"
724
+ ? { exists: true, sha256: "bytes" in change ? sha256Of(change.bytes) : change.staged.sha256, mode: change.mode }
725
+ : { exists: true, symlink: true, sha256: sha256Of(change.target) }
726
+ if (current.exists) {
727
+ if (sameState(current, desired) && (change.type === "symlink" || kind === "file")) return this.skip()
728
+ if (change.replace === "refuse") return conflict("already exists with other content")
729
+ if (kind !== "file" && kind !== "symlink") return conflict("exists and is not a file")
730
+ }
731
+ await this.parents(action, path!)
732
+ const seq = this.journal.entries.length + 1
733
+ const backup = this.backup(seq, target!, current)
734
+ const entry = await this.append({ actionID: action.id, kind: action.kind, class: step.class, path: path!, before: current, after: desired, ...(backup ? { backup } : {}), status: "pending" })
735
+ this.recheck(action, step)
736
+ const staged = this.stagingPath(target!, entry.seq)
737
+ if (change.type === "symlink") fs.symlink(change.target, staged)
738
+ else if ("bytes" in change) fs.writeExclusive(staged, change.bytes, change.mode)
739
+ else this.place(change.staged, staged, change.mode, ctx.staging)
740
+ fs.rename(staged, target!)
741
+ fs.fsyncDir(dirname(target!))
742
+ const after = fileState(fs, target!)
743
+ if (!sameState(after, desired)) throw new TxError("conflict", `${action.id}: ${target} changed while it was being written`)
744
+ await this.settle(entry, { status: "done", after })
745
+ return txOk("applied")
746
+ }
747
+ case "tree": {
748
+ const desired: FileState = { exists: true, sha256: treeDigestOfFiles(change.files, change.mode), mode: change.mode }
749
+ if (current.exists) return sameState(current, desired) ? this.skip() : conflict("already exists with other content (install roots are never overwritten)")
750
+ await this.parents(action, path!)
751
+ const entry = await this.append({ actionID: action.id, kind: action.kind, class: step.class, path: path!, before: current, after: desired, status: "pending" })
752
+ this.recheck(action, step)
753
+ const staged = this.stagingPath(target!, entry.seq)
754
+ this.stageTree(staged, change.files, change.mode, ctx.staging)
755
+ fs.rename(staged, target!)
756
+ fs.fsyncDir(dirname(target!))
757
+ const after = fileState(fs, target!, { tree: true })
758
+ if (!sameState(after, desired)) throw new TxError("conflict", `${action.id}: ${target} changed while it was being installed`)
759
+ await this.settle(entry, { status: "done", after })
760
+ return txOk("applied")
761
+ }
762
+ }
763
+ }
764
+
765
+ private stageTree(dir: string, files: readonly TreeFile[], mode: number, staging: StagingArea): void {
766
+ fs.mkdir(dir, mode)
767
+ fs.chmod(dir, mode)
768
+ const dirs = new Set<string>()
769
+ for (const f of files) {
770
+ const parts = f.rel.split("/")
771
+ for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join("/"))
772
+ }
773
+ for (const d of [...dirs].sort()) {
774
+ fs.mkdir(`${dir}/${d}`, mode)
775
+ fs.chmod(`${dir}/${d}`, mode)
776
+ }
777
+ for (const f of files) {
778
+ if ("bytes" in f) fs.writeExclusive(`${dir}/${f.rel}`, f.bytes, f.mode)
779
+ else this.place(f.staged, `${dir}/${f.rel}`, f.mode, staging)
780
+ }
781
+ }
782
+
783
+ private cleanup(): void {
784
+ try {
785
+ this.store.dropBackups(this.txID)
786
+ } catch (error) {
787
+ if (!errnoOf(error)) throw error
788
+ }
789
+ }
790
+
791
+ // ---------- resume ----------
792
+
793
+ async resume(): Promise<TxOutcome> {
794
+ const last = this.journal.entries[this.journal.entries.length - 1]
795
+ if (last?.status === "pending") {
796
+ const r = this.settlePending(last)
797
+ if (!r.ok) return { ...r, report: this.report() }
798
+ }
799
+ if (this.journal.state === "prepared") this.persist({ state: "applying" })
800
+ return this.forward()
801
+ }
802
+
803
+ /** Decides an interrupted (pending) last entry before resuming: finished, never started, or changed by someone. */
804
+ private settlePending(entry: JournalEntry): TxResult<void> {
805
+ const truncate = () => this.persist({ entries: this.journal.entries.slice(0, -1) })
806
+ if (!entry.path) {
807
+ truncate() // an external step: run it again
808
+ return txOk(undefined)
809
+ }
810
+ const target = this.resolve(entry.path, this.resolveOptions(entry))
811
+ if (!target.ok) return txFail("recovery_required", `${entry.actionID}: ${target.message}; roll back instead`)
812
+ const current = fileState(fs, target.value, { tree: entry.class === "harness" })
813
+ if (entry.after && sameState(current, entry.after)) {
814
+ this.persist({ entries: this.journal.entries.map((e) => (e.seq === entry.seq ? { ...e, status: "done" } : e)) })
815
+ return txOk(undefined)
816
+ }
817
+ if (sameState(current, entry.before)) {
818
+ this.removeStaging(target.value, entry.seq)
819
+ truncate()
820
+ return txOk(undefined)
821
+ }
822
+ if (entry.class === "credential" && !entry.after) {
823
+ // The credential store writes atomically: the interrupted enrollment stored its credential.
824
+ this.persist({ entries: this.journal.entries.map((e) => (e.seq === entry.seq ? { ...e, status: "done", after: current } : e)) })
825
+ return txOk(undefined)
826
+ }
827
+ return txFail("recovery_required", `${entry.actionID}: ${target.value} changed during the interruption; roll back instead`)
828
+ }
829
+
830
+ private resolveOptions(entry: JournalEntry): ResolveOptions {
831
+ return entry.class === "link" ? { finalMayBeSymlink: true } : {}
832
+ }
833
+
834
+ // ---------- rollback ----------
835
+
836
+ async rollbackAll(): Promise<TxOutcome> {
837
+ const decided: RecoveryStep[] = []
838
+ try {
839
+ if (this.journal.state !== "rolling-back") this.persist({ state: "rolling-back" })
840
+ for (const entry of [...this.journal.entries].reverse()) {
841
+ if (entry.status === "undone" || entry.status === "skipped") {
842
+ // An interrupted earlier rollback may have left this entry's staging (or a tree moved aside) behind.
843
+ const r = entry.path ? this.resolve(entry.path, this.resolveOptions(entry)) : undefined
844
+ if (r?.ok) this.removeStaging(r.value, entry.seq)
845
+ continue
846
+ }
847
+ decided.push({ seq: entry.seq, actionID: entry.actionID, decision: await this.undo(entry) })
848
+ }
849
+ this.persist({ state: "rolled-back" })
850
+ this.cleanup()
851
+ await hooks.onRecover?.(copy(this.journal), copy(decided))
852
+ return { ok: true, report: this.report() }
853
+ } catch (error) {
854
+ const f = failureOf(error)
855
+ try {
856
+ this.persist({ state: "failed" })
857
+ } catch {}
858
+ return txFail("recovery_required", `rollback of ${this.txID} did not finish (${f.message}); run \`hubctl setup recover\` again`)
859
+ }
860
+ }
861
+
862
+ private async conflict(entry: JournalEntry, path: string | undefined, reason: string, current: FileState = { exists: false }) {
863
+ this.conflicts.push({ seq: entry.seq, actionID: entry.actionID, ...(path ? { path } : {}), reason })
864
+ await hooks.onConflict?.(copy(entry), copy(current))
865
+ }
866
+
867
+ private async mark(entry: JournalEntry, status: "undone" | "skipped"): Promise<void> {
868
+ await this.settle(entry, { status }, false)
869
+ }
870
+
871
+ private actionOf(entry: JournalEntry): Action | undefined {
872
+ return this.plan?.actions.find((a) => a.id === entry.actionID)
873
+ }
874
+
875
+ private async undo(entry: JournalEntry): Promise<RecoveryStep["decision"]> {
876
+ const action = this.actionOf(entry)
877
+ const exec = action ? this.executor(action) : undefined
878
+ if (entry.class === "external") {
879
+ if (entry.status === "done" && action && exec?.undo) {
880
+ const r = await exec.undo(action as never, { fs, roots: config.roots, context, host: config.host, now, reason: "revert-external" })
881
+ if (!r.ok) await this.conflict(entry, undefined, `could not be reverted: ${r.message}`)
882
+ }
883
+ await this.mark(entry, "undone")
884
+ return "nothing-to-do"
885
+ }
886
+ const resolved = this.resolve(entry.path!, this.resolveOptions(entry))
887
+ if (!resolved.ok) {
888
+ await this.conflict(entry, undefined, `left alone: ${resolved.message}`)
889
+ return "keep-user-edit"
890
+ }
891
+ const target = resolved.value
892
+ this.removeStaging(target, entry.seq)
893
+ const current = fileState(fs, target, { tree: entry.class === "harness" })
894
+ if (sameState(current, entry.before)) {
895
+ await this.mark(entry, entry.after ? "undone" : "skipped")
896
+ return "nothing-to-do"
897
+ }
898
+ // An enrollment interrupted before it was journaled done stored a credential we never recorded: the contract
899
+ // clears it (recoveryDecision, v1.1).
900
+ const decision = recoveryDecision(entry, current)
901
+ switch (decision) {
902
+ case "nothing-to-do":
903
+ await this.mark(entry, entry.after ? "undone" : "skipped")
904
+ break
905
+ case "keep-user-edit":
906
+ await this.conflict(entry, target, "changed since hub wrote it; kept as it is", current)
907
+ break
908
+ case "restore-backup": {
909
+ // A symlink's backup is its target text; a file's is read and copied in chunks, never whole.
910
+ const link = entry.backup && entry.before.symlink ? this.store.readBackup(this.txID, entry.backup.id) : undefined
911
+ const file = entry.backup && !entry.before.symlink ? this.store.backupFile(this.txID, entry.backup.id) : undefined
912
+ const backupDigest = link ? sha256Of(new TextDecoder().decode(link)) : file?.sha256
913
+ if ((!link && !file) || backupDigest !== entry.before.sha256) {
914
+ await this.conflict(entry, target, "its backup is missing or does not match what was backed up; left as hub wrote it", current)
915
+ return "keep-user-edit"
916
+ }
917
+ const staged = this.stagingPath(target, entry.seq)
918
+ if (link) fs.symlink(new TextDecoder().decode(link), staged)
919
+ else if (copyFile(fs, file!.path, staged, entry.before.mode ?? 0o600) !== entry.before.sha256) {
920
+ fs.unlink(staged)
921
+ await this.conflict(entry, target, "its backup changed while it was restored; left as hub wrote it", current)
922
+ return "keep-user-edit"
923
+ }
924
+ fs.rename(staged, target)
925
+ fs.fsyncDir(dirname(target))
926
+ await this.mark(entry, "undone")
927
+ break
928
+ }
929
+ case "remove-created": {
930
+ const kind = fs.lstat(target)?.kind
931
+ if (kind === "directory" && entry.class === "harness") {
932
+ // Move the tree aside atomically, then delete it: an interrupted delete never leaves half a tree.
933
+ const trash = this.stagingPath(target, entry.seq)
934
+ fs.rename(target, trash)
935
+ await this.mark(entry, "undone")
936
+ this.removeTree(trash)
937
+ } else if (kind === "directory") {
938
+ try {
939
+ fs.rmdir(target)
940
+ } catch (error) {
941
+ if (errnoOf(error) !== "ENOTEMPTY" && errnoOf(error) !== "EEXIST") throw error
942
+ // Informational: hub made this directory, and something else (often the harness itself) wrote into it.
943
+ this.kept.push({ seq: entry.seq, actionID: entry.actionID, path: target, reason: "hub created this directory, and it now also holds files hub did not write (a harness may have written them); left in place" })
944
+ return "keep-user-edit"
945
+ }
946
+ await this.mark(entry, "undone")
947
+ } else {
948
+ fs.unlink(target)
949
+ await this.mark(entry, "undone")
950
+ }
951
+ break
952
+ }
953
+ case "clear-credential": {
954
+ if (!action || !exec?.undo) {
955
+ await this.conflict(entry, target, "holds a credential this hubctl cannot clear; remove it with `hubctl machine` commands", current)
956
+ return "keep-user-edit"
957
+ }
958
+ const r = await exec.undo(action as never, { fs, roots: config.roots, context, host: config.host, now, target, reason: "clear-credential" })
959
+ if (!r.ok) {
960
+ await this.conflict(entry, target, `the credential could not be cleared: ${r.message}`, current)
961
+ return "keep-user-edit"
962
+ }
963
+ // An interrupted enrollment recorded no "after"; the journal records what it cleared.
964
+ await this.settle(entry, { status: "undone", after: entry.after ?? current }, false)
965
+ break
966
+ }
967
+ }
968
+ return decision
969
+ }
970
+
971
+ private removeStaging(target: string, seq: number): void {
972
+ const staged = this.stagingPath(target, seq)
973
+ const info = fs.lstat(staged)
974
+ if (!info) return
975
+ if (info.kind === "directory") this.removeTree(staged)
976
+ else fs.unlink(staged)
977
+ }
978
+
979
+ private removeTree(dir: string): void {
980
+ for (const name of fs.readdir(dir)) {
981
+ const p = `${dir}/${name}`
982
+ if (fs.lstat(p)?.kind === "directory") this.removeTree(p)
983
+ else fs.unlink(p)
984
+ }
985
+ fs.rmdir(dir)
986
+ }
987
+ }
988
+
989
+ return {
990
+ apply: async (request) => redact(await apply(request)),
991
+ recover: async (request) => redact(await recover(request)),
992
+ rollback: async (request) => redact(await rollback(request)),
993
+ }
994
+ }