@huynle/hub 0.8.4 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -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/launchers.ts +145 -9
- 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
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// Executables on PATH: where a command is, whether it is a native binary, a wrapper script or a shim into a
|
|
2
2
|
// container (docker/podman exec), what it resolves to, and how (and at which version) a harness was installed,
|
|
3
|
-
// read from the install layout. A harness is never run to find out.
|
|
4
|
-
|
|
3
|
+
// read from the install layout. A harness is never run to find out. On Windows (the probe's platform "win32") PATH is
|
|
4
|
+
// split on ";", commands are looked up with their PATHEXT extension (only .com/.exe/.bat/.cmd), "MZ" marks a native
|
|
5
|
+
// binary, and a .cmd/.bat file is a wrapper: npm's shim (into its node_modules) or hubctl's own `@"<target>" %*`.
|
|
6
|
+
import { isWindowsPath, rootPathIn, SEMVER, type ExecutionContext, type HarnessID, type InstallMethod, type RootMapping, type Shim, type ShimKind } from "../contracts/index.ts"
|
|
5
7
|
import type { DiscoveryProbe } from "./probe.ts"
|
|
6
|
-
import { decode, join, normalizeAbs } from "./util.ts"
|
|
8
|
+
import { decode, hostAbs, join, normalizeAbs, underDir, WINDOWS_COMMAND_EXTENSIONS } from "./util.ts"
|
|
7
9
|
|
|
8
10
|
/** How much of an executable discovery reads to tell a native binary from a script. */
|
|
9
11
|
const HEAD_BYTES = 4096
|
|
@@ -16,19 +18,28 @@ export const HARNESS_BINARIES: Readonly<Record<HarnessID, string>> = Object.free
|
|
|
16
18
|
|
|
17
19
|
export function pathDirs(probe: DiscoveryProbe): string[] {
|
|
18
20
|
const dirs: string[] = []
|
|
19
|
-
for (const raw of (probe.env.PATH ?? "").split(":")) {
|
|
20
|
-
const d =
|
|
21
|
+
for (const raw of (probe.env.PATH ?? "").split(probe.platform === "win32" ? ";" : ":")) {
|
|
22
|
+
const d = hostAbs(probe.platform, raw)
|
|
21
23
|
if (d && !dirs.includes(d)) dirs.push(d)
|
|
22
24
|
if (dirs.length >= MAX_PATH_DIRS) break
|
|
23
25
|
}
|
|
24
26
|
return dirs
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
/** The extensions a command is looked up with on Windows: PATHEXT's order (lower-cased), only command extensions. */
|
|
30
|
+
export function commandExtensions(probe: DiscoveryProbe): readonly string[] {
|
|
31
|
+
const listed = (probe.env.PATHEXT ?? "").split(";").map((e) => e.trim().toLowerCase())
|
|
32
|
+
const known = listed.filter((e, i): e is (typeof WINDOWS_COMMAND_EXTENSIONS)[number] => (WINDOWS_COMMAND_EXTENSIONS as readonly string[]).includes(e) && listed.indexOf(e) === i)
|
|
33
|
+
return known.length > 0 ? known : WINDOWS_COMMAND_EXTENSIONS
|
|
34
|
+
}
|
|
35
|
+
|
|
27
36
|
export function which(probe: DiscoveryProbe, dirs: readonly string[], name: string): string | undefined {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
const names = probe.platform === "win32" ? commandExtensions(probe).map((e) => `${name}${e}`) : [name]
|
|
38
|
+
for (const d of dirs)
|
|
39
|
+
for (const n of names) {
|
|
40
|
+
const candidate = join(d, n)
|
|
41
|
+
if (probe.executable(candidate)) return candidate
|
|
42
|
+
}
|
|
32
43
|
return undefined
|
|
33
44
|
}
|
|
34
45
|
|
|
@@ -68,16 +79,35 @@ const MAGIC = [
|
|
|
68
79
|
[0xfe, 0xed, 0xfa, 0xce],
|
|
69
80
|
[0xca, 0xfe, 0xba, 0xbe], // Mach-O (thin and universal)
|
|
70
81
|
]
|
|
82
|
+
/** A Windows PE image ("MZ"); native only for a file on a Windows path. */
|
|
83
|
+
const PE_MAGIC = [0x4d, 0x5a]
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What a Windows cmd shim runs: hubctl's own (`@"C:\\…\\opencode.exe" %*`), else npm's last `"%dp0%\\…"` (the
|
|
87
|
+
* package's bin script, relative to the shim's directory; node.exe is the interpreter, not the target).
|
|
88
|
+
*/
|
|
89
|
+
function cmdShimTarget(script: string, shimPath: string): string | undefined {
|
|
90
|
+
const own = /^\s*@?"([A-Za-z]:\\[^"%]+)"\s+%\*/m.exec(script)
|
|
91
|
+
if (own) return normalizeAbs(own[1])
|
|
92
|
+
const relative = [...script.matchAll(/"%~?dp0%?\\([^"%]+)"/g)].map((m) => m[1]!).filter((rel) => !/^node(?:\.exe)?$/i.test(rel))
|
|
93
|
+
const rel = relative.at(-1)
|
|
94
|
+
if (!rel || rel.split(/[\\/]/).includes("..")) return undefined
|
|
95
|
+
return normalizeAbs(`${shimPath.slice(0, shimPath.lastIndexOf("/"))}/${rel}`)
|
|
96
|
+
}
|
|
71
97
|
|
|
72
98
|
/** What an executable on PATH is: a native binary, a script wrapper, or a shim into a container. */
|
|
73
99
|
export function classifyExecutable(probe: DiscoveryProbe, name: string, path: string): Shim {
|
|
74
100
|
const isLink = probe.lstat(path) === "symlink"
|
|
75
101
|
const real = isLink ? normalizeAbs(probe.realpath(path)) : undefined
|
|
76
102
|
const head = probe.readFile(path, HEAD_BYTES) ?? new Uint8Array()
|
|
77
|
-
const
|
|
103
|
+
const windows = isWindowsPath(path)
|
|
104
|
+
const native = [...MAGIC, ...(windows ? [PE_MAGIC] : [])].some((m) => m.every((b, i) => head[i] === b))
|
|
78
105
|
let kind: ShimKind = native || isLink ? "host" : "unknown"
|
|
79
106
|
let resolvesTo = real && real !== path ? real : undefined
|
|
80
|
-
if (!native &&
|
|
107
|
+
if (!native && windows && /\.(?:cmd|bat)$/i.test(path)) {
|
|
108
|
+
kind = "wrapper"
|
|
109
|
+
resolvesTo ??= cmdShimTarget(decode(head), path)
|
|
110
|
+
} else if (!native && head[0] === 0x23 && head[1] === 0x21) {
|
|
81
111
|
const script = decode(head)
|
|
82
112
|
if (CONTAINER_EXEC.test(script)) {
|
|
83
113
|
kind = "container-shim"
|
|
@@ -113,21 +143,26 @@ export function installOf(probe: DiscoveryProbe, id: HarnessID, shim: Shim, root
|
|
|
113
143
|
}
|
|
114
144
|
const data = local("data")
|
|
115
145
|
const home = local("home")
|
|
116
|
-
|
|
146
|
+
// NTFS names are case-insensitive: so are the Windows layouts.
|
|
147
|
+
const windows = isWindowsPath(real)
|
|
148
|
+
const hubLayout = data ? new RegExp(`^${escape(data)}/hub/harnesses/${escape(id)}/([^/]+)(?:/|$)`, windows ? "i" : "") : undefined
|
|
117
149
|
let hub = hubLayout?.exec(real) ?? null
|
|
118
150
|
// v1.1 layout: `current` is a link to the version directory in use; the version is where it leads.
|
|
119
151
|
if (hub?.[1] === "current") hub = hubLayout!.exec(normalizeAbs(probe.realpath(real)) ?? "") ?? null
|
|
120
152
|
if (hub && semver(hub[1])) return { installMethod: "hub", version: hub[1] }
|
|
121
153
|
const brew = /\/(?:Cellar|Caskroom)\/[^/]+\/([^/]+)\//.exec(real)
|
|
122
154
|
if (brew) return { installMethod: "brew", ...(semver(brew[1]) ? { version: brew[1] } : {}) }
|
|
123
|
-
if (real.includes("/node_modules/")) {
|
|
155
|
+
if (windows ? /\/node_modules\//i.test(real) : real.includes("/node_modules/")) {
|
|
124
156
|
const version = packageVersion(probe, real)
|
|
125
157
|
return { installMethod: "npm", ...(version ? { version } : {}) }
|
|
126
158
|
}
|
|
127
159
|
const claude = /\/claude\/versions\/([^/]+)$/.exec(real)
|
|
128
160
|
if (id === "claude-code" && claude) return { installMethod: "native", ...(semver(claude[1]) ? { version: claude[1] } : {}) }
|
|
129
|
-
if (home && id === "opencode" && real
|
|
161
|
+
if (home && id === "opencode" && underDir(real, `${home}/.opencode/bin`)) return { installMethod: "native" }
|
|
130
162
|
if (/^\/(?:usr\/)?s?bin\//.test(real)) return { installMethod: "package-manager" }
|
|
163
|
+
// Windows: npm's global dir without a resolvable shim, and Scoop, WinGet and Chocolatey.
|
|
164
|
+
if (windows && /\/AppData\/Roaming\/npm\/[^/]+$/i.test(real)) return { installMethod: "npm" }
|
|
165
|
+
if (windows && /\/(?:scoop\/(?:shims|apps)|Microsoft\/WinGet\/(?:Links|Packages)|chocolatey\/(?:bin|lib))\//i.test(real)) return { installMethod: "package-manager" }
|
|
131
166
|
return { installMethod: "unknown" }
|
|
132
167
|
}
|
|
133
168
|
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
// Host facts: path roots (and their host location inside a container), C library, OS name and version, and
|
|
2
|
-
// container kind. Everything is read through the probe; nothing is executed.
|
|
2
|
+
// container kind. Everything is read through the probe; nothing is executed. On Windows (PHASE2-CONTRACT.md) the
|
|
3
|
+
// roots are the same XDG-style layout under %USERPROFILE%; only drive-letter values count there (hostAbs), so a Git
|
|
4
|
+
// Bash HOME ("/c/Users/x") is ignored.
|
|
3
5
|
import { ROOT_IDS, type ContainerKind, type Libc, type RootID, type RootMapping } from "../contracts/index.ts"
|
|
4
6
|
import type { DiscoveryProbe } from "./probe.ts"
|
|
5
7
|
import path from "node:path"
|
|
6
8
|
import { resolveHubStateDir } from "../../../../../src/migrate.ts"
|
|
7
|
-
import { decode, join, normalizeAbs, oneLine } from "./util.ts"
|
|
9
|
+
import { decode, hostAbs, join, normalizeAbs, oneLine } from "./util.ts"
|
|
8
10
|
|
|
9
11
|
// ---------- roots ----------
|
|
10
12
|
|
|
11
13
|
export function discoverRoots(probe: DiscoveryProbe): RootMapping[] | undefined {
|
|
12
|
-
const
|
|
14
|
+
const abs = (v: string | undefined) => hostAbs(probe.platform, v)
|
|
15
|
+
const windows = probe.platform === "win32"
|
|
16
|
+
const home = abs(probe.env.HOME) ?? (windows ? abs(probe.env.USERPROFILE) : undefined) ?? abs(probe.home)
|
|
13
17
|
if (!home) return undefined
|
|
14
|
-
const xdg = (key: string, fallback: string) =>
|
|
18
|
+
const xdg = (key: string, fallback: string) => abs(probe.env[key]) ?? join(home, fallback)
|
|
15
19
|
const config = xdg("XDG_CONFIG_HOME", ".config")
|
|
16
20
|
const paths: Partial<Record<RootID, string>> = {
|
|
17
21
|
home,
|
|
@@ -19,7 +23,7 @@ export function discoverRoots(probe: DiscoveryProbe): RootMapping[] | undefined
|
|
|
19
23
|
state: hubStateRoot(probe, home) ?? xdg("XDG_STATE_HOME", ".local/state"),
|
|
20
24
|
data: xdg("XDG_DATA_HOME", ".local/share"),
|
|
21
25
|
"user-bin": join(home, ".local/bin"),
|
|
22
|
-
"system-bin": "/usr/local/bin",
|
|
26
|
+
...(windows ? {} : { "system-bin": "/usr/local/bin" }),
|
|
23
27
|
}
|
|
24
28
|
if (probe.platform === "darwin") paths["launch-agents"] = join(home, "Library/LaunchAgents")
|
|
25
29
|
if (probe.platform === "linux") paths["systemd-user"] = join(config, "systemd/user")
|
|
@@ -32,9 +36,12 @@ export function discoverRoots(probe: DiscoveryProbe): RootMapping[] | undefined
|
|
|
32
36
|
* "hub" (a custom HUB_STATE_DIR): the plan's paths are <state>/hub/….
|
|
33
37
|
*/
|
|
34
38
|
function hubStateRoot(probe: DiscoveryProbe, home: string): string | undefined {
|
|
35
|
-
const env
|
|
39
|
+
const env: Record<string, string | undefined> = { ...probe.env }
|
|
40
|
+
if (probe.env.XDG_STATE_HOME !== undefined) env.XDG_STATE_HOME = hostAbs(probe.platform, probe.env.XDG_STATE_HOME)
|
|
41
|
+
// A HUB_STATE_DIR this platform cannot use (a POSIX path on Windows) is as if unset.
|
|
42
|
+
if (probe.platform === "win32" && env.HUB_STATE_DIR !== undefined && !hostAbs(probe.platform, env.HUB_STATE_DIR)) delete env.HUB_STATE_DIR
|
|
36
43
|
const { dir } = resolveHubStateDir(env, home, (p) => probe.lstat(p) !== "missing")
|
|
37
|
-
const abs =
|
|
44
|
+
const abs = hostAbs(probe.platform, dir)
|
|
38
45
|
return abs && path.posix.basename(abs) === "hub" ? path.posix.dirname(abs) : undefined
|
|
39
46
|
}
|
|
40
47
|
|
|
@@ -72,7 +79,7 @@ export function mapContainerRoots(roots: RootMapping[], mounts: readonly Mount[]
|
|
|
72
79
|
// ---------- host ----------
|
|
73
80
|
|
|
74
81
|
export function discoverLibc(probe: DiscoveryProbe): Libc {
|
|
75
|
-
if (probe.platform === "darwin") return "none"
|
|
82
|
+
if (probe.platform === "darwin" || probe.platform === "win32") return "none"
|
|
76
83
|
if (probe.platform !== "linux") return "unknown"
|
|
77
84
|
const names = ["/lib", "/lib64", "/usr/lib"].flatMap((d) => probe.readdir(d) ?? [])
|
|
78
85
|
if (names.some((n) => n.startsWith("ld-musl-"))) return "musl"
|
|
@@ -91,6 +98,7 @@ export function discoverOS(probe: DiscoveryProbe): { name: string; version: stri
|
|
|
91
98
|
const value = (key: string) => oneLine(new RegExp(`^${key}=(.*)$`, "m").exec(text)?.[1]?.trim().replace(/^(["'])(.*)\1$/, "$2"))
|
|
92
99
|
return { name: value("NAME") ?? "Linux", version: value("VERSION_ID") ?? "unknown" }
|
|
93
100
|
}
|
|
101
|
+
if (probe.platform === "win32") return { name: oneLine(probe.osInfo?.name) ?? "Windows", version: oneLine(probe.osInfo?.version) ?? "unknown" }
|
|
94
102
|
return { name: oneLine(probe.platform, 32) ?? "unknown", version: "unknown" }
|
|
95
103
|
}
|
|
96
104
|
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
// The real DiscoveryProbe, over node:fs. Every method is a read: lstat/stat, a bounded read of a file opened
|
|
2
2
|
// read-only, readdir, readlink, realpath and an access(X_OK) check. Errors mean "missing" or "unknown", never a
|
|
3
|
-
// throw. The only process it may start is the
|
|
4
|
-
//
|
|
3
|
+
// throw. The only process it may start is the machine-identity query, and only when no machine ID was injected:
|
|
4
|
+
// `ioreg` on macOS, `reg query` of HKLM\SOFTWARE\Microsoft\Cryptography MachineGuid on Windows (both a fixed
|
|
5
|
+
// read-only argv, no shell); Linux reads /etc/machine-id. On Windows, where X_OK means nothing, an executable is a
|
|
6
|
+
// regular file with a command extension, and the OS name and version come from os.version()/os.release().
|
|
5
7
|
import childProcess from "node:child_process"
|
|
6
8
|
import fs from "node:fs"
|
|
7
9
|
import os from "node:os"
|
|
8
10
|
import { programLayout, type EntryKind } from "../contracts/index.ts"
|
|
9
11
|
import type { DiscoveryProbe, RuntimeFact } from "./probe.ts"
|
|
12
|
+
import { isWindowsExecutable, probePath } from "./util.ts"
|
|
10
13
|
|
|
11
14
|
export interface NodeProbeOptions {
|
|
12
15
|
/** Defaults to process.env (read, never recorded). */
|
|
@@ -48,9 +51,37 @@ function osMachineID(): string | undefined {
|
|
|
48
51
|
const out = attempt(() => childProcess.execFileSync("/usr/sbin/ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }))
|
|
49
52
|
return out ? /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out)?.[1] : undefined
|
|
50
53
|
}
|
|
54
|
+
if (process.platform === "win32") {
|
|
55
|
+
const root = /^[A-Za-z]:\\[^"<>|?*]*$/.test(process.env.SystemRoot ?? "") ? process.env.SystemRoot! : "C:\\Windows"
|
|
56
|
+
const out = attempt(() =>
|
|
57
|
+
childProcess.execFileSync(`${root.replace(/\\+$/, "")}\\System32\\reg.exe`, ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"], {
|
|
58
|
+
encoding: "utf8",
|
|
59
|
+
timeout: 5000,
|
|
60
|
+
windowsHide: true,
|
|
61
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
62
|
+
}),
|
|
63
|
+
)
|
|
64
|
+
return out ? parseMachineGuid(out) : undefined
|
|
65
|
+
}
|
|
51
66
|
return undefined
|
|
52
67
|
}
|
|
53
68
|
|
|
69
|
+
/** The MachineGuid value of `reg query HKLM\SOFTWARE\Microsoft\Cryptography /v MachineGuid` output. */
|
|
70
|
+
export const parseMachineGuid = (text: string): string | undefined => /^\s*MachineGuid\s+REG_SZ\s+(\S+)\s*$/m.exec(text)?.[1]
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Windows' name and version from os.version() ("Windows 10 Pro") and os.release() ("10.0.26100"). Windows 11 still
|
|
74
|
+
* calls itself Windows 10 there; build 22000 and later is Windows 11.
|
|
75
|
+
*/
|
|
76
|
+
export function windowsOS(version: string, release: string): { name: string; version: string } {
|
|
77
|
+
const build = Number(/^10\.0\.(\d+)/.exec(release)?.[1] ?? 0)
|
|
78
|
+
let name = /^Windows\b[ -~]{0,56}$/.test(version.trim()) ? version.trim() : "Windows"
|
|
79
|
+
if (build >= 22000) name = name.replace(/^Windows 10\b/, "Windows 11")
|
|
80
|
+
return { name, version: /^[0-9.]{1,32}$/.test(release) ? release : "unknown" }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export { isWindowsExecutable }
|
|
84
|
+
|
|
54
85
|
export function createNodeProbe(options: NodeProbeOptions = {}): DiscoveryProbe {
|
|
55
86
|
let machineID: string | undefined | null = options.machineID ?? null
|
|
56
87
|
const runtime = options.runtime === null ? undefined : (options.runtime ?? currentRuntime())
|
|
@@ -62,6 +93,7 @@ export function createNodeProbe(options: NodeProbeOptions = {}): DiscoveryProbe
|
|
|
62
93
|
hostname: os.hostname(),
|
|
63
94
|
env: options.env ?? process.env,
|
|
64
95
|
...(runtime ? { runtime } : {}),
|
|
96
|
+
...(process.platform === "win32" ? { osInfo: windowsOS(attempt(() => os.version()) ?? "", os.release()) } : {}),
|
|
65
97
|
lstat: (p) => attempt(() => kindOf(fs.lstatSync(p))) ?? "missing",
|
|
66
98
|
stat: (p) => attempt(() => kindOf(fs.statSync(p))) ?? "missing",
|
|
67
99
|
readFile(p, limit) {
|
|
@@ -86,12 +118,16 @@ export function createNodeProbe(options: NodeProbeOptions = {}): DiscoveryProbe
|
|
|
86
118
|
}
|
|
87
119
|
},
|
|
88
120
|
readdir: (p) => attempt(() => fs.readdirSync(p).sort()),
|
|
89
|
-
readlink: (p) => attempt(() => fs.readlinkSync(p)),
|
|
90
|
-
realpath: (p) => attempt(() => fs.realpathSync(p)),
|
|
91
|
-
executable: (p) =>
|
|
121
|
+
readlink: (p) => probePath(process.platform, attempt(() => fs.readlinkSync(p))),
|
|
122
|
+
realpath: (p) => probePath(process.platform, attempt(() => fs.realpathSync(p))),
|
|
123
|
+
executable: (p) =>
|
|
124
|
+
process.platform === "win32"
|
|
125
|
+
? isWindowsExecutable(p) && (attempt(() => fs.statSync(p).isFile()) ?? false)
|
|
126
|
+
: (attempt(() => fs.statSync(p).isFile() && (fs.accessSync(p, fs.constants.X_OK), true)) ?? false),
|
|
92
127
|
machineID() {
|
|
93
128
|
if (machineID === null) machineID = osMachineID()
|
|
94
129
|
return machineID
|
|
95
130
|
},
|
|
96
131
|
}
|
|
97
132
|
}
|
|
133
|
+
|
|
@@ -15,14 +15,18 @@ export interface DiscoveryProbe {
|
|
|
15
15
|
/** process.platform and process.arch as found (may be unsupported). */
|
|
16
16
|
readonly platform: string
|
|
17
17
|
readonly arch: string
|
|
18
|
+
/** The POSIX uid; 0 on Windows, which has none (the home directory tells its users apart in the fingerprint). */
|
|
18
19
|
readonly uid: number
|
|
19
20
|
/** The user's home directory (os.homedir()). */
|
|
20
21
|
readonly home: string
|
|
21
22
|
readonly hostname: string
|
|
22
|
-
/** Read, never recorded: discovery looks at PATH, HOME, XDG_*, HUB_STATE_DIR and `container` only
|
|
23
|
+
/** Read, never recorded: discovery looks at PATH, HOME, XDG_*, HUB_STATE_DIR and `container` only (on Windows also USERPROFILE and
|
|
24
|
+
* PATHEXT). */
|
|
23
25
|
readonly env: Readonly<Record<string, string | undefined>>
|
|
24
26
|
/** The runtime running this script, when hubctl runs as a script (absent for a standalone binary). */
|
|
25
27
|
readonly runtime?: RuntimeFact
|
|
28
|
+
/** OS name and version when the OS states them only to the process (Windows: os.version(), os.release()). */
|
|
29
|
+
readonly osInfo?: { readonly name: string; readonly version: string }
|
|
26
30
|
/** Does not follow a final symlink. */
|
|
27
31
|
lstat(path: string): EntryKind
|
|
28
32
|
/** Follows symlinks. */
|
|
@@ -34,8 +38,8 @@ export interface DiscoveryProbe {
|
|
|
34
38
|
readlink(path: string): string | undefined
|
|
35
39
|
/** The canonical path (every link resolved), or undefined when it does not exist. */
|
|
36
40
|
realpath(path: string): string | undefined
|
|
37
|
-
/** A regular file (after links) this user may execute. */
|
|
41
|
+
/** A regular file (after links) this user may execute; on Windows, one with a command extension (.exe, .cmd, …). */
|
|
38
42
|
executable(path: string): boolean
|
|
39
|
-
/** /etc/machine-id (Linux)
|
|
43
|
+
/** /etc/machine-id (Linux), IOPlatformUUID (macOS) or MachineGuid (Windows); never recorded raw. */
|
|
40
44
|
machineID(): string | undefined
|
|
41
45
|
}
|
|
@@ -1,13 +1,50 @@
|
|
|
1
1
|
// Small path and text helpers shared by discovery and preview.
|
|
2
|
-
import { isAbsolutePath } from "../contracts/index.ts"
|
|
2
|
+
import { isAbsolutePath, isWindowsPath } from "../contracts/index.ts"
|
|
3
3
|
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* A canonical absolute path (validate.ts isAbsolutePath) from a host's own form, or undefined: duplicate and trailing
|
|
6
|
+
* separators removed; on Windows forms, backslashes become "/" and the drive letter is upper-cased. UNC (\\\\server)
|
|
7
|
+
* and device (\\\\?\\, \\\\.\\) paths are refused.
|
|
8
|
+
*/
|
|
5
9
|
export function normalizeAbs(value: string | undefined): string | undefined {
|
|
6
|
-
if (typeof value !== "string"
|
|
7
|
-
|
|
10
|
+
if (typeof value !== "string") return undefined
|
|
11
|
+
let p = value
|
|
12
|
+
if (/^[A-Za-z]:[\\/]/.test(p)) p = p[0]!.toUpperCase() + p.slice(1).replace(/\\/g, "/")
|
|
13
|
+
else if (!p.startsWith("/") || p.startsWith("//")) return undefined
|
|
14
|
+
p = p.replace(/\/{2,}/g, "/")
|
|
15
|
+
if (p.endsWith("/") && p !== "/" && !/^[A-Z]:\/$/.test(p)) p = p.slice(0, -1)
|
|
8
16
|
return isAbsolutePath(p) ? p : undefined
|
|
9
17
|
}
|
|
10
18
|
|
|
11
|
-
|
|
19
|
+
/** A link or real path Windows reports (`\\\\?\\C:\\a\\`, `\\??\\c:\\a`, `C:\\a`) as a canonical path; anything else unchanged. */
|
|
20
|
+
export function canonicalWinLink(text: string): string {
|
|
21
|
+
const stripped = text.replace(/^(\\\\\?\\|\\\?\?\\)/, "")
|
|
22
|
+
return normalizeAbs(stripped) ?? text
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A path a probe read from the OS (realpath, readlink), in canonical form on win32; unchanged elsewhere. */
|
|
26
|
+
export const probePath = (platform: string, text: string | undefined): string | undefined => (text !== undefined && platform === "win32" ? canonicalWinLink(text) : text)
|
|
27
|
+
|
|
28
|
+
export const join = (dir: string, rel: string) => (dir.endsWith("/") ? `${dir}${rel}` : `${dir}/${rel}`)
|
|
12
29
|
export const decode = (bytes: Uint8Array | undefined) => (bytes ? new TextDecoder("utf-8", { fatal: false }).decode(bytes) : "")
|
|
13
30
|
export const oneLine = (v: string | undefined, max = 64) => (v && v.length <= max && !/[\u0000-\u001f\u007f]/.test(v) ? v : undefined)
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* normalizeAbs for a value read from a host on `platform`: on win32 only a drive-letter path counts (a Git Bash
|
|
34
|
+
* HOME such as "/c/Users/x", or a POSIX PATH entry, is no path a native Windows program can use).
|
|
35
|
+
*/
|
|
36
|
+
export const hostAbs = (platform: string, value: string | undefined): string | undefined => {
|
|
37
|
+
const p = normalizeAbs(value)
|
|
38
|
+
return p && (platform !== "win32" || isWindowsPath(p)) ? p : undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Path prefix test; Windows paths compare case-insensitively (NTFS names are). */
|
|
42
|
+
export const underDir = (p: string, dir: string): boolean => {
|
|
43
|
+
const prefix = dir.endsWith("/") ? dir : `${dir}/`
|
|
44
|
+
return isWindowsPath(p) ? p.toLowerCase().startsWith(prefix.toLowerCase()) : p.startsWith(prefix)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The extensions hubctl treats as commands on Windows (PATHEXT may list more, e.g. .JS; those are not run). */
|
|
48
|
+
export const WINDOWS_COMMAND_EXTENSIONS = [".com", ".exe", ".bat", ".cmd"] as const
|
|
49
|
+
|
|
50
|
+
export const isWindowsExecutable = (path: string): boolean => WINDOWS_COMMAND_EXTENSIONS.some((e) => path.toLowerCase().endsWith(e))
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// The machine credential store: the spoke's config.json (src/config.ts), the one file every spoke and `hub node` on
|
|
2
2
|
// this machine read their identity from. Writes merge into the raw JSON (every key the file holds survives, I4),
|
|
3
3
|
// atomically (private temp file + rename) with mode 0600 in a 0700 directory. A symlinked config.json or config
|
|
4
|
-
// directory is refused: a machine token must never be written into a dotfiles repository (T23).
|
|
4
|
+
// directory is refused: a machine token must never be written into a dotfiles repository (T23). On Windows the file
|
|
5
|
+
// gets an owner-only ACL instead of a mode (src/win-acl.ts), set before the credential is written.
|
|
5
6
|
import { randomBytes } from "node:crypto"
|
|
6
7
|
import { closeSync, lstatSync, mkdirSync, openSync, rmSync } from "node:fs"
|
|
7
8
|
import { hostname } from "node:os"
|
|
8
9
|
import path from "node:path"
|
|
9
10
|
import { configPath, normalize, prepareConfigPath, readRawConfig, updateRawConfig, type Mode } from "../../../../../src/config.ts"
|
|
11
|
+
import { defaultWinAcl, isWin32, nativeWinPath, type SecretFileOptions } from "../../../../../src/win-acl.ts"
|
|
10
12
|
import type { CredentialStore, MachineCredential } from "../contracts/index.ts"
|
|
11
13
|
|
|
12
14
|
export type CredentialStoreFailure = "symlink" | "not_object" | "invalid" | "io"
|
|
@@ -54,9 +56,12 @@ const isLink = (p: string) => {
|
|
|
54
56
|
|
|
55
57
|
export class ConfigCredentialStore implements CredentialStore {
|
|
56
58
|
readonly location: string
|
|
59
|
+
private readonly secrets: SecretFileOptions | undefined
|
|
57
60
|
|
|
58
|
-
|
|
61
|
+
/** `secrets`: the platform and ACL helper for writes (default: this process's). */
|
|
62
|
+
constructor(file: string, secrets?: SecretFileOptions) {
|
|
59
63
|
this.location = file
|
|
64
|
+
this.secrets = secrets
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
/** The raw config object ({} when there is none); throws a CredentialStoreError when it is not a JSON object. */
|
|
@@ -77,7 +82,8 @@ export class ConfigCredentialStore implements CredentialStore {
|
|
|
77
82
|
|
|
78
83
|
/**
|
|
79
84
|
* Checks, before a one-time enrollment code is redeemed, that write() can succeed: no symlink, a JSON object (or
|
|
80
|
-
* no file), and a writable private directory (created 0700 if missing).
|
|
85
|
+
* no file), and a writable private directory (created 0700 if missing). On Windows, also that a file there can be
|
|
86
|
+
* restricted to the current user (icacls). Leaves no file behind.
|
|
81
87
|
*/
|
|
82
88
|
async preflight(): Promise<void> {
|
|
83
89
|
this.refuseLinks()
|
|
@@ -87,6 +93,7 @@ export class ConfigCredentialStore implements CredentialStore {
|
|
|
87
93
|
try {
|
|
88
94
|
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
89
95
|
closeSync(openSync(probe, "wx", 0o600))
|
|
96
|
+
if (isWin32(this.secrets)) (this.secrets?.acl ?? defaultWinAcl()).restrictToOwner(nativeWinPath(probe))
|
|
90
97
|
} catch (cause) {
|
|
91
98
|
throw ioError(dir, "write to", cause)
|
|
92
99
|
} finally {
|
|
@@ -114,7 +121,7 @@ export class ConfigCredentialStore implements CredentialStore {
|
|
|
114
121
|
this.refuseLinks()
|
|
115
122
|
if ("mode" in this.raw()) return false
|
|
116
123
|
try {
|
|
117
|
-
updateRawConfig(this.location, (raw) => ("mode" in raw ? raw : { ...raw, mode: DEFAULT_SHARING }))
|
|
124
|
+
updateRawConfig(this.location, (raw) => ("mode" in raw ? raw : { ...raw, mode: DEFAULT_SHARING }), this.secrets)
|
|
118
125
|
} catch (cause) {
|
|
119
126
|
throw ioError(this.location, "write", cause)
|
|
120
127
|
}
|
|
@@ -145,7 +152,7 @@ export class ConfigCredentialStore implements CredentialStore {
|
|
|
145
152
|
token: credential.token,
|
|
146
153
|
// An enrolled machine shares by default (the /hub dialog asks; hubctl cannot): only when the user never chose.
|
|
147
154
|
...("mode" in raw ? {} : { mode: DEFAULT_SHARING }),
|
|
148
|
-
}))
|
|
155
|
+
}), this.secrets)
|
|
149
156
|
} catch (cause) {
|
|
150
157
|
throw ioError(this.location, "write", cause)
|
|
151
158
|
}
|
|
@@ -156,7 +163,7 @@ export class ConfigCredentialStore implements CredentialStore {
|
|
|
156
163
|
const raw = this.raw()
|
|
157
164
|
if (!("token" in raw) && !("machineID" in raw)) return
|
|
158
165
|
try {
|
|
159
|
-
updateRawConfig(this.location, ({ token: _token, machineID: _machineID, ...rest }) => rest)
|
|
166
|
+
updateRawConfig(this.location, ({ token: _token, machineID: _machineID, ...rest }) => rest, this.secrets)
|
|
160
167
|
} catch (cause) {
|
|
161
168
|
throw ioError(this.location, "write", cause)
|
|
162
169
|
}
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
type Result,
|
|
43
43
|
type SetupPlan,
|
|
44
44
|
} from "../contracts/index.ts"
|
|
45
|
+
import { NO_SERVICES_ON_TARGET } from "../profiles/desired.ts"
|
|
45
46
|
|
|
46
47
|
/** What a contributor plans from: the adapters' input, and the actions planned before it (adapters first). */
|
|
47
48
|
export type ContributorInput = PlanInput & { readonly planned?: readonly Action[] }
|
|
@@ -146,6 +147,8 @@ export function planSetup(
|
|
|
146
147
|
const profile = prof.value
|
|
147
148
|
const target = checkTarget(snapshot.host.target)
|
|
148
149
|
if (!target.ok || !snapshot.host.supported) return failed(issuesAt("/snapshot/host/target", target.ok ? fail("", "unsupported_platform", "unsupported host") : target))
|
|
150
|
+
if (profile.services.length > 0 && target.value.serviceManager === "none")
|
|
151
|
+
return fail("/profile/services", "unsupported_platform", `${profile.id} ${NO_SERVICES_ON_TARGET}`)
|
|
149
152
|
if (profile.services.length > 0 && snapshot.managers.service === "none")
|
|
150
153
|
return fail("/profile/services", "no_service_manager", `the ${profile.id} profile installs services, but this host has no user service manager (launchd or systemd --user)`)
|
|
151
154
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Profile → desired state: what this host should end up with, as a pure function of (profile, discovery snapshot).
|
|
2
2
|
// It is a description, never a write: the planner turns it into actions, the audit mode only verifies it. Hosts it
|
|
3
|
-
// cannot serve fail explicitly (an unsupported target, services
|
|
4
|
-
// absent or was not discovered) instead of being half-configured.
|
|
5
|
-
// or configures it. A harness the user installed some other way stays theirs (owner "user"): hub verifies it and
|
|
3
|
+
// cannot serve fail explicitly (an unsupported target, services on a target without them (windows-x64) or on a host
|
|
4
|
+
// without a user service manager, OpenShell that is absent or was not discovered) instead of being half-configured.
|
|
5
|
+
// OpenShell is verify-only: hubctl never installs or configures it. A harness the user installed some other way stays theirs (owner "user"): hub verifies it and
|
|
6
6
|
// never replaces it.
|
|
7
7
|
import {
|
|
8
|
+
checkTarget,
|
|
8
9
|
Checker,
|
|
9
10
|
fail,
|
|
10
11
|
REQUIRED_PROBES,
|
|
@@ -21,6 +22,9 @@ import {
|
|
|
21
22
|
type ServiceManagerID,
|
|
22
23
|
} from "../contracts/index.ts"
|
|
23
24
|
|
|
25
|
+
/** PHASE2-CONTRACT.md: a target without a service manager (windows-x64) runs session-only; preview refuses the rest. */
|
|
26
|
+
export const NO_SERVICES_ON_TARGET = "needs user services, which hubctl does not install on Windows yet"
|
|
27
|
+
|
|
24
28
|
export interface DesiredHarness {
|
|
25
29
|
readonly id: HarnessID
|
|
26
30
|
readonly presence: "installed" | "missing"
|
|
@@ -66,6 +70,9 @@ export function desiredState(profileDoc: unknown, snapshotDoc: unknown): Result<
|
|
|
66
70
|
|
|
67
71
|
if (!snapshot.host.supported) return fail("/snapshot/host/target", "unsupported_platform", `cannot set up ${snapshot.host.target}`)
|
|
68
72
|
const manager = snapshot.managers.service
|
|
73
|
+
const target = checkTarget(snapshot.host.target)
|
|
74
|
+
if (profile.services.length > 0 && target.ok && target.value.serviceManager === "none")
|
|
75
|
+
return fail("/profile/services", "unsupported_platform", `${profile.id} ${NO_SERVICES_ON_TARGET}`)
|
|
69
76
|
if (profile.services.length > 0 && manager === "none")
|
|
70
77
|
c.add("/profile/services", "no_service_manager", `${profile.id} runs services, but this host has no user service manager (launchd or systemd --user)`)
|
|
71
78
|
const runtimes = profile.runtimes.map((id, i): DesiredRuntime => {
|
|
@@ -8,10 +8,10 @@ unchanged.
|
|
|
8
8
|
|---|---|
|
|
9
9
|
| `verify.ts` | The installer's checks on a downloaded hubctl: signature (trust root), exact target, size and digest, downgrade policy. |
|
|
10
10
|
| `manifest.ts` | Builds the manifest body (every supported target, or an explicit `targets` subset; matrix order; harness pins) and signs it (ed25519, detached, over `manifestPayload`). |
|
|
11
|
-
| `pins.ts` | The reviewed harness pin list (`harness-pins.json`): format, validation, re-download check. |
|
|
11
|
+
| `pins.ts` | The reviewed harness pin list (`harness-pins.json`) and the pending, unreviewed one (`harness-pins.pending.json`): format, validation, re-download check. |
|
|
12
12
|
| `trust-root.ts` | The trust root embedded at build time (`--define HUBCTL_TRUST_ROOT=…`), never read at run time. |
|
|
13
13
|
| `assets.ts` | The six service templates of `EMBEDDED_ASSETS`, imported as text so the binary carries them. |
|
|
14
|
-
| `cli.ts` | The release tool behind `bun scripts/hubctl-release.ts build\|manifest\|sign\|verify\|trust-root`. |
|
|
14
|
+
| `cli.ts` | The release tool behind `bun scripts/hubctl-release.ts build\|manifest\|sign\|verify\|trust-root\|dev`. |
|
|
15
15
|
|
|
16
16
|
Everything except `build` needs only bun and node's built-ins, so the signing job installs no npm packages.
|
|
17
17
|
|
|
@@ -24,11 +24,14 @@ scripts/build-hubctl.sh --require-trust-root # release build: refuses without
|
|
|
24
24
|
scripts/build-hubctl.sh linux-x64 --outdir /tmp/out # binaries and checksums in /tmp/out
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Outputs: `dist/hubctl-<target>` and `dist/HUBCTL_SHA256SUMS` (in
|
|
28
|
-
compiled against Bun's `-baseline` runtime, which needs no AVX2
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
Outputs: `dist/hubctl-<target>` (`dist/hubctl-windows-x64.exe` for Windows) and `dist/HUBCTL_SHA256SUMS` (in
|
|
28
|
+
`--outdir` instead, when given). x64 targets are compiled against Bun's `-baseline` runtime, which needs no AVX2
|
|
29
|
+
(older CPUs, QEMU's default `kvm64` model). The supported targets are `darwin-arm64`, `darwin-x64`, `linux-x64`,
|
|
30
|
+
`linux-arm64` (glibc) and `windows-x64` (`bun-windows-x64-baseline`, Phase 2: session-only). `windows-arm64` and musl
|
|
31
|
+
are refused with their reasons. The binary is the whole program: it runs without bun, node or the repository, and a
|
|
32
|
+
`hub` link to it is the same command surface (on Windows a `hub.cmd` shim hubctl writes at install). A build without
|
|
33
|
+
a trust root cannot verify any release (`shippedTrustRoot` fails closed). No Authenticode signature yet: SmartScreen
|
|
34
|
+
may warn once; the signed manifest and the checksum protect the download.
|
|
32
35
|
|
|
33
36
|
## The harness pin list (`harness-pins.json`)
|
|
34
37
|
|
|
@@ -60,6 +63,13 @@ The list is empty until the harness adapter tasks confirm the upstream sources (
|
|
|
60
63
|
- `evidence-listed`: `evidence` lists exactly the upstream evidence published for that file (§5 of the
|
|
61
64
|
contracts doc).
|
|
62
65
|
3. CI validates the file (`bootstrap-release-pins.test.ts`).
|
|
66
|
+
- A pin an author prepared but nobody has reviewed yet lives in `harness-pins.pending.json` (schema
|
|
67
|
+
`hub.harness-pin-pending`): the same pin, with a `prepared` record (`by`, `on`, `note`) instead of `review`. The
|
|
68
|
+
release job reads only `harness-pins.json` and `validatePinList` refuses the pending schema, so a pending pin is
|
|
69
|
+
never signed into a release. Only the dev release (below) includes it. To promote one, the reviewer redoes the
|
|
70
|
+
checks, moves the entry into `harness-pins.json` with their own `review`, and removes it from the pending file.
|
|
71
|
+
- Pending today: OpenCode 2.2.0 `windows-x64` (`opencode-windows-x64-baseline.zip`, member `opencode.exe`),
|
|
72
|
+
prepared 2026-11-05 by an agent. It needs the owner's review.
|
|
63
73
|
4. At release time, the `hubctl-sign` job downloads every pinned file again and refuses to sign on any size or
|
|
64
74
|
digest mismatch. The signature never vouches for a file that nobody fetched during that release.
|
|
65
75
|
|
|
@@ -87,14 +97,16 @@ To rotate the key, publish a version-2 trust root signed by the current key (`ro
|
|
|
87
97
|
|
|
88
98
|
The hubctl jobs are added alongside the existing jobs. They run only when `HUBCTL_RELEASE` is `true`.
|
|
89
99
|
|
|
90
|
-
1. `hubctl` (needs `test`): `scripts/build-hubctl.sh --require-trust-root` cross-compiles all
|
|
100
|
+
1. `hubctl` (needs `test`): `scripts/build-hubctl.sh --require-trust-root` cross-compiles all five targets.
|
|
91
101
|
Then it smoke-tests `linux-x64` with an empty `PATH` (no bun or node): `--version` must equal the CLI
|
|
92
102
|
version, and `hub --help` must equal `hubctl --help`.
|
|
103
|
+
`hubctl-windows` (needs `hubctl`, `windows-latest`) runs `hubctl-windows-x64.exe --version` (must equal the CLI
|
|
104
|
+
version) and `--help` with only `System32` on `PATH`.
|
|
93
105
|
2. `hubctl-sign` (needs `hubctl`, environment `hubctl-release`, no npm install):
|
|
94
106
|
- `manifest` re-downloads the pins and writes the body. `issuedAt` is the tagged commit's time.
|
|
95
107
|
- `sign` signs the body and verifies it against `trust-root.json`. A wrong secret writes nothing.
|
|
96
108
|
- `verify --dist dist` runs the installer's checks on every artifact.
|
|
97
|
-
3. `hubctl-publish` (needs the existing `release` job and `hubctl-
|
|
109
|
+
3. `hubctl-publish` (needs the existing `release` job, `hubctl-sign` and `hubctl-windows`): it verifies again, then attaches
|
|
98
110
|
`hubctl-*`, `hubctl-manifest.json` and `HUBCTL_SHA256SUMS` to the GitHub release.
|
|
99
111
|
|
|
100
112
|
## Verifying by hand
|
|
@@ -113,3 +125,45 @@ The exit code is 0 when the artifact is accepted and 11 when it is rejected: `si
|
|
|
113
125
|
targets a test or partial release ships. Each listed target must be built and is checked in full; an artifact for
|
|
114
126
|
any other target is refused. `sign` refuses a partial body unless `--targets` names the same list, so a partial
|
|
115
127
|
release is never signed by accident. Without `--targets`, every supported target is required.
|
|
128
|
+
|
|
129
|
+
## Testing on real machines (a dev release)
|
|
130
|
+
|
|
131
|
+
`dev` makes a throwaway release that test machines install exactly like a real one, through a hub's
|
|
132
|
+
`HUB_RELEASE_BASE`, without the release key:
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
bun scripts/hubctl-release.ts dev --targets linux-x64,windows-x64 --out /tmp/hubctl-dev
|
|
136
|
+
cd /tmp/hubctl-dev && python3 -m http.server 8000 --bind 0.0.0.0 # or: bunx serve -l 8000 .
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Then start the hub with `HUB_RELEASE_BASE=http://<LAN IP of the serving machine>:8000` in its environment. Plain http
|
|
140
|
+
is allowed only on loopback or a private-network address. Its `/install.sh` and `/install.ps1` now download from
|
|
141
|
+
there.
|
|
142
|
+
|
|
143
|
+
It writes into `--out`:
|
|
144
|
+
|
|
145
|
+
| File | What |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `hubctl-<target>`, `hubctl-windows-x64.exe` | hubctl for each listed target, built with the dev trust root embedded. |
|
|
148
|
+
| `hubctl-manifest.json` | The manifest (exactly the listed targets), signed with the throwaway key. |
|
|
149
|
+
| `HUBCTL_SHA256SUMS` | As `sha256sum` prints it, for install.sh and install.ps1. |
|
|
150
|
+
| `dev-trust-root.json` | The dev trust root (public key only), for `verify --trust-root`. |
|
|
151
|
+
|
|
152
|
+
- **The key.** A fresh ed25519 key with key ID `dev-<8 hex>` is generated for each run and held only in memory. The
|
|
153
|
+
private half is never written, so nothing in the served directory can sign anything. Only the binaries of that run
|
|
154
|
+
trust it. A release-built hubctl rejects the dev manifest (`signature_invalid`), and a dev hubctl rejects the real
|
|
155
|
+
one. Rebuild to get a new release.
|
|
156
|
+
- **The pins.** `harness-pins.json` plus `harness-pins.pending.json` (`--pending FILE`, or `--no-pending`). Each
|
|
157
|
+
pending pin is announced on stderr. Every pinned file is downloaded again and checked, unless you pass
|
|
158
|
+
`--skip-pin-download`.
|
|
159
|
+
- **Options.** `--release V` (default: the CLI version) and `--minimum-version V` (default `0.0.0`). The release
|
|
160
|
+
must be newer than what a test machine has installed, or you need `--allow-downgrade` there.
|
|
161
|
+
- **Never shipped.** `dev` refuses to run when `HUB_RELEASE_SIGNING_KEY` is set (the signing job), on GitHub Actions
|
|
162
|
+
(`GITHUB_ACTIONS=true`), or with `--out` inside the repository's `dist/`, where release builds go.
|
|
163
|
+
|
|
164
|
+
Check a dev release by hand:
|
|
165
|
+
|
|
166
|
+
```sh
|
|
167
|
+
bun scripts/hubctl-release.ts verify --manifest /tmp/hubctl-dev/hubctl-manifest.json \
|
|
168
|
+
--trust-root /tmp/hubctl-dev/dev-trust-root.json --dist /tmp/hubctl-dev --targets linux-x64,windows-x64
|
|
169
|
+
```
|