@huynle/hub 0.8.4 → 0.8.6

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 (96) hide show
  1. package/CHANGELOG.md +55 -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/runtime-openshell/package.json +1 -1
  53. package/packages/runtime-sdk/package.json +1 -1
  54. package/packages/sdk/package.json +1 -1
  55. package/packages/spoke-sdk/package.json +1 -1
  56. package/src/central/config.ts +12 -0
  57. package/src/central/http.ts +10 -0
  58. package/src/central/install/CONTRACT.md +66 -0
  59. package/src/central/install/install.ps1 +349 -0
  60. package/src/central/install/install.sh +266 -0
  61. package/src/central/install/render.ts +146 -0
  62. package/src/central/install/templates.ts +6 -0
  63. package/src/central/install/text-assets.d.ts +9 -0
  64. package/src/config.ts +12 -7
  65. package/src/spoke/env-inject.ts +17 -2
  66. package/src/spoke/herdr.ts +3 -2
  67. package/src/spoke/launchers.ts +145 -9
  68. package/src/spoke/localhub.ts +1 -0
  69. package/src/win-acl.ts +154 -0
  70. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +1 -0
  71. package/web/dist/assets/{ArtifactPane-XTnwXl-Y.js → ArtifactPane-DKwbMTX9.js} +1 -1
  72. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +6 -0
  73. package/web/dist/assets/Library-D6-Go6Cu.js +2 -0
  74. package/web/dist/assets/{ProfilesDestination-dZa3vioh.js → ProfilesDestination-BOML2Dgf.js} +1 -1
  75. package/web/dist/assets/{TeamsPage-DJt-Kk84.js → TeamsPage-CNBTE8gB.js} +1 -1
  76. package/web/dist/assets/Watchtower-Dor1Ubjy.js +2 -0
  77. package/web/dist/assets/Watchtower-RphjcL0_.css +1 -0
  78. package/web/dist/assets/hub-CrbItiwP.js +13 -0
  79. package/web/dist/assets/index-82Ce0IAf.css +1 -0
  80. package/web/dist/assets/index-D9SP3_0z.js +22 -0
  81. package/web/dist/assets/{live-D-RZbeUi.js → live-BNJgy0vg.js} +3 -3
  82. package/web/dist/assets/{pane-layout-Csk-vPNe.js → pane-layout-EdR1kRrG.js} +1 -1
  83. package/web/dist/assets/preload-helper-DqrJTq0t.js +1 -0
  84. package/web/dist/assets/{renderer2d-DILxLFEq.js → renderer2d-CYag1CnR.js} +1 -1
  85. package/web/dist/assets/{renderer3d-PvN9Qbt2.js → renderer3d-BRB6rgDJ.js} +1 -1
  86. package/web/dist/assets/{safe-area-BS6ii3M7.js → safe-area-fOy0AOuG.js} +1 -1
  87. package/web/dist/assets/{source-BFLJQ43_.js → source-Wbq35qGp.js} +1 -1
  88. package/web/dist/assets/{ui-Qo2Pd9-O.js → ui-j5FFk90B.js} +1 -1
  89. package/web/dist/index.html +6 -4
  90. package/web/dist/assets/ArtifactPage-Cb1DcrQf.js +0 -1
  91. package/web/dist/assets/ArtifactWorkbench-C37tZZ3e.js +0 -6
  92. package/web/dist/assets/Library-CZSSHvOJ.js +0 -2
  93. package/web/dist/assets/Watchtower-BXaUaXO1.js +0 -2
  94. package/web/dist/assets/Watchtower-CTgqHk5g.css +0 -1
  95. package/web/dist/assets/index-DVSkeAvn.css +0 -1
  96. package/web/dist/assets/index-DifgVuKS.js +0 -34
