@huynle/hub 0.8.4 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +40 -3
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +5288 -4415
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/bootstrap/PHASE2-CONTRACT.md +87 -0
  8. package/packages/cli/src/bootstrap/adapters/claude/node.ts +11 -43
  9. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +19 -12
  10. package/packages/cli/src/bootstrap/adapters/opencode/README.md +42 -6
  11. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +18 -7
  12. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +36 -19
  13. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +19 -3
  14. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +2 -5
  15. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +37 -6
  16. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +49 -26
  17. package/packages/cli/src/bootstrap/adapters/process-tree.ts +263 -0
  18. package/packages/cli/src/bootstrap/contracts/paths.ts +7 -4
  19. package/packages/cli/src/bootstrap/contracts/platform.ts +9 -5
  20. package/packages/cli/src/bootstrap/contracts/validate.ts +20 -4
  21. package/packages/cli/src/bootstrap/discovery/discover.ts +3 -1
  22. package/packages/cli/src/bootstrap/discovery/executables.ts +49 -14
  23. package/packages/cli/src/bootstrap/discovery/host.ts +16 -8
  24. package/packages/cli/src/bootstrap/discovery/node-probe.ts +41 -5
  25. package/packages/cli/src/bootstrap/discovery/probe.ts +7 -3
  26. package/packages/cli/src/bootstrap/discovery/util.ts +42 -5
  27. package/packages/cli/src/bootstrap/enrollment/store.ts +13 -6
  28. package/packages/cli/src/bootstrap/preview/plan.ts +3 -0
  29. package/packages/cli/src/bootstrap/profiles/desired.ts +10 -3
  30. package/packages/cli/src/bootstrap/release/README.md +63 -9
  31. package/packages/cli/src/bootstrap/release/cli.ts +94 -16
  32. package/packages/cli/src/bootstrap/release/harness-pins.pending.json +24 -0
  33. package/packages/cli/src/bootstrap/release/manifest.ts +3 -0
  34. package/packages/cli/src/bootstrap/release/pins.ts +60 -9
  35. package/packages/cli/src/bootstrap/transaction/builtin.ts +18 -6
  36. package/packages/cli/src/bootstrap/transaction/engine.ts +166 -12
  37. package/packages/cli/src/bootstrap/transaction/executor.ts +5 -0
  38. package/packages/cli/src/bootstrap/transaction/fs.ts +142 -3
  39. package/packages/cli/src/bootstrap/transaction/shim.ts +31 -0
  40. package/packages/cli/src/bootstrap/transaction/state.ts +11 -6
  41. package/packages/cli/src/credentials.ts +5 -2
  42. package/packages/cli/src/main.ts +3 -2
  43. package/packages/cli/src/setup/commands.ts +96 -18
  44. package/packages/cli/src/setup/contributors.ts +45 -10
  45. package/packages/cli/src/setup/runtime.ts +25 -6
  46. package/packages/cli/src/setup/self-aside.ts +130 -0
  47. package/packages/codex/package.json +1 -1
  48. package/packages/node/package.json +1 -1
  49. package/packages/node/src/opencode-warm.ts +12 -4
  50. package/packages/preset/package.json +1 -1
  51. package/packages/protocol/package.json +1 -1
  52. package/packages/runtime-openshell/package.json +1 -1
  53. package/packages/runtime-sdk/package.json +1 -1
  54. package/packages/sdk/package.json +1 -1
  55. package/packages/spoke-sdk/package.json +1 -1
  56. package/src/central/config.ts +12 -0
  57. package/src/central/http.ts +10 -0
  58. package/src/central/install/CONTRACT.md +66 -0
  59. package/src/central/install/install.ps1 +349 -0
  60. package/src/central/install/install.sh +266 -0
  61. package/src/central/install/render.ts +146 -0
  62. package/src/central/install/templates.ts +6 -0
  63. package/src/central/install/text-assets.d.ts +9 -0
  64. package/src/config.ts +12 -7
  65. package/src/spoke/env-inject.ts +17 -2
  66. package/src/spoke/herdr.ts +3 -2
  67. package/src/spoke/launchers.ts +145 -9
  68. package/src/spoke/localhub.ts +1 -0
  69. package/src/win-acl.ts +154 -0
  70. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +1 -0
  71. package/web/dist/assets/{ArtifactPane-XTnwXl-Y.js → ArtifactPane-DKwbMTX9.js} +1 -1
  72. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +6 -0
  73. package/web/dist/assets/Library-D6-Go6Cu.js +2 -0
  74. package/web/dist/assets/{ProfilesDestination-dZa3vioh.js → ProfilesDestination-BOML2Dgf.js} +1 -1
  75. package/web/dist/assets/{TeamsPage-DJt-Kk84.js → TeamsPage-CNBTE8gB.js} +1 -1
  76. package/web/dist/assets/Watchtower-Dor1Ubjy.js +2 -0
  77. package/web/dist/assets/Watchtower-RphjcL0_.css +1 -0
  78. package/web/dist/assets/hub-CrbItiwP.js +13 -0
  79. package/web/dist/assets/index-82Ce0IAf.css +1 -0
  80. package/web/dist/assets/index-D9SP3_0z.js +22 -0
  81. package/web/dist/assets/{live-D-RZbeUi.js → live-BNJgy0vg.js} +3 -3
  82. package/web/dist/assets/{pane-layout-Csk-vPNe.js → pane-layout-EdR1kRrG.js} +1 -1
  83. package/web/dist/assets/preload-helper-DqrJTq0t.js +1 -0
  84. package/web/dist/assets/{renderer2d-DILxLFEq.js → renderer2d-CYag1CnR.js} +1 -1
  85. package/web/dist/assets/{renderer3d-PvN9Qbt2.js → renderer3d-BRB6rgDJ.js} +1 -1
  86. package/web/dist/assets/{safe-area-BS6ii3M7.js → safe-area-fOy0AOuG.js} +1 -1
  87. package/web/dist/assets/{source-BFLJQ43_.js → source-Wbq35qGp.js} +1 -1
  88. package/web/dist/assets/{ui-Qo2Pd9-O.js → ui-j5FFk90B.js} +1 -1
  89. package/web/dist/index.html +6 -4
  90. package/web/dist/assets/ArtifactPage-Cb1DcrQf.js +0 -1
  91. package/web/dist/assets/ArtifactWorkbench-C37tZZ3e.js +0 -6
  92. package/web/dist/assets/Library-CZSSHvOJ.js +0 -2
  93. package/web/dist/assets/Watchtower-BXaUaXO1.js +0 -2
  94. package/web/dist/assets/Watchtower-CTgqHk5g.css +0 -1
  95. package/web/dist/assets/index-DVSkeAvn.css +0 -1
  96. package/web/dist/assets/index-DifgVuKS.js +0 -34
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-cli",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "private": true,
5
5
  "description": "The hub CLI (`hub` / `hubctl`) for agents and scripts (internal; bundled into @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -0,0 +1,87 @@
