@huynle/hub 0.8.3 → 0.8.5

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 (99) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +40 -3
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +5288 -4415
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/bootstrap/PHASE2-CONTRACT.md +87 -0
  8. package/packages/cli/src/bootstrap/adapters/claude/node.ts +11 -43
  9. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +19 -12
  10. package/packages/cli/src/bootstrap/adapters/opencode/README.md +42 -6
  11. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +18 -7
  12. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +36 -19
  13. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +19 -3
  14. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +2 -5
  15. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +37 -6
  16. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +49 -26
  17. package/packages/cli/src/bootstrap/adapters/process-tree.ts +263 -0
  18. package/packages/cli/src/bootstrap/contracts/paths.ts +7 -4
  19. package/packages/cli/src/bootstrap/contracts/platform.ts +9 -5
  20. package/packages/cli/src/bootstrap/contracts/validate.ts +20 -4
  21. package/packages/cli/src/bootstrap/discovery/discover.ts +3 -1
  22. package/packages/cli/src/bootstrap/discovery/executables.ts +49 -14
  23. package/packages/cli/src/bootstrap/discovery/host.ts +16 -8
  24. package/packages/cli/src/bootstrap/discovery/node-probe.ts +41 -5
  25. package/packages/cli/src/bootstrap/discovery/probe.ts +7 -3
  26. package/packages/cli/src/bootstrap/discovery/util.ts +42 -5
  27. package/packages/cli/src/bootstrap/enrollment/store.ts +13 -6
  28. package/packages/cli/src/bootstrap/preview/plan.ts +3 -0
  29. package/packages/cli/src/bootstrap/profiles/desired.ts +10 -3
  30. package/packages/cli/src/bootstrap/release/README.md +63 -9
  31. package/packages/cli/src/bootstrap/release/cli.ts +94 -16
  32. package/packages/cli/src/bootstrap/release/harness-pins.pending.json +24 -0
  33. package/packages/cli/src/bootstrap/release/manifest.ts +3 -0
  34. package/packages/cli/src/bootstrap/release/pins.ts +60 -9
  35. package/packages/cli/src/bootstrap/transaction/builtin.ts +18 -6
  36. package/packages/cli/src/bootstrap/transaction/engine.ts +166 -12
  37. package/packages/cli/src/bootstrap/transaction/executor.ts +5 -0
  38. package/packages/cli/src/bootstrap/transaction/fs.ts +142 -3
  39. package/packages/cli/src/bootstrap/transaction/shim.ts +31 -0
  40. package/packages/cli/src/bootstrap/transaction/state.ts +11 -6
  41. package/packages/cli/src/credentials.ts +5 -2
  42. package/packages/cli/src/main.ts +3 -2
  43. package/packages/cli/src/setup/commands.ts +96 -18
  44. package/packages/cli/src/setup/contributors.ts +45 -10
  45. package/packages/cli/src/setup/runtime.ts +25 -6
  46. package/packages/cli/src/setup/self-aside.ts +130 -0
  47. package/packages/codex/package.json +1 -1
  48. package/packages/node/package.json +1 -1
  49. package/packages/node/src/opencode-warm.ts +12 -4
  50. package/packages/preset/package.json +1 -1
  51. package/packages/protocol/package.json +1 -1
  52. package/packages/protocol/src/index.ts +7 -2
  53. package/packages/runtime-openshell/package.json +1 -1
  54. package/packages/runtime-sdk/package.json +1 -1
  55. package/packages/sdk/package.json +1 -1
  56. package/packages/spoke-sdk/package.json +1 -1
  57. package/src/central/config.ts +12 -0
  58. package/src/central/http.ts +10 -0
  59. package/src/central/install/CONTRACT.md +66 -0
  60. package/src/central/install/install.ps1 +349 -0
  61. package/src/central/install/install.sh +266 -0
  62. package/src/central/install/render.ts +146 -0
  63. package/src/central/install/templates.ts +6 -0
  64. package/src/central/install/text-assets.d.ts +9 -0
  65. package/src/central/registry.ts +93 -22
  66. package/src/config.ts +12 -7
  67. package/src/index.ts +28 -0
  68. package/src/spoke/env-inject.ts +17 -2
  69. package/src/spoke/launchers.ts +145 -9
  70. package/src/spoke/spoke.ts +150 -10
  71. package/src/status.ts +70 -14
  72. package/src/win-acl.ts +154 -0
  73. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +1 -0
  74. package/web/dist/assets/{ArtifactPane-DPHjiWce.js → ArtifactPane-DKwbMTX9.js} +1 -1
  75. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +6 -0
  76. package/web/dist/assets/Library-D6-Go6Cu.js +2 -0
  77. package/web/dist/assets/{ProfilesDestination-GriuOpiZ.js → ProfilesDestination-BOML2Dgf.js} +1 -1
  78. package/web/dist/assets/{TeamsPage-C-5lYeeM.js → TeamsPage-CNBTE8gB.js} +1 -1
  79. package/web/dist/assets/Watchtower-Dor1Ubjy.js +2 -0
  80. package/web/dist/assets/Watchtower-RphjcL0_.css +1 -0
  81. package/web/dist/assets/hub-CrbItiwP.js +13 -0
  82. package/web/dist/assets/index-82Ce0IAf.css +1 -0
  83. package/web/dist/assets/index-D9SP3_0z.js +22 -0
  84. package/web/dist/assets/{live-CDWcyavI.js → live-BNJgy0vg.js} +3 -3
  85. package/web/dist/assets/{pane-layout-Csk-vPNe.js → pane-layout-EdR1kRrG.js} +1 -1
  86. package/web/dist/assets/preload-helper-DqrJTq0t.js +1 -0
  87. package/web/dist/assets/{renderer2d-CNFdTDTr.js → renderer2d-CYag1CnR.js} +1 -1
  88. package/web/dist/assets/{renderer3d-C45jPvYX.js → renderer3d-BRB6rgDJ.js} +1 -1
  89. package/web/dist/assets/{safe-area-sA9a8vYJ.js → safe-area-fOy0AOuG.js} +1 -1
  90. package/web/dist/assets/{source-BFLJQ43_.js → source-Wbq35qGp.js} +1 -1
  91. package/web/dist/assets/{ui-Qo2Pd9-O.js → ui-j5FFk90B.js} +1 -1
  92. package/web/dist/index.html +6 -4
  93. package/web/dist/assets/ArtifactPage-CSotAWob.js +0 -1
  94. package/web/dist/assets/ArtifactWorkbench-CkmWnP6L.js +0 -6
  95. package/web/dist/assets/Library-DTLEyekn.js +0 -2
  96. package/web/dist/assets/Watchtower-CTgqHk5g.css +0 -1
  97. package/web/dist/assets/Watchtower-DOlPsiIy.js +0 -2
  98. package/web/dist/assets/index-6mdQ7eUY.js +0 -34
  99. package/web/dist/assets/index-C5OmwA1y.css +0 -1