@@ -22,6 +22,7 @@ import {
22
22
  resolvePathRef,
23
23
  rootPathIn,
24
24
  executionContextOf,
25
+ toNativePath,
25
26
  validateEnvelope,
26
27
  type DiscoverySnapshot,
27
28
  type EnrollmentMode,
@@ -36,11 +37,12 @@ import {
36
37
  type SetupPlan,
37
38
  type VerifiedManifest,
38
39
  } from "../bootstrap/contracts/index.ts"
39
- import { verifyManifest } from "../bootstrap/contracts/index.ts"
40
+ import { verifyManifest, type TransactionJournal } from "../bootstrap/contracts/index.ts"
40
41
  import { OpencodePlanError } from "../bootstrap/adapters/opencode/plan.ts"
41
42
  import { parseJsonc } from "../bootstrap/transaction/json-merge.ts"
42
43
  import { renderRoleVars } from "../bootstrap/ansible/render.ts"
43
44
  import { createNodeProbe } from "../bootstrap/discovery/node-probe.ts"
45
+ import { normalizeAbs } from "../bootstrap/discovery/util.ts"
44
46
  import { credentialStoreFor, defaultMachineName, machineEnroll, machineStatus, renderMachineResult, sharingOffWarning } from "../bootstrap/enrollment/index.ts"
45
47
  import { previewSetup, renderPreview, revalidateApply } from "../bootstrap/preview/index.ts"
46
48
  import { defaultProfile } from "../bootstrap/profiles/catalog.ts"
@@ -63,6 +65,8 @@ import { hubLaunchDriver } from "./launch-driver.ts"
63
65
  import { errorCodeOf, issuesText, PlanRefusal } from "./contributors.ts"
64
66
  import { createSetupRuntime, enrollPromptText, type RuntimePorts, type SetupRuntime } from "./runtime.ts"
65
67
  import { BootstrapStateStore, raiseFloors, validateSetupRequest, type BootstrapState, type SetupChoices, type SetupRequest } from "./state.ts"
68
+ import { defaultWinAcl, writeOwnerOnlyFile } from "../../../../src/win-acl.ts"
69
+ import { nodeSelfAsideIo, removeSelfAside, selfIdentity, type SelfAsideIo, type SelfAsideOutcome, type SelfIdentity } from "./self-aside.ts"
66
70
 
67
71
  /** Test seams (never set by main.ts): fakes for everything that reaches outside the process. */
68
72
  export interface BootstrapSeams {
@@ -75,6 +79,8 @@ export interface BootstrapSeams {
75
79
  readonly trustRoot?: string
76
80
  /** Floor of the enrollment poll interval. */
77
81
  readonly minIntervalMs?: number
82
+ /** Windows: the platform calls that delete this hubctl.exe's aside after exit (self-aside.ts). */
83
+ readonly selfAside?: SelfAsideIo
78
84
  }
79
85
 
80
86
  export interface SetupIo {
@@ -380,6 +386,12 @@ export function pathHint(snapshot: DiscoverySnapshot, plan: SetupPlan, env: Read
380
386
  const bin = snapshot.roots.find((r) => r.id === "user-bin")
381
387
  if (!bin) return undefined
382
388
  const dir = rootPathIn(bin, executionContextOf(snapshot))
389
+ if (snapshot.host.platform === "win32") {
390
+ // Windows: PATH is ";"-separated, case-insensitive; install.ps1 adds user-bin to the user PATH, which only new
391
+ // terminals see.
392
+ const onPath = (env.PATH ?? "").split(";").some((d) => normalizeAbs(d.trim())?.toLowerCase() === dir.toLowerCase())
393
+ return onPath ? undefined : `${toNativePath(dir, "win32")} is not on your PATH, so ${linked.join(", ")} will not be found by name; install.ps1 adds it to your user PATH: open a new terminal (or add it to PATH yourself)`
394
+ }
383
395
  const onPath = (env.PATH ?? "").split(":").some((d) => d.replace(/\/+$/, "") === dir)
384
396
  return onPath ? undefined : `${dir} is not on your PATH, so ${linked.join(", ")} will not be found by name; add it to PATH (for example in ~/.profile)`
385
397
  }
@@ -411,9 +423,13 @@ async function preview(io: SetupIo, flags: Parsed["flags"], json: boolean): Prom
411
423
  if (out) {
412
424
  const request: SetupRequest = { schema: "hub.setup-request", version: 1, preview: r.value, profile, choices }
413
425
  const file = path.resolve(io.cwd ?? process.cwd(), out)
414
- const tmp = `${file}.${process.pid}.tmp`
415
- writeFileSync(tmp, `${JSON.stringify(request, null, 2)}\n`, { mode: 0o600 })
416
- renameSync(tmp, file)
426
+ const text = `${JSON.stringify(request, null, 2)}\n`
427
+ if (snapshot.host.platform === "win32") writeOwnerOnlyFile(file, text, defaultWinAcl())
428
+ else {
429
+ const tmp = `${file}.${process.pid}.tmp`
430
+ writeFileSync(tmp, text, { mode: 0o600 })
431
+ renameSync(tmp, file)
432
+ }
417
433
  }
418
434
  const warnings = previewWarnings(snapshot, r.value.plan, bundle.node()?.notes ?? [], io.env)
419
435
  return emit(io, json, envelope("setup preview", true, { data: r.value, warnings }), () =>
@@ -686,7 +702,50 @@ async function drift(io: SetupIo, flags: Parsed["flags"], json: boolean): Promis
686
702
  )
687
703
  }
688
704
 
705
+ /** Where user-bin is on this host (the only place a hubctl.exe's aside is deleted after exit). */
706
+ function userBinOf(snapshot: DiscoverySnapshot): string | undefined {
707
+ const root = snapshot.roots.find((r) => r.id === "user-bin")
708
+ return root ? rootPathIn(root, executionContextOf(snapshot)) : undefined
709
+ }
710
+
711
+ const selfAsideIo = (io: SetupIo) => io.bootstrap?.selfAside ?? nodeSelfAsideIo(io.env)
712
+
713
+ /**
714
+ * Windows, after a rollback or recovery: when it removed the hubctl.exe running it, the aside it left is deleted now
715
+ * or once this process exits (self-aside.ts). The text says what happened, so the user knows hubctl is gone.
716
+ */
717
+ function selfRemoval(io: SetupIo, self: SelfIdentity | undefined, snapshot: DiscoverySnapshot): { outcome: SelfAsideOutcome; text?: string; warning?: string } {
718
+ let outcome: SelfAsideOutcome
719
+ try {
720
+ outcome = removeSelfAside(self, userBinOf(snapshot), selfAsideIo(io))
721
+ } catch (error) {
722
+ return { outcome: { state: "none" }, warning: `could not check for this hubctl's leftover file: ${(error as Error).message}` }
723
+ }
724
+ if (outcome.state === "none") return { outcome }
725
+ const exe = toNativePath(self!.path, "win32")
726
+ const aside = toNativePath(outcome.aside, "win32")
727
+ const gone = `this rolled back hubctl's own install: ${exe} is removed (install hubctl again to run hubctl setup)`
728
+ if (outcome.state === "left") return { outcome, text: gone, warning: `${aside} could not be deleted (${outcome.reason}); delete it once this command has exited` }
729
+ return { outcome, text: outcome.state === "scheduled" ? `${gone}; ${aside} is deleted once this command exits` : gone }
730
+ }
731
+
732
+ /** What to do after a recovery_required outcome of rollback or recover. */
733
+ const recoveryHint = (code: string): { hint?: string } => (code === "recovery_required" ? { hint: "Run hubctl setup recover (once nothing runs from the paths named) to finish it" } : {})
734
+
735
+ /** After a rollback: the state describes `target` (a committed transaction this host keeps), or nothing. */
736
+ function writeRolledBackState(snapshot: DiscoverySnapshot, state: BootstrapState, target: TransactionJournal | undefined): void {
737
+ const request = target ? stateStore(snapshot).readRequest(target.planDigest) : undefined
738
+ stateStore(snapshot).write({
739
+ schema: "hub.bootstrap-state",
740
+ version: 1,
741
+ ...(request ? { profile: request.profile, choices: request.choices, ...(request.profile.revision ? { catalogRevision: request.profile.revision } : {}), txID: target!.txID, planDigest: target!.planDigest } : {}),
742
+ floors: state.floors,
743
+ })
744
+ }
745
+
689
746
  async function rollback(io: SetupIo, flags: Parsed["flags"], json: boolean): Promise<number> {
747
+ // Before anything moves: the executable running this (a rollback may remove it, see selfRemoval).
748
+ const self = flags["dry-run"] === true ? undefined : selfIdentity(io.program.execPath, selfAsideIo(io))
690
749
  const snapshot = discovered(io)
691
750
  const to = str(flags, "to")
692
751
  const store = new TxStore(nodeFs, snapshot.roots, executionContextOf(snapshot))
@@ -703,25 +762,33 @@ async function rollback(io: SetupIo, flags: Parsed["flags"], json: boolean): Pro
703
762
  const runtime = runtimeFor(io, undefined, snapshot)
704
763
  const profile = state.profile ?? unwrapProfile(defaultProfile("session-only"))
705
764
  const bundle = await runtime.plan(profile, state.choices ?? {})
765
+ const committed = new Set(store.journals().filter((j) => j.state === "committed" && j.hostFingerprint === snapshot.host.fingerprint).map((j) => j.txID))
706
766
  const outcome = await runtime.applier(snapshot, runtime.executors(bundle, snapshot, {})).rollback(to ? { to } : {})
707
- if (!outcome.ok) return emit(io, json, envelope("setup rollback", false, { error: { code: outcome.code, message: outcome.message } }))
767
+ if (!outcome.ok) {
768
+ noteKept(io, json, outcome)
769
+ const report = reportData(outcome)
770
+ return emit(io, json, envelope("setup rollback", false, { ...(report ? { data: report } : {}), error: { code: outcome.code, message: outcome.message, ...recoveryHint(outcome.code) } }))
771
+ }
708
772
  if (!outcome.report) return emit(io, json, envelope("setup rollback", true, { data: { rolledBack: false } }), () => "nothing to roll back")
773
+ // Newest first, as they were undone.
774
+ const undone = store
775
+ .journals()
776
+ .filter((j) => committed.has(j.txID) && j.state === "rolled-back")
777
+ .sort((a, b) => b.startedAt - a.startedAt)
778
+ .map((j) => j.txID)
709
779
  // The state now describes the target transaction (or nothing, after a full rollback).
710
- const target = to ? store.journals().find((j) => j.txID === to) : undefined
711
- const request = target ? stateStore(snapshot).readRequest(target.planDigest) : undefined
712
- stateStore(snapshot).write({
713
- schema: "hub.bootstrap-state",
714
- version: 1,
715
- ...(request ? { profile: request.profile, choices: request.choices, ...(request.profile.revision ? { catalogRevision: request.profile.revision } : {}), txID: target!.txID, planDigest: target!.planDigest } : {}),
716
- floors: state.floors,
717
- })
780
+ writeRolledBackState(snapshot, state, to ? store.journals().find((j) => j.txID === to) : undefined)
718
781
  noteKept(io, json, outcome)
719
- const data = { rolledBack: true, ...reportData(outcome) }
720
- const warnings = outcome.report.conflicts.map((c) => `${c.actionID}: ${c.reason}`)
721
- return emit(io, json, envelope("setup rollback", true, { data, warnings }), () => `rolled back${to ? ` to ${to}` : ""}${warnings.length ? ` (${warnings.length} file(s) kept because they changed since)` : ""}`)
782
+ const removal = selfRemoval(io, self, snapshot)
783
+ const data = { rolledBack: true, ...reportData(outcome), transactions: undone, ...(removal.outcome.state !== "none" ? { selfRemoved: removal.outcome } : {}) }
784
+ const conflicts = outcome.report.conflicts.map((c) => `${c.actionID}: ${c.reason}`)
785
+ const warnings = [...conflicts, ...(removal.warning ? [removal.warning] : [])]
786
+ const summary = `rolled back ${undone.length} transaction(s)${to ? ` to ${to}` : ""}${undone.length ? `: ${undone.join(", ")}` : ""}${conflicts.length ? ` (${conflicts.length} file(s) kept because they changed since)` : ""}`
787
+ return emit(io, json, envelope("setup rollback", true, { data, warnings }), () => [summary, ...(removal.text ? [removal.text] : [])].join("\n"))
722
788
  }
723
789
 
724
790
  async function recover(io: SetupIo, flags: Parsed["flags"], json: boolean): Promise<number> {
791
+ const self = selfIdentity(io.program.execPath, selfAsideIo(io))
725
792
  const snapshot = discovered(io)
726
793
  const store = new TxStore(nodeFs, snapshot.roots, executionContextOf(snapshot))
727
794
  let pending
@@ -747,9 +814,20 @@ async function recover(io: SetupIo, flags: Parsed["flags"], json: boolean): Prom
747
814
  consent: { escalate: flags.escalate === true, confirmActivation: flags["confirm-activation"] === true },
748
815
  })
749
816
  noteKept(io, json, outcome)
750
- if (!outcome.ok) return emit(io, json, envelope("setup recover", false, { ...(outcome.report ? { data: reportData(outcome) } : {}), error: { code: outcome.code, message: outcome.message } }))
817
+ if (!outcome.ok) return emit(io, json, envelope("setup recover", false, { ...(outcome.report ? { data: reportData(outcome) } : {}), error: { code: outcome.code, message: outcome.message, ...recoveryHint(outcome.code) } }))
751
818
  if (!outcome.report) return emit(io, json, envelope("setup recover", true, { data: { recovered: false } }), () => "nothing to recover")
752
- return emit(io, json, envelope("setup recover", true, { data: { recovered: true, mode, ...reportData(outcome) } }), () => `recovered ${outcome.report!.txID} (${mode}): ${outcome.report!.state}`)
819
+ // A recovered rollback of the transaction the state describes (a `setup rollback` a busy path interrupted): the
820
+ // state now describes the newest transaction this host still has committed, or nothing.
821
+ const journals = store.journals()
822
+ if (state.txID && journals.find((j) => j.txID === state.txID)?.state === "rolled-back") {
823
+ const kept = journals.filter((j) => j.state === "committed" && j.hostFingerprint === snapshot.host.fingerprint).sort((a, b) => b.startedAt - a.startedAt)[0]
824
+ writeRolledBackState(snapshot, state, kept)
825
+ }
826
+ const removal = selfRemoval(io, self, snapshot)
827
+ const data = { recovered: true, mode, ...reportData(outcome), ...(removal.outcome.state !== "none" ? { selfRemoved: removal.outcome } : {}) }
828
+ return emit(io, json, envelope("setup recover", true, { data, ...(removal.warning ? { warnings: [removal.warning] } : {}) }), () =>
829
+ [`recovered ${outcome.report!.txID} (${mode}): ${outcome.report!.state}`, ...(removal.text ? [removal.text] : [])].join("\n"),
830
+ )
753
831
  }
