@huynle/hub 0.7.1 → 0.7.2

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 (185) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/Dockerfile +3 -0
  3. package/README.md +148 -11
  4. package/package.json +3 -1
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/main.ts +4 -4
  7. package/packages/claude-code/src/wrapper.ts +3 -2
  8. package/packages/cli/dist/hub-cli.js +19334 -7849
  9. package/packages/cli/package.json +11 -0
  10. package/packages/cli/src/acting.ts +70 -0
  11. package/packages/cli/src/args.ts +91 -0
  12. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  13. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  14. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  15. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  16. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  17. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  18. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  19. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  20. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  21. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  22. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  23. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  24. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  25. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  26. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  27. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  28. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  29. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  30. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  31. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  32. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  33. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  34. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  35. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  36. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  37. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  38. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  39. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  40. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  41. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  42. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  43. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  44. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  45. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  46. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  47. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  48. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  49. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  50. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  51. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  52. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  53. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  54. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  55. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  56. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  57. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  58. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  59. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  60. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  61. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  62. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  63. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  64. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  65. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  66. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  67. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  68. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  69. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  70. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  71. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  72. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  73. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  74. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  75. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  76. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  77. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  78. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  79. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  80. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  81. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  82. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  83. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  84. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  85. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  86. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  87. package/packages/cli/src/bootstrap/release/README.md +115 -0
  88. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  89. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  90. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  91. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  92. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  93. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  94. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  95. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  96. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  97. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  98. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  99. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  100. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  101. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  102. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  103. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  104. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  105. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  106. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  107. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  108. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  109. package/packages/cli/src/cli.ts +814 -0
  110. package/packages/cli/src/credentials.ts +75 -0
  111. package/packages/cli/src/decisions.ts +106 -0
  112. package/packages/cli/src/format.ts +185 -0
  113. package/packages/cli/src/grants.ts +105 -0
  114. package/packages/cli/src/http.ts +86 -0
  115. package/packages/cli/src/launches.ts +157 -0
  116. package/packages/cli/src/login.ts +160 -0
  117. package/packages/cli/src/main.ts +63 -0
  118. package/packages/cli/src/mcp.ts +879 -0
  119. package/packages/cli/src/messaging.ts +108 -0
  120. package/packages/cli/src/node.ts +279 -0
  121. package/packages/cli/src/openshell.ts +166 -0
  122. package/packages/cli/src/setup/commands.ts +809 -0
  123. package/packages/cli/src/setup/contributors.ts +171 -0
  124. package/packages/cli/src/setup/dispatch.ts +74 -0
  125. package/packages/cli/src/setup/launch-driver.ts +109 -0
  126. package/packages/cli/src/setup/runtime.ts +456 -0
  127. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  128. package/packages/cli/src/setup/state.ts +244 -0
  129. package/packages/cli/src/setup/user-bin.ts +39 -0
  130. package/packages/cli/src/spoke-services.ts +109 -0
  131. package/packages/cli/src/watch.ts +28 -0
  132. package/packages/codex/package.json +1 -1
  133. package/packages/codex/src/main.ts +3 -2
  134. package/packages/codex/src/spoke.ts +4 -1
  135. package/packages/codex/src/wrapper.ts +7 -3
  136. package/packages/node/package.json +1 -1
  137. package/packages/node/src/harnesses.ts +39 -4
  138. package/packages/node/src/run.ts +1 -1
  139. package/packages/preset/package.json +1 -1
  140. package/packages/protocol/package.json +1 -1
  141. package/packages/runtime-openshell/package.json +1 -1
  142. package/packages/runtime-sdk/package.json +1 -1
  143. package/packages/sdk/package.json +1 -1
  144. package/packages/sdk/src/client.ts +55 -4
  145. package/packages/sdk/src/errors.ts +1 -0
  146. package/packages/sdk/src/sse.ts +4 -1
  147. package/packages/sdk/src/types.ts +59 -0
  148. package/packages/spoke-sdk/package.json +1 -1
  149. package/packages/spoke-sdk/src/enroll.ts +26 -4
  150. package/src/central/agent/app-tokens.ts +27 -17
  151. package/src/central/agent/archive-inactive.ts +66 -0
  152. package/src/central/agent/directory-routes.ts +155 -12
  153. package/src/central/agent/directory.ts +1 -1
  154. package/src/central/agent/errors.ts +2 -0
  155. package/src/central/agent/event-cursor.ts +49 -0
  156. package/src/central/agent/event-writers.ts +181 -0
  157. package/src/central/agent/events-routes.ts +148 -0
  158. package/src/central/agent/events.ts +277 -0
  159. package/src/central/agent/names.ts +1 -1
  160. package/src/central/agent/read-routes.ts +32 -36
  161. package/src/central/agent/routes.ts +14 -11
  162. package/src/central/agent/watch.ts +65 -174
  163. package/src/central/agent/webhook-routes.ts +10 -2
  164. package/src/central/agent/webhooks.ts +49 -5
  165. package/src/central/auth.ts +127 -1
  166. package/src/central/backend/index.ts +3 -0
  167. package/src/central/backend/types.ts +2 -0
  168. package/src/central/http.ts +115 -32
  169. package/src/central/mcp-http.ts +116 -0
  170. package/src/central/store.ts +53 -0
  171. package/src/config.ts +70 -7
  172. package/src/index.ts +90 -91
  173. package/src/spoke/launchers.ts +109 -3
  174. package/web/dist/apple-touch-icon.png +0 -0
  175. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  176. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  177. package/web/dist/favicon.ico +0 -0
  178. package/web/dist/icon-192.png +0 -0
  179. package/web/dist/icon-512.png +0 -0
  180. package/web/dist/icon-maskable-512.png +0 -0
  181. package/web/dist/icon.svg +5 -5
  182. package/web/dist/index.html +10 -4
  183. package/web/dist/manifest.webmanifest +2 -2
  184. package/web/dist/assets/index-D9zMbxSg.css +0 -1
  185. package/web/dist/assets/index-db1z4duA.js +0 -20