@@ -20,6 +20,18 @@
20
20
  // credential, refuse a backup that does not match what it backed up;
21
21
  // - resume after a crash: continue the stored plan from the interrupted step, when the host still agrees.
22
22
  //
23
+ // On win32 (Phase 2 contract "Install primitives on win32"): a file or link that may be in use (hubctl.exe while
24
+ // OpenCode runs `hubctl mcp`) cannot be renamed over, so it is replaced by rename-aside: the old entry is moved to
25
+ // `<name>.old-<txID>-<seq>` in the same directory, the staged one moved in, and the aside deleted when possible (else
26
+ // a later apply or recover sweeps it). A crash between the two renames is healed by renaming the aside back before
27
+ // recovery or resume looks at the target, and a rollback renames the aside back rather than copying the backup.
28
+ // Journal entries record no mode there (not applicable; comparisons skip it).
29
+ // A path a rollback cannot move or delete because a program has it open (EBUSY; on win32 also EPERM/EACCES: a
30
+ // running exe, or the directory holding one) never stops the rollback: every other entry is undone, the transaction
31
+ // stays unfinished (`failed`, recovery required) with a message naming each busy path and what to do, and
32
+ // `hubctl setup recover` finishes it (including the version directory moved aside, `.<name>.hub-<txID>-<seq>.tmp`)
33
+ // once the program has exited.
34
+ //
23
35
  // The engine performs only the TxFs operations; it never spawns processes or runs commands.
24
36
  import { randomBytes } from "node:crypto"
