@huynle/hub 0.8.3 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +40 -3
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +5288 -4415
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/bootstrap/PHASE2-CONTRACT.md +87 -0
  8. package/packages/cli/src/bootstrap/adapters/claude/node.ts +11 -43
  9. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +19 -12
  10. package/packages/cli/src/bootstrap/adapters/opencode/README.md +42 -6
  11. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +18 -7
  12. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +36 -19
  13. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +19 -3
  14. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +2 -5
  15. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +37 -6
  16. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +49 -26
  17. package/packages/cli/src/bootstrap/adapters/process-tree.ts +263 -0
  18. package/packages/cli/src/bootstrap/contracts/paths.ts +7 -4
  19. package/packages/cli/src/bootstrap/contracts/platform.ts +9 -5
  20. package/packages/cli/src/bootstrap/contracts/validate.ts +20 -4
  21. package/packages/cli/src/bootstrap/discovery/discover.ts +3 -1
  22. package/packages/cli/src/bootstrap/discovery/executables.ts +49 -14
  23. package/packages/cli/src/bootstrap/discovery/host.ts +16 -8
  24. package/packages/cli/src/bootstrap/discovery/node-probe.ts +41 -5
  25. package/packages/cli/src/bootstrap/discovery/probe.ts +7 -3
  26. package/packages/cli/src/bootstrap/discovery/util.ts +42 -5
  27. package/packages/cli/src/bootstrap/enrollment/store.ts +13 -6
  28. package/packages/cli/src/bootstrap/preview/plan.ts +3 -0
  29. package/packages/cli/src/bootstrap/profiles/desired.ts +10 -3
  30. package/packages/cli/src/bootstrap/release/README.md +63 -9
  31. package/packages/cli/src/bootstrap/release/cli.ts +94 -16
  32. package/packages/cli/src/bootstrap/release/harness-pins.pending.json +24 -0
  33. package/packages/cli/src/bootstrap/release/manifest.ts +3 -0
  34. package/packages/cli/src/bootstrap/release/pins.ts +60 -9
  35. package/packages/cli/src/bootstrap/transaction/builtin.ts +18 -6
  36. package/packages/cli/src/bootstrap/transaction/engine.ts +166 -12
  37. package/packages/cli/src/bootstrap/transaction/executor.ts +5 -0
  38. package/packages/cli/src/bootstrap/transaction/fs.ts +142 -3
  39. package/packages/cli/src/bootstrap/transaction/shim.ts +31 -0
  40. package/packages/cli/src/bootstrap/transaction/state.ts +11 -6
  41. package/packages/cli/src/credentials.ts +5 -2
  42. package/packages/cli/src/main.ts +3 -2
  43. package/packages/cli/src/setup/commands.ts +96 -18
  44. package/packages/cli/src/setup/contributors.ts +45 -10
  45. package/packages/cli/src/setup/runtime.ts +25 -6
  46. package/packages/cli/src/setup/self-aside.ts +130 -0
  47. package/packages/codex/package.json +1 -1
  48. package/packages/node/package.json +1 -1
  49. package/packages/node/src/opencode-warm.ts +12 -4
  50. package/packages/preset/package.json +1 -1
  51. package/packages/protocol/package.json +1 -1
  52. package/packages/protocol/src/index.ts +7 -2
  53. package/packages/runtime-openshell/package.json +1 -1
  54. package/packages/runtime-sdk/package.json +1 -1
  55. package/packages/sdk/package.json +1 -1
  56. package/packages/spoke-sdk/package.json +1 -1
  57. package/src/central/config.ts +12 -0
  58. package/src/central/http.ts +10 -0
  59. package/src/central/install/CONTRACT.md +66 -0
  60. package/src/central/install/install.ps1 +349 -0
  61. package/src/central/install/install.sh +266 -0
  62. package/src/central/install/render.ts +146 -0
  63. package/src/central/install/templates.ts +6 -0
  64. package/src/central/install/text-assets.d.ts +9 -0
  65. package/src/central/registry.ts +93 -22
  66. package/src/config.ts +12 -7
  67. package/src/index.ts +28 -0
  68. package/src/spoke/env-inject.ts +17 -2
  69. package/src/spoke/launchers.ts +145 -9
  70. package/src/spoke/spoke.ts +150 -10
  71. package/src/status.ts +70 -14
  72. package/src/win-acl.ts +154 -0
  73. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +1 -0
  74. package/web/dist/assets/{ArtifactPane-DPHjiWce.js → ArtifactPane-DKwbMTX9.js} +1 -1
  75. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +6 -0
  76. package/web/dist/assets/Library-D6-Go6Cu.js +2 -0
  77. package/web/dist/assets/{ProfilesDestination-GriuOpiZ.js → ProfilesDestination-BOML2Dgf.js} +1 -1
  78. package/web/dist/assets/{TeamsPage-C-5lYeeM.js → TeamsPage-CNBTE8gB.js} +1 -1
  79. package/web/dist/assets/Watchtower-Dor1Ubjy.js +2 -0
  80. package/web/dist/assets/Watchtower-RphjcL0_.css +1 -0
  81. package/web/dist/assets/hub-CrbItiwP.js +13 -0
  82. package/web/dist/assets/index-82Ce0IAf.css +1 -0
  83. package/web/dist/assets/index-D9SP3_0z.js +22 -0
  84. package/web/dist/assets/{live-CDWcyavI.js → live-BNJgy0vg.js} +3 -3
  85. package/web/dist/assets/{pane-layout-Csk-vPNe.js → pane-layout-EdR1kRrG.js} +1 -1
  86. package/web/dist/assets/preload-helper-DqrJTq0t.js +1 -0
  87. package/web/dist/assets/{renderer2d-CNFdTDTr.js → renderer2d-CYag1CnR.js} +1 -1
  88. package/web/dist/assets/{renderer3d-C45jPvYX.js → renderer3d-BRB6rgDJ.js} +1 -1
  89. package/web/dist/assets/{safe-area-sA9a8vYJ.js → safe-area-fOy0AOuG.js} +1 -1
  90. package/web/dist/assets/{source-BFLJQ43_.js → source-Wbq35qGp.js} +1 -1
  91. package/web/dist/assets/{ui-Qo2Pd9-O.js → ui-j5FFk90B.js} +1 -1
  92. package/web/dist/index.html +6 -4
  93. package/web/dist/assets/ArtifactPage-CSotAWob.js +0 -1
  94. package/web/dist/assets/ArtifactWorkbench-CkmWnP6L.js +0 -6
  95. package/web/dist/assets/Library-DTLEyekn.js +0 -2
  96. package/web/dist/assets/Watchtower-CTgqHk5g.css +0 -1
  97. package/web/dist/assets/Watchtower-DOlPsiIy.js +0 -2
  98. package/web/dist/assets/index-6mdQ7eUY.js +0 -34
  99. package/web/dist/assets/index-C5OmwA1y.css +0 -1