754
832
 
755
833
  async function ansible(io: SetupIo, flags: Parsed["flags"], json: boolean): Promise<number> {
@@ -4,7 +4,8 @@
4
4
  //
5
5
  // hubctlContributor the standalone hubctl this verified manifest pins for the host's exact target, into
6
6
  // user-bin:hubctl (the stable authority services and MCP clients run), and the `hub` alias
7
- // next to it (one command surface; the name grants nothing).
7
+ // next to it (one command surface; the name grants nothing). On Windows (PHASE2-CONTRACT.md)
8
+ // user-bin:hubctl.exe and user-bin:hub.cmd (the applier writes the link as a cmd shim).
8
9
  // enrollmentContributor inline enrollment: one enroll-machine for the hub the user named, only while this machine
9
10
  // is not enrolled with it (the device code is approved by a person on the hub).
10
11
  // refusing a contributor that can refuse: planSetup has no refusal channel for contributors, so a
@@ -16,6 +17,17 @@ import { userBinLinkDecision } from "./user-bin.ts"
16
17
  export const HUBCTL_REF = { root: "user-bin", rel: "hubctl" } as const
17
18
  export const HUB_ALIAS_REF = { root: "user-bin", rel: "hub" } as const
18
19
 
20
+ const isWindowsTarget = (target: string) => target.startsWith("windows-")
21
+
22
+ /**
23
+ * hubctl and its `hub` alias on `target`: user-bin:hubctl and user-bin:hub; on Windows user-bin:hubctl.exe and
24
+ * user-bin:hub.cmd (link-executable there is a cmd shim, PHASE2-CONTRACT.md "Install primitives on win32").
25
+ */
26
+ export function hubctlRefs(target: string): { readonly hubctl: PathRef; readonly alias: PathRef } {
27
+ if (isWindowsTarget(target)) return { hubctl: { root: "user-bin", rel: "hubctl.exe" }, alias: { root: "user-bin", rel: "hub.cmd" } }
28
+ return { hubctl: { ...HUBCTL_REF }, alias: { ...HUB_ALIAS_REF } }
29
+ }
30
+
19
31
  /** A plan that cannot be made, with the envelope error code it maps to. */
20
32
  export class PlanRefusal extends Error {
21
33
  override readonly name = "PlanRefusal"
@@ -62,21 +74,22 @@ export function hubctlContributor(options: { readonly manifest?: VerifiedManifes
62
74
  const artifact = selectArtifact(manifest, snapshot.host.target)
63
75
  if (!artifact.ok) return []
64
76
  const installed = options.installed
77
+ const refs = hubctlRefs(snapshot.host.target)
65
78
  const actions: Action[] = []
66
- if (installed?.sha256(HUBCTL_REF) !== artifact.value.sha256)
79
+ if (installed?.sha256(refs.hubctl) !== artifact.value.sha256)
67
80
  actions.push({
68
81
  id: "install-hubctl",
69
82
  kind: "install-artifact",
70
83
  escalation: "user",
71
- summary: `Install hubctl ${manifest.release} for ${snapshot.host.target} as ~/.local/bin/hubctl (pinned by the signed release manifest)`,
84
+ summary: `Install hubctl ${manifest.release} for ${snapshot.host.target} as ~/.local/bin/${refs.hubctl.rel} (pinned by the signed release manifest)`,
72
85
  artifact: "hubctl",
73
86
  version: manifest.release,
74
87
  target: snapshot.host.target,
75
88
  sha256: artifact.value.sha256,
76
- path: { ...HUBCTL_REF },
89
+ path: { ...refs.hubctl },
77
90
  })
78
- if (!installed?.linksTo(HUB_ALIAS_REF, HUBCTL_REF))
79
- actions.push({ id: "link-hub", kind: "link-executable", escalation: "user", summary: "Link hub to hubctl (the same command surface)", path: { ...HUB_ALIAS_REF }, target: { ...HUBCTL_REF } })
91
+ if (!installed?.linksTo(refs.alias, refs.hubctl))
92
+ actions.push({ id: "link-hub", kind: "link-executable", escalation: "user", summary: "Link hub to hubctl (the same command surface)", path: { ...refs.alias }, target: { ...refs.hubctl } })
80
93
  return actions
81
94
  }
82
95
  }
@@ -100,6 +113,27 @@ export const HARNESS_EXECUTABLES: Readonly<Record<HarnessID, { readonly name: st
100
113
  codex: { name: "codex", rel: "bin/codex", label: "Codex" },
101
114
  })
102
115
 
116
+ export interface HarnessExecutable {
117
+ /** The command name (what PATH is searched for). */
118
+ readonly name: string
119
+ /** The link's name in user-bin: the command name; on Windows `<name>.cmd` (a cmd shim). */
120
+ readonly link: string
121
+ /** The binary inside the install root. */
122
+ readonly rel: string
123
+ readonly label: string
124
+ }
125
+
126
+ /**
127
+ * A harness's executable on `target`. On Windows only OpenCode has a known layout (bin/opencode.exe, Phase 2); the
128
+ * others are undefined there, so no link is planned for them.
129
+ */
130
+ export function harnessExecutable(harness: HarnessID, target: string): HarnessExecutable | undefined {
131
+ const exe = HARNESS_EXECUTABLES[harness]
132
+ if (!isWindowsTarget(target)) return { name: exe.name, link: exe.name, rel: exe.rel, label: exe.label }
133
+ if (harness !== "opencode") return undefined
134
+ return { name: exe.name, link: `${exe.name}.cmd`, rel: `${exe.rel}.exe`, label: exe.label }
135
+ }
136
+
103
137
  /**
104
138
  * Contract amendment A6 (HBS-12 E4): a hub-installed harness goes on PATH through a typed link in user-bin
105
139
  * (`~/.local/bin/opencode` → `data:hub/harnesses/opencode/current/bin/opencode`), journaled and rolled back like any
@@ -108,16 +142,17 @@ export const HARNESS_EXECUTABLES: Readonly<Record<HarnessID, { readonly name: st
108
142
  * hub's harness directory): a user's binary is never shadowed or replaced.
109
143
  */
110
144
  export function harnessLinkContributor(inspector: PathInspector): PlanContributor {
111
- return ({ profile, planned = [] }) => {
145
+ return ({ snapshot, profile, planned = [] }) => {
112
146
  const actions: Action[] = []
113
147
  for (const harness of HARNESS_ORDER) {
114
148
  if (!profile.harnesses.includes(harness)) continue
115
- const exe = HARNESS_EXECUTABLES[harness]
149
+ const exe = harnessExecutable(harness, snapshot.host.target)
150
+ if (!exe) continue
116
151
  const current = harnessCurrentRef(harness)
117
152
  const target: PathRef = { root: current.root, rel: `${current.rel}/${exe.rel}` }
118
153
  const selected = planned.some((a) => a.kind === "select-harness-version" && a.harness === harness)
119
154
  if (!selected && inspector.entry(target) !== "file") continue
120
- const link: PathRef = { root: "user-bin", rel: exe.name }
155
+ const link: PathRef = { root: "user-bin", rel: exe.link }
121
156
  const linkPath = inspector.where(link)
122
157
  const targetPath = inspector.where(target)
123
158
  const ownDir = inspector.where({ root: current.root, rel: `hub/harnesses/${harness}` })
@@ -137,7 +172,7 @@ export function harnessLinkContributor(inspector: PathInspector): PlanContributo
137
172
  id: `link-${harness}`,
138
173
  kind: "link-executable",
139
174
  escalation: "user",
140
- summary: `Put the hub-installed ${exe.label} on PATH: link ~/.local/bin/${exe.name} to its current version`,
175
+ summary: `Put the hub-installed ${exe.label} on PATH: link ~/.local/bin/${exe.link} to its current version`,
141
176
  path: link,
142
177
  target,
143
178
  })
@@ -64,11 +64,12 @@ import { builtinExecutors, type HarnessSource, type HubctlSource } from "../boot
64
64
  import { createApplier, type Applier } from "../bootstrap/transaction/engine.ts"
65
65
  import type { ActionExecutor, ExecutorMap } from "../bootstrap/transaction/executor.ts"
66
66
  import { nodeFs, type TxFs } from "../bootstrap/transaction/fs.ts"
67
+ import { parseCmdShim } from "../bootstrap/transaction/shim.ts"
67
68
  import { fileDigest } from "../bootstrap/transaction/stream.ts"
68
69
  import { txFail, txOk } from "../bootstrap/transaction/result.ts"
69
70
  import { TxStore } from "../bootstrap/transaction/store.ts"
70
71
  import { checkOpenShell } from "../openshell.ts"
71
- import { enrollmentContributor, harnessLinkContributor, HUBCTL_REF, hubctlContributor, refusing, type InstalledInspector, type PathInspector } from "./contributors.ts"
72
+ import { enrollmentContributor, harnessLinkContributor, hubctlContributor, hubctlRefs, refusing, type InstalledInspector, type PathInspector } from "./contributors.ts"
72
73
  import { dispatchByHarness, dispatchHarnessSource } from "./dispatch.ts"
73
74
  import { claudeSpokeProbes, codexSpokeStatus, opencodeSpokeProbe, type PresenceOptions } from "./spoke-probe.ts"
74
75
  import type { BootstrapState, SetupChoices } from "./state.ts"
@@ -151,6 +152,7 @@ export interface SetupRuntime {
151
152
  /**
152
153
  * What the files a plan would write hold now, read-only through the applier's filesystem: a regular file's digest
153
154
  * (in chunks; hubctl is ~105 MB) and whether a path is a symlink to a target, in the snapshot's execution context.
155
+ * On a win32 host a hub cmd shim (link-executable's stand-in for a symlink there, shim.ts) is the link it names.
154
156
  */
155
157
  export function installedInspector(snapshot: DiscoverySnapshot, fs: TxFs): InstalledInspector {
156
158
  const where = (ref: PathRef) => {
@@ -170,7 +172,9 @@ export function installedInspector(snapshot: DiscoverySnapshot, fs: TxFs): Insta
170
172
  const link = where(ref)
171
173
  const to = where(target)
172
174
  try {
173
- return link !== undefined && to !== undefined && fs.lstat(link)?.kind === "symlink" && nodePath.resolve(nodePath.dirname(link), fs.readlink(link)) === to
175
+ if (link === undefined || to === undefined) return false
176
+ if (snapshot.host.platform === "win32" && fs.lstat(link)?.kind === "file") return parseCmdShim(fs.readRange(link, 0, SHIM_MAX)) === to
177
+ return fs.lstat(link)?.kind === "symlink" && nodePath.resolve(nodePath.dirname(link), fs.readlink(link)) === to
174
178
  } catch {
175
179
  return false
176
180
  }
@@ -178,21 +182,36 @@ export function installedInspector(snapshot: DiscoverySnapshot, fs: TxFs): Insta
178
182
  }
179
183
  }
180
184
 
181
- /** Paths and PATH as the probe sees them (read-only), in the snapshot's execution context. */
185
+ /** More than any cmd shim hub writes (`@"<path>" %*`). */
186
+ const SHIM_MAX = 4097
187
+
188
+ /**
189
+ * Paths and PATH as the probe sees them (read-only), in the snapshot's execution context. On a win32 host a hub cmd
190
+ * shim is reported as a symlink to its target, so the user-bin link rule treats it as hub's own link.
191
+ */
182
192
  export function pathInspector(snapshot: DiscoverySnapshot, probe: DiscoveryProbe): PathInspector {
183
193
  const where = (ref: PathRef) => {
184
194
  const r = resolvePathRef(ref, snapshot.roots, executionContextOf(snapshot))
185
195
  return r.ok ? r.value : undefined
186
196
  }
197
+ const shimTarget = (p: string) => {
198
+ if (snapshot.host.platform !== "win32" || probe.lstat(p) !== "file") return undefined
199
+ const bytes = probe.readFile(p, SHIM_MAX)
200
+ return bytes ? parseCmdShim(bytes) : undefined
201
+ }
187
202
  return {
188
203
  where,
189
204
  entry: (ref) => {
190
205
  const p = where(ref)
191
- return p ? probe.lstat(p) : "missing"
206
+ if (!p) return "missing"
207
+ return shimTarget(p) !== undefined ? "symlink" : probe.lstat(p)
192
208
  },
193
209
  linkTarget: (ref) => {
194
210
  const p = where(ref)
195
- if (!p || probe.lstat(p) !== "symlink") return undefined
211
+ if (!p) return undefined
212
+ const shim = shimTarget(p)
213
+ if (shim !== undefined) return shim
214
+ if (probe.lstat(p) !== "symlink") return undefined
196
215
  const text = probe.readlink(p)
197
216
  return text === undefined ? undefined : nodePath.resolve(nodePath.dirname(p), text)
198
217
  },
@@ -281,7 +300,7 @@ export function createSetupRuntime(deps: RuntimeDeps): SetupRuntime {
281
300
  function serviceLayout(snap: DiscoverySnapshot): ProgramLayout {
282
301
  const root = snap.roots.find((r) => r.id === "user-bin")
283
302
  const installs = deps.manifest?.artifacts.some((a) => a.target === snap.host.target)
284
- if (root && installs) return { kind: "standalone", executable: `${rootPathIn(root, executionContextOf(snap))}/${HUBCTL_REF.rel}` }
303
+ if (root && installs) return { kind: "standalone", executable: `${rootPathIn(root, executionContextOf(snap))}/${hubctlRefs(snap.host.target).hubctl.rel}` }
285
304
  return deps.layout
286
305
  }
287
306
 
@@ -0,0 +1,130 @@
1
+ // Windows: the setup commands delete, once this process has exited, the aside of the hubctl.exe that is running them.
2
+ //
3
+ // A rollback that undoes hubctl's own install (`~\.local\bin\hubctl.exe setup rollback`) removes the very executable
4
+ // that runs it. The engine moves it aside (`<name>.old-<txid>-<seq>-undo`, Phase 2 contract "rename-aside"), but a
5
+ // running image cannot be deleted on Windows, and a later apply or recover would sweep it only if some hubctl were
6
+ // left to run one. So the command hands the delete to a detached, hidden `cmd.exe` that retries it once a second
7
+ // (bounded) until the file is gone, which is right after this process exits. Only for:
8
+ // - the aside that is this executable: the same file ID as the hubctl.exe this process started from;
9
+ // - inside user-bin, with the aside name pattern;
10
+ // - a path cmd takes literally (printable ASCII without " % ^ & | < > ! ( )); the command line is fixed otherwise,
11
+ // so nothing from outside reaches cmd's parser.
12
+ // Anything else is left as it was, and the command says so. POSIX unlinks a running executable directly: nothing here.
13
+ // MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) would need FFI (and admin for the PendingFileRenameOperations key).
14
+ import { spawn } from "node:child_process"
15
+ import { readdirSync, statSync, unlinkSync } from "node:fs"
16
+ import { toNativePath } from "../bootstrap/contracts/index.ts"
17
+ import { normalizeAbs } from "../bootstrap/discovery/util.ts"
18
+
19
+ /** The platform calls, injectable (tests). */
20
+ export interface SelfAsideIo {
21
+ readonly platform: string
22
+ /** %SystemRoot% (C:\Windows): where cmd.exe and PING.EXE are run from, never through PATH. */
23
+ readonly systemRoot: string
24
+ /** The file ID of `path`, undefined when it is not there. */
25
+ stat(path: string): { readonly ino: bigint } | undefined
26
+ readdir(path: string): string[]
27
+ unlink(path: string): void
28
+ /** Starts `file` with exactly `line` as its arguments (no quoting added), detached and hidden, not waited for. */
29
+ spawnDetached(file: string, line: string, cwd: string): void
30
+ }
31
+
32
+ /** The executable this process runs from, as it was when the command started. */
33
+ export interface SelfIdentity {
34
+ /** Canonical (C:/… on Windows). */
35
+ readonly path: string
36
+ readonly ino: bigint
37
+ }
38
+
39
+ export type SelfAsideOutcome =
40
+ | { readonly state: "none" }
41
+ | { readonly state: "deleted" | "scheduled"; readonly aside: string }
42
+ | { readonly state: "left"; readonly aside: string; readonly reason: string }
43
+
44
+ export const nodeSelfAsideIo = (env: Readonly<Record<string, string | undefined>> = process.env): SelfAsideIo => ({
45
+ platform: process.platform,
46
+ systemRoot: env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows",
47
+ stat: (path) => {
48
+ try {
49
+ return { ino: statSync(path, { bigint: true }).ino }
50
+ } catch {
51
+ return undefined
52
+ }
53
+ },
54
+ readdir: (path) => readdirSync(path),
55
+ unlink: (path) => unlinkSync(path),
56
+ spawnDetached: (file, line, cwd) => {
57
+ const child = spawn(file, [line], { cwd, detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true })
58
+ child.on("error", () => {})
59
+ child.unref()
60
+ },
61
+ })
62
+
63
+ /** The aside names rename-aside gives `base` (engine.ts asidePath). */
64
+ const ASIDE = /\.old-tx_[A-Za-z0-9]{1,64}-\d{1,9}(-undo)?$/
65
+ /** What cmd takes literally inside double quotes, outside any variable expansion or grouping. */
66
+ const CMD_SAFE = /^[\x20-\x7e]+$/
67
+ const CMD_SPECIAL = /["%^&|<>!()]/
68
+
69
+ const key = (p: string) => p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()
70
+ const parent = (p: string) => p.slice(0, p.lastIndexOf("/")) || "/"
71
+ const nameOf = (p: string) => p.slice(p.lastIndexOf("/") + 1)
72
+
73
+ function cmdLiteral(path: string, what: string): string {
74
+ if (!CMD_SAFE.test(path) || CMD_SPECIAL.test(path)) throw new Error(`${what} ${JSON.stringify(path)} is not a path cmd takes literally (printable ASCII without " % ^ & | < > ! ( ))`)
75
+ return path
76
+ }
77
+
78
+ /** The fixed cmd.exe call that deletes `aside` once it is no longer running: about two minutes of retries at most. */
79
+ export function deleteAfterExitCommand(aside: string, systemRoot: string): { file: string; line: string } {
80
+ if (!ASIDE.test(aside)) throw new Error(`${JSON.stringify(aside)} is not a rename-aside name`)
81
+ const p = cmdLiteral(toNativePath(aside, "win32"), "the aside")
82
+ const root = cmdLiteral(systemRoot.replace(/\//g, "\\").replace(/\\+$/, ""), "SystemRoot")
83
+ return {
84
+ file: `${root}\\System32\\cmd.exe`,
85
+ line: `/d /s /c "for /l %i in (1,1,120) do @if exist "${p}" ("${root}\\System32\\PING.EXE" -n 2 127.0.0.1 >nul & del /f /q "${p}" >nul 2>&1)"`,
86
+ }
87
+ }
88
+
89
+ /** Records the executable this process runs from (before a command may move it aside). */
90
+ export function selfIdentity(execPath: string, io: SelfAsideIo = nodeSelfAsideIo()): SelfIdentity | undefined {
91
+ const path = normalizeAbs(execPath)
92
+ if (!path) return undefined
93
+ const st = io.stat(path)
94
+ return st ? { path, ino: st.ino } : undefined
95
+ }
96
+
97
+ /**
98
+ * After a command: when `self` (this process's executable, inside `userBin`) was moved aside, deletes the aside, or
99
+ * when it is still running (it is: this process), schedules its deletion for after this process exits.
100
+ */
101
+ export function removeSelfAside(self: SelfIdentity | undefined, userBin: string | undefined, io: SelfAsideIo = nodeSelfAsideIo()): SelfAsideOutcome {
102
+ if (io.platform !== "win32" || !self || !userBin) return { state: "none" }
103
+ const dir = parent(self.path)
104
+ if (key(dir) !== key(userBin)) return { state: "none" }
105
+ if (io.stat(self.path)?.ino === self.ino) return { state: "none" }
106
+ let names: string[]
107
+ try {
108
+ names = io.readdir(dir)
109
+ } catch {
110
+ return { state: "none" }
111
+ }
112
+ const base = nameOf(self.path).toLowerCase()
113
+ for (const name of names) {
114
+ if (!name.toLowerCase().startsWith(`${base}.old-`) || !ASIDE.test(name) || name.length - base.length !== name.match(ASIDE)![0].length) continue
115
+ const aside = `${dir}/${name}`
116
+ if (io.stat(aside)?.ino !== self.ino) continue
117
+ try {
118
+ io.unlink(aside)
119
+ return { state: "deleted", aside }
120
+ } catch {}
121
+ try {
122
+ const { file, line } = deleteAfterExitCommand(aside, io.systemRoot)
123
+ io.spawnDetached(file, line, file.slice(0, file.lastIndexOf("\\")))
124
+ return { state: "scheduled", aside }
125
+ } catch (error) {
126
+ return { state: "left", aside, reason: error instanceof Error ? `${(error as NodeJS.ErrnoException).code ?? ""} ${error.message}`.trim() : String(error) }
127
+ }
128
+ }
129
+ return { state: "none" }
130
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-codex",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "private": true,
5
5
  "description": "The Codex app-server spoke: attaches to Codex's app-server (its own over stdio, and the shared daemon's socket when present), and the `hub codex` wrapper (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-node",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "private": true,
5
5
  "description": "`hub node`: the per-machine daemon that prepares, collects and cleans up launch workspaces (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -17,6 +17,7 @@
17
17
  import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync, type Stats } from "node:fs"
18
18
  import path from "node:path"
19
19
  import { listServiceSessions, type ListingClient, type RawProject, type RawSession } from "../../../src/spoke/listing.ts"
20
+ import { defaultWinAcl, isWin32, nativeWinPath, type SecretFileOptions } from "../../../src/win-acl.ts"
20
21
 
21
22
  /** How often the daemon asks (a restarted server is warmed within this). */
22
23
  export const WARM_INTERVAL_MS = 30_000
@@ -290,9 +291,11 @@ const PSEUDO_ROOTS = ["/proc", "/sys", "/dev"]
290
291
  * A server password from a file (its first line): only a private regular file (not a link, FIFO, device or socket,
291
292
  * nothing under /proc, /sys or /dev, no symbolic link in its directory path, no group or other access, owned by this
292
293
  * user) of 1 byte to 4 KiB. Never blocks: the file is checked with lstat before it is opened, and opened with
293
- * O_NONBLOCK and O_NOFOLLOW (then checked again on the descriptor). Problems never include the content.
294
+ * O_NONBLOCK and O_NOFOLLOW (then checked again on the descriptor). Problems never include the content. On Windows
295
+ * (no modes, no uid) "private" is an access list that allows no one but this user, SYSTEM and Administrators
296
+ * (src/win-acl.ts); `options` injects the platform and the ACL helper.
294
297
  */
295
- export function readPasswordFile(file: string): { password: string } | { problem: string } {
298
+ export function readPasswordFile(file: string, options?: SecretFileOptions): { password: string } | { problem: string } {
296
299
  if (!path.isAbsolute(file)) return { problem: `password file ${file} is not an absolute path` }
297
300
  const resolved = path.resolve(file)
298
301
  if (PSEUDO_ROOTS.some((root) => resolved === root || resolved.startsWith(`${root}/`))) return { problem: `password file ${file} is not a regular file` }
@@ -320,8 +323,13 @@ export function readPasswordFile(file: string): { password: string } | { problem
320
323
  try {
321
324
  const stat = fstatSync(fd)
322
325
  if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino) return { problem: `password file ${file} is not a regular file` }
323
- if ((stat.mode & 0o077) !== 0) return { problem: `password file ${file} is readable by others (chmod 600 it)` }
324
- if (typeof process.getuid === "function" && stat.uid !== process.getuid()) return { problem: `password file ${file} is not owned by this user` }
326
+ if (isWin32(options)) {
327
+ const acl = (options?.acl ?? defaultWinAcl()).check(nativeWinPath(resolved))
328
+ if (!acl.ok) return { problem: `password file ${file} is readable by others (${acl.problem})` }
329
+ } else {
330
+ if ((stat.mode & 0o077) !== 0) return { problem: `password file ${file} is readable by others (chmod 600 it)` }
331
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid()) return { problem: `password file ${file} is not owned by this user` }
332
+ }
325
333
  if (stat.size === 0) return { problem: `password file ${file} is empty` }
326
334
  if (stat.size > PASSWORD_FILE_MAX) return { problem: `password file ${file} is too large` }
327
335
  const buffer = Buffer.alloc(PASSWORD_FILE_MAX + 1)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-preset",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "private": true,
5
5
  "description": "The preset spoke: drives a harness without a spoke of its own through a terminal pane (tmux), described by a data-only preset; official presets in presets/ (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-protocol",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "private": true,
5
5
  "description": "Hub ↔ spoke wire protocol (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-runtime-openshell",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "private": true,
5
5
  "description": "The `openshell` runtime of `hub node`: launches in NVIDIA OpenShell sandboxes, driven through the openshell CLI (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-runtime-sdk",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "private": true,
5
5
  "description": "The Runtime interface `hub node` runtimes implement (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-sdk",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "private": true,
5
5
  "description": "Typed client for the hub's /api/agent (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-spoke-sdk",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "private": true,
5
5
  "description": "Harness-neutral spoke building blocks: hub connection, enrollment, tokens, idempotent deliver, prompt references, HarnessSpoke (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",