@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.
- package/CHANGELOG.md +80 -0
- package/README.md +40 -3
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +5288 -4415
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/PHASE2-CONTRACT.md +87 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +11 -43
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +19 -12
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +42 -6
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +18 -7
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +36 -19
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +19 -3
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +2 -5
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +37 -6
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +49 -26
- package/packages/cli/src/bootstrap/adapters/process-tree.ts +263 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +7 -4
- package/packages/cli/src/bootstrap/contracts/platform.ts +9 -5
- package/packages/cli/src/bootstrap/contracts/validate.ts +20 -4
- package/packages/cli/src/bootstrap/discovery/discover.ts +3 -1
- package/packages/cli/src/bootstrap/discovery/executables.ts +49 -14
- package/packages/cli/src/bootstrap/discovery/host.ts +16 -8
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +41 -5
- package/packages/cli/src/bootstrap/discovery/probe.ts +7 -3
- package/packages/cli/src/bootstrap/discovery/util.ts +42 -5
- package/packages/cli/src/bootstrap/enrollment/store.ts +13 -6
- package/packages/cli/src/bootstrap/preview/plan.ts +3 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +10 -3
- package/packages/cli/src/bootstrap/release/README.md +63 -9
- package/packages/cli/src/bootstrap/release/cli.ts +94 -16
- package/packages/cli/src/bootstrap/release/harness-pins.pending.json +24 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +3 -0
- package/packages/cli/src/bootstrap/release/pins.ts +60 -9
- package/packages/cli/src/bootstrap/transaction/builtin.ts +18 -6
- package/packages/cli/src/bootstrap/transaction/engine.ts +166 -12
- package/packages/cli/src/bootstrap/transaction/executor.ts +5 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +142 -3
- package/packages/cli/src/bootstrap/transaction/shim.ts +31 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +11 -6
- package/packages/cli/src/credentials.ts +5 -2
- package/packages/cli/src/main.ts +3 -2
- package/packages/cli/src/setup/commands.ts +96 -18
- package/packages/cli/src/setup/contributors.ts +45 -10
- package/packages/cli/src/setup/runtime.ts +25 -6
- package/packages/cli/src/setup/self-aside.ts +130 -0
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/opencode-warm.ts +12 -4
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/index.ts +7 -2
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/config.ts +12 -0
- package/src/central/http.ts +10 -0
- package/src/central/install/CONTRACT.md +66 -0
- package/src/central/install/install.ps1 +349 -0
- package/src/central/install/install.sh +266 -0
- package/src/central/install/render.ts +146 -0
- package/src/central/install/templates.ts +6 -0
- package/src/central/install/text-assets.d.ts +9 -0
- package/src/central/registry.ts +93 -22
- package/src/config.ts +12 -7
- package/src/index.ts +28 -0
- package/src/spoke/env-inject.ts +17 -2
- package/src/spoke/launchers.ts +145 -9
- package/src/spoke/spoke.ts +150 -10
- package/src/status.ts +70 -14
- package/src/win-acl.ts +154 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +1 -0
- package/web/dist/assets/{ArtifactPane-DPHjiWce.js → ArtifactPane-DKwbMTX9.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +6 -0
- package/web/dist/assets/Library-D6-Go6Cu.js +2 -0
- package/web/dist/assets/{ProfilesDestination-GriuOpiZ.js → ProfilesDestination-BOML2Dgf.js} +1 -1
- package/web/dist/assets/{TeamsPage-C-5lYeeM.js → TeamsPage-CNBTE8gB.js} +1 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +2 -0
- package/web/dist/assets/Watchtower-RphjcL0_.css +1 -0
- package/web/dist/assets/hub-CrbItiwP.js +13 -0
- package/web/dist/assets/index-82Ce0IAf.css +1 -0
- package/web/dist/assets/index-D9SP3_0z.js +22 -0
- package/web/dist/assets/{live-CDWcyavI.js → live-BNJgy0vg.js} +3 -3
- package/web/dist/assets/{pane-layout-Csk-vPNe.js → pane-layout-EdR1kRrG.js} +1 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +1 -0
- package/web/dist/assets/{renderer2d-CNFdTDTr.js → renderer2d-CYag1CnR.js} +1 -1
- package/web/dist/assets/{renderer3d-C45jPvYX.js → renderer3d-BRB6rgDJ.js} +1 -1
- package/web/dist/assets/{safe-area-sA9a8vYJ.js → safe-area-fOy0AOuG.js} +1 -1
- package/web/dist/assets/{source-BFLJQ43_.js → source-Wbq35qGp.js} +1 -1
- package/web/dist/assets/{ui-Qo2Pd9-O.js → ui-j5FFk90B.js} +1 -1
- package/web/dist/index.html +6 -4
- package/web/dist/assets/ArtifactPage-CSotAWob.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-CkmWnP6L.js +0 -6
- package/web/dist/assets/Library-DTLEyekn.js +0 -2
- package/web/dist/assets/Watchtower-CTgqHk5g.css +0 -1
- package/web/dist/assets/Watchtower-DOlPsiIy.js +0 -2
- package/web/dist/assets/index-6mdQ7eUY.js +0 -34
- package/web/dist/assets/index-C5OmwA1y.css +0 -1
|
@@ -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
|
|
5
|
-
// dropped, stdout capped, and a hard timeout
|
|
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,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
/**
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
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
|
|
50
|
-
nothing.
|
|
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
|
|
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"
|
|
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
|
-
/**
|
|
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
|
|
121
|
-
checkAuthority(
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
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 !==
|
|
40
|
-
if (hasBinary) throw new ArchiveError(`${
|
|
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(
|
|
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(
|
|
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
|
|
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 ${
|
|
83
|
-
return txOk([{ rel:
|
|
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:
|
|
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(
|
|
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]
|
|
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 ?
|
|
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 })
|