@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.
- package/CHANGELOG.md +70 -0
- package/Dockerfile +3 -0
- package/README.md +148 -11
- package/package.json +3 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/main.ts +4 -4
- package/packages/claude-code/src/wrapper.ts +3 -2
- package/packages/cli/dist/hub-cli.js +19334 -7849
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/main.ts +3 -2
- package/packages/codex/src/spoke.ts +4 -1
- package/packages/codex/src/wrapper.ts +7 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/harnesses.ts +39 -4
- package/packages/node/src/run.ts +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +55 -4
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/sse.ts +4 -1
- package/packages/sdk/src/types.ts +59 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/enroll.ts +26 -4
- package/src/central/agent/app-tokens.ts +27 -17
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/directory-routes.ts +155 -12
- package/src/central/agent/directory.ts +1 -1
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/names.ts +1 -1
- package/src/central/agent/read-routes.ts +32 -36
- package/src/central/agent/routes.ts +14 -11
- package/src/central/agent/watch.ts +65 -174
- package/src/central/agent/webhook-routes.ts +10 -2
- package/src/central/agent/webhooks.ts +49 -5
- package/src/central/auth.ts +127 -1
- package/src/central/backend/index.ts +3 -0
- package/src/central/backend/types.ts +2 -0
- package/src/central/http.ts +115 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/store.ts +53 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +10 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/assets/index-D9zMbxSg.css +0 -1
- package/web/dist/assets/index-db1z4duA.js +0 -20
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// How the Hub plugin and the hubctl MCP server sit in an OpenCode 2 config (opencode.json / opencode.jsonc).
|
|
2
|
+
//
|
|
3
|
+
// OpenCode 2's config schema (@opencode/schema, Config): `plugins` is an array of package specs, each a string or
|
|
4
|
+
// `{ package, options? }`; `mcp.servers` maps a server name to `{ type: "local", command: string[], environment?,
|
|
5
|
+
// disabled?, … }` or `{ type: "remote", url, … }`. This module only reads: it classifies what is there and computes the
|
|
6
|
+
// exact JSON-pointer sets for the lossless JSONC merge (transaction/json-merge.ts), which edits nothing else.
|
|
7
|
+
//
|
|
8
|
+
// plugin absent → append the spec to /plugins; registered (an exact or unpinned npm entry, or the user's own
|
|
9
|
+
// checkout/GitHub install) → nothing; drift (an older pin, or the legacy name @huynle/opencode-voice) →
|
|
10
|
+
// replace just that entry's spec; a duplicate, or a pin newer than this hubctl → conflict (refused).
|
|
11
|
+
// mcp absent → add mcp.servers.hub; registered (hubctl at the stable authority) → nothing; stale (a hub/hubctl
|
|
12
|
+
// `mcp` at another path, e.g. the plugin's launcher in the hub state dir) → replace only its command;
|
|
13
|
+
// another server named hub, or hub's mcp under another name → conflict (refused).
|
|
14
|
+
// text unparseable JSONC or duplicate keys → manual_edit_required; a shape hubctl cannot merge into → conflict.
|
|
15
|
+
import { compareVersions, SEMVER, type ConfigSet, type Json } from "../../contracts/index.ts"
|
|
16
|
+
import { parseJsonc } from "../../transaction/json-merge.ts"
|
|
17
|
+
import { txFail, txOk, type TxResult } from "../../transaction/result.ts"
|
|
18
|
+
|
|
19
|
+
/** The npm package of the Hub OpenCode plugin. */
|
|
20
|
+
export const HUB_PLUGIN_PACKAGE = "@huynle/hub"
|
|
21
|
+
/** The plugin's former name; its last release only re-exports @huynle/hub, so both would load the plugin twice. */
|
|
22
|
+
export const LEGACY_PLUGIN_PACKAGE = "@huynle/opencode-voice"
|
|
23
|
+
/** The name hubctl's MCP server is registered under. */
|
|
24
|
+
export const HUB_MCP_NAME = "hub"
|
|
25
|
+
/** The subcommand an MCP client runs (the command is implied by register-mcp-server, never carried by a plan). */
|
|
26
|
+
export const MCP_SUBCOMMAND = "mcp"
|
|
27
|
+
/** Program names that are hub's one command surface (contract COMMAND_ALIASES). */
|
|
28
|
+
const HUB_PROGRAMS = new Set(["hub", "hubctl"])
|
|
29
|
+
|
|
30
|
+
export interface DesiredRegistration {
|
|
31
|
+
/** The absolute path of the stable hubctl authority the MCP server runs. */
|
|
32
|
+
readonly authority: string
|
|
33
|
+
/** Pin the plugin to this exact version (this hubctl's own); unpinned when absent. */
|
|
34
|
+
readonly pluginVersion?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type PluginSource = "npm" | "legacy" | "custom"
|
|
38
|
+
|
|
39
|
+
export type PluginState =
|
|
40
|
+
| { readonly state: "absent" }
|
|
41
|
+
| { readonly state: "registered"; readonly pointer: string; readonly source: PluginSource; readonly found?: string }
|
|
42
|
+
| { readonly state: "drift"; readonly pointer: string; readonly source: PluginSource; readonly found?: string; readonly object: boolean }
|
|
43
|
+
|
|
44
|
+
export type McpState = { readonly state: "absent" } | { readonly state: "registered" } | { readonly state: "stale"; readonly found: string }
|
|
45
|
+
|
|
46
|
+
export type FindingCode =
|
|
47
|
+
| "plugin-unpinned"
|
|
48
|
+
| "plugin-custom-source"
|
|
49
|
+
| "plugin-legacy-name"
|
|
50
|
+
| "plugin-drift"
|
|
51
|
+
| "mcp-stale-authority"
|
|
52
|
+
| "harness-user-installed"
|
|
53
|
+
| "harness-newer-than-pin"
|
|
54
|
+
|
|
55
|
+
/** Something worth telling the user that needs no refusal (secret-free: specs, versions and paths only). */
|
|
56
|
+
export interface Finding {
|
|
57
|
+
readonly code: FindingCode
|
|
58
|
+
readonly message: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ConfigInspection {
|
|
62
|
+
readonly plugin: PluginState
|
|
63
|
+
readonly mcp: McpState
|
|
64
|
+
readonly findings: readonly Finding[]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type Value = null | boolean | number | string | Value[] | { [key: string]: Value }
|
|
68
|
+
const isObject = (v: unknown): v is { [key: string]: Value } => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
69
|
+
const basename = (p: string) => p.slice(p.lastIndexOf("/") + 1)
|
|
70
|
+
|
|
71
|
+
/** The desired plugin spec: `@huynle/hub`, or `@huynle/hub@<version>` when pinned. */
|
|
72
|
+
export const pluginSpec = (desired: { readonly pluginVersion?: string }): string => (desired.pluginVersion ? `${HUB_PLUGIN_PACKAGE}@${desired.pluginVersion}` : HUB_PLUGIN_PACKAGE)
|
|
73
|
+
|
|
74
|
+
/** What a plugin spec names: the hub plugin (from npm, by its legacy name, or a custom source) or something else. */
|
|
75
|
+
export function classifyPluginSpec(spec: string): { readonly source: PluginSource; readonly version?: string } | undefined {
|
|
76
|
+
const npm = spec.startsWith("npm:") ? spec.slice(4) : spec
|
|
77
|
+
for (const [pkg, source] of [
|
|
78
|
+
[HUB_PLUGIN_PACKAGE, "npm"],
|
|
79
|
+
[LEGACY_PLUGIN_PACKAGE, "legacy"],
|
|
80
|
+
] as const) {
|
|
81
|
+
if (npm === pkg) return { source }
|
|
82
|
+
if (npm.startsWith(`${pkg}@`)) {
|
|
83
|
+
const v = npm.slice(pkg.length + 1)
|
|
84
|
+
return SEMVER.test(v) ? { source, version: v } : { source }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// The user's own install of the plugin: a GitHub spec, or a checkout named hub (README: { "package": "/path/to/hub" }).
|
|
88
|
+
if (/^(?:github:|git\+https:\/\/github\.com\/|https:\/\/github\.com\/)huynle\/hub(?:\.git)?(?:#.*)?$/.test(spec)) return { source: "custom" }
|
|
89
|
+
const path = spec.startsWith("file://") ? spec.slice(7) : spec
|
|
90
|
+
if (path.startsWith("/") && basename(path.replace(/\/+$/, "")) === "hub") return { source: "custom" }
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const specOf = (entry: Value): string | undefined => (typeof entry === "string" ? entry : isObject(entry) && typeof entry.package === "string" ? entry.package : undefined)
|
|
95
|
+
|
|
96
|
+
const parse = (text: string): TxResult<{ [key: string]: Value }> => {
|
|
97
|
+
const parsed = parseJsonc(text)
|
|
98
|
+
if (!parsed.ok) return txFail("manual_edit_required", `the OpenCode config cannot be edited losslessly (${parsed.message}); make the edit by hand`)
|
|
99
|
+
if (!isObject(parsed.value)) return txFail("conflict", "the OpenCode config is not a JSON object")
|
|
100
|
+
return txOk(parsed.value)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function inspectPlugin(config: { [key: string]: Value }, desired: DesiredRegistration, findings: Finding[]): TxResult<PluginState> {
|
|
104
|
+
const found: { pointer: string; spec: string; object: boolean; source: PluginSource; version?: string }[] = []
|
|
105
|
+
// `plugins` is OpenCode 2's key; `plugin` is OpenCode 1's, still found in older configs.
|
|
106
|
+
for (const key of ["plugins", "plugin"]) {
|
|
107
|
+
const list = config[key]
|
|
108
|
+
if (list === undefined) continue
|
|
109
|
+
if (!Array.isArray(list)) return txFail("conflict", `"${key}" in the OpenCode config is not a list; fix it by hand`)
|
|
110
|
+
list.forEach((entry, i) => {
|
|
111
|
+
const spec = specOf(entry)
|
|
112
|
+
const kind = spec === undefined ? undefined : classifyPluginSpec(spec)
|
|
113
|
+
if (kind) found.push({ pointer: `/${key}/${i}`, spec: spec!, object: isObject(entry), ...kind })
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
if (found.length === 0) return txOk({ state: "absent" })
|
|
117
|
+
if (found.length > 1)
|
|
118
|
+
return txFail("conflict", `the Hub plugin is listed ${found.length} times (${found.map((f) => `${f.pointer} ${f.spec}`).join(", ")}); OpenCode would load only one of them, so remove all but one by hand`)
|
|
119
|
+
const entry = found[0]!
|
|
120
|
+
const base = { pointer: entry.pointer, source: entry.source, ...(entry.version ? { found: entry.version } : {}) }
|
|
121
|
+
if (entry.source === "custom") {
|
|
122
|
+
findings.push({ code: "plugin-custom-source", message: `the Hub plugin comes from ${entry.spec} (your own install); hubctl leaves it as it is` })
|
|
123
|
+
return txOk({ state: "registered", ...base })
|
|
124
|
+
}
|
|
125
|
+
if (entry.source === "legacy") {
|
|
126
|
+
findings.push({ code: "plugin-legacy-name", message: `${entry.spec} is the plugin's old name; it becomes ${pluginSpec(desired)}` })
|
|
127
|
+
return txOk({ state: "drift", ...base, object: entry.object })
|
|
128
|
+
}
|
|
129
|
+
if (!desired.pluginVersion) return txOk({ state: "registered", ...base })
|
|
130
|
+
if (!entry.version) {
|
|
131
|
+
findings.push({ code: "plugin-unpinned", message: `${entry.spec} tracks the latest release; this hubctl is ${desired.pluginVersion}` })
|
|
132
|
+
return txOk({ state: "registered", ...base })
|
|
133
|
+
}
|
|
134
|
+
const order = compareVersions(entry.version, desired.pluginVersion)
|
|
135
|
+
if (order === 0) return txOk({ state: "registered", ...base })
|
|
136
|
+
if (order > 0) return txFail("conflict", `the OpenCode config pins the Hub plugin ${entry.version}, newer than this hubctl (${desired.pluginVersion}); upgrade hubctl or change the pin by hand`)
|
|
137
|
+
findings.push({ code: "plugin-drift", message: `the Hub plugin is pinned to ${entry.version}; it becomes ${pluginSpec(desired)}` })
|
|
138
|
+
return txOk({ state: "drift", ...base, object: entry.object })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const isHubMcp = (server: Value): server is { [key: string]: Value } & { command: string[] } =>
|
|
142
|
+
isObject(server) &&
|
|
143
|
+
server.type === "local" &&
|
|
144
|
+
Array.isArray(server.command) &&
|
|
145
|
+
server.command.length === 2 &&
|
|
146
|
+
server.command.every((w) => typeof w === "string") &&
|
|
147
|
+
HUB_PROGRAMS.has(basename(server.command[0] as string)) &&
|
|
148
|
+
server.command[1] === MCP_SUBCOMMAND
|
|
149
|
+
|
|
150
|
+
function inspectMcp(config: { [key: string]: Value }, desired: DesiredRegistration, findings: Finding[]): TxResult<McpState> {
|
|
151
|
+
const mcp = config.mcp
|
|
152
|
+
if (mcp === undefined) return txOk({ state: "absent" })
|
|
153
|
+
if (!isObject(mcp)) return txFail("conflict", '"mcp" in the OpenCode config is not an object; fix it by hand')
|
|
154
|
+
const servers = mcp.servers
|
|
155
|
+
if (servers === undefined) return txOk({ state: "absent" })
|
|
156
|
+
if (!isObject(servers)) return txFail("conflict", '"mcp.servers" in the OpenCode config is not an object; fix it by hand')
|
|
157
|
+
const elsewhere = Object.entries(servers).find(([name, server]) => name !== HUB_MCP_NAME && isHubMcp(server))
|
|
158
|
+
if (elsewhere) return txFail("conflict", `hub's MCP server is already registered as "${elsewhere[0]}"; remove it or rename it to "${HUB_MCP_NAME}" by hand`)
|
|
159
|
+
const server = servers[HUB_MCP_NAME]
|
|
160
|
+
if (server === undefined) return txOk({ state: "absent" })
|
|
161
|
+
if (!isHubMcp(server)) return txFail("conflict", `another MCP server is already named "${HUB_MCP_NAME}" in the OpenCode config; hubctl will not replace it`)
|
|
162
|
+
if (server.command[0] === desired.authority) return txOk({ state: "registered" })
|
|
163
|
+
findings.push({ code: "mcp-stale-authority", message: `the hub MCP server runs ${server.command[0]}; it becomes ${desired.authority}` })
|
|
164
|
+
return txOk({ state: "stale", found: server.command[0]! })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Classifies the config's Hub registrations (undefined text: the file does not exist). Reads only. */
|
|
168
|
+
export function inspectOpencodeConfig(text: string | undefined, desired: DesiredRegistration): TxResult<ConfigInspection> {
|
|
169
|
+
if (text === undefined) return txOk({ plugin: { state: "absent" }, mcp: { state: "absent" }, findings: [] })
|
|
170
|
+
const config = parse(text)
|
|
171
|
+
if (!config.ok) return config
|
|
172
|
+
const findings: Finding[] = []
|
|
173
|
+
const plugin = inspectPlugin(config.value, desired, findings)
|
|
174
|
+
if (!plugin.ok) return plugin
|
|
175
|
+
const mcp = inspectMcp(config.value, desired, findings)
|
|
176
|
+
if (!mcp.ok) return mcp
|
|
177
|
+
return txOk({ plugin: plugin.value, mcp: mcp.value, findings })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The merge-config sets that register the plugin (none when it already is). */
|
|
181
|
+
export function pluginSets(plugin: PluginState, desired: { readonly pluginVersion?: string }): ConfigSet[] {
|
|
182
|
+
const spec = pluginSpec(desired)
|
|
183
|
+
switch (plugin.state) {
|
|
184
|
+
case "absent":
|
|
185
|
+
return [{ pointer: "/plugins/-", value: spec }]
|
|
186
|
+
case "drift":
|
|
187
|
+
return [{ pointer: plugin.object ? `${plugin.pointer}/package` : plugin.pointer, value: spec }]
|
|
188
|
+
case "registered":
|
|
189
|
+
return []
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The MCP server entry hubctl registers: this hubctl's `mcp` subcommand at the stable authority. */
|
|
194
|
+
export const mcpServerValue = (authority: string): Json => ({ type: "local", command: [authority, MCP_SUBCOMMAND] })
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The sets that register hubctl's MCP server in this config text (computed at apply, from the file as it is then):
|
|
198
|
+
* the whole entry when absent, only the command when it is stale, nothing when it is registered.
|
|
199
|
+
*/
|
|
200
|
+
export function mcpServerSets(text: string | undefined, authority: string): TxResult<ConfigSet[]> {
|
|
201
|
+
const inspected = inspectOpencodeConfig(text, { authority })
|
|
202
|
+
if (!inspected.ok) return inspected
|
|
203
|
+
const at = `/mcp/servers/${HUB_MCP_NAME}`
|
|
204
|
+
switch (inspected.value.mcp.state) {
|
|
205
|
+
case "absent":
|
|
206
|
+
return txOk([{ pointer: at, value: mcpServerValue(authority) }])
|
|
207
|
+
case "stale":
|
|
208
|
+
return txOk([{ pointer: `${at}/command`, value: [authority, MCP_SUBCOMMAND] }])
|
|
209
|
+
case "registered":
|
|
210
|
+
return txOk([])
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The OpenCode harness adapter (HBS-06): one entry point for the integrator (HBS-13), built on the frozen bootstrap
|
|
2
|
+
// contracts. createOpencodeHarness wires the pure planner (plan.ts), discovery-backed presence, the real readiness
|
|
3
|
+
// probes (verify.ts), the pinned-artifact source (apply.ts, archive.ts) and the executors for register-mcp-server
|
|
4
|
+
// and verify-harness. Every capability is injected: a read-only DiscoveryProbe, a fixed-argv ProcessRunner, the hub
|
|
5
|
+
// spoke probe, the download. See README.md for the upstream source findings and the integration requirements.
|
|
6
|
+
import type { AdapterContext, HarnessAdapter, HarnessPresence, HarnessVerification, PathRef, VerifiedManifest } from "../../contracts/index.ts"
|
|
7
|
+
import { discover } from "../../discovery/discover.ts"
|
|
8
|
+
import type { DiscoveryProbe } from "../../discovery/probe.ts"
|
|
9
|
+
import type { HarnessSource } from "../../transaction/builtin.ts"
|
|
10
|
+
import type { ExecutorMap } from "../../transaction/executor.ts"
|
|
11
|
+
import type { ReadinessGate } from "../readiness.ts"
|
|
12
|
+
import { createOpencodeHarnessSource, httpsDownload, opencodeExecutors, type Download } from "./apply.ts"
|
|
13
|
+
import { createOpencodeAdapter, type OpencodePlanOptions } from "./plan.ts"
|
|
14
|
+
import { locateOpencodeBinary, nodeProcessRunner, verifyOpencode, type ProcessRunner, type ProviderProbe, type SpokeProbe } from "./verify.ts"
|
|
15
|
+
|
|
16
|
+
export * from "./archive.ts"
|
|
17
|
+
export * from "./config.ts"
|
|
18
|
+
export * from "./plan.ts"
|
|
19
|
+
export * from "./verify.ts"
|
|
20
|
+
export * from "./apply.ts"
|
|
21
|
+
|
|
22
|
+
export interface OpencodeHarnessOptions extends Omit<OpencodePlanOptions, "readConfig"> {
|
|
23
|
+
/** Read-only host access (createNodeProbe() in the CLI, memoryProbe() in tests). */
|
|
24
|
+
readonly probe: DiscoveryProbe
|
|
25
|
+
/** Default: nodeProcessRunner (execFile, no shell). */
|
|
26
|
+
readonly runner?: ProcessRunner
|
|
27
|
+
/** Whether the hub sees this machine's OpenCode spoke; without it OpenCode is never ready. */
|
|
28
|
+
readonly spoke?: SpokeProbe
|
|
29
|
+
readonly provider?: ProviderProbe
|
|
30
|
+
/** Default: httpsDownload(fetch). */
|
|
31
|
+
readonly download?: Download
|
|
32
|
+
/** Downgrade-policy inputs for the harness source (from the install history). */
|
|
33
|
+
readonly installed?: () => { readonly version: string; readonly sha256: string } | undefined
|
|
34
|
+
readonly floor?: () => string | undefined
|
|
35
|
+
readonly allowDowngrade?: boolean
|
|
36
|
+
readonly onVerification?: (verification: HarnessVerification) => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface OpencodeHarness {
|
|
40
|
+
readonly adapter: HarnessAdapter
|
|
41
|
+
/** Absent without a manifest (nothing can be installed then). */
|
|
42
|
+
readonly source?: HarnessSource
|
|
43
|
+
/** Executors for apply's context: merge them over builtinExecutors. `gate`: the v1.2 readiness gate. */
|
|
44
|
+
executors(context: () => AdapterContext, gate?: ReadinessGate): Pick<ExecutorMap, "register-mcp-server" | "verify-harness">
|
|
45
|
+
}
|
|
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
|
+
export function createOpencodeHarness(options: OpencodeHarnessOptions): OpencodeHarness {
|
|
51
|
+
const { probe } = options
|
|
52
|
+
const runner = options.runner ?? nodeProcessRunner
|
|
53
|
+
const presence = async (context: AdapterContext): Promise<HarnessPresence> => {
|
|
54
|
+
const snap = discover(probe, { now: context.now() })
|
|
55
|
+
const found = snap.ok ? snap.value.harnesses.find((h) => h.id === "opencode") : undefined
|
|
56
|
+
return found ?? { id: "opencode", installed: false, health: { state: "unverified", probes: [] } }
|
|
57
|
+
}
|
|
58
|
+
const verify = async (context: AdapterContext): Promise<HarnessVerification> => {
|
|
59
|
+
// Located fresh at every verification: after an install, `current` points at the new version.
|
|
60
|
+
const located = locateOpencodeBinary(probe)
|
|
61
|
+
const binary = located.ok ? located.value : undefined
|
|
62
|
+
const expectVersion = binary ? HUB_VERSION.exec(binary)?.[1] : undefined
|
|
63
|
+
return verifyOpencode(context, { runner, binary, ...(options.spoke ? { spoke: options.spoke } : {}), ...(options.provider ? { provider: options.provider } : {}), ...(expectVersion ? { expectVersion } : {}) })
|
|
64
|
+
}
|
|
65
|
+
const adapter = createOpencodeAdapter({ ...options, readConfig: (path) => probe.readFile(path), discover: presence, verify })
|
|
66
|
+
const source = options.manifest
|
|
67
|
+
? createOpencodeHarnessSource({
|
|
68
|
+
manifest: options.manifest as VerifiedManifest,
|
|
69
|
+
download: options.download ?? httpsDownload(),
|
|
70
|
+
...(options.installed ? { installed: options.installed } : {}),
|
|
71
|
+
...(options.floor ? { floor: options.floor } : {}),
|
|
72
|
+
...(options.allowDowngrade ? { allowDowngrade: true } : {}),
|
|
73
|
+
})
|
|
74
|
+
: undefined
|
|
75
|
+
return {
|
|
76
|
+
adapter,
|
|
77
|
+
...(source ? { source } : {}),
|
|
78
|
+
executors: (context, gate = {}) =>
|
|
79
|
+
opencodeExecutors({ adapter, context, ...gate, ...(options.authority ? { authority: options.authority as PathRef } : {}), ...(options.onVerification ? { onVerification: options.onVerification } : {}) }),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// Planning OpenCode (contract §3.10): the OpenCode HarnessAdapter's plan, a pure function of the discovery snapshot,
|
|
2
|
+
// the profile and what the adapter was built with (the verified hub manifest, a read-only reader for the configs the
|
|
3
|
+
// snapshot already found, the stable hubctl authority). It proposes, in this order (preview/plan.ts PHASE):
|
|
4
|
+
//
|
|
5
|
+
// install-harness the artifact hub's signed manifest pins for this host's exact target (§3.6.1), into its
|
|
6
|
+
// own immutable version directory; only when OpenCode is missing (or `install: "always"`),
|
|
7
|
+
// or hub manages it at another, older version. Never a downgrade from planning.
|
|
8
|
+
// select-harness-version makes that version `current`.
|
|
9
|
+
// merge-config the Hub plugin in `plugins` (lossless JSONC merge; nothing else changes).
|
|
10
|
+
// register-mcp-server hubctl's `mcp` at the stable authority (the command is implied by the action kind).
|
|
11
|
+
// verify-harness real probes: the binary runs, a provider answers, the spoke is connected.
|
|
12
|
+
//
|
|
13
|
+
// Determinism: config bytes are read only for files the snapshot lists, and must still have the snapshot's digest
|
|
14
|
+
// (else preview_stale), so the same snapshot always gives the same plan. A config hubctl must not edit (a duplicate
|
|
15
|
+
// plugin, a newer pin, a foreign "hub" server, an unparseable file) is refused, never half-planned: planOpencode
|
|
16
|
+
// returns the failure, and the adapter's plan() throws it as an OpencodePlanError (HarnessAdapter.plan returns only
|
|
17
|
+
// actions). A config behind a symlink is not this module's concern: planSetup turns those actions into manual edits.
|
|
18
|
+
import {
|
|
19
|
+
compareVersions,
|
|
20
|
+
executionContextOf,
|
|
21
|
+
harnessCurrentRef,
|
|
22
|
+
harnessVersionRef,
|
|
23
|
+
REQUIRED_PROBES,
|
|
24
|
+
resolvePathRef,
|
|
25
|
+
type Action,
|
|
26
|
+
type ConfigFormat,
|
|
27
|
+
type DiscoverySnapshot,
|
|
28
|
+
type ErrorCode,
|
|
29
|
+
type HarnessAdapter,
|
|
30
|
+
type HarnessPin,
|
|
31
|
+
type HarnessVerification,
|
|
32
|
+
type AdapterContext,
|
|
33
|
+
type PathRef,
|
|
34
|
+
type PlanInput,
|
|
35
|
+
type Precondition,
|
|
36
|
+
type VerifiedManifest,
|
|
37
|
+
} from "../../contracts/index.ts"
|
|
38
|
+
import { txFail, txOk, type TxResult } from "../../transaction/result.ts"
|
|
39
|
+
import { sha256Of } from "../../transaction/state.ts"
|
|
40
|
+
import { inspectOpencodeConfig, pluginSets, pluginSpec, type ConfigInspection, type Finding } from "./config.ts"
|
|
41
|
+
|
|
42
|
+
/** The Hub plugin is an OpenCode 2 plugin (the @opencode/plugin 2 API): no older OpenCode is ever installed for it. */
|
|
43
|
+
export const MIN_OPENCODE_VERSION = "2.0.0"
|
|
44
|
+
|
|
45
|
+
/** The stable hubctl authority: the standalone hubctl hub installs (contract install-artifact, user-bin:hubctl). */
|
|
46
|
+
export const DEFAULT_AUTHORITY: PathRef = { root: "user-bin", rel: "hubctl" }
|
|
47
|
+
/** Authorities the MCP server may run: hubctl or its hub alias (one command surface), in a bin root. */
|
|
48
|
+
const AUTHORITY_ROOTS = new Set<PathRef["root"]>(["user-bin", "system-bin"])
|
|
49
|
+
const AUTHORITY_NAMES = new Set(["hubctl", "hub"])
|
|
50
|
+
|
|
51
|
+
/** The config files OpenCode reads from its global config dir, in the order hubctl prefers to edit them. */
|
|
52
|
+
export const CONFIG_REFS: readonly { readonly ref: PathRef; readonly format: ConfigFormat }[] = [
|
|
53
|
+
{ ref: { root: "config", rel: "opencode/opencode.jsonc" }, format: "jsonc" },
|
|
54
|
+
{ ref: { root: "config", rel: "opencode/opencode.json" }, format: "json" },
|
|
55
|
+
]
|
|
56
|
+
/** The file a new config is created as (plain JSON is valid JSONC too). */
|
|
57
|
+
const NEW_CONFIG = CONFIG_REFS[1]!
|
|
58
|
+
|
|
59
|
+
export interface OpencodePlanOptions {
|
|
60
|
+
/** The verified hub release manifest whose pins vouch for OpenCode downloads; without it nothing is installed. */
|
|
61
|
+
readonly manifest?: VerifiedManifest
|
|
62
|
+
/** Reads a file the snapshot listed (DiscoveryProbe.readFile): read-only, following links. */
|
|
63
|
+
readonly readConfig: (path: string) => Uint8Array | undefined
|
|
64
|
+
/** The hubctl the MCP server runs (default user-bin:hubctl; user-bin:hub is the same command surface). */
|
|
65
|
+
readonly authority?: PathRef
|
|
66
|
+
/** Pin the Hub plugin to this exact version (this hubctl's own); unpinned when absent. */
|
|
67
|
+
readonly pluginVersion?: string
|
|
68
|
+
/** "if-missing" (default): keep an OpenCode the user installed. "always": install hub's pinned one beside it. */
|
|
69
|
+
readonly install?: "if-missing" | "always"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface OpencodePlan {
|
|
73
|
+
readonly actions: readonly Action[]
|
|
74
|
+
readonly findings: readonly Finding[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A refusal from the adapter's plan(): the config or host cannot be planned for without overwriting something. */
|
|
78
|
+
export class OpencodePlanError extends Error {
|
|
79
|
+
constructor(
|
|
80
|
+
readonly code: ErrorCode,
|
|
81
|
+
message: string,
|
|
82
|
+
) {
|
|
83
|
+
super(message)
|
|
84
|
+
this.name = "OpencodePlanError"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Throws if `ref` is not a stable hubctl authority (never a plugin-cache launcher or a path under state/data). */
|
|
89
|
+
export function checkAuthority(ref: PathRef): void {
|
|
90
|
+
if (!AUTHORITY_ROOTS.has(ref.root) || !AUTHORITY_NAMES.has(ref.rel))
|
|
91
|
+
throw new Error(`the MCP authority must be the installed hubctl (or its hub alias) in a bin directory, not ${ref.root}:${ref.rel}`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const LABEL = "OpenCode"
|
|
95
|
+
|
|
96
|
+
function installActions(snapshot: DiscoverySnapshot, manifest: VerifiedManifest, pin: HarnessPin): Action[] {
|
|
97
|
+
return [
|
|
98
|
+
{
|
|
99
|
+
id: "install-opencode",
|
|
100
|
+
kind: "install-harness",
|
|
101
|
+
escalation: "user",
|
|
102
|
+
summary: `Install ${LABEL} ${pin.version} for ${pin.target} (pinned by hub ${manifest.release})`,
|
|
103
|
+
harness: "opencode",
|
|
104
|
+
version: pin.version,
|
|
105
|
+
target: pin.target,
|
|
106
|
+
source: { manifest: manifest.release, url: pin.url, file: pin.file },
|
|
107
|
+
size: pin.size,
|
|
108
|
+
sha256: pin.sha256,
|
|
109
|
+
evidence: [...pin.evidence],
|
|
110
|
+
path: harnessVersionRef("opencode", pin.version),
|
|
111
|
+
owner: "hub",
|
|
112
|
+
rollback: "restore-previous",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "select-opencode",
|
|
116
|
+
kind: "select-harness-version",
|
|
117
|
+
escalation: "user",
|
|
118
|
+
summary: `Make ${LABEL} ${pin.version} the current version`,
|
|
119
|
+
harness: "opencode",
|
|
120
|
+
version: pin.version,
|
|
121
|
+
path: harnessCurrentRef("opencode"),
|
|
122
|
+
target: harnessVersionRef("opencode", pin.version),
|
|
123
|
+
},
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Whether (and what) to install. */
|
|
128
|
+
function planInstall(snapshot: DiscoverySnapshot, options: OpencodePlanOptions, findings: Finding[]): TxResult<Action[]> {
|
|
129
|
+
const presence = snapshot.harnesses.find((h) => h.id === "opencode")
|
|
130
|
+
const installed = presence?.installed === true
|
|
131
|
+
const pin = options.manifest?.harnesses.find((h) => h.harness === "opencode" && h.target === snapshot.host.target)
|
|
132
|
+
if (pin && compareVersions(pin.version, MIN_OPENCODE_VERSION) < 0) return txFail("invalid", `hub pins ${LABEL} ${pin.version}, but the Hub plugin needs ${LABEL} ${MIN_OPENCODE_VERSION} or later`)
|
|
133
|
+
if (presence?.installMethod === "hub" && presence.version) {
|
|
134
|
+
if (!pin || pin.version === presence.version) return txOk([])
|
|
135
|
+
if (compareVersions(pin.version, presence.version) < 0) {
|
|
136
|
+
findings.push({ code: "harness-newer-than-pin", message: `hub manages ${LABEL} ${presence.version}, newer than the pinned ${pin.version}; planning never downgrades it` })
|
|
137
|
+
return txOk([])
|
|
138
|
+
}
|
|
139
|
+
return txOk(installActions(snapshot, options.manifest!, pin))
|
|
140
|
+
}
|
|
141
|
+
if (installed && (options.install ?? "if-missing") === "if-missing") {
|
|
142
|
+
findings.push({ code: "harness-user-installed", message: `${LABEL} is already installed at ${presence!.path} (${presence!.installMethod ?? "unknown"}); hubctl keeps it` })
|
|
143
|
+
return txOk([])
|
|
144
|
+
}
|
|
145
|
+
if (!pin || !options.manifest) return txFail("unpinned_artifact", `this hub release pins no ${LABEL} for ${snapshot.host.target}; install ${LABEL} 2 yourself, or use a hub release that pins it`)
|
|
146
|
+
return txOk(installActions(snapshot, options.manifest, pin))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface ConfigView {
|
|
150
|
+
readonly ref: PathRef
|
|
151
|
+
readonly format: ConfigFormat
|
|
152
|
+
readonly precondition: Precondition
|
|
153
|
+
readonly inspection: ConfigInspection
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The configs the snapshot found, read and inspected (their bytes pinned by the snapshot's digests). */
|
|
157
|
+
function readConfigs(snapshot: DiscoverySnapshot, options: OpencodePlanOptions, authority: string): TxResult<ConfigView[]> {
|
|
158
|
+
const context = executionContextOf(snapshot)
|
|
159
|
+
const views: ConfigView[] = []
|
|
160
|
+
for (const { ref, format } of CONFIG_REFS) {
|
|
161
|
+
const where = resolvePathRef(ref, snapshot.roots, context)
|
|
162
|
+
if (!where.ok) return txFail("invalid", `this host has no ${ref.root} root`)
|
|
163
|
+
const found = snapshot.configs.find((c) => c.path === where.value)
|
|
164
|
+
if (!found || found.sha256 === null) continue
|
|
165
|
+
const bytes = options.readConfig(where.value)
|
|
166
|
+
if (!bytes || sha256Of(bytes) !== found.sha256) return txFail("preview_stale", `${where.value} changed since discovery; discover and preview again`)
|
|
167
|
+
const inspection = inspectOpencodeConfig(new TextDecoder().decode(bytes), { authority, ...(options.pluginVersion ? { pluginVersion: options.pluginVersion } : {}) })
|
|
168
|
+
if (!inspection.ok) return txFail(inspection.code, `${where.value}: ${inspection.message}`)
|
|
169
|
+
views.push({ ref, format, precondition: { exists: true, sha256: found.sha256 }, inspection: inspection.value })
|
|
170
|
+
}
|
|
171
|
+
return txOk(views)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function planConfig(snapshot: DiscoverySnapshot, options: OpencodePlanOptions, authorityRef: PathRef, findings: Finding[]): TxResult<Action[]> {
|
|
175
|
+
const authority = resolvePathRef(authorityRef, snapshot.roots, executionContextOf(snapshot))
|
|
176
|
+
if (!authority.ok) return txFail("invalid", `this host has no ${authorityRef.root} root for hubctl`)
|
|
177
|
+
const views = readConfigs(snapshot, options, authority.value)
|
|
178
|
+
if (!views.ok) return views
|
|
179
|
+
const fresh: ConfigView = { ...NEW_CONFIG, precondition: { exists: false }, inspection: { plugin: { state: "absent" }, mcp: { state: "absent" }, findings: [] } }
|
|
180
|
+
const preferred = views.value[0] ?? fresh
|
|
181
|
+
// OpenCode merges its config files: an entry in two of them is a duplicate, whichever wins.
|
|
182
|
+
const withPlugin = views.value.filter((v) => v.inspection.plugin.state !== "absent")
|
|
183
|
+
const withMcp = views.value.filter((v) => v.inspection.mcp.state !== "absent")
|
|
184
|
+
if (withPlugin.length > 1) return txFail("conflict", `the Hub plugin is listed in both ${CONFIG_REFS.map((c) => c.ref.rel).join(" and ")}; keep it in one of them`)
|
|
185
|
+
if (withMcp.length > 1) return txFail("conflict", `the hub MCP server is registered in both ${CONFIG_REFS.map((c) => c.ref.rel).join(" and ")}; keep it in one of them`)
|
|
186
|
+
for (const v of views.value) findings.push(...v.inspection.findings)
|
|
187
|
+
const pluginFile = withPlugin[0] ?? preferred
|
|
188
|
+
const mcpFile = withMcp[0] ?? preferred
|
|
189
|
+
const actions: Action[] = []
|
|
190
|
+
const sets = pluginSets(pluginFile.inspection.plugin, options)
|
|
191
|
+
if (sets.length > 0)
|
|
192
|
+
actions.push({
|
|
193
|
+
id: "opencode-plugin",
|
|
194
|
+
kind: "merge-config",
|
|
195
|
+
escalation: "user",
|
|
196
|
+
summary: pluginFile.inspection.plugin.state === "drift" ? `Update the Hub plugin in ${LABEL}'s config to ${pluginSpec(options)}` : `Add the Hub plugin (${pluginSpec(options)}) to ${LABEL}'s config`,
|
|
197
|
+
path: pluginFile.ref,
|
|
198
|
+
format: pluginFile.format,
|
|
199
|
+
owner: "harness:opencode",
|
|
200
|
+
precondition: pluginFile.precondition,
|
|
201
|
+
set: sets,
|
|
202
|
+
preserveUnknown: true,
|
|
203
|
+
})
|
|
204
|
+
if (mcpFile.inspection.mcp.state !== "registered")
|
|
205
|
+
actions.push({
|
|
206
|
+
id: "opencode-mcp",
|
|
207
|
+
kind: "register-mcp-server",
|
|
208
|
+
escalation: "user",
|
|
209
|
+
summary: `Register hubctl's MCP server (${authority.value} mcp) with ${LABEL} as "hub"`,
|
|
210
|
+
harness: "opencode",
|
|
211
|
+
path: mcpFile.ref,
|
|
212
|
+
name: "hub",
|
|
213
|
+
precondition: mcpFile.precondition,
|
|
214
|
+
})
|
|
215
|
+
return txOk(actions)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const verifyAction = (): Action => ({
|
|
219
|
+
id: "verify-opencode",
|
|
220
|
+
kind: "verify-harness",
|
|
221
|
+
escalation: "user",
|
|
222
|
+
summary: `Verify ${LABEL} really runs, reaches its provider and connects to the hub`,
|
|
223
|
+
harness: "opencode",
|
|
224
|
+
probes: REQUIRED_PROBES.opencode.filter((p): p is Exclude<typeof p, "presence"> => p !== "presence"),
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
/** The OpenCode actions for this snapshot and profile, with the findings worth showing; or the refusal. */
|
|
228
|
+
export function planOpencode(input: PlanInput, options: OpencodePlanOptions): TxResult<OpencodePlan> {
|
|
229
|
+
if (!input.profile.harnesses.includes("opencode")) return txOk({ actions: [], findings: [] })
|
|
230
|
+
const authority = options.authority ?? DEFAULT_AUTHORITY
|
|
231
|
+
try {
|
|
232
|
+
checkAuthority(authority)
|
|
233
|
+
} catch (error) {
|
|
234
|
+
return txFail("invalid", (error as Error).message)
|
|
235
|
+
}
|
|
236
|
+
const findings: Finding[] = []
|
|
237
|
+
const install = planInstall(input.snapshot, options, findings)
|
|
238
|
+
if (!install.ok) return install
|
|
239
|
+
const config = planConfig(input.snapshot, options, authority, findings)
|
|
240
|
+
if (!config.ok) return config
|
|
241
|
+
return txOk({ actions: [...install.value, ...config.value, verifyAction()], findings })
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface OpencodeAdapterOptions extends OpencodePlanOptions {
|
|
245
|
+
/** Presence (adapters/opencode/verify.ts wires discovery); absent: discover() is not available here. */
|
|
246
|
+
readonly discover?: HarnessAdapter["discover"]
|
|
247
|
+
/** The real probes (adapters/opencode/verify.ts); absent: every required probe fails (never ready). */
|
|
248
|
+
readonly verify?: (context: AdapterContext) => Promise<HarnessVerification>
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The OpenCode HarnessAdapter (register it with createAdapterRegistry). */
|
|
252
|
+
export function createOpencodeAdapter(options: OpencodeAdapterOptions): HarnessAdapter {
|
|
253
|
+
checkAuthority(options.authority ?? DEFAULT_AUTHORITY)
|
|
254
|
+
return {
|
|
255
|
+
id: "opencode",
|
|
256
|
+
discover: options.discover ?? (async () => ({ id: "opencode", installed: false, health: { state: "unverified", probes: [] } })),
|
|
257
|
+
plan(input) {
|
|
258
|
+
const planned = planOpencode(input, options)
|
|
259
|
+
if (!planned.ok) throw new OpencodePlanError(planned.code, planned.message)
|
|
260
|
+
return planned.value.actions
|
|
261
|
+
},
|
|
262
|
+
verify:
|
|
263
|
+
options.verify ??
|
|
264
|
+
(async (context) => ({ harness: "opencode", probes: REQUIRED_PROBES.opencode.map((kind) => ({ kind, ok: false, observedAt: context.now(), detail: "no probe configured" })) })),
|
|
265
|
+
}
|
|
266
|
+
}
|