@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
@@ -20,8 +20,17 @@
20
20
  // trust-root [--key-id ID]
21
21
  // Prints the v1 trust root (public key only) for the key in $HUB_RELEASE_SIGNING_KEY, for the maintainer
22
22
  // who sets up the release key (README.md next to this file).
23
+ // dev --targets T,… --out DIR [--pins FILE] [--pending FILE | --no-pending] [--release V] [--minimum-version V]
24
+ // [--skip-pin-download] [--bun BIN]
25
+ // A throwaway release for testing on real machines (README "Testing on real machines"): a fresh ed25519 key,
26
+ // held only in memory, whose public trust root (DIR/dev-trust-root.json) the binaries embed; the hubctl
27
+ // builds; the reviewed and the pending (unreviewed) harness pins; a manifest signed with that key; and
28
+ // HUBCTL_SHA256SUMS. Serve DIR over HTTP and point a hub's HUB_RELEASE_BASE at it. Refuses to run with
29
+ // $HUB_RELEASE_SIGNING_KEY set, on GitHub Actions, or into the repository's dist/: it never makes a shipped
30
+ // artifact, and nothing but the binaries built here trusts its key.
23
31
  //
24
32
  // Exit codes: 0 ok, 1 other, 2 usage, 11 artifact rejected (contracts §3.11).
33
+ import { generateKeyPairSync, randomBytes } from "node:crypto"
25
34
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
26
35
  import path from "node:path"
27
36
  import pkg from "../../../package.json" with { type: "json" }
@@ -37,8 +46,8 @@ import {
37
46
  type Result,
38
47
  type TrustRoot,
39
48
  } from "../contracts/index.ts"
40
- import { buildManifestBody, manifestJSON, parseSigningKey, signVerified, trustRootFor } from "./manifest.ts"
41
- import { PIN_LIST_FILE, validatePinList, verifyPinDownloads, type Downloader } from "./pins.ts"
49
+ import { buildManifestBody, hubctlFile, manifestJSON, parseSigningKey, signVerified, trustRootFor } from "./manifest.ts"
50
+ import { PENDING_PIN_LIST_FILE, PIN_LIST_FILE, redownload, sortPins, validatePendingPinList, validatePinList, verifyPinDownloads, type Downloader } from "./pins.ts"
42
51
  import { shippedTrustRoot, trustRootDefine } from "./trust-root.ts"
43
52
  import { digestBytes, parseManifest, verifyHubctlRelease } from "./verify.ts"
44
53
 
@@ -171,7 +180,7 @@ export function hubctlBuildArgv(target: string, options: { readonly bun: string;
171
180
  ...(options.root ? trustRootDefine(options.root) : []),
172
181
  HUBCTL_ENTRY,
173
182
  "--outfile",
174
- path.join(options.outdir, `hubctl-${t.target}`),
183
+ path.join(options.outdir, hubctlFile(t.target)),
175
184
  ]
176
185
  }
177
186
 
@@ -199,6 +208,20 @@ async function build(argv: readonly string[], io: ReleaseIo): Promise<number> {
199
208
  return 0
200
209
  }
201
210
 