@@ -0,0 +1,204 @@
1
+ # OpenCode harness adapter (HBS-06)
2
+
3
+ The OpenCode `HarnessAdapter` for the bootstrap (contracts §3.10, §3.6.1, §3.12.1 and §5 in
4
+ `docs/bootstrap-contracts.md`). It only imports the frozen contracts and the transaction, discovery and preview
5
+ modules. Every capability is injected.
6
+
7
+ | File | Role |
8
+ |---|---|
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
+ | `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
+ | `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. |
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
+ | `index.ts` | `createOpencodeHarness`, the single entry point for the integrator. |
15
+
16
+ ## Behaviour
17
+
18
+ - **Install.** OpenCode is installed only from the pin in hub's verified manifest for the host's exact target, into
19
+ `data:hub/harnesses/opencode/<version>`. That version is then made `current`.
20
+ - An OpenCode the user installed (native, brew or npm) is kept by default (`install: "if-missing"`).
21
+ - A hub-managed OpenCode at an older version gets the pinned version installed next to it. A newer one is never
22
+ downgraded by planning.
23
+ - Without a pin, a missing OpenCode is `unpinned_artifact`.
24
+ - A pin below 2.0.0 is refused, because the Hub plugin needs OpenCode 2.
25
+ - **Config.** OpenCode 2 uses `plugins: (string | {package, options?})[]` and `mcp.servers.<name>` (from
26
+ `@opencode/schema` 2.0.26, `config/plugin.js` and `mcp.js`). hubctl edits `config:opencode/opencode.jsonc` if it
27
+ exists, otherwise `opencode.json`. When neither exists, it creates `opencode.json`. The config is read only if
28
+ the snapshot lists it, and must still have the snapshot's digest, otherwise `preview_stale`.
29
+ - **Plugin.** If the Hub plugin is absent, it is appended to `/plugins`.
30
+ - These count as registered: an exact npm entry, an unpinned npm entry, or a custom source (a `github:huynle/hub`
31
+ spec or a checkout named `hub`).
32
+ - These are drift, fixed by one set on that entry (or on its `package` field only): an older pin, or the legacy
33
+ `@huynle/opencode-voice`.
34
+ - These are refused as `conflict`: a duplicate (including across `plugins` and the OpenCode 1 key `plugin`, or
35
+ across both files), or a pin newer than this hubctl.
36
+ - **MCP.** `mcp.servers.hub = {type: "local", command: [<authority>, "mcp"]}`.
37
+ - A `hub`/`hubctl mcp` server at another path (the plugin's launcher in the state dir, or a plugin cache) is
38
+ stale. Only its `command` is replaced; `environment`, `disabled` and other fields stay.
39
+ - These are refused: a foreign server named `hub`, or hub's MCP server under another name.
40
+ - **Authority.** The authority is `user-bin:hubctl` by default, or `user-bin:hub` (the same command surface). Any
41
+ other root or name (`state:hub/bin/…`, `data:…/node_modules/…`) is refused when the adapter is built.
42
+ - **Unparseable files.** Unparseable JSONC, or JSONC with duplicate keys, is `manual_edit_required`.
43
+ - **Symlinked configs.** The adapter plans normally. `planSetup` replaces both config actions with `symlinked-config`
44
+ manual edits that name the dotfiles file. Nothing is written through the link.
45
+ - **Readiness.** A planned or applied install is never readiness. The three required probes are:
46
+ - `version-exec`: `<binary> --version` must report OpenCode ≥ 2.0.0, and exactly the hub `current` version when
47
+ hub manages the binary. The binary is hub's `current` binary, otherwise what PATH resolves to. A container shim
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.
51
+ - `spoke-connected`: an injected `SpokeProbe`. Without one, the probe fails, so OpenCode is never ready by default.
52
+
53
+ On a cold start (the first use of a directory) OpenCode 2's background service can take longer than the CLI's ~5 s
54
+ to activate its plugins, and the CLI fails with HTTP 503 "Location plugins did not finish activating". The
55
+ `--version` and `models` probes retry once, after 1 s, on exactly that failure (`ACTIVATION_FAILURE`, read from the
56
+ child's output only to recognise it); any other failure is reported at once (HBS-12).
57
+
58
+ Details are built by the adapter, never copied from harness output. Anything that looks like a credential is
59
+ withheld. The child process gets the user's own environment, and nothing from it is recorded.
60
+
61
+ Contract v1.2 classifies them: `version-exec` is an integrity probe (its failure rolls the apply back);
62
+ `provider-auth` and `spoke-connected` wait on the user (`opencode auth login`, starting OpenCode), so by default
63
+ the `verify-harness` step commits OpenCode as installed, not ready (`requireReady: false`, set by `hubctl setup
64
+ apply` unless `--require-ready`). The executor's own default stays strict.
65
+ - **Rollback.** All rollback goes through the engine. The new version directory is removed, `current` is restored
66
+ from its backup, and the config is restored from its backup, except when the user changed it after hub wrote it.
67
+ Then it is kept and reported as a conflict (`bootstrap-adapter-opencode-apply.test.ts`).
68
+
69
+ ## Upstream source of OpenCode 2.x (researched 2026-10-28)
70
+
71
+ | Channel | Finding | Source |
72
+ |---|---|---|
73
+ | GitHub releases | `sst/opencode` redirects to **`anomalyco/opencode`** (HTTP 301). The 2.x CLI is published there as tag **`v2.2.0`** (2026-10-27, by github-actions[bot], from `dev`). The same repository still ships a **1.x line** (`v1.19.5`, 2026-10-28). Because 1.19.5 is newer, GitHub's "latest release" is the 1.x line: never use `latest`, pin the exact tag. | https://github.com/anomalyco/opencode/releases/tag/v2.2.0, https://api.github.com/repos/anomalyco/opencode/releases |
74
+ | Release assets (v2.2.0) | CLI archives: `opencode-darwin-arm64.zip`, `opencode-darwin-x64.zip` (and `-baseline`), `opencode-linux-x64.tar.gz` (and `-baseline`, `-musl`, `-baseline-musl`), `opencode-linux-arm64.tar.gz` (and `-musl`), plus Windows zips, `opencode-desktop-*` apps and a `SHA256SUMS` file. | release page above |
75
+ | Archive layout | Checked with HTTP range reads, without downloading the archives. The zip has one entry, `opencode` (deflate, unix mode 0100755, 220 826 080 bytes uncompressed). The tar.gz has one ustar (GNU) entry, `opencode`, mode 0755. The uncompressed darwin-arm64 size equals the size of the locally installed `~/.opencode/bin/opencode`, which reports `opencode v2.2.0`. | range requests on the asset URLs |
76
+ | Authenticity | No signatures: no `.sig`, `.asc`, `.sigstore` or attestation assets. `SHA256SUMS` is unsigned. GitHub's API reports a per-asset `digest`. Both only help to cross-check the pinned digest (contract §5: OpenCode is vouched for only by hub's pin). | release API `assets[].digest`, `SHA256SUMS` |
77
+ | npm `opencode-ai` | dist-tag `latest` = **1.19.5**. No 2.x versions are published: this is the 1.x line. | https://registry.npmjs.org/opencode-ai |
78
+ | npm `@opencode/cli` | dist-tag `latest` = **2.2.0**, repository `anomalyco/opencode`. This is the 2.x CLI on npm under its new name. The SDK this repo uses (`@opencode/client`, `@opencode/plugin` ^2.0.26) comes from the same repository. | https://registry.npmjs.org/@opencode%2fcli |
79
+
80
+ **Recommendation.** Pin the **GitHub release archives of `anomalyco/opencode`, exact tag `v2.x.y`**, not npm:
81
+
82
+ - An npm install runs a package manager, with lifecycle scripts and platform-specific optional dependencies. That
83
+ is an installer, which `install-harness` must never carry (T21).
84
+ - The release archive is a single file, which hubctl checks against the pin and unpacks itself.
85
+
86
+ | Target | Asset | Format |
87
+ |---|---|---|
88
+ | `darwin-arm64` | `opencode-darwin-arm64.zip` | zip (deflate) |
89
+ | `darwin-x64` | `opencode-darwin-x64-baseline.zip` | zip (deflate) |
90
+ | `linux-x64` (glibc) | `opencode-linux-x64-baseline.tar.gz` | tar.gz |
91
+ | `linux-arm64` (glibc) | `opencode-linux-arm64.tar.gz` | tar.gz |
92
+
93
+ **Use the `-baseline` assets on x64.** The non-baseline x64 builds require AVX2 and die with `SIGILL` (exit 132)
94
+ on CPUs without it: older Intel/AMD hosts and QEMU guests with the default `kvm64` CPU model. hubctl itself is built
95
+ for the same baseline (`bun-<os>-x64-baseline`, contracts §3.1), so a host that runs hubctl can run the harness it
96
+ installs. The baseline build runs on AVX2 CPUs too, at a small speed cost.
97
+
98
+ The musl and Windows assets are out of scope (decision Q7, §3.1). The URL is
99
+ `https://github.com/anomalyco/opencode/releases/download/v<version>/<asset>`. `evidence` is always `[]`.
100
+
101
+ ### Proposed `harness-pins.json` entries (template: fill in only from your own download)
102
+
103
+ ```json
104
+ {
105
+ "pin": {
106
+ "harness": "opencode",
107
+ "version": "2.2.0",
108
+ "target": "darwin-arm64",
109
+ "url": "https://github.com/anomalyco/opencode/releases/download/v2.2.0/opencode-darwin-arm64.zip",
110
+ "file": "opencode-darwin-arm64.zip",
111
+ "size": "<bytes you measured>",
112
+ "sha256": "sha256:<hex you computed>",
113
+ "evidence": [],
114
+ "minimumVersion": "2.0.0"
115
+ },
116
+ "review": { "by": "<your GitHub handle>", "on": "<YYYY-MM-DD>", "checked": ["upstream-release", "digest-redownloaded", "evidence-listed"] }
117
+ }
118
+ ```
119
+
120
+ Repeat the entry for `darwin-x64` (`opencode-darwin-x64-baseline.zip`), `linux-x64`
121
+ (`opencode-linux-x64-baseline.tar.gz`) and
122
+ `linux-arm64` (`opencode-linux-arm64.tar.gz`). `size` must be an integer. The placeholders above are deliberately
123
+ invalid, so an unfilled template fails `bootstrap-release-pins.test.ts`.
124
+
125
+ ### Reviewer instructions
126
+
127
+ 1. Open the release page for the tag. Confirm it is the 2.x line, published by the upstream workflow, and that the
128
+ asset name is exactly the target's asset in the table above.
129
+ 2. Download the asset yourself, then measure and hash it:
130
+ `curl -fsSLo f "$URL" && wc -c < f && shasum -a 256 f`.
131
+ 3. Cross-check against both upstream values: the asset's line in the release's `SHA256SUMS`, and `assets[].digest`
132
+ from `https://api.github.com/repos/anomalyco/opencode/releases/tags/v<version>`. If any value differs, stop.
133
+ 4. List the archive and confirm it holds exactly one regular file, `opencode`: `unzip -Z1 f` or `tar -tzvf f`. The
134
+ adapter refuses anything else.
135
+ 5. Keep `evidence: []`, because OpenCode publishes no signatures. Set `minimumVersion` to `2.0.0`, or higher to
136
+ raise the floor after a security release.
137
+ 6. Fill in `review` and open the PR. Someone other than the author reviews it (`release/README.md`).
138
+
139
+ ## Integration requirements (HBS-13)
140
+
141
+ HBS-13 made these edits (`packages/cli/src/setup/`, docs/bootstrap-contracts.md §7): A1 at the composition layer
142
+ (refusals become envelope errors), A2 (`setup/dispatch.ts`), the spoke probe (`GET /spoke/presence`), the launchers
143
+ (they prefer `~/.local/bin/hubctl`) and A4 (the shared fixtures use `plugins`). A3, A5 and A6 stay open (§7.6).
144
+ The original list:
145
+
146
+ - **Construction.** `packages/cli/src/cli.ts` (setup preview, apply and recover):
147
+ - Build `createOpencodeHarness({ probe: createNodeProbe(), manifest: <verified manifest>, pluginVersion?: <hubctl
148
+ version>, spoke: <SpokeProbe>, installed, floor, allowDowngrade })`.
149
+ - Register `.adapter` in the `AdapterRegistry` passed to **both** `previewSetup` and `revalidateApply`, with
150
+ identical options, so the plan digests match.
151
+ - **Refusals.** `planSetup` calls `adapter.plan()`, which **throws `OpencodePlanError`** (`code`: `conflict`,
152
+ `manual_edit_required`, `unpinned_artifact`, `preview_stale` or `invalid`). Catch it around
153
+ `previewSetup`/`revalidateApply` and print it as the envelope error with `exitFor(code)`. Amendment A1 would make
154
+ this unnecessary.
155
+ - **Executors.** The `ExecutorMap` holds one executor per kind. `register-mcp-server` and `verify-harness` must
156
+ dispatch on `action.harness` to each adapter's executor: `harness.executors(() => adapterContext)` for OpenCode.
157
+ `builtinExecutors({ harness })` takes one `HarnessSource`, so the integrator needs a source that dispatches on
158
+ `action.harness` (OpenCode: `harness.source`). See A2.
159
+ - **Adapter context.** `AdapterContext.env` must be the user's real `process.env`: the provider probe needs the user's
160
+ own provider configuration. `now: Date.now`.
161
+ - **Spoke probe.** The hub needs a machine-credential query that answers "is an OpenCode spoke of this machine
162
+ connected?". Today `GET /api/machines` (user auth) lists `processes` per machine. Without a `SpokeProbe`, OpenCode
163
+ is never ready, which is correct; since contract v1.2 it only blocks a strict (`--require-ready`) apply.
164
+ - **Authority.** The MCP server runs `user-bin:hubctl`, which this plan's `install-artifact` installs.
165
+ `src/spoke/launchers.ts` writes `hub`/`hubctl` launchers into the hub state dir that run the CLI bundle from the
166
+ plugin's install (cache) directory. They must not become what services or MCP clients depend on. Suggested
167
+ change: when a standalone `user-bin:hubctl` exists, the launchers exec it instead of the bundle.
168
+ - **No dependencies.** No new dependency and no `package.json` change. zlib, `child_process` and fetch come from the
169
+ runtime.
170
+
171
+ ## Amendment requests (contracts are not modified here)
172
+
173
+ - **A1.** `HarnessAdapter.plan` returns only `Action[]`, so it cannot report a refusal or findings (drift, unpinned
174
+ plugin, stale MCP authority).
175
+ - Proposal: `plan` may return `{ actions, findings?, refusal?: {code, message} }`, or `planSetup` catches a
176
+ contract-defined `PlanRefusal` and returns it as a failed `Result`. Findings would appear in the `SetupPreview`
177
+ (a new optional `findings[]`).
178
+ - **A2.** `ExecutorMap` and `BuiltinDeps.harness` allow one executor and one source per kind, but three adapters
179
+ each supply `register-mcp-server`, `verify-harness` and a `HarnessSource`.
180
+ - Proposal: a `harnessDispatch` helper in `transaction/`, or `ExecutorMap` entries keyed `kind` →
181
+ `Record<HarnessID, ActionExecutor>` for harness-scoped kinds.
182
+ - **A3.** An unparseable JSONC config is only a bare `manual_edit_required` at plan time. The planner turns TOML,
183
+ not broken JSONC, into an `unmergeable-format` manual edit.
184
+ - Proposal: let an adapter mark a config action unmergeable, so the user gets the structured edit.
185
+ - **A4.** The shared fixtures use OpenCode 1 keys: `ACTIONS["merge-config"]` sets `/plugin/-`, and the discovery
186
+ fixture's `OPENCODE_CONFIG` has `plugin: []`. OpenCode 2 uses `plugins`.
187
+ - **A5.** `install-harness.previous` and `select-harness-version.previous` need a `sha256` of the previous version,
188
+ which discovery does not record, so the adapter omits `previous`.
189
+ - Proposal: discovery records the tree digest of a hub-managed version directory, or the history supplies it.
190
+ - **A6.** Nothing puts a hub-installed OpenCode on PATH, because `link-executable` is reserved for the hub ↔ hubctl
191
+ alias.
192
+ - Proposal: allow `link-executable` from `user-bin:opencode` to `harnessCurrentRef("opencode")/bin/opencode`, or
193
+ decide that services and launches always use the absolute path.
194
+
195
+ ## Open questions
196
+
197
+ - Should the plugin default to being pinned to hubctl's own version? Today it is unpinned unless `pluginVersion` is
198
+ passed, and an existing unpinned entry is kept as the user's choice.
199
+ - `OPENCODE_CONFIG` / `OPENCODE_CONFIG_DIR` and project-level configs are not considered: discovery reports only
200
+ `config:opencode/opencode.json(c)`.
201
+ - Is `opencode models` strong enough as `provider-auth`, or should HBS-13 inject a one-token completion probe that
202
+ 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?
@@ -0,0 +1,205 @@
1
+ // What the OpenCode adapter gives the transactional applier (transaction/executor.ts): the HarnessSource for its
2
+ // pinned downloads and the executors for the two action kinds the built-ins leave to adapters.
3
+ //
4
+ // HarnessSource fetch: exactly the URL hub's verified manifest pins (checkHarnessPin), bounded by the
5
+ // pinned size, over https only, streamed into the step's staging area (never held whole);
6
+ // OpenCode publishes no upstream evidence, so only the pinned digest vouches for the bytes
7
+ // (installHarnessExecutor checks the staging area's digest before unpack is called).
8
+ // unpack: archive.ts (streams the binary into the staging area).
9
+ // register-mcp-server hubctl's `mcp` at the stable authority under mcp.servers.hub, decided from the file as it is
10
+ // at apply (config.ts) and spliced in by the lossless JSONC merge; only while the file is as
11
+ // previewed (precondition); a foreign "hub" server is a conflict. Journal class config: backed up,
12
+ // so a rollback restores it unless the user changed it since.
13
+ // verify-harness runs the adapter's real probes and classifies them (contract v1.2, harnessReadiness): an
14
+ // integrity failure (the binary is missing, does not run, is not the pinned version) fails
15
+ // the apply (harness_not_ready), which rolls the transaction back; with requireReady (the
16
+ // default) so does anything short of isHarnessReady; without it, a harness that only waits on
17
+ // the user (provider login, spoke) passes as "installed, not ready" (onReadiness).
18
+ import {
19
+ checkHarnessPin,
20
+ harnessReadiness,
21
+ isHarnessReady,
22
+ PROBE_MAX_AGE_MS,
23
+ type AdapterContext,
24
+ type HarnessAdapter,
25
+ type HarnessID,
26
+ type HarnessVerification,
27
+ type InstallHarness,
28
+ type PathRef,
29
+ type VerifiedManifest,
30
+ } from "../../contracts/index.ts"
31
+ import type { HarnessSource } from "../../transaction/builtin.ts"
32
+ import type { ActionExecutor, ExecutorMap, StepContext } from "../../transaction/executor.ts"
33
+ import { mergeConfigText } from "../../transaction/json-merge.ts"
34
+ import { txFail, txOk } from "../../transaction/result.ts"
35
+ import { responseChunks, type ByteSource } from "../../transaction/stream.ts"
36
+ import { passesAsPending, type ReadinessGate } from "../readiness.ts"
37
+ import { unpackOpencodeFile } from "./archive.ts"
38
+ import { mcpServerSets } from "./config.ts"
39
+ import { checkAuthority, DEFAULT_AUTHORITY } from "./plan.ts"
40
+
41
+ // ---------- downloads ----------
42
+
43
+ export interface DownloadLimits {
44
+ /** The pinned size: a longer body is refused while it streams. */
45
+ readonly maxBytes: number
46
+ readonly signal?: AbortSignal
47
+ }
48
+
49
+ /**
50
+ * Fetches a body (no verification: the executor checks it against the pin): bytes, or chunks as they arrive (the
51
+ * source streams them into the staging area).
52
+ */
53
+ export type Download = (url: string, limits: DownloadLimits) => Promise<ByteSource>
54
+
55
+ /**
56
+ * A Download over fetch: https only (every hop: the final URL too), refused once it exceeds the limit. The status
57
+ * and declared length are checked before it resolves; the body then streams, chunk by chunk, and fails as soon as
58
+ * it runs past the limit.
59
+ */
60
+ export function httpsDownload(fetchImpl: (url: string, init?: RequestInit) => Promise<Response> = fetch): Download {
61
+ return async (url, limits) => {
62
+ if (!url.startsWith("https://")) throw new Error(`refusing to download ${url}: https only`)
63
+ const response = await fetchImpl(url, { redirect: "follow", ...(limits.signal ? { signal: limits.signal } : {}) })
64
+ if (response.url && !response.url.startsWith("https://")) throw new Error(`refusing ${url}: it redirected to a non-https location`)
65
+ if (!response.ok) throw new Error(`download of ${url} failed: HTTP ${response.status}`)
66
+ const declared = Number(response.headers.get("content-length") ?? "")
67
+ if (Number.isFinite(declared) && declared > limits.maxBytes) throw new Error(`${url} is larger than the pinned ${limits.maxBytes} bytes`)
68
+ return responseChunks(response, limits.maxBytes, () => new Error(`${url} is larger than the pinned ${limits.maxBytes} bytes`))
69
+ }
70
+ }
71
+
72
+ // ---------- the harness source ----------
73
+
74
+ export interface OpencodeSourceOptions {
75
+ readonly manifest: VerifiedManifest
76
+ readonly download: Download
77
+ /** The OpenCode hub has installed and made current (downgrade policy), from the integrator's install history. */
78
+ readonly installed?: () => { readonly version: string; readonly sha256: string } | undefined
79
+ /** The highest OpenCode floor this machine has seen. */
80
+ readonly floor?: () => string | undefined
81
+ /** Explicit --allow-downgrade. */
82
+ readonly allowDowngrade?: boolean
83
+ }
84
+
85
+ const onlyOpencode = (harness: HarnessID) => {
86
+ if (harness !== "opencode") throw new Error(`the OpenCode harness source cannot install ${harness}`)
87
+ }
88
+
89
+ /** The HarnessSource for OpenCode artifacts (installHarnessExecutor verifies every byte against the pin). */
90
+ export function createOpencodeHarnessSource(options: OpencodeSourceOptions): HarnessSource {
91
+ return {
92
+ manifest: options.manifest,
93
+ async fetch(action: InstallHarness, staging) {
94
+ onlyOpencode(action.harness)
95
+ const pin = checkHarnessPin(options.manifest, action)
96
+ if (!pin.ok) throw new Error(`refusing to download ${action.source.url}: it is not the artifact hub ${options.manifest.release} pins (${pin.issues[0]!.message})`)
97
+ const body = await options.download(pin.value.url, { maxBytes: pin.value.size })
98
+ const file = await staging.write(action.source.file, body, { maxBytes: pin.value.size })
99
+ // OpenCode publishes no signatures (contract §5): there is no upstream evidence to attempt.
100
+ return { file, evidence: [] }
101
+ },
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}`),
104
+ installed: (harness) => (harness === "opencode" ? options.installed?.() : undefined),
105
+ floor: (harness) => (harness === "opencode" ? options.floor?.() : undefined),
106
+ ...(options.allowDowngrade ? { allowDowngrade: true } : {}),
107
+ }
108
+ }
109
+
110
+ // ---------- executors ----------
111
+
112
+ function preconditionHolds(ctx: StepContext, precondition: { readonly exists: boolean; readonly sha256?: string }): boolean {
113
+ const o = ctx.original
114
+ if (o.symlink) return false
115
+ return precondition.exists ? o.exists && o.sha256 === precondition.sha256 : !o.exists
116
+ }
117
+
118
+ /** register-mcp-server for OpenCode: hubctl's mcp at `authority` (default user-bin:hubctl). */
119
+ export function opencodeMcpExecutor(options: { readonly authority?: PathRef } = {}): ActionExecutor<"register-mcp-server"> {
120
+ const authorityRef = options.authority ?? DEFAULT_AUTHORITY
121
+ checkAuthority(authorityRef)
122
+ return {
123
+ kind: "register-mcp-server",
124
+ plan: (action) => ({ class: "config", path: action.path }),
125
+ check: async (action, ctx) => {
126
+ if (action.harness !== "opencode") return txFail("invalid", `this executor registers MCP servers with OpenCode, not ${action.harness}`)
127
+ if (action.name !== "hub") return txFail("invalid", `hubctl's MCP server is registered as "hub", not ${JSON.stringify(action.name)}`)
128
+ 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 })
130
+ return authority.ok ? txOk(undefined) : authority
131
+ },
132
+ change: async (_action, ctx) => {
133
+ const authority = ctx.resolve(authorityRef, { finalMayBeSymlink: true })
134
+ if (!authority.ok) return authority
135
+ const text = ctx.current.exists ? new TextDecoder().decode(ctx.fs.readFile(ctx.target!)) : undefined
136
+ const sets = mcpServerSets(text, authority.value)
137
+ if (!sets.ok) return sets
138
+ if (sets.value.length === 0) return txOk({ type: "none", reason: "hubctl's MCP server is already registered" })
139
+ const merged = mergeConfigText(text, ctx.target!.endsWith(".jsonc") ? "jsonc" : "json", sets.value)
140
+ if (!merged.ok) return merged
141
+ return txOk({ type: "file", bytes: new TextEncoder().encode(merged.value), mode: ctx.current.mode ?? 0o600, replace: "backup" })
142
+ },
143
+ }
144
+ }
145
+
146
+
147
+ /** verify-harness for OpenCode: ready (isHarnessReady), or only waiting on the user without requireReady; else it fails. */
148
+ export function opencodeVerifyExecutor(
149
+ options: {
150
+ readonly adapter: HarnessAdapter
151
+ readonly context: () => AdapterContext
152
+ /** Sees every verification (for the apply report); cannot change it. */
153
+ readonly onVerification?: (verification: HarnessVerification) => void
154
+ } & ReadinessGate,
155
+ ): ActionExecutor<"verify-harness"> {
156
+ return {
157
+ kind: "verify-harness",
158
+ plan: () => ({ class: "external" }),
159
+ check: async (action) => (action.harness === options.adapter.id ? txOk(undefined) : txFail("invalid", `this executor verifies ${options.adapter.id}, not ${action.harness}`)),
160
+ change: async (action) =>
161
+ txOk({
162
+ type: "effect",
163
+ perform: async () => {
164
+ const context = options.context()
165
+ const verification = await options.adapter.verify(context)
166
+ options.onVerification?.(structuredClone(verification))
167
+ const now = context.now()
168
+ const readiness = harnessReadiness(verification, now, action.probes)
169
+ options.onReadiness?.(structuredClone(readiness))
170
+ // Every required probe (isHarnessReady) and every probe the action asks for: present, passing, fresh.
171
+ const missing = action.probes.filter((kind) => !passing(verification, kind, now))
172
+ if (verification.harness === "opencode" && isHarnessReady(verification, now) && missing.length === 0) return txOk(undefined)
173
+ if (verification.harness === "opencode" && passesAsPending(readiness, options)) return txOk(undefined)
174
+ const failed = verification.probes.filter((p) => !p.ok)
175
+ const why = [
176
+ ...failed.map((p) => `${p.kind}${p.detail ? ` (${p.detail})` : ""}`),
177
+ ...missing.filter((kind) => !failed.some((p) => p.kind === kind)).map((kind) => `${kind} (not run, or stale)`),
178
+ ]
179
+ return txFail("harness_not_ready", `OpenCode is installed but not ready: ${why.join("; ") || "required probes missing"}`)
180
+ },
181
+ }),
182
+ }
183
+ }
184
+
185
+ /** The clock skew isHarnessReady tolerates (contracts/harness.ts). */
186
+ const CLOCK_SKEW_MS = 60_000
187
+
188
+ /** One probe kind present, passing and fresh (the same rule as isHarnessReady, for one kind). */
189
+ const passing = (v: HarnessVerification, kind: string, now: number) => {
190
+ const probes = v.probes.filter((p) => p.kind === kind)
191
+ return probes.length > 0 && probes.every((p) => p.ok && p.observedAt <= now + CLOCK_SKEW_MS && now - p.observedAt <= PROBE_MAX_AGE_MS)
192
+ }
193
+
194
+ /** The OpenCode executors to merge over builtinExecutors (see the integration notes in README.md). */
195
+ export function opencodeExecutors(options: {
196
+ readonly adapter: HarnessAdapter
197
+ readonly context: () => AdapterContext
198
+ readonly authority?: PathRef
199
+ readonly onVerification?: (verification: HarnessVerification) => void
200
+ } & ReadinessGate): Pick<ExecutorMap, "register-mcp-server" | "verify-harness"> {
201
+ return {
202
+ "register-mcp-server": opencodeMcpExecutor(options.authority ? { authority: options.authority } : {}),
203
+ "verify-harness": opencodeVerifyExecutor(options),
204
+ }
205
+ }
@@ -0,0 +1,91 @@
1
+ // Unpacking a verified OpenCode release archive into the files of its immutable version directory (contract §3.6.1,
2
+ // data:hub/harnesses/opencode/<version>). Upstream (github.com/anomalyco/opencode releases) ships exactly one file,
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
5
+ // still refuses anything an archive could smuggle in: links, devices, names that climb out or are absolute, extra
6
+ // files (an installer script beside the binary), a duplicate, or no binary at all. The binary always becomes
7
+ // bin/opencode, mode 0755.
8
+ //
9
+ // Streaming (HBS-12: a 4 GB host was OOM-killed holding the archive and the ~350 MB binary several times over): the
10
+ // archive is read from the staging area in chunks (archive-stream.ts), every member is judged before its body is
11
+ // read, and the binary is streamed straight into a staging file, hashed as it is written. Nothing is held whole.
12
+ import { ArchiveError, readTarGz, zipMemberData, zipMembers } from "../archive-stream.ts"
13
+ import type { TreeFile } from "../../transaction/executor.ts"
14
+ import { txFail, txOk, type TxResult } from "../../transaction/result.ts"
15
+ import { bytesReader, memoryStaging, type ByteReader, type StagedFile, type StagingArea } from "../../transaction/stream.ts"
16
+
17
+ /** Where the binary goes inside the version directory. */
18
+ export const OPENCODE_BINARY_REL = "bin/opencode"
19
+ const BINARY_NAME = "opencode"
20
+ /** A generous bound on what one release may unpack to (the 2.2.0 binary is about 350 MB). */
21
+ export const MAX_UNPACKED_BYTES = 1024 * 1024 * 1024
22
+
23
+ const invalid = (file: string, why: string) => txFail("invalid", `${file}: ${why}; refusing to install it`)
24
+
25
+ /** An archive member name, normalized: "./x" and "x/" are fine; empty, absolute, "..", backslashes and NUL are not. */
26
+ function memberName(raw: string): string | undefined {
27
+ if (raw.startsWith("/") || raw.includes("\\") || raw.includes("\u0000")) return undefined
28
+ const parts = raw.split("/").filter((p) => p !== "" && p !== ".")
29
+ if (parts.some((p) => p === "..")) return undefined
30
+ return parts.join("/")
31
+ }
32
+
33
+ /** 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 {
35
+ const name = memberName(raw)
36
+ if (name === undefined) throw new ArchiveError(`the member ${JSON.stringify(raw)} has an unsafe name`)
37
+ if (kind === "directory" && name === "") return undefined
38
+ 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`)
41
+ return "binary"
42
+ }
43
+
44
+ const tarKind = (type: string) => (type === "0" || type === "7" ? "file" : type === "5" ? "directory" : "other")
45
+
46
+ async function fromTarGz(reader: ByteReader, staging: StagingArea, maxBytes: number): Promise<StagedFile | undefined> {
47
+ let binary: StagedFile | undefined
48
+ // Symlinks (2), hard links (1), devices (3, 4), FIFOs (6), and pax/GNU headers that could rename a member (x, g,
49
+ // L, K) are all "other": refused.
50
+ 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 })
53
+ })
54
+ return binary
55
+ }
56
+
57
+ async function fromZip(reader: ByteReader, staging: StagingArea, maxBytes: number): Promise<StagedFile | undefined> {
58
+ // The whole directory is judged before any member is inflated.
59
+ const members = zipMembers(reader, { maxBytes })
60
+ let chosen: (typeof members)[number] | undefined
61
+ for (const m of members) if (judge(m.name, m.kind, chosen !== undefined) === "binary") chosen = m
62
+ if (!chosen) return undefined
63
+ return staging.write(BINARY_NAME, zipMemberData(reader, chosen), { maxBytes: chosen.size })
64
+ }
65
+
66
+ /**
67
+ * The install tree of a verified OpenCode archive named `file` (its format follows the upstream asset name), read
68
+ * from `reader`; the binary is streamed into `staging`. `maxBytes` (default MAX_UNPACKED_BYTES) bounds what it
69
+ * inflates to.
70
+ */
71
+ export async function unpackOpencodeFile(file: string, reader: ByteReader, staging: StagingArea, options: { readonly maxBytes?: number } = {}): Promise<TxResult<TreeFile[]>> {
72
+ const maxBytes = options.maxBytes ?? MAX_UNPACKED_BYTES
73
+ const zip = file.endsWith(".zip")
74
+ if (!zip && !file.endsWith(".tar.gz") && !file.endsWith(".tgz")) return invalid(file, "not an OpenCode CLI archive (expected .zip or .tar.gz)")
75
+ let binary: StagedFile | undefined
76
+ try {
77
+ binary = zip ? await fromZip(reader, staging, maxBytes) : await fromTarGz(reader, staging, maxBytes)
78
+ } catch (error) {
79
+ if (error instanceof ArchiveError) return invalid(file, error.message)
80
+ throw error
81
+ }
82
+ if (!binary) return invalid(file, `no ${BINARY_NAME} binary inside`)
83
+ return txOk([{ rel: OPENCODE_BINARY_REL, staged: binary, mode: 0o755 }])
84
+ }
85
+
86
+ /** 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[]>> {
88
+ const staging = memoryStaging()
89
+ const r = await unpackOpencodeFile(file, bytesReader(bytes), staging, options)
90
+ return r.ok ? txOk(r.value.map((f) => ("staged" in f ? { rel: f.rel, mode: f.mode, bytes: staging.bytes(f.staged) } : f))) : r
91
+ }