From 4231d299308ee5557d73537e3ea5c2a635876ed5 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Fri, 27 Mar 2026 00:08:33 +0100 Subject: [PATCH] check plugin compat --- packages/opencode/specs/tui-plugins.md | 20 ++++++++++++++++ .../src/cli/cmd/tui/plugin/runtime.ts | 19 ++++++++++++--- packages/opencode/src/plugin/index.ts | 24 +++++++++++++++++-- packages/opencode/src/plugin/shared.ts | 13 ++++++++++ 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index c415195bc6..fac4e206fa 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -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 ` resolves and installs the package first, then reads `oc-plugin`, then patches config. - Alias: `opencode plug `. - `-g` / `--global` writes into the global config dir. diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index c3c96023d5..0f39aa7a38 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -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) => { diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 8e42bc3ee3..c48ef7b778 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -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 }) diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index c1f7232acc..f48a04b3f2 100644 --- a/packages/opencode/src/plugin/shared.ts +++ b/packages/opencode/src/plugin/shared.ts @@ -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)