@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.
- package/CHANGELOG.md +55 -0
- package/README.md +40 -3
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +5288 -4415
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/PHASE2-CONTRACT.md +87 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +11 -43
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +19 -12
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +42 -6
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +18 -7
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +36 -19
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +19 -3
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +2 -5
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +37 -6
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +49 -26
- package/packages/cli/src/bootstrap/adapters/process-tree.ts +263 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +7 -4
- package/packages/cli/src/bootstrap/contracts/platform.ts +9 -5
- package/packages/cli/src/bootstrap/contracts/validate.ts +20 -4
- package/packages/cli/src/bootstrap/discovery/discover.ts +3 -1
- package/packages/cli/src/bootstrap/discovery/executables.ts +49 -14
- package/packages/cli/src/bootstrap/discovery/host.ts +16 -8
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +41 -5
- package/packages/cli/src/bootstrap/discovery/probe.ts +7 -3
- package/packages/cli/src/bootstrap/discovery/util.ts +42 -5
- package/packages/cli/src/bootstrap/enrollment/store.ts +13 -6
- package/packages/cli/src/bootstrap/preview/plan.ts +3 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +10 -3
- package/packages/cli/src/bootstrap/release/README.md +63 -9
- package/packages/cli/src/bootstrap/release/cli.ts +94 -16
- package/packages/cli/src/bootstrap/release/harness-pins.pending.json +24 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +3 -0
- package/packages/cli/src/bootstrap/release/pins.ts +60 -9
- package/packages/cli/src/bootstrap/transaction/builtin.ts +18 -6
- package/packages/cli/src/bootstrap/transaction/engine.ts +166 -12
- package/packages/cli/src/bootstrap/transaction/executor.ts +5 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +142 -3
- package/packages/cli/src/bootstrap/transaction/shim.ts +31 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +11 -6
- package/packages/cli/src/credentials.ts +5 -2
- package/packages/cli/src/main.ts +3 -2
- package/packages/cli/src/setup/commands.ts +96 -18
- package/packages/cli/src/setup/contributors.ts +45 -10
- package/packages/cli/src/setup/runtime.ts +25 -6
- package/packages/cli/src/setup/self-aside.ts +130 -0
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/opencode-warm.ts +12 -4
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/config.ts +12 -0
- package/src/central/http.ts +10 -0
- package/src/central/install/CONTRACT.md +66 -0
- package/src/central/install/install.ps1 +349 -0
- package/src/central/install/install.sh +266 -0
- package/src/central/install/render.ts +146 -0
- package/src/central/install/templates.ts +6 -0
- package/src/central/install/text-assets.d.ts +9 -0
- package/src/config.ts +12 -7
- package/src/spoke/env-inject.ts +17 -2
- package/src/spoke/herdr.ts +3 -2
- package/src/spoke/launchers.ts +145 -9
- package/src/spoke/localhub.ts +1 -0
- package/src/win-acl.ts +154 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +1 -0
- package/web/dist/assets/{ArtifactPane-XTnwXl-Y.js → ArtifactPane-DKwbMTX9.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +6 -0
- package/web/dist/assets/Library-D6-Go6Cu.js +2 -0
- package/web/dist/assets/{ProfilesDestination-dZa3vioh.js → ProfilesDestination-BOML2Dgf.js} +1 -1
- package/web/dist/assets/{TeamsPage-DJt-Kk84.js → TeamsPage-CNBTE8gB.js} +1 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +2 -0
- package/web/dist/assets/Watchtower-RphjcL0_.css +1 -0
- package/web/dist/assets/hub-CrbItiwP.js +13 -0
- package/web/dist/assets/index-82Ce0IAf.css +1 -0
- package/web/dist/assets/index-D9SP3_0z.js +22 -0
- package/web/dist/assets/{live-D-RZbeUi.js → live-BNJgy0vg.js} +3 -3
- package/web/dist/assets/{pane-layout-Csk-vPNe.js → pane-layout-EdR1kRrG.js} +1 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +1 -0
- package/web/dist/assets/{renderer2d-DILxLFEq.js → renderer2d-CYag1CnR.js} +1 -1
- package/web/dist/assets/{renderer3d-PvN9Qbt2.js → renderer3d-BRB6rgDJ.js} +1 -1
- package/web/dist/assets/{safe-area-BS6ii3M7.js → safe-area-fOy0AOuG.js} +1 -1
- package/web/dist/assets/{source-BFLJQ43_.js → source-Wbq35qGp.js} +1 -1
- package/web/dist/assets/{ui-Qo2Pd9-O.js → ui-j5FFk90B.js} +1 -1
- package/web/dist/index.html +6 -4
- package/web/dist/assets/ArtifactPage-Cb1DcrQf.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-C37tZZ3e.js +0 -6
- package/web/dist/assets/Library-CZSSHvOJ.js +0 -2
- package/web/dist/assets/Watchtower-BXaUaXO1.js +0 -2
- package/web/dist/assets/Watchtower-CTgqHk5g.css +0 -1
- package/web/dist/assets/index-DVSkeAvn.css +0 -1
- 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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
119
|
-
header(c, "", r,
|
|
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",
|
|
123
|
+
const e = shape(c, p, entry, ["pin", record])
|
|
123
124
|
pins.push(e?.pin)
|
|
124
125
|
if (!e) return
|
|
125
|
-
if (e
|
|
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
|
|
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)
|
|
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,
|
|
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(
|
|
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
|
|
11
|
-
// link is backed up, so a rollback
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|