25
37
  import {
@@ -32,6 +44,7 @@ import {
32
44
  revalidatePreview,
33
45
  SETUP_PREVIEW_SCHEMA,
34
46
  snapshotDigest,
47
+ toNativePath,
35
48
  validateSetupPlan,
36
49
  type Action,
37
50
  type ApplyLock,
@@ -52,7 +65,7 @@ import {
52
65
  type TransactionJournal,
53
66
  } from "../contracts/index.ts"
54
67
  import type { ActionExecutor, Change, ExecutorMap, HostInfo, StepContext, StepPlan, TreeFile } from "./executor.ts"
55
- import { nodeFs, type TxFs } from "./fs.ts"
68
+ import { createNodeFs, nodeFs, type TxFs } from "./fs.ts"
56
69
  import { breakStaleLock, releaseLock, renewLock, tryAcquireLock, type LockIO } from "./lock.ts"
57
70
  import { resolveTarget, rootDir, type ResolveOptions } from "./resolve.ts"
58
71
  import { errnoOf, fromResult, TxError, txFail, txOk, type TxFailure, type TxResult } from "./result.ts"
@@ -72,6 +85,8 @@ export interface ApplierConfig {
72
85
  readonly host: HostInfo
73
86
  readonly executors: ExecutorMap
74
87
  readonly fs?: TxFs
88
+ /** The platform (default process.platform): "win32" switches links, replacement and modes (see above). */
89
+ readonly platform?: string
75
90
  readonly now?: () => number
76
91
  readonly pid?: number
77
92
  /** Whether a process on this host is alive (default: signal 0). */
@@ -209,8 +224,44 @@ function failureOf(error: unknown): TxFailure {
209
224
  return txFail("internal", code ? `filesystem error ${code}: ${(error as Error).message}` : `unexpected error: ${error instanceof Error ? error.message : String(error)}`)
210
225
  }
211
226
 
227
+ /** A path a rollback could not move or delete because a program has it open (Windows: a running exe). */
228
+ interface Busy {
229
+ readonly path: string
230
+ readonly code: string
231
+ readonly directory: boolean
232
+ }
233
+
234
+ /**
235
+ * Whether `error` is a path in use: EBUSY anywhere; on win32 also EPERM/EACCES, which is what deleting or renaming a
236
+ * running exe (or a directory holding one) fails with. The path is the error's own, else `fallback`.
237
+ */
238
+ function busyOf(error: unknown, fallback: string | undefined, win: boolean): Omit<Busy, "directory"> | undefined {
239
+ const code = errnoOf(error)
240
+ if (!(code === "EBUSY" || (win && (code === "EPERM" || code === "EACCES")))) return undefined
241
+ const own = (error as { path?: unknown }).path
242
+ const path = typeof own === "string" && own ? own.replace(/\\/g, "/") : fallback
243
+ return path ? { path, code } : undefined
244
+ }
245
+
246
+ /** Names every busy path and what to do; the native form on Windows. */
247
+ function busyMessage(txID: string, busy: readonly Busy[], platform: string): string {
248
+ const native = (p: string) => toNativePath(p, platform)
249
+ // Where the program runs from, as the user knows it: a tree a rollback moved aside (`.<name>.hub-<txID>-<seq>.tmp`)
250
+ // under its own name.
251
+ const known = (p: string) => p.replace(/\/\.([^/]+)\.hub-tx_[A-Za-z0-9_]+-\d+\.tmp(?=\/|$)/g, "/$1")
252
+ const dirs = [...new Set(busy.map((b) => native(known(b.directory ? b.path : dirname(b.path)))))]
253
+ return `rollback of ${txID} is not finished: ${busy.map((b) => `${native(b.path)} is in use (${b.code})`).join("; ")}; close OpenCode or any program running from ${dirs.join(", ")}, then run \`hubctl setup recover\``
254
+ }
255
+
256
+ /** Where rename-aside moves the old entry of `target` (forward), or the entry hub wrote (a rollback's undo). */
257
+ const asidePath = (target: string, txID: string, seq: number, undo = false) => `${target}.old-${txID}-${seq}${undo ? "-undo" : ""}`
258
+
212
259
  export function createApplier(config: ApplierConfig): Applier {
213
- const fs = config.fs ?? nodeFs
260
+ const platform = config.platform ?? process.platform
261
+ const win = platform === "win32"
262
+ const fs = config.fs ?? (config.platform === undefined ? nodeFs : createNodeFs(platform))
263
+ /** A mode for the journal: none on win32. */
264
+ const modeIn = (mode: number): { mode?: number } => (win ? {} : { mode })
214
265
  const now = config.now ?? Date.now
215
266
  const pid = config.pid ?? process.pid
216
267
  const isAlive = config.isAlive ?? defaultIsAlive
@@ -218,6 +269,28 @@ export function createApplier(config: ApplierConfig): Applier {
218
269
  const hooks = config.hooks ?? {}
219
270
  const context: ExecutionContext = config.context ?? "host"
220
271
 
272
+ /** Removes `path` if it is there; false when it is still in use (a running executable on Windows). */
273
+ const discard = (path: string): boolean => {
274
+ try {
275
+ if (fs.lstat(path)) fs.unlink(path)
276
+ return true
277
+ } catch {
278
+ return false
279
+ }
280
+ }
281
+
282
+ /** win32: deletes what rename-aside left of `journal`'s steps (asides that were still in use then). */
283
+ const sweepAsides = (journal: TransactionJournal): void => {
284
+ if (!win) return
285
+ for (const e of journal.entries) {
286
+ if (!e.path) continue
287
+ const r = resolveTarget(e.path, config.roots, fs, { context, ...(e.class === "link" ? { finalMayBeSymlink: true } : {}) })
288
+ if (!r.ok) continue
289
+ discard(asidePath(r.value, journal.txID, e.seq))
290
+ discard(asidePath(r.value, journal.txID, e.seq, true))
291
+ }
292
+ }
293
+
221
294
  const lockIO = (store: TxStore): LockIO => ({ fs, path: store.lockPath, hostFingerprint: config.host.fingerprint, pid, now, isAlive })
222
295
 
223
296
  /** Interrupted transactions of this host. Another host's (a shared home) are reported, never touched here. */
@@ -262,6 +335,7 @@ export function createApplier(config: ApplierConfig): Applier {
262
335
  const lock = { current: attempt.lock }
263
336
  try {
264
337
  store.sweep()
338
+ if (win) for (const j of store.journals()) if (FINISHED.includes(j.state) && j.hostFingerprint === config.host.fingerprint) sweepAsides(j)
265
339
  return await body(store, lock)
266
340
  } catch (error) {
267
341
  return failureOf(error)
@@ -489,6 +563,7 @@ export function createApplier(config: ApplierConfig): Applier {
489
563
  context,
490
564
  host: config.host,
491
565
  now,
566
+ platform,
492
567
  target,
493
568
  current,
494
569
  original: this.original(step.path, current),
@@ -524,7 +599,10 @@ export function createApplier(config: ApplierConfig): Applier {
524
599
  break
525
600
  }
526
601
  }
527
- if (!failure) this.persist({ state: "committed" })
602
+ if (!failure) {
603
+ this.persist({ state: "committed" })
604
+ sweepAsides(this.journal)
605
+ }
528
606
  } catch (error) {
529
607
  failure = failureOf(error)
530
608
  }
@@ -622,7 +700,7 @@ export function createApplier(config: ApplierConfig): Applier {
622
700
  const info = fs.lstat(abs)
623
701
  if (info?.kind === "directory") return
624
702
  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" })
703
+ const entry = await this.append({ actionID: action.id, kind: action.kind, class: "directory", path: ref, before: { exists: false }, after: { exists: true, ...modeIn(PARENT_MODE) }, status: "pending" })
626
704
  const again = this.resolve(ref)
627
705
  if (!again.ok) throw new TxError(again.code, `${action.id}: ${again.message}`)
628
706
  fs.mkdir(abs, PARENT_MODE)
@@ -710,7 +788,7 @@ export function createApplier(config: ApplierConfig): Applier {
710
788
  case "directory": {
711
789
  if (current.exists) return kind === "directory" ? this.skip() : conflict("exists and is not a directory")
712
790
  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" })
791
+ const entry = await this.append({ actionID: action.id, kind: action.kind, class: step.class, path: path!, before: current, after: { exists: true, ...modeIn(change.mode) }, status: "pending" })
714
792
  this.recheck(action, step)
715
793
  fs.mkdir(target!, change.mode)
716
794
  fs.chmod(target!, change.mode)
@@ -721,7 +799,7 @@ export function createApplier(config: ApplierConfig): Applier {
721
799
  case "symlink": {
722
800
  const desired: FileState =
723
801
  change.type === "file"
724
- ? { exists: true, sha256: "bytes" in change ? sha256Of(change.bytes) : change.staged.sha256, mode: change.mode }
802
+ ? { exists: true, sha256: "bytes" in change ? sha256Of(change.bytes) : change.staged.sha256, ...modeIn(change.mode) }
725
803
  : { exists: true, symlink: true, sha256: sha256Of(change.target) }
726
804
  if (current.exists) {
727
805
  if (sameState(current, desired) && (change.type === "symlink" || kind === "file")) return this.skip()
@@ -737,7 +815,7 @@ export function createApplier(config: ApplierConfig): Applier {
737
815
  if (change.type === "symlink") fs.symlink(change.target, staged)
738
816
  else if ("bytes" in change) fs.writeExclusive(staged, change.bytes, change.mode)
739
817
  else this.place(change.staged, staged, change.mode, ctx.staging)
740
- fs.rename(staged, target!)
818
+ this.replace(staged, target!, asidePath(target!, this.txID, entry.seq))
741
819
  fs.fsyncDir(dirname(target!))
742
820
  const after = fileState(fs, target!)
743
821
  if (!sameState(after, desired)) throw new TxError("conflict", `${action.id}: ${target} changed while it was being written`)
@@ -745,7 +823,7 @@ export function createApplier(config: ApplierConfig): Applier {
745
823
  return txOk("applied")
746
824
  }
747
825
  case "tree": {
748
- const desired: FileState = { exists: true, sha256: treeDigestOfFiles(change.files, change.mode), mode: change.mode }
826
+ const desired: FileState = { exists: true, sha256: treeDigestOfFiles(change.files, change.mode, { modes: !win }), ...modeIn(change.mode) }
749
827
  if (current.exists) return sameState(current, desired) ? this.skip() : conflict("already exists with other content (install roots are never overwritten)")
750
828
  await this.parents(action, path!)
751
829
  const entry = await this.append({ actionID: action.id, kind: action.kind, class: step.class, path: path!, before: current, after: desired, status: "pending" })
@@ -762,6 +840,43 @@ export function createApplier(config: ApplierConfig): Applier {
762
840
  }
763
841
  }
764
842
 
843
+ /**
844
+ * Moves `staged` over `target`. POSIX: one atomic rename. win32: rename-aside when something is there (a rename
845
+ * cannot replace a directory link or a file in use): the old entry to `aside`, the staged one in (on failure the
846
+ * old one is moved back), then the aside deleted if it is no longer in use (else swept later).
847
+ */
848
+ private replace(staged: string, target: string, aside: string): void {
849
+ if (!win || !fs.lstat(target)) {
850
+ fs.rename(staged, target)
851
+ return
852
+ }
853
+ discard(aside)
854
+ fs.rename(target, aside)
855
+ try {
856
+ fs.rename(staged, target)
857
+ } catch (error) {
858
+ try {
859
+ fs.rename(aside, target)
860
+ } catch {}
861
+ throw error
862
+ }
863
+ discard(aside)
864
+ }
865
+
866
+ /**
867
+ * win32: a crash between the two renames of a rename-aside leaves `target` missing and its aside in place; this
868
+ * renames the aside back (a rollback's, else the forward step's) so recovery sees the target as it was.
869
+ */
870
+ private heal(target: string, seq: number): void {
871
+ if (!win || fs.lstat(target)) return
872
+ for (const aside of [asidePath(target, this.txID, seq, true), asidePath(target, this.txID, seq)]) {
873
+ if (fs.lstat(aside)) {
874
+ fs.rename(aside, target)
875
+ return
876
+ }
877
+ }
878
+ }
879
+
765
880
  private stageTree(dir: string, files: readonly TreeFile[], mode: number, staging: StagingArea): void {
766
881
  fs.mkdir(dir, mode)
767
882
  fs.chmod(dir, mode)
@@ -809,6 +924,7 @@ export function createApplier(config: ApplierConfig): Applier {
809
924
  }
810
925
  const target = this.resolve(entry.path, this.resolveOptions(entry))
811
926
  if (!target.ok) return txFail("recovery_required", `${entry.actionID}: ${target.message}; roll back instead`)
927
+ this.heal(target.value, entry.seq)
812
928
  const current = fileState(fs, target.value, { tree: entry.class === "harness" })
813
929
  if (entry.after && sameState(current, entry.after)) {
814
930
  this.persist({ entries: this.journal.entries.map((e) => (e.seq === entry.seq ? { ...e, status: "done" } : e)) })
@@ -835,18 +951,40 @@ export function createApplier(config: ApplierConfig): Applier {
835
951
 
836
952
  async rollbackAll(): Promise<TxOutcome> {
837
953
  const decided: RecoveryStep[] = []
954
+ const busy: Busy[] = []
955
+ /** Runs one entry's undo; a path in use (a running program) is noted and the rollback goes on. */
956
+ const tolerant = async (entry: JournalEntry, work: () => Promise<void> | void) => {
957
+ try {
958
+ await work()
959
+ } catch (error) {
960
+ const fallback = entry.path ? this.resolve(entry.path, this.resolveOptions(entry)) : undefined
961
+ const found = busyOf(error, fallback?.ok ? fallback.value : undefined, win)
962
+ if (!found) throw error
963
+ if (!busy.some((b) => b.path === found.path)) busy.push({ ...found, directory: fs.lstat(found.path)?.kind === "directory" })
964
+ }
965
+ }
838
966
  try {
839
967
  if (this.journal.state !== "rolling-back") this.persist({ state: "rolling-back" })
840
968
  for (const entry of [...this.journal.entries].reverse()) {
841
969
  if (entry.status === "undone" || entry.status === "skipped") {
842
970
  // 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)
971
+ await tolerant(entry, () => {
972
+ const r = entry.path ? this.resolve(entry.path, this.resolveOptions(entry)) : undefined
973
+ if (r?.ok) this.removeStaging(r.value, entry.seq)
974
+ })
845
975
  continue
846
976
  }
847
- decided.push({ seq: entry.seq, actionID: entry.actionID, decision: await this.undo(entry) })
977
+ await tolerant(entry, async () => {
978
+ decided.push({ seq: entry.seq, actionID: entry.actionID, decision: await this.undo(entry) })
979
+ })
980
+ }
981
+ if (busy.length > 0) {
982
+ // Everything else is undone; the transaction stays unfinished until the program has exited.
983
+ this.persist({ state: "failed" })
984
+ return { ...txFail("recovery_required", busyMessage(this.txID, busy, platform)), report: this.report() }
848
985
  }
849
986
  this.persist({ state: "rolled-back" })
987
+ sweepAsides(this.journal)
850
988
  this.cleanup()
851
989
  await hooks.onRecover?.(copy(this.journal), copy(decided))
852
990
  return { ok: true, report: this.report() }
@@ -890,6 +1028,7 @@ export function createApplier(config: ApplierConfig): Applier {
890
1028
  }
891
1029
  const target = resolved.value
892
1030
  this.removeStaging(target, entry.seq)
1031
+ this.heal(target, entry.seq)
893
1032
  const current = fileState(fs, target, { tree: entry.class === "harness" })
894
1033
  if (sameState(current, entry.before)) {
895
1034
  await this.mark(entry, entry.after ? "undone" : "skipped")
@@ -906,6 +1045,14 @@ export function createApplier(config: ApplierConfig): Applier {
906
1045
  await this.conflict(entry, target, "changed since hub wrote it; kept as it is", current)
907
1046
  break
908
1047
  case "restore-backup": {
1048
+ // win32: the old entry rename-aside moved away is still there when it was in use: rename it back (the very
1049
+ // file that may be running), rather than copying the backup over it.
1050
+ const aside = asidePath(target, this.txID, entry.seq)
1051
+ if (win && fs.lstat(aside) && sameState(fileState(fs, aside), entry.before)) {
1052
+ this.replace(aside, target, asidePath(target, this.txID, entry.seq, true))
1053
+ await this.mark(entry, "undone")
1054
+ break
1055
+ }
909
1056
  // A symlink's backup is its target text; a file's is read and copied in chunks, never whole.
910
1057
  const link = entry.backup && entry.before.symlink ? this.store.readBackup(this.txID, entry.backup.id) : undefined
911
1058
  const file = entry.backup && !entry.before.symlink ? this.store.backupFile(this.txID, entry.backup.id) : undefined
@@ -921,7 +1068,7 @@ export function createApplier(config: ApplierConfig): Applier {
921
1068
  await this.conflict(entry, target, "its backup changed while it was restored; left as hub wrote it", current)
922
1069
  return "keep-user-edit"
923
1070
  }
924
- fs.rename(staged, target)
1071
+ this.replace(staged, target, asidePath(target, this.txID, entry.seq, true))
925
1072
  fs.fsyncDir(dirname(target))
926
1073
  await this.mark(entry, "undone")
927
1074
  break
@@ -944,6 +1091,13 @@ export function createApplier(config: ApplierConfig): Applier {
944
1091
  return "keep-user-edit"
945
1092
  }
946
1093
  await this.mark(entry, "undone")
1094
+ } else if (win && kind === "file") {
1095
+ // A file hub created may be running now (hubctl.exe): move it aside, then delete it if it is not.
1096
+ const aside = asidePath(target, this.txID, entry.seq, true)
1097
+ discard(aside)
1098
+ fs.rename(target, aside)
1099
+ await this.mark(entry, "undone")
1100
+ discard(aside)
947
1101
  } else {
948
1102
  fs.unlink(target)
949
1103
  await this.mark(entry, "undone")
@@ -72,6 +72,11 @@ export interface StepContext {
72
72
  readonly context: ExecutionContext
73
73
  readonly host: HostInfo
74
74
  readonly now: () => number
75
+ /**
76
+ * The platform apply runs on (process.platform unless injected). On "win32" links differ: link-executable writes a
77
+ * cmd shim, directory links are junctions with an absolute target (Phase 2 contract).
78
+ */
79
+ readonly platform?: string
75
80
  /** The resolved, symlink-checked absolute path of the step's target (when it has one). */
76
81
  readonly target?: string
77
82
  /** The target's state now. */
@@ -5,6 +5,12 @@
5
5
  // Writes are only ever "create a new file exclusively" (O_CREAT|O_EXCL|O_NOFOLLOW, then fsync); replacing a file is
6
6
  // always stage-then-rename, so a reader (or a crash) sees the old file or the new one, never a torn mix. Large files
7
7
  // (a harness binary, hubctl itself) are written and read in chunks (createExclusive, readRange), never whole.
8
+ //
9
+ // On Windows (createNodeFs("win32"), Phase 2 contract): O_NOFOLLOW does not exist, so every open that must not
10
+ // follow a link is preceded by an explicit lstat that refuses one (lstat reports junctions and symlinks alike as
11
+ // links); there are no POSIX modes (lstat reports `mode: null`, chmod and fchmod are no-ops, files are created
12
+ // writable); a directory cannot be opened for fsync (fsyncDir is a no-op); and a directory link is an NTFS junction
13
+ // with an absolute target (no admin or Developer Mode needed), read back in canonical form (C:/…).
8
14
  import {
9
15
  chmodSync,
10
16
  closeSync,
@@ -26,11 +32,12 @@ import {
26
32
  writeSync,
27
33
  } from "node:fs"
28
34
  import type { EntryKind } from "../contracts/index.ts"
35
+ import { canonicalWinLink, normalizeAbs } from "../discovery/util.ts"
29
36
 
30
37
  export interface EntryInfo {
31
38
  readonly kind: EntryKind
32
- /** Permission bits (0..0o7777). */
33
- readonly mode: number
39
+ /** Permission bits (0..0o7777); null where the platform has none (win32). */
40
+ readonly mode: number | null
34
41
  readonly size: number
35
42
  }
36
43
 
@@ -87,7 +94,8 @@ function kindOf(mode: number): EntryKind {
87
94
 
88
95
  const errno = (error: unknown) => (error as NodeJS.ErrnoException | undefined)?.code
89
96
 
90
- export const nodeFs: TxFs = {
97
+ /** The POSIX filesystem (macOS, Linux). */
98
+ const posixFs: TxFs = {
91
99
  lstat(path) {
92
100
  try {
93
101
  const s = lstatSync(path)
@@ -192,3 +200,134 @@ export const nodeFs: TxFs = {
192
200
  } catch {}
193
201
  },
194
202
  }
203
+
204
+ export { canonicalWinLink }
205
+
206
+ const linkError = (path: string) => Object.assign(new Error(`ELOOP: ${path} is a link; refusing to open it`), { code: "ELOOP", path })
207
+
208
+ /** O_NOFOLLOW's guarantee where it does not exist: refuse a link (symlink or junction) at `path` right before an open. */
209
+ function refuseLink(path: string): void {
210
+ let link = false
211
+ try {
212
+ link = lstatSync(path).isSymbolicLink()
213
+ } catch {}
214
+ if (link) throw linkError(path)
215
+ }
216
+
217
+ /**
218
+ * POSIX modes do not exist on Windows; Node maps a mode without 0o200 to a read-only file, which is never wanted.
219
+ * Opens use string flags ("wx", "r"): Bun on Windows does not honour numeric O_CREAT/O_EXCL from fs.constants
220
+ * (seen on Windows 11, Bun 1.3.5: O_CREAT|O_EXCL|O_WRONLY on a missing file fails with ENOENT, and on an existing
221
+ * junction opens it instead of failing).
222
+ */
223
+ const WIN_CREATE_MODE = 0o666
224
+
225
+ const parentOf = (p: string) => {
226
+ const at = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"))
227
+ return at <= 0 ? p : p.slice(0, at)
228
+ }
229
+ const isAbsolute = (p: string) => p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p)
230
+
231
+ const winFs: TxFs = {
232
+ ...posixFs,
233
+ lstat(path) {
234
+ const info = posixFs.lstat(path)
235
+ return info ? { ...info, mode: null } : undefined
236
+ },
237
+ readRange(path, offset, length) {
238
+ refuseLink(path)
239
+ const fd = openSync(path, "r")
240
+ try {
241
+ const out = new Uint8Array(length)
242
+ let got = 0
243
+ while (got < length) {
244
+ const n = readSync(fd, out, got, length - got, offset + got)
245
+ if (n === 0) break
246
+ got += n
247
+ }
248
+ return got === length ? out : out.subarray(0, got)
249
+ } finally {
250
+ closeSync(fd)
251
+ }
252
+ },
253
+ readlink: (path) => canonicalWinLink(readlinkSync(path)),
254
+ writeExclusive(path, data, _mode) {
255
+ refuseLink(path)
256
+ const fd = openSync(path, "wx", WIN_CREATE_MODE)
257
+ let ok = false
258
+ try {
259
+ const bytes = typeof data === "string" ? Buffer.from(data, "utf8") : data
260
+ let offset = 0
261
+ while (offset < bytes.byteLength) offset += writeSync(fd, bytes, offset, bytes.byteLength - offset)
262
+ fsyncSync(fd)
263
+ ok = true
264
+ } finally {
265
+ closeSync(fd)
266
+ if (!ok) {
267
+ try {
268
+ unlinkSync(path)
269
+ } catch {}
270
+ }
271
+ }
272
+ },
273
+ createExclusive(path, _mode) {
274
+ refuseLink(path)
275
+ const fd = openSync(path, "wx", WIN_CREATE_MODE)
276
+ let open = true
277
+ const finish = (complete: boolean) => {
278
+ if (!open) return
279
+ open = false
280
+ try {
281
+ if (complete) fsyncSync(fd)
282
+ } finally {
283
+ closeSync(fd)
284
+ if (!complete) {
285
+ try {
286
+ unlinkSync(path)
287
+ } catch {}
288
+ }
289
+ }
290
+ }
291
+ return {
292
+ write(chunk) {
293
+ if (!open) throw new Error(`${path} is already closed`)
294
+ let offset = 0
295
+ while (offset < chunk.byteLength) offset += writeSync(fd, chunk, offset, chunk.byteLength - offset)
296
+ },
297
+ close() {
298
+ try {
299
+ finish(true)
300
+ } catch (error) {
301
+ try {
302
+ unlinkSync(path)
303
+ } catch {}
304
+ throw error
305
+ }
306
+ },
307
+ abort: () => finish(false),
308
+ }
309
+ },
310
+ unlink(path) {
311
+ try {
312
+ unlinkSync(path)
313
+ } catch (error) {
314
+ // A junction is a directory link: some runtimes remove it with rmdir only (the target is never touched).
315
+ const code = errno(error)
316
+ if ((code === "EPERM" || code === "EISDIR" || code === "EACCES") && lstatSync(path).isSymbolicLink()) rmdirSync(path)
317
+ else throw error
318
+ }
319
+ },
320
+ mkdir: (path) => mkdirSync(path),
321
+ mkdirp: (path) => void mkdirSync(path, { recursive: true }),
322
+ symlink(target, path) {
323
+ const absolute = isAbsolute(target) ? target : `${parentOf(path)}/${target}`
324
+ symlinkSync(absolute, path, "junction")
325
+ },
326
+ chmod: () => {},
327
+ fsyncDir: () => {},
328
+ }
329
+
330
+ /** The filesystem for `platform` (default: this process's). */
331
+ export const createNodeFs = (platform: string = process.platform): TxFs => (platform === "win32" ? winFs : posixFs)
332
+
333
+ export const nodeFs: TxFs = createNodeFs()
@@ -0,0 +1,31 @@
1
+ // The cmd shim that stands in for an executable symlink on Windows (Phase 2 contract): symlinks to executables need
2
+ // Developer Mode or admin there, so link-executable writes `user-bin:<name>.cmd` holding exactly
3
+ // `@"<native target>" %*\r\n` (ASCII). The applier journals it as a file it owns; readers that check "is this the
4
+ // hub link to X" (setup verify, the user-bin link rule) parse it back with parseCmdShim.
5
+ import { normalizeAbs } from "../discovery/util.ts"
6
+ import { toNativePath } from "../contracts/index.ts"
7
+
8
+ // Inside the quotes cmd still expands %VAR%; a quote would end them. Printable ASCII only.
9
+ const UNSAFE = /[^\x20-\x7e]|["%]/
10
+
11
+ /** `path` (canonical C:/… or native) as one double-quoted cmd word. Throws for a path cmd would not take literally. */
12
+ export function cmdWord(path: string): string {
13
+ const native = toNativePath(path, "win32")
14
+ if (UNSAFE.test(native)) throw new Error(`cannot write a cmd shim for ${JSON.stringify(path)}: the path must be printable ASCII without " or %`)
15
+ return `"${native}"`
16
+ }
17
+
18
+ /** The shim's bytes for the canonical `target` (C:/… on Windows). Throws for a target cmd would not run literally. */
19
+ export function cmdShim(target: string): Uint8Array {
20
+ return new TextEncoder().encode(`@${cmdWord(target)} %*\r\n`)
21
+ }
22
+
23
+ const SHIM = /^@"([\x20-\x7e]+)" %\*\r\n$/
24
+
25
+ /** The canonical target of a hub cmd shim, or undefined when `bytes` are anything else. */
26
+ export function parseCmdShim(bytes: Uint8Array): string | undefined {
27
+ if (bytes.byteLength > 4096) return undefined
28
+ const m = SHIM.exec(new TextDecoder("latin1").decode(bytes))
29
+ if (!m || UNSAFE.test(m[1]!)) return undefined
30
+ return normalizeAbs(m[1]!)
31
+ }
@@ -7,6 +7,9 @@ import type { TreeFile } from "./executor.ts"
7
7
  import type { TxFs } from "./fs.ts"
8
8
  import { fileDigest } from "./stream.ts"
9
9
 
10
+ /** A FileState's mode, omitted where the platform has none (win32: lstat reports null, comparisons skip it). */
11
+ const modeOf = (mode: number | null) => (mode === null ? {} : { mode })
12
+
10
13
  export const sha256Of = (data: Uint8Array | string): string => `sha256:${sha256Hex(data)}`
11
14
 
12
15
  export function fileState(fs: TxFs, path: string, options: { readonly tree?: boolean } = {}): FileState {
@@ -14,11 +17,11 @@ export function fileState(fs: TxFs, path: string, options: { readonly tree?: boo
14
17
  if (!info) return { exists: false }
15
18
  switch (info.kind) {
16
19
  case "file":
17
- return { exists: true, sha256: fileDigest(fs, path), mode: info.mode }
20
+ return { exists: true, sha256: fileDigest(fs, path), ...modeOf(info.mode) }
18
21
  case "symlink":
19
22
  return { exists: true, symlink: true, sha256: sha256Of(fs.readlink(path)) }
20
23
  case "directory":
21
- return options.tree ? { exists: true, sha256: treeDigest(fs, path), mode: info.mode } : { exists: true, mode: info.mode }
24
+ return options.tree ? { exists: true, sha256: treeDigest(fs, path), ...modeOf(info.mode) } : { exists: true, ...modeOf(info.mode) }
22
25
  default:
23
26
  return { exists: true }
24
27
  }
@@ -47,9 +50,11 @@ export function treeDigest(fs: TxFs, dir: string): string {
47
50
 
48
51
  /**
49
52
  * The digest treeDigest would compute for a directory holding exactly `files` (directories implied by their paths,
50
- * all with `dirMode`), so the journal can record a tree's state before it is staged.
53
+ * all with `dirMode`), so the journal can record a tree's state before it is staged. `modes: false` where the
54
+ * platform has none (win32): every entry's mode is null, as treeDigest reads it there.
51
55
  */
52
- export function treeDigestOfFiles(files: readonly TreeFile[], dirMode: number): string {
56
+ export function treeDigestOfFiles(files: readonly TreeFile[], dirMode: number, options: { readonly modes?: boolean } = {}): string {
57
+ const withModes = options.modes !== false
53
58
  type Dir = Map<string, Dir | { readonly sha256: string; readonly mode: number }>
54
59
  const root: Dir = new Map()
55
60
  for (const f of files) {
@@ -71,9 +76,9 @@ export function treeDigestOfFiles(files: readonly TreeFile[], dirMode: number):
71
76
  const child = dir.get(name)!
72
77
  const childRel = rel ? `${rel}/${name}` : name
73
78
  if (child instanceof Map) {
74
- entries.push({ rel: childRel, kind: "directory", mode: dirMode })
79
+ entries.push({ rel: childRel, kind: "directory", mode: withModes ? dirMode : null })
75
80
  walk(child, childRel)
76
- } else entries.push({ rel: childRel, kind: "file", mode: child.mode, sha256: child.sha256 })
81
+ } else entries.push({ rel: childRel, kind: "file", mode: withModes ? child.mode : null, sha256: child.sha256 })
77
82
  }
78
83
  }
79
84
  walk(root, "")
@@ -5,6 +5,7 @@ import { randomBytes } from "node:crypto"
5
5
  import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
6
6
  import path from "node:path"
7
7
  import type { Fetch } from "../../sdk/src/index.ts"
8
+ import { defaultWinAcl, isWin32, writeOwnerOnlyFile, type SecretFileOptions } from "../../../src/win-acl.ts"
8
9
 
9
10
  export type Env = Record<string, string | undefined>
10
11
 
@@ -42,9 +43,11 @@ export function readLogin(file: string): Login | undefined {
42
43
 
43
44
  /**
44
45
  * Writes the login file, owner-only (0600; its directory 0700 when created), atomically: a private temporary file
45
- * next to it is renamed over it, so a reader never sees half a file and a failed write leaves the old one.
46
+ * next to it is renamed over it, so a reader never sees half a file and a failed write leaves the old one. On
47
+ * Windows the temporary file is restricted to the current user (an ACL) before the token is written (win-acl.ts).
46
48
  */
47
- export function writeLogin(file: string, login: Login): void {
49
+ export function writeLogin(file: string, login: Login, options?: SecretFileOptions): void {
50
+ if (isWin32(options)) return writeOwnerOnlyFile(file, `${JSON.stringify({ url: login.url, token: login.token }, null, 2)}\n`, options?.acl ?? defaultWinAcl())
48
51
  mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
49
52
  const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`
50
53
  try {
@@ -38,9 +38,10 @@ const presetRun = argv[0] === "preset" && argv[1] === "run"
38
38
  // `hubctl machine enroll` and `hub login` (device code) poll until the code is approved: Ctrl-C cancels them (exit 4,
39
39
  // nothing stored). `hubctl setup apply` / `recover` pass the signal to their probes and the verification launch, so
40
40
  // Ctrl-C fails the running step and the transaction rolls back, instead of the process dying between a write and its
41
- // journal entry.
41
+ // journal entry. `setup verify` passes it to its probes too: they lead their own process group (process-tree.ts), so
42
+ // the terminal's Ctrl-C reaches them only through this signal, which ends each probe's whole tree.
42
43
  const enroll = (argv[0] === "machine" && argv[1] === "enroll") || argv[0] === "login"
43
- const applying = argv[0] === "setup" && (argv[1] === "apply" || argv[1] === "recover")
44
+ const applying = argv[0] === "setup" && (argv[1] === "apply" || argv[1] === "recover" || argv[1] === "verify")
44
45
  const controller = new AbortController()
45
46
  if (argv[0] === "watch" || argv[0] === "mcp" || nodeRun || claudeDaemon || claudeMcp || codexServe || presetRun || enroll || applying)
46
47
  for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, () => controller.abort())