check plugin compat

This commit is contained in:
Sebastian Herrlinger
2026-03-27 00:08:33 +01:00
parent fcf463c3e6
commit 4231d29930
4 changed files with 71 additions and 5 deletions

View File

@@ -96,6 +96,9 @@ Example:
"name": "@acme/opencode-plugin",
"type": "module",
"main": "./dist/index.js",
"engines": {
"opencode": "^1.0.0"
},
"oc-plugin": [
["server", { "custom": true }],
["tui", { "compact": true }]
@@ -103,6 +106,23 @@ Example:
}
```
### Version compatibility
npm plugins can declare a version compatibility range in `package.json` using the standard `engines` field:
```json
{
"engines": {
"opencode": "^1.0.0"
}
}
```
- The value is a semver range checked against the running OpenCode version.
- If the range is not satisfied, the plugin is skipped with a warning and a session error.
- If `engines.opencode` is absent, no check is performed (backward compatible).
- File plugins are never checked; only npm package plugins are validated.
- `opencode plugin <module>` resolves and installs the package first, then reads `oc-plugin`, then patches config.
- Alias: `opencode plug <module>`.
- `-g` / `--global` writes into the global config dir.

View File

@@ -18,6 +18,7 @@ import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
import {
checkPluginCompatibility,
getDefaultPlugin,
isDeprecatedPlugin,
pluginSource,
@@ -32,6 +33,7 @@ import { addTheme, hasTheme } from "../context/theme"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Flag } from "@/flag/flag"
import { Installation } from "@/installation"
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
import { setupSlots, Slot as View } from "./slots"
import type { HostPluginApi, HostSlots } from "./slots"
@@ -182,12 +184,24 @@ async function loadExternalPlugin(
const spec = Config.pluginSpecifier(item)
if (isDeprecatedPlugin(spec)) return
log.info("loading tui plugin", { path: spec, retry })
const target = await resolvePluginTarget(spec).catch((error) => {
const resolved = await resolvePluginTarget(spec).catch((error) => {
fail("failed to resolve tui plugin", { path: spec, retry, error })
return
})
if (!target) return
if (!resolved) return
const source = pluginSource(spec)
if (source === "npm") {
const ok = await checkPluginCompatibility(resolved, Installation.VERSION)
.then(() => true)
.catch((error) => {
fail("tui plugin incompatible", { path: spec, retry, error })
return false
})
if (!ok) return
}
const target = resolved
const meta = config.plugin_meta?.[spec]
if (!meta) {
log.warn("missing tui plugin metadata", {
@@ -197,7 +211,6 @@ async function loadExternalPlugin(
return
}
const source = pluginSource(spec)
const root = resolveRoot(source === "file" ? spec : target)
const install_theme = createThemeInstaller(meta, root, spec)
const entry = await resolvePluginEntrypoint(spec, target, "tui").catch((error) => {

View File

@@ -14,10 +14,13 @@ import { Effect, Layer, ServiceMap, Stream } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { errorMessage } from "@/util/error"
import { Installation } from "@/installation"
import {
checkPluginCompatibility,
getDefaultPlugin,
isDeprecatedPlugin,
parsePluginSpecifier,
pluginSource,
resolvePluginEntrypoint,
resolvePluginTarget,
} from "./shared"
@@ -106,9 +109,26 @@ export namespace Plugin {
const spec = Config.pluginSpecifier(item)
if (isDeprecatedPlugin(spec)) return
log.info("loading plugin", { path: spec })
const target = await resolvePlugin(spec)
if (!target) return
const resolved = await resolvePlugin(spec)
if (!resolved) return
if (pluginSource(spec) === "npm") {
const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
.then(() => false)
.catch((err) => {
const message = errorMessage(err)
log.warn("plugin incompatible", { path: spec, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Plugin ${spec} skipped: ${message}`,
}).toObject(),
})
return true
})
if (incompatible) return
}
const target = resolved
const entry = await resolvePluginEntrypoint(spec, target, "server").catch((err) => {
const message = errorMessage(err)
log.error("failed to resolve plugin server entry", { path: spec, target, error: message })

View File

@@ -1,5 +1,6 @@
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import semver from "semver"
import { BunProc } from "@/bun"
import { Filesystem } from "@/util/filesystem"
import { isRecord } from "@/util/record"
@@ -87,6 +88,18 @@ export async function resolvePathPluginTarget(spec: string) {
return pathToFileURL(path.resolve(file, pkg.main)).href
}
export async function checkPluginCompatibility(target: string, opencodeVersion: string) {
const pkg = await readPluginPackage(target).catch(() => undefined)
if (!pkg) return
const engines = pkg.json.engines
if (!isRecord(engines)) return
const range = engines.opencode
if (typeof range !== "string") return
if (!semver.satisfies(opencodeVersion, range)) {
throw new Error(`Plugin requires opencode ${range} but running ${opencodeVersion}`)
}
}
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
return BunProc.install(parsed.pkg, parsed.version)