1
+ # Phase 2: hubctl on Windows (session-only), shared contract
2
+
3
+ Design: `docs/plans/2026-11-05-machine-setup-installer-design.md` §3 (Phase 2). Decision: Brain `1bwf3cqx`. Four
4
+ workstreams build against this file in parallel; change it only through the integrator. The foundation below is
5
+ already on `feat/ms2` (commit after `bbb8d43a`).
6
+
7
+ ## Foundation (done; do not change without the integrator)
8
+
9
+ - **Canonical paths** (`contracts/validate.ts` `isAbsolutePath`): POSIX `/a/b` or Windows `C:/a/b` (upper-case drive,
10
+ `/` separators, roots `/` and `C:/`). UNC (`\\server`, `//server`) and device paths (`\\?\`, `\\.\`) are never
11
+ canonical. Contracts, plans, journals and snapshots hold canonical paths only.
12
+ - **Edges**: `discovery/util.ts` `normalizeAbs` turns a host's native path into a canonical one (backslashes → `/`,
13
+ drive upper-cased); `contracts/validate.ts` `toNativePath(p, process.platform)` turns it back for external programs
14
+ (icacls, error messages). Node's `fs` accepts canonical `C:/…` paths directly on Windows, so fs calls need no
15
+ conversion. `isWindowsPath(p)` tells them apart. `join` and `resolvePathRef` handle the `C:/` root.
16
+ - **Target**: `windows-x64` is in `SUPPORTED_TARGETS` with `platform: "windows"`, `libc: "none"`,
17
+ `serviceManager: "none"` (type `TargetServiceManager`), `bunTarget: "bun-windows-x64-baseline"`. `windows-arm64`
18
+ stays unsupported. `targetOf("win32", "x64", "none")` is `windows-x64`.
19
+
20
+ ## Windows host layout (all workstreams)
21
+
22
+ Same XDG-style layout under the user's profile as on macOS/Linux (it is what OpenCode itself uses on Windows):
23
+
24
+ | Root | Windows path |
25
+ |---|---|
26
+ | `home` | `%USERPROFILE%` (canonical, e.g. `C:/Users/dacra`); `HOME` wins when set and absolute |
27
+ | `config` | `$XDG_CONFIG_HOME` or `home/.config` |
28
+ | `state` | as today (`resolveHubStateDir`), else `home/.local/state` |
29
+ | `data` | `$XDG_DATA_HOME` or `home/.local/share` |
30
+ | `user-bin` | `home/.local/bin` (install.ps1 adds it to the user PATH) |
31
+ | `system-bin`, `launch-agents`, `systemd-user` | not mapped on Windows |
32
+
33
+ Executables on `windows-x64` carry `.exe` in their PathRefs (`user-bin:hubctl.exe`, harness `…/bin/opencode.exe`).
34
+
35
+ ## Install primitives on win32 (transaction workstream implements; adapters/planner rely on them)
36
+
37
+ - **`link-executable`** (the `hub` alias, `opencode` on PATH): on win32 the planner names the link
38
+ `user-bin:<name>.cmd` and the applier writes a **cmd shim** instead of a symlink: exactly
39
+ `@"<native target>" %*\r\n` (ASCII). Journaled as a file it owns; rollback removes/restores it. No symlinks for
40
+ executables on Windows (they need Developer Mode or admin).
41
+ - **Directory "current" links** (`select-harness` and friends): an NTFS **junction** (`fs.symlinkSync(target, path,
42
+ "junction")`), no admin needed. `lstat` reports junctions as symbolic links, so `checkNoSymlinkEscape` still refuses
43
+ to write *through* one.
44
+ - **Replacing a file that may be running** (`hubctl.exe` while OpenCode runs `hubctl mcp`): rename-aside: move the old
45
+ file to `<name>.old-<txid>` in the same directory, move the staged file in, delete the aside file when possible
46
+ (else leave it; a later apply or `setup recover` cleans `*.old-*`). Rollback renames back. Names are
47
+ `<name>.old-<txID>-<seq>` (`-undo` during a rollback). When the aside is the running hubctl.exe itself (a rollback
48
+ that removes hubctl), `setup rollback`/`recover` start a hidden `cmd.exe` with a fixed command line that deletes it
49
+ once it is no longer running (`setup/self-aside.ts`).
50
+ - **`O_NOFOLLOW`** is undefined on Windows: replace it with an explicit `lstat` reparse-point check right before the
51
+ open, and refuse links.
52
+ - **File modes**: Windows has no POSIX modes. Journals record `mode: null` ("not applicable") for win32 entries;
53
+ comparisons skip it. The machine credential and other secret files get an owner-only ACL instead (next section).
54
+
55
+ ## Secrets on win32 (transaction workstream)
56
+
57
+ Files that are 0600 on POSIX (`config.json` with the machine token, `cli.json`, preview files with tokens) are, on
58
+ win32, created empty, restricted with `icacls <native> /inheritance:r /grant:r *<current user SID>:(F)` **before** the
59
+ secret is written, then moved into place (the ACL travels with the move). Checks that today refuse `mode & 0o077`
60
+ (e.g. `packages/node/src/opencode-warm.ts:323`) instead verify on win32 that the ACL has no allow entry for anyone but
61
+ the current user, SYSTEM and Administrators. Keep a small helper (`win-acl.ts`) both sides import; tests inject the
62
+ `icacls` runner.
63
+
64
+ ## Profiles on a target without services
65
+
66
+ `session-only` works on `windows-x64`. `launch-node` and `full-harness` are refused at preview with
67
+ `unsupported_platform` and the message "needs user services, which hubctl does not install on Windows yet". The
68
+ server (`/install.ps1`) and the console already allow only `session-only` on Windows.
69
+
70
+ ## install.ps1 (scripts workstream)
71
+
72
+ Switches from the plugin path to hubctl, mirroring install.sh: download `hubctl-windows-x64.exe`,
73
+ `HUBCTL_SHA256SUMS`, `hubctl-manifest.json` from `{{RELEASE_BASE}}` (env `HUB_INSTALL_RELEASE_BASE` overrides, as in
74
+ install.sh), check the sha256 with `Get-FileHash`, then `machine status --json` / `machine enroll --server …
75
+ [--allow-insecure-http]` / `setup preview --profile session-only --enrollment separate --manifest … --out …` /
76
+ `setup apply` / `setup verify` with install.sh's exit-code handling (15 → done with next steps; 10 → manual edits),
77
+ and finally add `%USERPROFILE%\.local\bin` to the user PATH (`[Environment]::SetEnvironmentVariable('Path', …,
78
+ 'User')`, only when missing) and say to open a new terminal. Never `exit`; ASCII only; PowerShell 5.1 and 7.
79
+
80
+ ## Release (release workstream)
81
+
82
+ `hubctl-windows-x64.exe` is built (`--target bun-windows-x64-baseline`, `.exe` outfile), listed in the manifest and
83
+ `HUBCTL_SHA256SUMS`, and smoke-tested on `windows-latest` in `release.yml`. The OpenCode harness pin for
84
+ `windows-x64` follows the reviewed-pin process (`release/README.md`): the agent may prepare the entry with a
85
+ re-downloaded digest, but the `review` block must be filled in by the owner (flag it, never self-review).
86
+ A local **dev release** script builds hubctl for chosen targets with a throwaway trust root and signs a manifest with
87
+ a throwaway key, so the integrator can test on real machines through `HUB_RELEASE_BASE` without the release key.
@@ -1,60 +1,28 @@
1
1
  // Default Node implementations of the Claude Code adapter's ports (ports.ts). The integrator (HBS-13) passes them;
2
2
  // tests inject fakes.
3
3
  //
4
- // nodeRunner spawn(file, args) with shell: false, exactly the given environment, stdin closed, stderr
5
- // dropped, stdout capped, and a hard timeout (SIGKILL). The only process starter here.
4
+ // nodeRunner the bounded runner (../process-tree.ts): shell: false, exactly the given environment, stdin
5
+ // closed, stderr dropped, stdout capped, and a hard timeout that ends the whole process tree;
6
+ // whatever the probe left running when it exited is ended too. The only process starter here.
6
7
  // httpsDownloader https only (also after redirects), non-2xx refused, the body streamed and capped
7
8
  // (DownloadTooLarge, as soon as it runs past the limit).
8
9
  // tempScratch a 0700 directory under the system temp dir with one 0600 file (written as its content
9
10
  // streams), removed afterwards.
10
- import { spawn } from "node:child_process"
11
11
  import { closeSync, mkdtempSync, openSync, rmSync, writeSync } from "node:fs"
12
12
  import { tmpdir } from "node:os"
13
13
  import { join } from "node:path"
14
14
  import { chunksOf, responseChunks } from "../../transaction/stream.ts"
15
+ import { runBounded, type BoundedOptions } from "../process-tree.ts"
15
16
  import { DownloadTooLarge, type Downloader, type ProbeRunner, type RunResult, type Scratch } from "./ports.ts"
16
17
 
17
- export function nodeRunner(): ProbeRunner {
18
+ export function nodeRunner(options: BoundedOptions = {}): ProbeRunner {
18
19
  return {
19
- run: (file, args, options) =>
20
- new Promise<RunResult>((resolve) => {
21
- let settled = false
22
- const done = (r: RunResult) => {
23
- if (settled) return
24
- settled = true
25
- clearTimeout(timer)
26
- resolve(r)
27
- }
28
- let child: ReturnType<typeof spawn>
29
- try {
30
- child = spawn(file, [...args], { shell: false, env: { ...options.env }, stdio: ["ignore", "pipe", "ignore"], windowsHide: true })
31
- } catch (error) {
32
- return resolve({ kind: "error", message: error instanceof Error ? error.message : String(error) })
33
- }
34
- const timer = setTimeout(() => {
35
- child.kill("SIGKILL")
36
- done({ kind: "timeout" })
37
- }, options.timeoutMs)
38
- const chunks: Buffer[] = []
39
- let size = 0
40
- child.stdout?.on("data", (chunk: Buffer) => {
41
- if (size >= options.maxOutputBytes) return
42
- const part = chunk.subarray(0, options.maxOutputBytes - size)
43
- chunks.push(part)
44
- size += part.length
45
- })
46
- child.on("error", (error: NodeJS.ErrnoException) => done(error.code === "ENOENT" ? { kind: "not-found" } : { kind: "error", message: error.code ?? "spawn failed" }))
47
- // Settle once the process exited and its stdout ended (whichever comes last), or on "close".
48
- let exitCode: number | undefined
49
- let ended = !child.stdout
50
- const finish = () => {
51
- if (exitCode !== undefined && ended) done({ kind: "exited", code: exitCode, stdout: Buffer.concat(chunks).toString("utf8") })
52
- }
53
- child.stdout?.on("end", () => ((ended = true), finish()))
54
- child.stdout?.on("close", () => ((ended = true), finish()))
55
- child.on("exit", (code, signal) => ((exitCode = code ?? (signal ? -1 : 0)), finish()))
56
- child.on("close", (code) => ((exitCode ??= code ?? -1), (ended = true), finish()))
57
- }),
20
+ run: async (file, args, run) => {
21
+ const r = await runBounded({ file, args, env: run.env, timeoutMs: run.timeoutMs, maxOutputBytes: run.maxOutputBytes, overflow: "truncate" }, options)
22
+ if (r.spawnError) return r.spawnError.code === "ENOENT" ? { kind: "not-found" } : { kind: "error", message: r.spawnError.code ?? "spawn failed" }
23
+ if (r.timedOut) return { kind: "timeout" }
24
+ return { kind: "exited", code: r.exitCode ?? -1, stdout: r.stdout }
25
+ },
58
26
  }
59
27
  }
60
28
 
@@ -16,7 +16,6 @@
16
16
  // Probes run with a minimal environment (PATH, HOME, CODEX_HOME, locale): never the hub token or a provider key. A
17
17
  // probe's detail is one short line with emails and credential-shaped words removed. codexCapabilities advertises
18
18
  // Codex as ready only when isHarnessReady holds and its app-server answered, each probe fresh.
19
- import { execFile } from "node:child_process"
20
19
  import { openAppServer } from "../../../../../codex/src/app-server.ts"
21
20
  import type { Channel } from "../../../../../codex/src/jsonrpc.ts"
22
21
  import { CODEX_APP_SERVER_ARGS } from "../../../../../codex/src/spoke.ts"
@@ -38,6 +37,7 @@ import {
38
37
  } from "../../contracts/index.ts"
39
38
  import type { ActionExecutor } from "../../transaction/executor.ts"
40
39
  import { txFail, txOk } from "../../transaction/result.ts"
40
+ import { runBounded, type BoundedOptions } from "../process-tree.ts"
41
41
  import { passesAsPending, type ReadinessGate } from "../readiness.ts"
42
42
 
43
43
  export interface ProbeResult {
@@ -275,20 +275,27 @@ export function codexVerifyExecutor(
275
275
  }
276
276
  }
277
277
 
278
- /** A ProbeRunner over execFile: the word list as is (no shell), bounded output and time. */
279
- export const nodeProbeRunner: ProbeRunner = {
280
- run: (argv, options) =>
281
- new Promise((resolve) => {
278
+ /**
279
+ * A ProbeRunner over the bounded runner (process-tree.ts): the word list as is (no shell), bounded output and time,
280
+ * and nothing the probe starts outlives it (a timeout or an abort ends its tree; what it left running is reaped).
281
+ */
282
+ export function probeRunner(options: BoundedOptions = {}): ProbeRunner {
283
+ return {
284
+ run: async (argv, run) => {
282
285
  const [file, ...args] = argv
283
- if (!file) return resolve({ exitCode: null, stdout: "", error: "empty argv" })
284
- execFile(file, args, { env: options.env, timeout: options.timeoutMs, maxBuffer: 64 * 1024, shell: false, windowsHide: true, ...(options.signal ? { signal: options.signal } : {}) }, (error, stdout) => {
285
- if (!error) return resolve({ exitCode: 0, stdout: String(stdout) })
286
- const code = (error as NodeJS.ErrnoException & { code?: unknown }).code
287
- resolve(typeof code === "number" ? { exitCode: code, stdout: String(stdout ?? "") } : { exitCode: null, stdout: String(stdout ?? ""), error: message(error) })
288
- })
289
- }),
286
+ if (!file) return { exitCode: null, stdout: "", error: "empty argv" }
287
+ const r = await runBounded({ file, args, env: run.env, timeoutMs: run.timeoutMs, maxOutputBytes: 64 * 1024, overflow: "kill", ...(run.signal ? { signal: run.signal } : {}) }, options)
288
+ if (r.spawnError) return { exitCode: null, stdout: "", error: message(r.spawnError) }
289
+ if (r.exitCode !== null) return { exitCode: r.exitCode, stdout: r.stdout }
290
+ const why = r.timedOut ? `timed out after ${run.timeoutMs} ms` : r.aborted ? "cancelled" : r.overflowed ? "printed too much" : "killed by a signal"
291
+ return { exitCode: null, stdout: r.stdout, error: `${file} ${why}` }
292
+ },
293
+ }
290
294
  }
291
295
 
296
+ /** The CLI's runner: probeRunner with the platform's process tree. */
297
+ export const nodeProbeRunner: ProbeRunner = probeRunner()
298
+
292
299
  /** Starts the app-server with the packages/codex stdio channel (`argsBefore`: tests run a fake server script). */
293
300
  export const stdioAppServerLauncher = (options: { readonly argsBefore?: readonly string[] } = {}): AppServerLauncher => ({
294
301
  start(binary, env) {
@@ -9,7 +9,7 @@ modules. Every capability is injected.
9
9
  | `config.ts` | Reads an OpenCode 2 config (JSONC) and classifies the Hub plugin entry and the `hub` MCP server: absent, registered, drift, or a refusal. It computes the exact JSON-pointer sets for the lossless merge. |
10
10
  | `plan.ts` | `planOpencode` and `createOpencodeAdapter`. The pinned `install-harness` comes first, then `select-harness-version`, `merge-config` (plugin), `register-mcp-server` and `verify-harness`. The planner is pure and deterministic. |
11
11
  | `verify.ts` | Real readiness probes through a fixed-argv `ProcessRunner` (no shell). `locateOpencodeBinary` finds the binary that really runs. |
12
- | `archive.ts` | A streaming unpacker for the upstream zip and tar.gz (`../archive-stream.ts`). It accepts exactly one regular file, `opencode`, streams it into the step's staging area and installs it as `bin/opencode` with mode 0755. `unpackOpencodeArchive` is the in-memory form for small fixtures. |
12
+ | `archive.ts` | A streaming unpacker for the upstream zip and tar.gz (`../archive-stream.ts`). It accepts exactly one regular file, `opencode` (`opencode.exe` in a zip on Windows targets), streams it into the step's staging area and installs it as `bin/opencode` (`bin/opencode.exe`) with mode 0755. `unpackOpencodeArchive` is the in-memory form for small fixtures. |
13
13
  | `apply.ts` | The `HarnessSource` (the pinned URL only, https only, bounded by the pinned size, streamed into the staging area), the `register-mcp-server` and `verify-harness` executors, and `httpsDownload` (the body as chunks). |
14
14
  | `index.ts` | `createOpencodeHarness`, the single entry point for the integrator. |
15
15
 
@@ -46,8 +46,12 @@ modules. Every capability is injected.
46
46
  - `version-exec`: `<binary> --version` must report OpenCode ≥ 2.0.0, and exactly the hub `current` version when
47
47
  hub manages the binary. The binary is hub's `current` binary, otherwise what PATH resolves to. A container shim
48
48
  is refused.
49
- - `provider-auth`: `<binary> models` must list at least one `provider/model`. This sends no prompt and costs
50
- nothing. A stronger `ProviderProbe` can be injected.
49
+ - `provider-auth`: `<binary> models --standalone` must list at least one `provider/model`. This sends no prompt
50
+ and costs nothing. `--standalone` runs a private server inside the probe: it tests exactly the installed binary
51
+ and starts no background service (`opencode serve --service`). Without it the first probe started that service
52
+ from the hub-installed version directory, where it kept running after verify; on Windows it kept that directory
53
+ busy, so `hubctl setup rollback` could not remove it (Phase 2 end-to-end run). A stronger `ProviderProbe` can
54
+ be injected.
51
55
  - `spoke-connected`: an injected `SpokeProbe`. Without one, the probe fails, so OpenCode is never ready by default.
52
56
 
53
57
  On a cold start (the first use of a directory) OpenCode 2's background service can take longer than the CLI's ~5 s
@@ -55,6 +59,11 @@ modules. Every capability is injected.
55
59
  `--version` and `models` probes retry once, after 1 s, on exactly that failure (`ACTIVATION_FAILURE`, read from the
56
60
  child's output only to recognise it); any other failure is reported at once (HBS-12).
57
61
 
62
+ Every probe runs through the bounded runner (`../process-tree.ts`): nothing it starts outlives it. A timeout, a
63
+ cancel or too much output ends its whole process tree, and whatever it left running when it exited is ended too
64
+ (POSIX: its own process group; Windows: `taskkill /T /F` from System32, and the exited probe's children found by a
65
+ fixed CIM query).
66
+
58
67
  Details are built by the adapter, never copied from harness output. Anything that looks like a credential is
59
68
  withheld. The child process gets the user's own environment, and nothing from it is recorded.
60
69
 
@@ -66,6 +75,35 @@ modules. Every capability is injected.
66
75
  from its backup, and the config is restored from its backup, except when the user changed it after hub wrote it.
67
76
  Then it is kept and reported as a conflict (`bootstrap-adapter-opencode-apply.test.ts`).
68
77
 
78
+ ## Windows (`windows-x64`, Phase 2: session-only)
79
+
80
+ See `../../PHASE2-CONTRACT.md` for the shared rules. For this adapter:
81
+
82
+ - **Archive.** The asset is `opencode-windows-x64-baseline.zip`. Baseline is used for the same reason as on the other
83
+ x64 targets, and matches hubctl's own `bun-windows-x64-baseline`. Its only member is `opencode.exe`, zipped on a
84
+ FAT host, so there are no unix modes and the member's kind follows its name. It is installed as
85
+ `data:hub/harnesses/opencode/<version>/bin/opencode.exe`. The unpacker takes the binary name from the
86
+ install-harness action's `target`, so a Windows binary is never installed for a POSIX target, or the other way
87
+ round. A tar.gz is refused for Windows.
88
+ - **Release assets (v2.2.0).** `opencode-windows-x64.zip` (118 610 002 bytes, needs AVX2),
89
+ `opencode-windows-x64-baseline.zip` (118 610 005 bytes) and `opencode-windows-arm64.zip`. All three are listed in
90
+ the release's `SHA256SUMS` and in the API's `assets[].digest`.
91
+ - **Pin.** The windows-x64 pin is prepared, not reviewed, in `../../release/harness-pins.pending.json`. Only the dev
92
+ release includes it (`../../release/README.md`).
93
+ - **MCP authority.** `user-bin:hubctl.exe`, the real executable (`authorityFor`). The `hub` alias is a cmd shim
94
+ (`user-bin:hub.cmd`) on Windows, and a `.cmd` cannot be the MCP command because OpenCode starts it without a
95
+ shell. So `authority: user-bin:hub` is refused on Windows. `.exe` names are refused on POSIX targets.
96
+ - **Config path form.** The command is written as the native path (`C:\Users\…\.local\bin\hubctl.exe`, through
97
+ `toNativePath`):
98
+ - CreateProcess would accept `C:/…` too.
99
+ - The native form is what OpenCode's Windows users and docs write, so a user reading the config recognises it.
100
+ - It survives `cmd.exe`, should anything route the command through a shell (`/` starts a switch there).
101
+
102
+ Matching is lenient: an existing entry in either separator form and any letter case counts as registered (NTFS is
103
+ case-insensitive). A `hub`/`hubctl` program with `.exe` or `.cmd` at another path counts as stale.
104
+ - **Readiness.** The probes run `…/current/bin/opencode.exe`. `nodeProcessRunner` takes a canonical absolute path
105
+ (POSIX or `C:/…`) and passes the OS its native form.
106
+
69
107
  ## Upstream source of OpenCode 2.x (researched 2026-10-28)
70
108
 
71
109
  | Channel | Finding | Source |
@@ -95,7 +133,7 @@ on CPUs without it: older Intel/AMD hosts and QEMU guests with the default `kvm6
95
133
  for the same baseline (`bun-<os>-x64-baseline`, contracts §3.1), so a host that runs hubctl can run the harness it
96
134
  installs. The baseline build runs on AVX2 CPUs too, at a small speed cost.
97
135
 
98
- The musl and Windows assets are out of scope (decision Q7, §3.1). The URL is
136
+ The musl assets are out of scope (decision Q7, §3.1). Windows is Phase 2 (above). The URL is
99
137
  `https://github.com/anomalyco/opencode/releases/download/v<version>/<asset>`. `evidence` is always `[]`.
100
138
 
101
139
  ### Proposed `harness-pins.json` entries (template: fill in only from your own download)
@@ -200,5 +238,3 @@ The original list:
200
238
  `config:opencode/opencode.json(c)`.
201
239
  - Is `opencode models` strong enough as `provider-auth`, or should HBS-13 inject a one-token completion probe that
202
240
  costs money?
203
- - `opencode models` talks to the background service by default (the 2.x CLI also has `--standalone`). Should the
204
- probe pin `--standalone`, so it tests exactly the binary that was installed?
@@ -36,7 +36,7 @@ import { responseChunks, type ByteSource } from "../../transaction/stream.ts"
36
36
  import { passesAsPending, type ReadinessGate } from "../readiness.ts"
37
37
  import { unpackOpencodeFile } from "./archive.ts"
38
38
  import { mcpServerSets } from "./config.ts"
39
- import { checkAuthority, DEFAULT_AUTHORITY } from "./plan.ts"
39
+ import { authorityFor, checkAuthority, DEFAULT_AUTHORITY, mcpCommandPath } from "./plan.ts"
40
40
 
41
41
  // ---------- downloads ----------
42
42
 
@@ -100,7 +100,9 @@ export function createOpencodeHarnessSource(options: OpencodeSourceOptions): Har
100
100
  return { file, evidence: [] }
101
101
  },