211
+ /** Each target's built hubctl in `dist`, with its size and digest. */
212
+ function readArtifacts(dist: string, targets: readonly string[], what: string) {
213
+ return targets.map((target) => {
214
+ const file = hubctlFile(target)
215
+ let bytes: Uint8Array
216
+ try {
217
+ bytes = readFileSync(path.join(dist, file))
218
+ } catch {
219
+ throw new Failure(`missing ${path.join(dist, file)}: build ${what} first`, EXIT_OTHER)
220
+ }
221
+ return { target, file, ...digestBytes(bytes) }
222
+ })
223
+ }
224
+
202
225
  async function manifest(argv: readonly string[], io: ReleaseIo): Promise<number> {
203
226
  const p = parseArgs(argv, ["--dist", "--pins", "--minimum-version", "--release", "--issued-at", "--out", "--targets"], [])
204
227
  const listed = targetsArg(p)
@@ -210,16 +233,7 @@ async function manifest(argv: readonly string[], io: ReleaseIo): Promise<number>
210
233
  const issuedAt = issuedText === undefined ? io.now() : Number(issuedText)
211
234
  if (!Number.isSafeInteger(issuedAt) || issuedAt < 0) throw new Usage("--issued-at takes milliseconds since the epoch")
212
235
  const pins = unwrap(validatePinList(readJSON(path.resolve(io.cwd, p.values.get("--pins") ?? PIN_LIST_FILE), "pin list")), "the pin list is invalid")
213
- const artifacts = targets.map((target) => {
214
- const file = `hubctl-${target}`
215
- let bytes: Uint8Array
216
- try {
217
- bytes = readFileSync(path.join(dist, file))
218
- } catch {
219
- throw new Failure(`missing ${path.join(dist, file)}: build ${listed ? "every listed target" : "every supported target (or name a subset with --targets)"} first`, EXIT_OTHER)
220
- }
221
- return { target, file, ...digestBytes(bytes) }
222
- })
236
+ const artifacts = readArtifacts(dist, targets, listed ? "every listed target" : "every supported target (or name a subset with --targets)")
223
237
  io.stderr(`re-downloading ${pins.pins.length} pinned harness file(s)\n`)
224
238
  const harnesses = unwrap(await verifyPinDownloads(pins, io.download), "a pinned harness file does not match its pin")
225
239
  const body = unwrap(
@@ -268,7 +282,7 @@ async function verify(argv: readonly string[], io: ReleaseIo): Promise<number> {
268
282
  const dist = p.values.get("--dist")
269
283
  if (listed && !dist) throw new Usage("--targets goes with --dist")
270
284
  const jobs: { target: string; file: string }[] = dist
271
- ? (listed ?? SUPPORTED_TARGETS.map((t) => t.target)).map((target) => ({ target, file: path.resolve(io.cwd, dist, `hubctl-${target}`) }))
285
+ ? (listed ?? SUPPORTED_TARGETS.map((t) => t.target)).map((target) => ({ target, file: path.resolve(io.cwd, dist, hubctlFile(target)) }))
272
286
  : [{ target: required(p, "--target"), file: path.resolve(io.cwd, required(p, "--file")) }]
273
287
  const installedVersion = p.values.get("--installed")
274
288
  const installedSha = p.values.get("--installed-sha256")
@@ -304,9 +318,73 @@ async function trustRoot(argv: readonly string[], io: ReleaseIo): Promise<number
304
318
  return 0
305
319
  }
306
320
 
307
- const COMMANDS: Record<string, (argv: readonly string[], io: ReleaseIo) => Promise<number>> = { build, manifest, sign, verify, "trust-root": trustRoot }
321
+ /** The file a dev release writes its (public) trust root to, in --out. */
322
+ export const DEV_TRUST_ROOT_FILE = "dev-trust-root.json"
323
+
324
+ async function dev(argv: readonly string[], io: ReleaseIo): Promise<number> {
325
+ const p = parseArgs(argv, ["--targets", "--out", "--pins", "--pending", "--release", "--minimum-version", "--bun"], ["--skip-pin-download", "--no-pending"])
326
+ // Never where a real release is made: a dev release is signed by a throwaway key and must never ship.
327
+ if (io.env[SIGNING_KEY_ENV] !== undefined) throw new Usage(`refusing a dev release while ${SIGNING_KEY_ENV} is set (that is the release signing job); dev releases never ship`)
328
+ if (io.env.GITHUB_ACTIONS === "true") throw new Usage("refusing a dev release on GitHub Actions; dev releases are for testing on your own machines and never ship")
329
+ const listed = targetsArg(p)
330
+ if (!listed) throw new Usage("--targets is required (e.g. --targets linux-x64,windows-x64)")
331
+ const out = path.resolve(io.cwd, required(p, "--out"))
332
+ const repoDist = path.resolve(io.cwd, "dist")
333
+ if (out === repoDist || out.startsWith(`${repoDist}${path.sep}`)) throw new Usage(`refusing a dev release into ${repoDist}, where release builds go; choose another --out`)
334
+ if (p.flags.has("--no-pending") && p.values.has("--pending")) throw new Usage("--pending and --no-pending exclude each other")
335
+
336
+ // The pins: the reviewed list, plus the pending (unreviewed) one, which only a dev release ever includes.
337
+ const reviewed = unwrap(validatePinList(readJSON(path.resolve(io.cwd, p.values.get("--pins") ?? PIN_LIST_FILE), "pin list")), "the pin list is invalid")
338
+ const pending = p.flags.has("--no-pending")
339
+ ? []
340
+ : unwrap(validatePendingPinList(readJSON(path.resolve(io.cwd, p.values.get("--pending") ?? PENDING_PIN_LIST_FILE), "pending pin list")), "the pending pin list is invalid").pins
341
+ const pins = [...reviewed.pins.map((e) => e.pin), ...pending.map((e) => e.pin)]
342
+ const seen = new Set<string>()
343
+ for (const pin of pins) {
344
+ const key = `${pin.harness} ${pin.target}`
345
+ if (seen.has(key)) throw new Failure(`${pin.harness} ${pin.target} is pinned in both the reviewed and the pending list; keep it in one`, EXIT_OTHER)
346
+ seen.add(key)
347
+ }
348
+ for (const { pin } of pending) io.stderr(`including a PENDING (unreviewed) pin: ${pin.harness} ${pin.version} ${pin.target} (${pin.file}); dev release only\n`)
349
+ if (!p.flags.has("--skip-pin-download")) {
350
+ io.stderr(`re-downloading ${pins.length} pinned harness file(s)\n`)
351
+ unwrap(await redownload(pins, io.download), "a pinned harness file does not match its pin")
352
+ }
353
+
354
+ // The throwaway key: in memory only. Its public trust root is written for reference (and for `verify`).
355
+ const { privateKey } = generateKeyPairSync("ed25519")
356
+ const keyID = `dev-${randomBytes(4).toString("hex")}`
357
+ const root = trustRootFor(privateKey, keyID)
358
+ mkdirSync(out, { recursive: true })
359
+ writeFileSync(path.join(out, DEV_TRUST_ROOT_FILE), `${JSON.stringify(root, null, 2)}\n`)
360
+
361
+ const bun = p.values.get("--bun") ?? process.execPath
362
+ for (const target of listed) {
363
+ const words = hubctlBuildArgv(target, { bun, outdir: out, root })
364
+ io.stderr(`==> ${words.at(-1)}\n`)
365
+ const code = await io.run(words)
366
+ if (code !== 0) throw new Failure(`bun build failed for ${target} (exit ${code})`, EXIT_OTHER)
367
+ }
368
+ const artifacts = readArtifacts(out, listed, "every listed target")
369
+ const body = unwrap(
370
+ buildManifestBody({ release: p.values.get("--release") ?? HUBCTL_VERSION, minimumVersion: p.values.get("--minimum-version") ?? "0.0.0", issuedAt: io.now(), artifacts, harnesses: sortPins(pins), targets: listed }),
371
+ "the manifest is invalid",
372
+ )
373
+ const verified = unwrap(signVerified(body, { keyID, privateKey }, root), "the dev manifest does not verify against its own trust root")
374
+ writeFileSync(path.join(out, "hubctl-manifest.json"), manifestJSON(verified))
375
+ // As sha256sum prints it (install.sh and install.ps1 read this file).
376
+ writeFileSync(path.join(out, "HUBCTL_SHA256SUMS"), artifacts.map((a) => `${a.sha256.slice("sha256:".length)} ${a.file}\n`).join(""))
377
+ io.stdout(
378
+ `${out}: DEV release hubctl ${body.release} (${artifacts.map((a) => a.file).join(", ")}), ${body.harnesses.length} harness pins (${pending.length} pending), signed by throwaway key ${keyID}\n` +
379
+ `serve it: cd ${out} && python3 -m http.server 8000 --bind 0.0.0.0\n` +
380
+ `on the hub: HUB_RELEASE_BASE=http://<this machine's LAN IP>:8000\n`,
381
+ )
382
+ return 0
383
+ }
384
+
385
+ const COMMANDS: Record<string, (argv: readonly string[], io: ReleaseIo) => Promise<number>> = { build, manifest, sign, verify, "trust-root": trustRoot, dev }
308
386
 
309
- const USAGE = "usage: hubctl-release <build|manifest|sign|verify|trust-root> [options] (see packages/cli/src/bootstrap/release/cli.ts)\n"
387
+ const USAGE = "usage: hubctl-release <build|manifest|sign|verify|trust-root|dev> [options] (see packages/cli/src/bootstrap/release/cli.ts)\n"
310
388
 
311
389
  export async function runRelease(argv: readonly string[], io: ReleaseIo): Promise<number> {
312
390
  const [name, ...rest] = argv
@@ -0,0 +1,24 @@
1
+ {
2
+ "schema": "hub.harness-pin-pending",
3
+ "version": 1,
4
+ "pins": [
5
+ {
6
+ "pin": {
7
+ "harness": "opencode",
8
+ "version": "2.2.0",
9
+ "target": "windows-x64",
10
+ "url": "https://github.com/anomalyco/opencode/releases/download/v2.2.0/opencode-windows-x64-baseline.zip",
11
+ "file": "opencode-windows-x64-baseline.zip",
12
+ "size": 118610005,
13
+ "sha256": "sha256:810fe13f6d85c6121e8ab0dd09933ababa2d52dccce9f3bc9ec70de5a9ae126e",
14
+ "evidence": [],
15
+ "minimumVersion": "2.0.0"
16
+ },
17
+ "prepared": {
18
+ "by": "agent (Phase 2 release workstream; NOT a review)",
19
+ "on": "2026-11-05",
20
+ "note": "UNREVIEWED. The author downloaded the asset (curl -fsSL), measured 118610005 bytes and sha256 810fe13f…126e, which equal the release's SHA256SUMS line and the GitHub API assets[].digest for tag v2.2.0 (published 2026-10-27 by github-actions[bot] from dev). unzip -Zl lists exactly one member, opencode.exe (253303688 bytes, deflate, FAT host). The owner must redo every check of release/README.md themselves and move this entry into harness-pins.json with their own review record; until then only the dev release includes it."
21
+ }
22
+ }
23
+ ]
24
+ }
@@ -46,6 +46,9 @@ export interface SigningKey {
46
46
  readonly privateKey: KeyObject
47
47
  }
48
48
 
49
+ /** The published file name of a target's hubctl: hubctl-<target>, with .exe on Windows (hubctl-windows-x64.exe). */
50
+ export const hubctlFile = (target: string): string => `hubctl-${target}${target.startsWith("windows-") ? ".exe" : ""}`
51
+
49
52
  /** A pre-release (x.y.z-…) goes to `next`, everything else to `stable`. */
50
53
  export const channelFor = (release: string): "stable" | "next" => (SEMVER.exec(release)?.[4] ? "next" : "stable")
51
54
 
@@ -112,17 +112,18 @@ function checkSource(c: Checker, p: string, pin: unknown) {
112
112
  if (typeof pin.file === "string" && last !== pin.file) c.add(pointer(p, "file"), "inconsistent", `the file must be the URL's last path segment (${last})`)
113
113
  }
114
114
 
115
- export function validatePinList(value: unknown): Result<PinList> {
115
+ /** The shared checks of both lists: the header, each entry (`record` names its review or prepared record), the pins. */
116
+ function validateList(value: unknown, schema: string, record: string, checkRecord: (c: Checker, p: string, v: unknown) => void): Checker {
116
117
  const c = new Checker()
117
118
  const r = shape(c, "", value, ["schema", "version", "pins"])
118
- if (!r) return c.result(value)
119
- header(c, "", r, PIN_LIST_SCHEMA, PIN_LIST_VERSION)
119
+ if (!r) return c
120
+ header(c, "", r, schema, PIN_LIST_VERSION)
120
121
  const pins: unknown[] = []
121
122
  const listed = list(c, "/pins", r.pins, MAX_PINS, (p, entry) => {
122
- const e = shape(c, p, entry, ["pin", "review"])
123
+ const e = shape(c, p, entry, ["pin", record])
123
124
  pins.push(e?.pin)
124
125
  if (!e) return
125
- if (e.review !== undefined) checkReview(c, pointer(p, "review"), e.review)
126
+ if (e[record] !== undefined) checkRecord(c, pointer(p, record), e[record])
126
127
  checkSource(c, pointer(p, "pin"), e.pin)
127
128
  })
128
129
  if (listed) {
@@ -133,7 +134,48 @@ export function validatePinList(value: unknown): Result<PinList> {
133
134
  }
134
135
  }
135
136
  for (const path of findSecrets(value)) c.add(path, "secret", "a pin list never carries a secret")
136
- return c.result(value)
137
+ return c
138
+ }
139
+
140
+ export function validatePinList(value: unknown): Result<PinList> {
141
+ return validateList(value, PIN_LIST_SCHEMA, "review", checkReview).result<PinList>(value)
142
+ }
143
+
144
+ // ---------- pins prepared but not reviewed yet ----------
145
+ //
146
+ // harness-pins.pending.json (schema `hub.harness-pin-pending`): pins an author (e.g. an agent) prepared from their own
147
+ // download, waiting for the owner's review. Each carries a `prepared` record instead of a `review`, so it is never a
148
+ // pin list the release job accepts (validatePinList refuses it), and only the dev release (`hubctl-release dev`)
149
+ // includes it. Promoting a pin = moving the entry into harness-pins.json with the reviewer's own `review`.
150
+
151
+ export const PENDING_PIN_LIST_SCHEMA = "hub.harness-pin-pending"
152
+ export const PENDING_PIN_LIST_FILE = "packages/cli/src/bootstrap/release/harness-pins.pending.json"
153
+
154
+ export interface PinPreparation {
155
+ /** Who prepared the entry (not a review). */
156
+ readonly by: string
157
+ /** YYYY-MM-DD. */
158
+ readonly on: string
159
+ /** What the author checked, and what the reviewer still has to. */
160
+ readonly note?: string
161
+ }
162
+
163
+ export interface PendingPinList {
164
+ readonly schema: typeof PENDING_PIN_LIST_SCHEMA
165
+ readonly version: typeof PIN_LIST_VERSION
166
+ readonly pins: readonly { readonly pin: HarnessPin; readonly prepared: PinPreparation }[]
167
+ }
168
+
169
+ function checkPrepared(c: Checker, p: string, v: unknown) {
170
+ const r = shape(c, p, v, ["by", "on"], ["note"])
171
+ if (!r) return
172
+ text(c, pointer(p, "by"), r.by, 80)
173
+ text(c, pointer(p, "on"), r.on, 10, DATE)
174
+ if (r.note !== undefined) text(c, pointer(p, "note"), r.note, 2000)
175
+ }
176
+
177
+ export function validatePendingPinList(value: unknown): Result<PendingPinList> {
178
+ return validateList(value, PENDING_PIN_LIST_SCHEMA, "prepared", checkPrepared).result<PendingPinList>(value)
137
179
  }
138
180
 
139
181
  const targetOrder = (a: HarnessPin, b: HarnessPin) =>
@@ -141,9 +183,12 @@ const targetOrder = (a: HarnessPin, b: HarnessPin) =>
141
183
 
142
184
  /** The manifest's `harnesses`: the pins in HARNESS_ORDER, then by target (deterministic). */
143
185
  export function harnessPins(list: PinList): HarnessPin[] {
144
- return list.pins.map((p) => p.pin).sort(targetOrder)
186
+ return sortPins(list.pins.map((p) => p.pin))
145
187
  }
146
188
 
189
+ /** Pins in the manifest's order (HARNESS_ORDER, then target). */
190
+ export const sortPins = (pins: readonly HarnessPin[]): HarnessPin[] => [...pins].sort(targetOrder)
191
+
147
192
  export type Downloader = (url: string) => Promise<{ readonly size: number; readonly sha256: string }>
148
193
 
149
194
  /**
@@ -153,8 +198,14 @@ export type Downloader = (url: string) => Promise<{ readonly size: number; reado
153
198
  export async function verifyPinDownloads(value: unknown, download: Downloader): Promise<Result<HarnessPin[]>> {
154
199
  const valid = validatePinList(value)
155
200
  if (!valid.ok) return valid
201
+ const checked = await redownload(valid.value.pins.map((p) => p.pin), download)
202
+ return checked.ok ? ok(harnessPins(valid.value)) : checked
203
+ }
204
+
205
+ /** Downloads every pin again and compares size and digest (issue paths /pins/<i>/pin/…, in `pins` order). */
206
+ export async function redownload(pins: readonly HarnessPin[], download: Downloader): Promise<Result<void>> {
156
207
  const c = new Checker()
157
- for (const [i, { pin }] of valid.value.pins.entries()) {
208
+ for (const [i, pin] of pins.entries()) {
158
209
  const at = `/pins/${i}/pin`
159
210
  try {
160
211
  const got = await download(pin.url)
@@ -165,7 +216,7 @@ export async function verifyPinDownloads(value: unknown, download: Downloader):
165
216
  }
166
217
  }
167
218
  if (c.issues.length > 0) return { ok: false, issues: c.issues }
168
- return ok(harnessPins(valid.value))
219
+ return ok(undefined)
169
220
  }
170
221
 
171
222
  /** Streams a URL and returns its size and digest (redirects followed); throws on an HTTP error. */
@@ -5,10 +5,12 @@
5
5
  // ensure-directory a directory (an existing directory is left as it is)
6
6
  // write-file an embedded/rendered asset whose bytes must match the plan's digest; never overwrites
7
7
  // merge-config a lossless JSON merge, only if the file is still as previewed (precondition digest)
8
- // link-executable the hub ↔ hubctl alias symlink (an existing entry is backed up and replaced)
8
+ // link-executable the hub ↔ hubctl alias symlink (an existing entry is backed up and replaced); on win32 a
9
+ // cmd shim file instead (shim.ts), since executable symlinks need admin there
9
10
  // select-harness-version v1.1: the harness's `current` link, switched atomically to an installed version
10
- // directory (relative, so it reads the same inside and outside a container); the previous
11
- // link is backed up, so a rollback makes the previous version current again
11
+ // directory (relative, so it reads the same inside and outside a container; on win32 an NTFS
12
+ // junction, whose target is always absolute); the previous link is backed up, so a rollback
13
+ // makes the previous version current again
12
14
  // install-service the rendered unit, whose bytes must match the plan's digest
13
15
  // deactivate-service v1.1: unloads a hub service through the injected ServiceControl (an external effect; its
14
16
  // undo loads it again)
@@ -43,6 +45,7 @@ import {
43
45
  import type { ActionExecutor, ExecutorMap, StepContext, TreeFile } from "./executor.ts"
44
46
  import { mergeConfigText } from "./json-merge.ts"
45
47
  import { fromResult, txFail, txOk, type TxResult } from "./result.ts"
48
+ import { cmdShim } from "./shim.ts"
46
49
  import { sha256Of } from "./state.ts"
47
50
  import { fileDigest, type StagedFile, type StagingArea } from "./stream.ts"
48
51
 
@@ -156,7 +159,15 @@ export const linkExecutableExecutor = (): ActionExecutor<"link-executable"> => (
156
159
  // The link's own path is symlink-checked (it is what gets written). Its target is only the link's text, never
157
160
  // written through, and may lead through a harness's `current` link, which is a symlink by design (§3.6.1).
158
161
  const target = ctx.resolve(action.target, { targetOnly: true })
159
- return target.ok ? txOk({ type: "symlink", target: target.value, replace: "backup" }) : target
162
+ if (!target.ok) return target
163
+ if ((ctx.platform ?? process.platform) !== "win32") return txOk({ type: "symlink", target: target.value, replace: "backup" })
164
+ let shim: Uint8Array
165
+ try {
166
+ shim = cmdShim(target.value)
167
+ } catch (error) {
168
+ return txFail("invalid", (error as Error).message)
169
+ }
170
+ return txOk({ type: "file", bytes: shim, mode: 0o755, replace: "backup" })
160
171
  },
161
172
  })
162
173
 
@@ -167,8 +178,9 @@ export const selectHarnessVersionExecutor = (): ActionExecutor<"select-harness-v
167
178
  const dir = ctx.resolve(action.target)
168
179
  if (!dir.ok) return dir
169
180
  if (ctx.fs.lstat(dir.value)?.kind !== "directory") return txFail("not_found", `${action.harness} ${action.version} is not installed (${dir.value} is not a directory)`)
170
- // `current` and the version directory are siblings (contract harnessCurrentRef / harnessVersionRef).
171
- return txOk({ type: "symlink", target: action.version, replace: "backup" })
181
+ // `current` and the version directory are siblings (contract harnessCurrentRef / harnessVersionRef). A junction
182
+ // stores an absolute target, so on win32 the link's text is the version directory's canonical path.
183
+ return txOk({ type: "symlink", target: (ctx.platform ?? process.platform) === "win32" ? dir.value : action.version, replace: "backup" })
172
184
  },
173
185
  })
174
186