@@ -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
- import { rootPathIn, SEMVER, type ExecutionContext, type HarnessID, type InstallMethod, type RootMapping, type Shim, type ShimKind } from "../contracts/index.ts"
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 = normalizeAbs(raw)
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
- for (const d of dirs) {
29
- const candidate = join(d, name)
30
- if (probe.executable(candidate)) return candidate
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 native = MAGIC.some((m) => m.every((b, i) => head[i] === b))
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 && head[0] === 0x23 && head[1] === 0x21) {
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
- const hubLayout = data ? new RegExp(`^${escape(data)}/hub/harnesses/${escape(id)}/([^/]+)(?:/|$)`) : undefined
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.startsWith(`${home}/.opencode/bin/`)) return { installMethod: "native" }
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 home = normalizeAbs(probe.env.HOME) ?? normalizeAbs(probe.home)
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) => normalizeAbs(probe.env[key]) ?? join(home, fallback)
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 = { ...probe.env, ...(probe.env.XDG_STATE_HOME !== undefined ? { XDG_STATE_HOME: normalizeAbs(probe.env.XDG_STATE_HOME) } : {}) }
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 = normalizeAbs(dir)
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 macOS machine-identity query (`ioreg`, a fixed read-only argv, no
4
- // shell), and only when no machine ID was injected; Linux reads /etc/machine-id.
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) => attempt(() => fs.statSync(p).isFile() && (fs.accessSync(p, fs.constants.X_OK), true)) ?? false,
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) or IOPlatformUUID (macOS); never recorded raw. */
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
- /** A normalized absolute path (duplicate and trailing slashes removed), or undefined. */
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" || !value.startsWith("/")) return undefined
7
- const p = value.replace(/\/{2,}/g, "/").replace(/(.)\/+$/, "$1")
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
- export const join = (dir: string, rel: string) => (dir === "/" ? `/${rel}` : `${dir}/${rel}`)
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
- constructor(file: string) {
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). Leaves no file behind.
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 without a user service manager, OpenShell that is
4
- // absent or was not discovered) instead of being half-configured. OpenShell is verify-only: hubctl never installs
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 `--outdir` instead, when given). x64 targets are
28
- compiled against Bun's `-baseline` runtime, which needs no AVX2 (older CPUs, QEMU's default `kvm64` model). The supported targets are `darwin-arm64`,
29
- `darwin-x64`, `linux-x64` and `linux-arm64` (glibc). Windows and musl are refused with their reasons. The
30
- binary is the whole program: it runs without bun, node or the repository, and a `hub` link to it is the same
31
- command surface. A build without a trust root cannot verify any release (`shippedTrustRoot` fails closed).
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 four targets.
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-sign`): it verifies again, then attaches
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
+ ```