102
102
  unpack: async (action, file, staging) =>
103
- action.harness === "opencode" ? unpackOpencodeFile(action.source.file, staging.open(file), staging) : txFail("invalid", `the OpenCode harness source cannot unpack ${action.harness}`),
103
+ action.harness === "opencode"
104
+ ? unpackOpencodeFile(action.source.file, staging.open(file), staging, { target: action.target })
105
+ : txFail("invalid", `the OpenCode harness source cannot unpack ${action.harness}`),
104
106
  installed: (harness) => (harness === "opencode" ? options.installed?.() : undefined),
105
107
  floor: (harness) => (harness === "opencode" ? options.floor?.() : undefined),
106
108
  ...(options.allowDowngrade ? { allowDowngrade: true } : {}),
@@ -115,10 +117,19 @@ function preconditionHolds(ctx: StepContext, precondition: { readonly exists: bo
115
117
  return precondition.exists ? o.exists && o.sha256 === precondition.sha256 : !o.exists
116
118
  }
117
119
 
118
- /** register-mcp-server for OpenCode: hubctl's mcp at `authority` (default user-bin:hubctl). */
120
+ /**
121
+ * register-mcp-server for OpenCode: hubctl's mcp at `authority` (default user-bin:hubctl), for the host's target
122
+ * (authorityFor: user-bin:hubctl.exe on Windows, written as its native path, mcpCommandPath).
123
+ */
119
124
  export function opencodeMcpExecutor(options: { readonly authority?: PathRef } = {}): ActionExecutor<"register-mcp-server"> {
120
- const authorityRef = options.authority ?? DEFAULT_AUTHORITY
121
- checkAuthority(authorityRef)
125
+ const given = options.authority ?? DEFAULT_AUTHORITY
126
+ checkAuthority(given)
127
+ const authorityOf = (ctx: StepContext) => {
128
+ const ref = authorityFor(given, ctx.host.target)
129
+ if (!ref.ok) return ref
130
+ const resolved = ctx.resolve(ref.value, { finalMayBeSymlink: true })
131
+ return resolved.ok ? txOk(mcpCommandPath(resolved.value, ctx.host.target)) : resolved
132
+ }
122
133
  return {
123
134
  kind: "register-mcp-server",
124
135
  plan: (action) => ({ class: "config", path: action.path }),
@@ -126,11 +137,11 @@ export function opencodeMcpExecutor(options: { readonly authority?: PathRef } =
126
137
  if (action.harness !== "opencode") return txFail("invalid", `this executor registers MCP servers with OpenCode, not ${action.harness}`)
127
138
  if (action.name !== "hub") return txFail("invalid", `hubctl's MCP server is registered as "hub", not ${JSON.stringify(action.name)}`)
128
139
  if (!preconditionHolds(ctx, action.precondition)) return txFail("conflict", `${ctx.target} changed since the preview; nothing was overwritten (preview again)`)
129
- const authority = ctx.resolve(authorityRef, { finalMayBeSymlink: true })
140
+ const authority = authorityOf(ctx)
130
141
  return authority.ok ? txOk(undefined) : authority
131
142
  },
132
143
  change: async (_action, ctx) => {
133
- const authority = ctx.resolve(authorityRef, { finalMayBeSymlink: true })
144
+ const authority = authorityOf(ctx)
134
145
  if (!authority.ok) return authority
135
146
  const text = ctx.current.exists ? new TextDecoder().decode(ctx.fs.readFile(ctx.target!)) : undefined
136
147
  const sets = mcpServerSets(text, authority.value)
@@ -1,10 +1,11 @@
1
1
  // Unpacking a verified OpenCode release archive into the files of its immutable version directory (contract §3.6.1,
2
2
  // data:hub/harnesses/opencode/<version>). Upstream (github.com/anomalyco/opencode releases) ships exactly one file,
3
3
  // `opencode`, in a zip for macOS (opencode-darwin-<arch>.zip, deflate) and a gzipped tar for Linux
4
- // (opencode-linux-<arch>.tar.gz). The executor has already checked the download against hub's pinned digest; this
4
+ // (opencode-linux-<arch>.tar.gz); on Windows exactly one file, `opencode.exe`, in a zip made on a FAT host
5
+ // (opencode-windows-x64-baseline.zip: no unix modes, so the kind follows the name). The executor has already checked the download against hub's pinned digest; this
5
6
  // still refuses anything an archive could smuggle in: links, devices, names that climb out or are absolute, extra
6
7
  // files (an installer script beside the binary), a duplicate, or no binary at all. The binary always becomes
7
- // bin/opencode, mode 0755.
8
+ // bin/opencode, mode 0755 (bin/opencode.exe on Windows targets, where modes do not apply and the applier records none).
8
9
  //
9
10
  // Streaming (HBS-12: a 4 GB host was OOM-killed holding the archive and the ~350 MB binary several times over): the
10
11
  // archive is read from the staging area in chunks (archive-stream.ts), every member is judged before its body is
@@ -14,9 +15,17 @@ import type { TreeFile } from "../../transaction/executor.ts"
14
15
  import { txFail, txOk, type TxResult } from "../../transaction/result.ts"
15
16
  import { bytesReader, memoryStaging, type ByteReader, type StagedFile, type StagingArea } from "../../transaction/stream.ts"
16
17
 
17
- /** Where the binary goes inside the version directory. */
18
+ /** Where the binary goes inside the version directory (POSIX targets; opencodeBinaryRel for any target). */
18
19
  export const OPENCODE_BINARY_REL = "bin/opencode"
19
- const BINARY_NAME = "opencode"
20
+
21
+ /** Whether `target` is a Windows target (its executables carry .exe, PHASE2-CONTRACT.md). */
22
+ export const isWindowsTarget = (target: string | undefined): boolean => target !== undefined && target.startsWith("windows-")
23
+
24
+ /** The binary's name inside the upstream archive (and inside bin/) for `target`: opencode, or opencode.exe on Windows. */
25
+ export const opencodeBinaryName = (target?: string): string => (isWindowsTarget(target) ? "opencode.exe" : "opencode")
26
+
27
+ /** Where the binary goes inside the version directory for `target`. */
28
+ export const opencodeBinaryRel = (target?: string): string => `bin/${opencodeBinaryName(target)}`
20
29
  /** A generous bound on what one release may unpack to (the 2.2.0 binary is about 350 MB). */
21
30
  export const MAX_UNPACKED_BYTES = 1024 * 1024 * 1024
22
31
 
@@ -31,60 +40,68 @@ function memberName(raw: string): string | undefined {
31
40
  }
32
41
 
33
42
  /** Judges one member: undefined to skip it (the archive's own root directory), "binary" to unpack it. */
34
- function judge(raw: string, kind: "file" | "directory" | "other", hasBinary: boolean): "binary" | undefined {
43
+ function judge(raw: string, kind: "file" | "directory" | "other", hasBinary: boolean, binaryName: string): "binary" | undefined {
35
44
  const name = memberName(raw)
36
45
  if (name === undefined) throw new ArchiveError(`the member ${JSON.stringify(raw)} has an unsafe name`)
37
46
  if (kind === "directory" && name === "") return undefined
38
47
  if (kind !== "file") throw new ArchiveError(`${JSON.stringify(raw)} is not a regular file (links and devices are never installed)`)
39
- if (name !== BINARY_NAME) throw new ArchiveError(`unexpected member ${JSON.stringify(raw)} (an OpenCode release holds only the ${BINARY_NAME} binary)`)
40
- if (hasBinary) throw new ArchiveError(`${BINARY_NAME} appears twice`)
48
+ if (name !== binaryName) throw new ArchiveError(`unexpected member ${JSON.stringify(raw)} (an OpenCode release for this target holds only the ${binaryName} binary)`)
49
+ if (hasBinary) throw new ArchiveError(`${binaryName} appears twice`)
41
50
  return "binary"
42
51
  }
43
52
 
44
53
  const tarKind = (type: string) => (type === "0" || type === "7" ? "file" : type === "5" ? "directory" : "other")
45
54
 
46
- async function fromTarGz(reader: ByteReader, staging: StagingArea, maxBytes: number): Promise<StagedFile | undefined> {
55
+ async function fromTarGz(reader: ByteReader, staging: StagingArea, maxBytes: number, binaryName: string): Promise<StagedFile | undefined> {
47
56
  let binary: StagedFile | undefined
48
57
  // Symlinks (2), hard links (1), devices (3, 4), FIFOs (6), and pax/GNU headers that could rename a member (x, g,
49
58
  // L, K) are all "other": refused.
50
59
  await readTarGz(reader, { maxBytes }, async (member) => {
51
- if (judge(member.header.name, tarKind(member.header.type), binary !== undefined) !== "binary") return
52
- binary = await staging.write(BINARY_NAME, member.body(), { maxBytes: member.header.size })
60
+ if (judge(member.header.name, tarKind(member.header.type), binary !== undefined, binaryName) !== "binary") return
61
+ binary = await staging.write(binaryName, member.body(), { maxBytes: member.header.size })
53
62
  })
54
63
  return binary
55
64
  }
56
65
 
57
- async function fromZip(reader: ByteReader, staging: StagingArea, maxBytes: number): Promise<StagedFile | undefined> {
66
+ async function fromZip(reader: ByteReader, staging: StagingArea, maxBytes: number, binaryName: string): Promise<StagedFile | undefined> {
58
67
  // The whole directory is judged before any member is inflated.
59
68
  const members = zipMembers(reader, { maxBytes })
60
69
  let chosen: (typeof members)[number] | undefined
61
- for (const m of members) if (judge(m.name, m.kind, chosen !== undefined) === "binary") chosen = m
70
+ for (const m of members) if (judge(m.name, m.kind, chosen !== undefined, binaryName) === "binary") chosen = m
62
71
  if (!chosen) return undefined
63
- return staging.write(BINARY_NAME, zipMemberData(reader, chosen), { maxBytes: chosen.size })
72
+ return staging.write(binaryName, zipMemberData(reader, chosen), { maxBytes: chosen.size })
64
73
  }
65
74
 
66
75
  /**
67
76
  * The install tree of a verified OpenCode archive named `file` (its format follows the upstream asset name), read
68
77
  * from `reader`; the binary is streamed into `staging`. `maxBytes` (default MAX_UNPACKED_BYTES) bounds what it
69
- * inflates to.
78
+ * inflates to. `target` (the install-harness action's target) picks the binary: `opencode.exe` → bin/opencode.exe
79
+ * on Windows (a zip only), `opencode` → bin/opencode elsewhere (the default).
70
80
  */
71
- export async function unpackOpencodeFile(file: string, reader: ByteReader, staging: StagingArea, options: { readonly maxBytes?: number } = {}): Promise<TxResult<TreeFile[]>> {
81
+ export interface UnpackOptions {
82
+ readonly maxBytes?: number
83
+ readonly target?: string
84
+ }
85
+
86
+ export async function unpackOpencodeFile(file: string, reader: ByteReader, staging: StagingArea, options: UnpackOptions = {}): Promise<TxResult<TreeFile[]>> {
72
87
  const maxBytes = options.maxBytes ?? MAX_UNPACKED_BYTES
88
+ const binaryName = opencodeBinaryName(options.target)
73
89
  const zip = file.endsWith(".zip")
74
90
  if (!zip && !file.endsWith(".tar.gz") && !file.endsWith(".tgz")) return invalid(file, "not an OpenCode CLI archive (expected .zip or .tar.gz)")
91
+ if (!zip && isWindowsTarget(options.target)) return invalid(file, "OpenCode for Windows ships as a .zip")
75
92
  let binary: StagedFile | undefined
76
93
  try {
77
- binary = zip ? await fromZip(reader, staging, maxBytes) : await fromTarGz(reader, staging, maxBytes)
94
+ binary = zip ? await fromZip(reader, staging, maxBytes, binaryName) : await fromTarGz(reader, staging, maxBytes, binaryName)
78
95
  } catch (error) {
79
96
  if (error instanceof ArchiveError) return invalid(file, error.message)
80
97
  throw error
81
98
  }
82
- if (!binary) return invalid(file, `no ${BINARY_NAME} binary inside`)
83
- return txOk([{ rel: OPENCODE_BINARY_REL, staged: binary, mode: 0o755 }])
99
+ if (!binary) return invalid(file, `no ${binaryName} binary inside`)
100
+ return txOk([{ rel: opencodeBinaryRel(options.target), staged: binary, mode: 0o755 }])
84
101
  }
85
102
 
86
103
  /** unpackOpencodeFile over bytes in memory, the tree as bytes (small archives: tests, fixtures). */
87
- export async function unpackOpencodeArchive(file: string, bytes: Uint8Array, options: { readonly maxBytes?: number } = {}): Promise<TxResult<TreeFile[]>> {
104
+ export async function unpackOpencodeArchive(file: string, bytes: Uint8Array, options: UnpackOptions = {}): Promise<TxResult<TreeFile[]>> {
88
105
  const staging = memoryStaging()
89
106
  const r = await unpackOpencodeFile(file, bytesReader(bytes), staging, options)
90
107
  return r.ok ? txOk(r.value.map((f) => ("staged" in f ? { rel: f.rel, mode: f.mode, bytes: staging.bytes(f.staged) } : f))) : r
@@ -28,7 +28,7 @@ export const MCP_SUBCOMMAND = "mcp"
28
28
  const HUB_PROGRAMS = new Set(["hub", "hubctl"])
29
29
 
30
30
  export interface DesiredRegistration {
31
- /** The absolute path of the stable hubctl authority the MCP server runs. */
31
+ /** The absolute path of the stable hubctl authority the MCP server runs (native on Windows: plan.ts mcpCommandPath). */
32
32
  readonly authority: string
33
33
  /** Pin the plugin to this exact version (this hubctl's own); unpinned when absent. */
34
34
  readonly pluginVersion?: string
@@ -67,6 +67,22 @@ export interface ConfigInspection {
67
67
  type Value = null | boolean | number | string | Value[] | { [key: string]: Value }
68
68
  const isObject = (v: unknown): v is { [key: string]: Value } => typeof v === "object" && v !== null && !Array.isArray(v)
69
69
  const basename = (p: string) => p.slice(p.lastIndexOf("/") + 1)
70
+ /** A Windows path in either form: C:/… (canonical) or C:\… (native, as OpenCode's config on Windows holds it). */
71
+ const WINDOWS_PATH = /^[A-Za-z]:[\\/]/
72
+ /** The program a command path names: its last segment; on Windows without .exe/.cmd and case-folded. */
73
+ const programName = (p: string): string => {
74
+ if (!WINDOWS_PATH.test(p)) return basename(p)
75
+ return p
76
+ .slice(Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")) + 1)
77
+ .replace(/\.(exe|cmd)$/i, "")
78
+ .toLowerCase()
79
+ }
80
+ /** Whether two command paths are the same file: exact on POSIX; on Windows either separator and any case (NTFS). */
81
+ export const samePath = (a: string, b: string): boolean => {
82
+ if (!WINDOWS_PATH.test(a) || !WINDOWS_PATH.test(b)) return a === b
83
+ const fold = (p: string) => p.replace(/\\/g, "/").toLowerCase()
84
+ return fold(a) === fold(b)
85
+ }
70
86
 
71
87
  /** The desired plugin spec: `@huynle/hub`, or `@huynle/hub@<version>` when pinned. */
72
88
  export const pluginSpec = (desired: { readonly pluginVersion?: string }): string => (desired.pluginVersion ? `${HUB_PLUGIN_PACKAGE}@${desired.pluginVersion}` : HUB_PLUGIN_PACKAGE)
@@ -144,7 +160,7 @@ const isHubMcp = (server: Value): server is { [key: string]: Value } & { command
144
160
  Array.isArray(server.command) &&
145
161
  server.command.length === 2 &&
146
162
  server.command.every((w) => typeof w === "string") &&
147
- HUB_PROGRAMS.has(basename(server.command[0] as string)) &&
163
+ HUB_PROGRAMS.has(programName(server.command[0] as string)) &&
148
164
  server.command[1] === MCP_SUBCOMMAND
149
165
 
150
166
  function inspectMcp(config: { [key: string]: Value }, desired: DesiredRegistration, findings: Finding[]): TxResult<McpState> {
@@ -159,7 +175,7 @@ function inspectMcp(config: { [key: string]: Value }, desired: DesiredRegistrati
159
175
  const server = servers[HUB_MCP_NAME]
160
176
  if (server === undefined) return txOk({ state: "absent" })
161
177
  if (!isHubMcp(server)) return txFail("conflict", `another MCP server is already named "${HUB_MCP_NAME}" in the OpenCode config; hubctl will not replace it`)
162
- if (server.command[0] === desired.authority) return txOk({ state: "registered" })
178
+ if (samePath(server.command[0]!, desired.authority)) return txOk({ state: "registered" })
163
179
  findings.push({ code: "mcp-stale-authority", message: `the hub MCP server runs ${server.command[0]}; it becomes ${desired.authority}` })
164
180
  return txOk({ state: "stale", found: server.command[0]! })
165
181
  }
@@ -11,7 +11,7 @@ import type { ExecutorMap } from "../../transaction/executor.ts"
11
11
  import type { ReadinessGate } from "../readiness.ts"
12
12
  import { createOpencodeHarnessSource, httpsDownload, opencodeExecutors, type Download } from "./apply.ts"
13
13
  import { createOpencodeAdapter, type OpencodePlanOptions } from "./plan.ts"
14
- import { locateOpencodeBinary, nodeProcessRunner, verifyOpencode, type ProcessRunner, type ProviderProbe, type SpokeProbe } from "./verify.ts"
14
+ import { hubManagedVersion, locateOpencodeBinary, nodeProcessRunner, verifyOpencode, type ProcessRunner, type ProviderProbe, type SpokeProbe } from "./verify.ts"
15
15
 
16
16
  export * from "./archive.ts"
17
17
  export * from "./config.ts"
@@ -44,9 +44,6 @@ export interface OpencodeHarness {
44
44
  executors(context: () => AdapterContext, gate?: ReadinessGate): Pick<ExecutorMap, "register-mcp-server" | "verify-harness">
45
45
  }
46
46
 
47
- /** The version directory a hub-managed binary lives in (data:hub/harnesses/opencode/<version>/bin/opencode). */
48
- const HUB_VERSION = /\/hub\/harnesses\/opencode\/([^/]+)\/bin\/opencode$/
49
-
50
47
  export function createOpencodeHarness(options: OpencodeHarnessOptions): OpencodeHarness {
51
48
  const { probe } = options
52
49
  const runner = options.runner ?? nodeProcessRunner
@@ -59,7 +56,7 @@ export function createOpencodeHarness(options: OpencodeHarnessOptions): Opencode
59
56
  // Located fresh at every verification: after an install, `current` points at the new version.
60
57
  const located = locateOpencodeBinary(probe)
61
58
  const binary = located.ok ? located.value : undefined
62
- const expectVersion = binary ? HUB_VERSION.exec(binary)?.[1] : undefined
59
+ const expectVersion = binary ? hubManagedVersion(binary) : undefined
63
60
  return verifyOpencode(context, { runner, binary, ...(options.spoke ? { spoke: options.spoke } : {}), ...(options.provider ? { provider: options.provider } : {}), ...(expectVersion ? { expectVersion } : {}) })
64
61
  }
65
62
  const adapter = createOpencodeAdapter({ ...options, readConfig: (path) => probe.readFile(path), discover: presence, verify })