From fa95a61c4e15d6b55ac2e3a1da0176ceca76d8c2 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 30 Mar 2026 20:36:21 +0200 Subject: [PATCH] Refactor into plugin loader and do not enforce (#20112) --- packages/opencode/specs/tui-plugins.md | 10 +- .../src/cli/cmd/tui/plugin/runtime.ts | 226 ++++++------- packages/opencode/src/config/config.ts | 40 +-- packages/opencode/src/config/tui.ts | 20 +- packages/opencode/src/plugin/index.ts | 185 +++++----- packages/opencode/src/plugin/loader.ts | 135 ++++++++ packages/opencode/src/plugin/shared.ts | 165 +++++++-- .../opencode/test/cli/tui/plugin-add.test.ts | 2 +- .../test/cli/tui/plugin-install.test.ts | 9 +- .../cli/tui/plugin-loader-entrypoint.test.ts | 315 +++++++++++++++++- .../test/cli/tui/plugin-loader-pure.test.ts | 7 +- .../test/cli/tui/plugin-loader.test.ts | 94 +++++- .../test/cli/tui/plugin-toggle.test.ts | 14 +- packages/opencode/test/config/config.test.ts | 32 +- packages/opencode/test/config/tui.test.ts | 34 +- packages/opencode/test/fixture/tui-runtime.ts | 19 +- .../test/plugin/loader-shared.test.ts | 100 ++++++ 17 files changed, 1056 insertions(+), 351 deletions(-) create mode 100644 packages/opencode/src/plugin/loader.ts diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index 5a7caa75b9..d5fe486299 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -84,12 +84,18 @@ export default plugin - TUI shape is `default export { id?, tui }`; including `server` is rejected. - A single module cannot export both `server` and `tui`. - `tui` signature is `(api, options, meta) => Promise`. -- If package `exports` contains `./tui`, the loader resolves that entrypoint. Otherwise it uses the resolved package target. +- If package `exports` contains `./tui`, the loader resolves that entrypoint. +- If package `exports` exists, loader only resolves `./tui` or `./server`; it never falls back to `exports["."]`. +- For npm package specs, TUI does not use `package.json` `main` as a fallback entry. +- `package.json` `main` is only used for server plugin entrypoint resolution. - If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module. - File/path plugins must export a non-empty `id`. - npm plugins may omit `id`; package `name` is used. - Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids. -- If a path spec points at a directory, that directory must have `package.json` with `main`. +- If a path spec points at a directory, server loading can use `package.json` `main`. +- TUI path loading never uses `package.json` `main`. +- Legacy compatibility: path specs like `./plugin` can resolve to `./plugin/index.ts` (or `index.js`) when `package.json` is missing. +- The `./plugin -> ./plugin/index.*` fallback applies to both server and TUI v1 loading. - There is no directory auto-discovery for TUI plugins; they must be listed in `tui.json`. ## Package manifest and install diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index e992577a6e..3fde4fc298 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -18,17 +18,8 @@ import { Log } from "@/util/log" import { errorData, errorMessage } from "@/util/error" import { isRecord } from "@/util/record" import { Instance } from "@/project/instance" -import { - checkPluginCompatibility, - isDeprecatedPlugin, - pluginSource, - readPluginId, - readV1Plugin, - resolvePluginEntrypoint, - resolvePluginId, - resolvePluginTarget, - type PluginSource, -} from "@/plugin/shared" +import { pluginSource, readPluginId, readV1Plugin, resolvePluginId, type PluginSource } from "@/plugin/shared" +import { PluginLoader } from "@/plugin/loader" import { PluginMeta } from "@/plugin/meta" import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install" import { hasTheme, upsertTheme } from "../context/theme" @@ -36,13 +27,12 @@ import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" import { Process } from "@/util/process" 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" type PluginLoad = { - item?: Config.PluginSpec + options: Config.PluginOptions | undefined spec: string target: string retry: boolean @@ -67,7 +57,6 @@ type PluginEntry = { meta: TuiPluginMeta themes: Record plugin: TuiPlugin - options: Config.PluginOptions | undefined enabled: boolean scope?: PluginScope } @@ -78,13 +67,7 @@ type RuntimeState = { slots: HostSlots plugins: PluginEntry[] plugins_by_id: Map - pending: Map< - string, - { - item: Config.PluginSpec - meta: TuiConfig.PluginMeta - } - > + pending: Map } const log = Log.create({ service: "tui.plugin" }) @@ -239,73 +222,76 @@ function createThemeInstaller( } } -async function loadExternalPlugin( - item: Config.PluginSpec, - meta: TuiConfig.PluginMeta | undefined, - retry = false, -): Promise { - const spec = Config.pluginSpecifier(item) - if (isDeprecatedPlugin(spec)) return - log.info("loading tui plugin", { path: spec, retry }) - const resolved = await resolvePluginTarget(spec).catch((error) => { - fail("failed to resolve tui plugin", { path: spec, retry, error }) - return - }) - if (!resolved) return +async function loadExternalPlugin(cfg: TuiConfig.PluginRecord, retry = false): Promise { + const plan = PluginLoader.plan(cfg.item) + if (plan.deprecated) 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 + log.info("loading tui plugin", { path: plan.spec, retry }) + const resolved = await PluginLoader.resolve(plan, "tui") + if (!resolved.ok) { + if (resolved.stage === "install") { + fail("failed to resolve tui plugin", { path: plan.spec, retry, error: resolved.error }) + return + } + if (resolved.stage === "compatibility") { + fail("tui plugin incompatible", { path: plan.spec, retry, error: resolved.error }) + return + } + fail("failed to resolve tui plugin entry", { path: plan.spec, retry, error: resolved.error }) + return } - const target = resolved - if (!meta) { - fail("missing tui plugin metadata", { - path: spec, + const loaded = await PluginLoader.load(resolved.value) + if (!loaded.ok) { + fail("failed to load tui plugin", { + path: plan.spec, + target: resolved.value.entry, retry, + error: loaded.error, }) return } - const root = resolveRoot(source === "file" ? spec : target) - const entry = await resolvePluginEntrypoint(spec, target, "tui").catch((error) => { - fail("failed to resolve tui plugin entry", { path: spec, target, retry, error }) - return - }) - if (!entry) return - - const mod = await import(entry) - .then((raw) => { - return readV1Plugin(raw as Record, spec, "tui") as TuiPluginModule + const mod = await Promise.resolve() + .then(() => { + return readV1Plugin(loaded.value.mod as Record, plan.spec, "tui") as TuiPluginModule }) .catch((error) => { - fail("failed to load tui plugin", { path: spec, target: entry, retry, error }) + fail("failed to load tui plugin", { + path: plan.spec, + target: loaded.value.entry, + retry, + error, + }) return }) if (!mod) return - const id = await resolvePluginId(source, spec, target, readPluginId(mod.id, spec)).catch((error) => { - fail("failed to load tui plugin", { path: spec, target, retry, error }) + const id = await resolvePluginId( + loaded.value.source, + plan.spec, + loaded.value.target, + readPluginId(mod.id, plan.spec), + loaded.value.pkg, + ).catch((error) => { + fail("failed to load tui plugin", { path: plan.spec, target: loaded.value.target, retry, error }) return }) if (!id) return return { - item, - spec, - target, + options: plan.options, + spec: plan.spec, + target: loaded.value.target, retry, - source, + source: loaded.value.source, id, module: mod, - theme_meta: meta, - theme_root: root, + theme_meta: { + scope: cfg.scope, + source: cfg.source, + }, + theme_root: loaded.value.pkg?.dir ?? resolveRoot(loaded.value.target), } } @@ -343,6 +329,7 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad { const target = spec return { + options: undefined, spec, target, retry: false, @@ -488,7 +475,7 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per const api = pluginApi(state, plugin, scope, plugin.id) const ok = await Promise.resolve() .then(async () => { - await plugin.plugin(api, plugin.options, plugin.meta) + await plugin.plugin(api, plugin.load.options, plugin.meta) return true }) .catch((error) => { @@ -613,21 +600,6 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop } } -function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta, themes: Record = {}) { - const options = load.item ? Config.pluginOptions(load.item) : undefined - return [ - { - id: load.id, - load, - meta, - themes, - plugin: load.module.tui, - options, - enabled: true, - }, - ] -} - function addPluginEntry(state: RuntimeState, plugin: PluginEntry) { if (state.plugins_by_id.has(plugin.id)) { fail("duplicate tui plugin id", { @@ -651,12 +623,8 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I } } -async function resolveExternalPlugins( - list: Config.PluginSpec[], - wait: () => Promise, - meta: (item: Config.PluginSpec) => TuiConfig.PluginMeta | undefined, -) { - const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item, meta(item)))) +async function resolveExternalPlugins(list: TuiConfig.PluginRecord[], wait: () => Promise) { + const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item))) const ready: PluginLoad[] = [] let deps: Promise | undefined @@ -665,13 +633,12 @@ async function resolveExternalPlugins( if (!entry) { const item = list[i] if (!item) continue - const spec = Config.pluginSpecifier(item) - if (pluginSource(spec) !== "file") continue + if (pluginSource(Config.pluginSpecifier(item.item)) !== "file") continue deps ??= wait().catch((error) => { log.warn("failed waiting for tui plugin dependencies", { error }) }) await deps - entry = await loadExternalPlugin(item, meta(item), true) + entry = await loadExternalPlugin(item, true) } if (!entry) continue ready.push(entry) @@ -713,20 +680,27 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[] const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id) const themes = hit?.entry.themes ? { ...hit.entry.themes } : {} - for (const plugin of collectPluginEntries(entry, row, themes)) { - if (!addPluginEntry(state, plugin)) { - ok = false - continue - } - plugins.push(plugin) + const plugin: PluginEntry = { + id: entry.id, + load: entry, + meta: row, + themes, + plugin: entry.module.tui, + enabled: true, } + if (!addPluginEntry(state, plugin)) { + ok = false + continue + } + plugins.push(plugin) } return { plugins, ok } } -function defaultPluginMeta(state: RuntimeState): TuiConfig.PluginMeta { +function defaultPluginRecord(state: RuntimeState, spec: string): TuiConfig.PluginRecord { return { + item: spec, scope: "local", source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"), } @@ -764,36 +738,28 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) { const spec = raw.trim() if (!spec) return false - const pending = state.pending.get(spec) - const item = pending?.item ?? spec - const nextSpec = Config.pluginSpecifier(item) - if (state.plugins.some((plugin) => plugin.load.spec === nextSpec)) { + const cfg = state.pending.get(spec) ?? defaultPluginRecord(state, spec) + const next = Config.pluginSpecifier(cfg.item) + if (state.plugins.some((plugin) => plugin.load.spec === next)) { state.pending.delete(spec) return true } - const meta = pending?.meta ?? defaultPluginMeta(state) - const ready = await Instance.provide({ directory: state.directory, - fn: () => - resolveExternalPlugins( - [item], - () => TuiConfig.waitForDependencies(), - () => meta, - ), + fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()), }).catch((error) => { - fail("failed to add tui plugin", { path: nextSpec, error }) + fail("failed to add tui plugin", { path: next, error }) return [] as PluginLoad[] }) if (!ready.length) { - fail("failed to add tui plugin", { path: nextSpec }) + fail("failed to add tui plugin", { path: next }) return false } const first = ready[0] if (!first) { - fail("failed to add tui plugin", { path: nextSpec }) + fail("failed to add tui plugin", { path: next }) return false } if (state.plugins_by_id.has(first.id)) { @@ -810,7 +776,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) { if (ok) state.pending.delete(spec) if (!ok) { - fail("failed to add tui plugin", { path: nextSpec }) + fail("failed to add tui plugin", { path: next }) } return ok } @@ -893,12 +859,11 @@ async function installPluginBySpec( const tui = manifest.targets.find((item) => item.kind === "tui") if (tui) { const file = patch.items.find((item) => item.kind === "tui")?.file + const item = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec state.pending.set(spec, { - item: tui.opts ? [spec, tui.opts] : spec, - meta: { - scope: global ? "global" : "local", - source: (file ?? dir.config) || path.join(patch.dir, "tui.json"), - }, + item, + scope: global ? "global" : "local", + source: (file ?? dir.config) || path.join(patch.dir, "tui.json"), }) } @@ -981,25 +946,26 @@ export namespace TuiPluginRuntime { directory: cwd, fn: async () => { const config = await TuiConfig.get() - const plugins = Flag.OPENCODE_PURE ? [] : (config.plugin ?? []) - if (Flag.OPENCODE_PURE && config.plugin?.length) { - log.info("skipping external tui plugins in pure mode", { count: config.plugin.length }) + const records = Flag.OPENCODE_PURE ? [] : (config.plugin_records ?? []) + if (Flag.OPENCODE_PURE && config.plugin_records?.length) { + log.info("skipping external tui plugins in pure mode", { count: config.plugin_records.length }) } for (const item of INTERNAL_TUI_PLUGINS) { log.info("loading internal tui plugin", { id: item.id }) const entry = loadInternalPlugin(item) const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id) - for (const plugin of collectPluginEntries(entry, meta)) { - addPluginEntry(next, plugin) - } + addPluginEntry(next, { + id: entry.id, + load: entry, + meta, + themes: {}, + plugin: entry.module.tui, + enabled: true, + }) } - const ready = await resolveExternalPlugins( - plugins, - () => TuiConfig.waitForDependencies(), - (item) => config.plugin_meta?.[Config.pluginSpecifier(item)], - ) + const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies()) await addExternalPluginEntries(next, ready) applyInitialPluginEnabledState(next, config) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3cbb341623..d02a1b2707 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1,7 +1,6 @@ import { Log } from "../util/log" import path from "path" import { pathToFileURL } from "url" -import { createRequire } from "module" import os from "os" import z from "zod" import { ModelsDev } from "../provider/models" @@ -366,33 +365,18 @@ export namespace Config { export async function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): Promise { const spec = pluginSpecifier(plugin) if (!isPathPluginSpec(spec)) return plugin - if (spec.startsWith("file://")) { - const resolved = await resolvePathPluginTarget(spec).catch(() => spec) - if (Array.isArray(plugin)) return [resolved, plugin[1]] - return resolved - } - if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) { - const base = pathToFileURL(spec).href - const resolved = await resolvePathPluginTarget(base).catch(() => base) - if (Array.isArray(plugin)) return [resolved, plugin[1]] - return resolved - } - try { - const base = import.meta.resolve!(spec, configFilepath) - const resolved = await resolvePathPluginTarget(base).catch(() => base) - if (Array.isArray(plugin)) return [resolved, plugin[1]] - return resolved - } catch { - try { - const require = createRequire(configFilepath) - const base = pathToFileURL(require.resolve(spec)).href - const resolved = await resolvePathPluginTarget(base).catch(() => base) - if (Array.isArray(plugin)) return [resolved, plugin[1]] - return resolved - } catch { - return plugin - } - } + + const base = path.dirname(configFilepath) + const file = (() => { + if (spec.startsWith("file://")) return spec + if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href + return pathToFileURL(path.resolve(base, spec)).href + })() + + const resolved = await resolvePathPluginTarget(file).catch(() => file) + + if (Array.isArray(plugin)) return [resolved, plugin[1]] + return resolved } /** diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index 857b673960..7f5d50df56 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -22,6 +22,12 @@ export namespace TuiConfig { source: string } + export type PluginRecord = { + item: Config.PluginSpec + scope: PluginMeta["scope"] + source: string + } + type PluginEntry = { item: Config.PluginSpec meta: PluginMeta @@ -33,7 +39,8 @@ export namespace TuiConfig { } export type Info = z.output & { - plugin_meta?: Record + // Internal resolved plugin list used by runtime loading. + plugin_records?: PluginRecord[] } function pluginScope(file: string): PluginMeta["scope"] { @@ -149,10 +156,13 @@ export namespace TuiConfig { const merged = dedupePlugins(acc.entries) acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {}) - acc.result.plugin = merged.map((item) => item.item) - acc.result.plugin_meta = merged.length - ? Object.fromEntries(merged.map((item) => [Config.pluginSpecifier(item.item), item.meta])) - : undefined + const list = merged.map((item) => ({ + item: item.item, + scope: item.meta.scope, + source: item.meta.source, + })) + acc.result.plugin = list.map((item) => item.item) + acc.result.plugin_records = list.length ? list : undefined const deps: Promise[] = [] if (acc.result.plugin?.length) { diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index a945b4b98a..6cecfaac73 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -14,19 +14,8 @@ 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, - isDeprecatedPlugin, - parsePluginSpecifier, - pluginSource, - readPluginId, - readV1Plugin, - resolvePluginEntrypoint, - resolvePluginId, - resolvePluginTarget, - type PluginSource, -} from "./shared" +import { PluginLoader } from "./loader" +import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -36,11 +25,7 @@ export namespace Plugin { } type Loaded = { - item: Config.PluginSpec - spec: string - target: string - source: PluginSource - mod: Record + row: PluginLoader.Loaded } // Hook names that follow the (input, output) => Promise trigger pattern @@ -93,91 +78,22 @@ export namespace Plugin { return result } - async function resolvePlugin(spec: string) { - const parsed = parsePluginSpecifier(spec) - const target = await resolvePluginTarget(spec, parsed).catch((err) => { - const cause = err instanceof Error ? err.cause : err - const detail = errorMessage(cause ?? err) - log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail }) - Bus.publish(Session.Event.Error, { - error: new NamedError.Unknown({ - message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${detail}`, - }).toObject(), - }) - return "" - }) - if (!target) return - return target - } - - async function prepPlugin(item: Config.PluginSpec): Promise { - const spec = Config.pluginSpecifier(item) - if (isDeprecatedPlugin(spec)) return - log.info("loading plugin", { path: spec }) - const resolved = await resolvePlugin(spec) - if (!resolved) return - - const source = pluginSource(spec) - if (source === "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 }) - Bus.publish(Session.Event.Error, { - error: new NamedError.Unknown({ - message: `Failed to load plugin ${spec}: ${message}`, - }).toObject(), - }) - return - }) - if (!entry) return - - const mod = await import(entry).catch((err) => { - const message = errorMessage(err) - log.error("failed to load plugin", { path: spec, target: entry, error: message }) - Bus.publish(Session.Event.Error, { - error: new NamedError.Unknown({ - message: `Failed to load plugin ${spec}: ${message}`, - }).toObject(), - }) - return - }) - if (!mod) return - - return { - item, - spec, - target, - source, - mod, - } - } - async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) { - const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") + const plugin = readV1Plugin(load.row.mod, load.row.spec, "server", "detect") if (plugin) { - await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec)) - hooks.push(await (plugin as PluginModule).server(input, Config.pluginOptions(load.item))) + await resolvePluginId( + load.row.source, + load.row.spec, + load.row.target, + readPluginId(plugin.id, load.row.spec), + load.row.pkg, + ) + hooks.push(await (plugin as PluginModule).server(input, load.row.options)) return } - for (const server of getLegacyPlugins(load.mod)) { - hooks.push(await server(input, Config.pluginOptions(load.item))) + for (const server of getLegacyPlugins(load.row.mod)) { + hooks.push(await server(input, load.row.options)) } } @@ -232,7 +148,74 @@ export namespace Plugin { } if (plugins.length) yield* config.waitForDependencies() - const loaded = yield* Effect.promise(() => Promise.all(plugins.map((item) => prepPlugin(item)))) + const loaded = yield* Effect.promise(() => + Promise.all( + plugins.map(async (item) => { + const plan = PluginLoader.plan(item) + if (plan.deprecated) return + log.info("loading plugin", { path: plan.spec }) + + const resolved = await PluginLoader.resolve(plan, "server") + if (!resolved.ok) { + const cause = + resolved.error instanceof Error ? (resolved.error.cause ?? resolved.error) : resolved.error + const message = errorMessage(cause) + + if (resolved.stage === "install") { + const parsed = parsePluginSpecifier(plan.spec) + log.error("failed to install plugin", { + pkg: parsed.pkg, + version: parsed.version, + error: message, + }) + Bus.publish(Session.Event.Error, { + error: new NamedError.Unknown({ + message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`, + }).toObject(), + }) + return + } + + if (resolved.stage === "compatibility") { + log.warn("plugin incompatible", { path: plan.spec, error: message }) + Bus.publish(Session.Event.Error, { + error: new NamedError.Unknown({ + message: `Plugin ${plan.spec} skipped: ${message}`, + }).toObject(), + }) + return + } + + log.error("failed to resolve plugin server entry", { + path: plan.spec, + error: message, + }) + Bus.publish(Session.Event.Error, { + error: new NamedError.Unknown({ + message: `Failed to load plugin ${plan.spec}: ${message}`, + }).toObject(), + }) + return + } + + const mod = await PluginLoader.load(resolved.value) + if (!mod.ok) { + const message = errorMessage(mod.error) + log.error("failed to load plugin", { path: plan.spec, target: resolved.value.entry, error: message }) + Bus.publish(Session.Event.Error, { + error: new NamedError.Unknown({ + message: `Failed to load plugin ${plan.spec}: ${message}`, + }).toObject(), + }) + return + } + + return { + row: mod.value, + } + }), + ), + ) for (const load of loaded) { if (!load) continue @@ -242,14 +225,14 @@ export namespace Plugin { try: () => applyPlugin(load, input, hooks), catch: (err) => { const message = errorMessage(err) - log.error("failed to load plugin", { path: load.spec, error: message }) + log.error("failed to load plugin", { path: load.row.spec, error: message }) return message }, }).pipe( Effect.catch((message) => bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ - message: `Failed to load plugin ${load.spec}: ${message}`, + message: `Failed to load plugin ${load.row.spec}: ${message}`, }).toObject(), }), ), diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts new file mode 100644 index 0000000000..63a2ddd117 --- /dev/null +++ b/packages/opencode/src/plugin/loader.ts @@ -0,0 +1,135 @@ +import { Config } from "@/config/config" +import { Installation } from "@/installation" +import { + checkPluginCompatibility, + createPluginEntry, + isDeprecatedPlugin, + resolvePluginTarget, + type PluginKind, + type PluginPackage, + type PluginSource, +} from "./shared" + +export namespace PluginLoader { + export type Plan = { + item: Config.PluginSpec + spec: string + options: Config.PluginOptions | undefined + deprecated: boolean + } + + export type Resolved = Plan & { + source: PluginSource + target: string + entry: string + pkg?: PluginPackage + } + + export type Loaded = Resolved & { + mod: Record + } + + export function plan(item: Config.PluginSpec): Plan { + const spec = Config.pluginSpecifier(item) + return { + item, + spec, + options: Config.pluginOptions(item), + deprecated: isDeprecatedPlugin(spec), + } + } + + export async function resolve( + plan: Plan, + kind: PluginKind, + ): Promise< + { ok: true; value: Resolved } | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown } + > { + let target = "" + try { + target = await resolvePluginTarget(plan.spec) + } catch (error) { + return { + ok: false, + stage: "install", + error, + } + } + if (!target) { + return { + ok: false, + stage: "install", + error: new Error(`Plugin ${plan.spec} target is empty`), + } + } + + let base + try { + base = await createPluginEntry(plan.spec, target, kind) + } catch (error) { + return { + ok: false, + stage: "entry", + error, + } + } + + if (!base.entry) { + return { + ok: false, + stage: "entry", + error: new Error(`Plugin ${plan.spec} entry is empty`), + } + } + + if (base.source === "npm") { + try { + await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg) + } catch (error) { + return { + ok: false, + stage: "compatibility", + error, + } + } + } + + return { + ok: true, + value: { + ...plan, + source: base.source, + target: base.target, + entry: base.entry, + pkg: base.pkg, + }, + } + } + + export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> { + let mod + try { + mod = await import(row.entry) + } catch (error) { + return { + ok: false, + error, + } + } + + if (!mod) { + return { + ok: false, + error: new Error(`Plugin ${row.spec} module is empty`), + } + } + + return { + ok: true, + value: { + ...row, + mod, + }, + } + } +} diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index b6b25f89cb..190d73301b 100644 --- a/packages/opencode/src/plugin/shared.ts +++ b/packages/opencode/src/plugin/shared.ts @@ -23,13 +23,25 @@ export type PluginSource = "file" | "npm" export type PluginKind = "server" | "tui" type PluginMode = "strict" | "detect" -export function pluginSource(spec: string): PluginSource { - return spec.startsWith("file://") ? "file" : "npm" +export type PluginPackage = { + dir: string + pkg: string + json: Record } -function hasEntrypoint(json: Record, kind: PluginKind) { - if (!isRecord(json.exports)) return false - return `./${kind}` in json.exports +export type PluginEntry = { + spec: string + source: PluginSource + target: string + pkg?: PluginPackage + entry: string +} + +const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"] + +export function pluginSource(spec: string): PluginSource { + if (isPathPluginSpec(spec)) return "file" + return "npm" } function resolveExportPath(raw: string, dir: string) { @@ -48,26 +60,97 @@ function extractExportValue(value: unknown): string | undefined { return undefined } -export async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind) { - const pkg = await readPluginPackage(target).catch(() => undefined) - if (!pkg) return target - if (!hasEntrypoint(pkg.json, kind)) return target - - const exports = pkg.json.exports - if (!isRecord(exports)) return target - const raw = extractExportValue(exports[`./${kind}`]) - if (!raw) return target +function packageMain(pkg: PluginPackage) { + const value = pkg.json.main + if (typeof value !== "string") return + const next = value.trim() + if (!next) return + return next +} +function resolvePackagePath(spec: string, raw: string, kind: PluginKind, pkg: PluginPackage) { const resolved = resolveExportPath(raw, pkg.dir) const root = Filesystem.resolve(pkg.dir) const next = Filesystem.resolve(resolved) if (!Filesystem.contains(root, next)) { throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`) } - return pathToFileURL(next).href } +function resolvePackageEntrypoint(spec: string, kind: PluginKind, pkg: PluginPackage) { + const exports = pkg.json.exports + if (isRecord(exports)) { + const raw = extractExportValue(exports[`./${kind}`]) + if (raw) return resolvePackagePath(spec, raw, kind, pkg) + } + + if (kind !== "server") return + const main = packageMain(pkg) + if (!main) return + return resolvePackagePath(spec, main, kind, pkg) +} + +function targetPath(target: string) { + if (target.startsWith("file://")) return fileURLToPath(target) + if (path.isAbsolute(target) || /^[A-Za-z]:[\\/]/.test(target)) return target +} + +async function resolveDirectoryIndex(dir: string) { + for (const name of INDEX_FILES) { + const file = path.join(dir, name) + if (await Filesystem.exists(file)) return file + } +} + +async function resolveTargetDirectory(target: string) { + const file = targetPath(target) + if (!file) return + const stat = await Filesystem.stat(file) + if (!stat?.isDirectory()) return + return file +} + +async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind, pkg?: PluginPackage) { + const source = pluginSource(spec) + const hit = + pkg ?? (source === "npm" ? await readPluginPackage(target) : await readPluginPackage(target).catch(() => undefined)) + if (!hit) return target + + const entry = resolvePackageEntrypoint(spec, kind, hit) + if (entry) return entry + + const dir = await resolveTargetDirectory(target) + + if (kind === "tui") { + if (source === "file" && dir) { + const index = await resolveDirectoryIndex(dir) + if (index) return pathToFileURL(index).href + } + + if (source === "npm") { + throw new TypeError(`Plugin ${spec} must define package.json exports["./tui"]`) + } + + if (dir) { + throw new TypeError(`Plugin ${spec} must define package.json exports["./tui"] or include index file`) + } + + return target + } + + if (dir && isRecord(hit.json.exports)) { + if (source === "file") { + const index = await resolveDirectoryIndex(dir) + if (index) return pathToFileURL(index).href + } + + throw new TypeError(`Plugin ${spec} must define package.json exports["./server"] or package.json main`) + } + + return target +} + export function isPathPluginSpec(spec: string) { return spec.startsWith("file://") || spec.startsWith(".") || path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec) } @@ -81,19 +164,21 @@ export async function resolvePathPluginTarget(spec: string) { return pathToFileURL(file).href } - const pkg = await Filesystem.readJson>(path.join(file, "package.json")).catch(() => undefined) - if (!pkg) throw new Error(`Plugin directory ${file} is missing package.json`) - if (typeof pkg.main !== "string" || !pkg.main.trim()) { - throw new Error(`Plugin directory ${file} must define package.json main`) + if (await Filesystem.exists(path.join(file, "package.json"))) { + return pathToFileURL(file).href } - return pathToFileURL(path.resolve(file, pkg.main)).href + + const index = await resolveDirectoryIndex(file) + if (index) return pathToFileURL(index).href + + throw new Error(`Plugin directory ${file} is missing package.json or index file`) } -export async function checkPluginCompatibility(target: string, opencodeVersion: string) { +export async function checkPluginCompatibility(target: string, opencodeVersion: string, pkg?: PluginPackage) { if (!semver.valid(opencodeVersion) || semver.major(opencodeVersion) === 0) return - const pkg = await readPluginPackage(target).catch(() => undefined) - if (!pkg) return - const engines = pkg.json.engines + const hit = pkg ?? (await readPluginPackage(target).catch(() => undefined)) + if (!hit) return + const engines = hit.json.engines if (!isRecord(engines)) return const range = engines.opencode if (typeof range !== "string") return @@ -107,7 +192,7 @@ export async function resolvePluginTarget(spec: string, parsed = parsePluginSpec return BunProc.install(parsed.pkg, parsed.version) } -export async function readPluginPackage(target: string) { +export async function readPluginPackage(target: string): Promise { const file = target.startsWith("file://") ? fileURLToPath(target) : target const stat = await Filesystem.stat(file) const dir = stat?.isDirectory() ? file : path.dirname(file) @@ -116,6 +201,20 @@ export async function readPluginPackage(target: string) { return { dir, pkg, json } } +export async function createPluginEntry(spec: string, target: string, kind: PluginKind): Promise { + const source = pluginSource(spec) + const pkg = + source === "npm" ? await readPluginPackage(target) : await readPluginPackage(target).catch(() => undefined) + const entry = await resolvePluginEntrypoint(spec, target, kind, pkg) + return { + spec, + source, + target, + pkg, + entry, + } +} + export function readPluginId(id: unknown, spec: string) { if (id === undefined) return if (typeof id !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof id}`) @@ -158,15 +257,21 @@ export function readV1Plugin( return value } -export async function resolvePluginId(source: PluginSource, spec: string, target: string, id: string | undefined) { +export async function resolvePluginId( + source: PluginSource, + spec: string, + target: string, + id: string | undefined, + pkg?: PluginPackage, +) { if (source === "file") { if (id) return id throw new TypeError(`Path plugin ${spec} must export id`) } if (id) return id - const pkg = await readPluginPackage(target) - if (typeof pkg.json.name !== "string" || !pkg.json.name.trim()) { - throw new TypeError(`Plugin package ${pkg.pkg} is missing name`) + const hit = pkg ?? (await readPluginPackage(target)) + if (typeof hit.json.name !== "string" || !hit.json.name.trim()) { + throw new TypeError(`Plugin package ${hit.pkg} is missing name`) } - return pkg.json.name.trim() + return hit.json.name.trim() } diff --git a/packages/opencode/test/cli/tui/plugin-add.test.ts b/packages/opencode/test/cli/tui/plugin-add.test.ts index d6ff4fc6cb..f42c52bb88 100644 --- a/packages/opencode/test/cli/tui/plugin-add.test.ts +++ b/packages/opencode/test/cli/tui/plugin-add.test.ts @@ -33,7 +33,7 @@ test("adds tui plugin at runtime from spec", async () => { process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") const get = spyOn(TuiConfig, "get").mockResolvedValue({ plugin: [], - plugin_meta: undefined, + plugin_records: undefined, }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) diff --git a/packages/opencode/test/cli/tui/plugin-install.test.ts b/packages/opencode/test/cli/tui/plugin-install.test.ts index a2477cc79e..b5cafe0466 100644 --- a/packages/opencode/test/cli/tui/plugin-install.test.ts +++ b/packages/opencode/test/cli/tui/plugin-install.test.ts @@ -48,7 +48,7 @@ test("installs plugin without loading it", async () => { process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") let cfg: Awaited> = { plugin: [], - plugin_meta: undefined, + plugin_records: undefined, } const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() @@ -68,12 +68,13 @@ test("installs plugin without loading it", async () => { await TuiPluginRuntime.init(api) cfg = { plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], - plugin_meta: { - [tmp.extra.spec]: { + plugin_records: [ + { + item: [tmp.extra.spec, { marker: tmp.extra.marker }], scope: "local", source: path.join(tmp.path, "tui.json"), }, - }, + ], } const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec) diff --git a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts index 92f7dc170a..6a3e679c66 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts @@ -1,6 +1,7 @@ import { expect, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" +import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" import { TuiConfig } from "../../../src/config/tui" @@ -45,9 +46,13 @@ test("loads npm tui plugin from package ./tui export", async () => { process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") const get = spyOn(TuiConfig, "get").mockResolvedValue({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], - plugin_meta: { - [tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") }, - }, + plugin_records: [ + { + item: [tmp.extra.spec, { marker: tmp.extra.marker }], + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + ], }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) @@ -70,6 +75,65 @@ test("loads npm tui plugin from package ./tui export", async () => { } }) +test("does not use npm package exports dot for tui entry", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const mod = path.join(dir, "mods", "acme-plugin") + const marker = path.join(dir, "dot-called.txt") + await fs.mkdir(mod, { recursive: true }) + + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify({ + name: "acme-plugin", + type: "module", + exports: { ".": "./index.js" }, + }), + ) + await Bun.write( + path.join(mod, "index.js"), + `export default { + id: "demo.dot", + tui: async () => { + await Bun.write(${JSON.stringify(marker)}, "called") + }, +} +`, + ) + + return { mod, marker, spec: "acme-plugin@1.0.0" } + }, + }) + + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") + const get = spyOn(TuiConfig, "get").mockResolvedValue({ + plugin: [tmp.extra.spec], + plugin_records: [ + { + item: tmp.extra.spec, + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + ], + }) + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod) + + try { + await TuiPluginRuntime.init(createTuiPluginApi()) + await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow() + expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false) + } finally { + await TuiPluginRuntime.dispose() + install.mockRestore() + cwd.mockRestore() + get.mockRestore() + wait.mockRestore() + delete process.env.OPENCODE_PLUGIN_META_FILE + } +}) + test("rejects npm tui export that resolves outside plugin directory", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -107,9 +171,13 @@ test("rejects npm tui export that resolves outside plugin directory", async () = process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") const get = spyOn(TuiConfig, "get").mockResolvedValue({ plugin: [tmp.extra.spec], - plugin_meta: { - [tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") }, - }, + plugin_records: [ + { + item: tmp.extra.spec, + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + ], }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) @@ -166,9 +234,13 @@ test("rejects npm tui plugin that exports server and tui together", async () => process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") const get = spyOn(TuiConfig, "get").mockResolvedValue({ plugin: [tmp.extra.spec], - plugin_meta: { - [tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") }, - }, + plugin_records: [ + { + item: tmp.extra.spec, + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + ], }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) @@ -187,3 +259,228 @@ test("rejects npm tui plugin that exports server and tui together", async () => delete process.env.OPENCODE_PLUGIN_META_FILE } }) + +test("does not use npm package main for tui entry", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const mod = path.join(dir, "mods", "acme-plugin") + const marker = path.join(dir, "main-called.txt") + await fs.mkdir(mod, { recursive: true }) + + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify({ + name: "acme-plugin", + type: "module", + main: "./index.js", + }), + ) + await Bun.write( + path.join(mod, "index.js"), + `export default { + id: "demo.main", + tui: async () => { + await Bun.write(${JSON.stringify(marker)}, "called") + }, +} +`, + ) + + return { mod, marker, spec: "acme-plugin@1.0.0" } + }, + }) + + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") + const get = spyOn(TuiConfig, "get").mockResolvedValue({ + plugin: [tmp.extra.spec], + plugin_records: [ + { + item: tmp.extra.spec, + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + ], + }) + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod) + + try { + await TuiPluginRuntime.init(createTuiPluginApi()) + await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow() + expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false) + } finally { + await TuiPluginRuntime.dispose() + install.mockRestore() + cwd.mockRestore() + get.mockRestore() + wait.mockRestore() + delete process.env.OPENCODE_PLUGIN_META_FILE + } +}) + +test("does not use directory package main for tui entry", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const mod = path.join(dir, "mods", "dir-plugin") + const spec = pathToFileURL(mod).href + const marker = path.join(dir, "dir-main-called.txt") + await fs.mkdir(mod, { recursive: true }) + + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify({ + name: "dir-plugin", + type: "module", + main: "./main.js", + }), + ) + await Bun.write( + path.join(mod, "main.js"), + `export default { + id: "demo.dir.main", + tui: async () => { + await Bun.write(${JSON.stringify(marker)}, "called") + }, +} +`, + ) + + return { marker, spec } + }, + }) + + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") + const get = spyOn(TuiConfig, "get").mockResolvedValue({ + plugin: [tmp.extra.spec], + plugin_records: [ + { + item: tmp.extra.spec, + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + ], + }) + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init(createTuiPluginApi()) + await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow() + expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false) + } finally { + await TuiPluginRuntime.dispose() + cwd.mockRestore() + get.mockRestore() + wait.mockRestore() + delete process.env.OPENCODE_PLUGIN_META_FILE + } +}) + +test("uses directory index fallback for tui when package.json is missing", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const mod = path.join(dir, "mods", "dir-index") + const spec = pathToFileURL(mod).href + const marker = path.join(dir, "dir-index-called.txt") + await fs.mkdir(mod, { recursive: true }) + await Bun.write( + path.join(mod, "index.ts"), + `export default { + id: "demo.dir.index", + tui: async () => { + await Bun.write(${JSON.stringify(marker)}, "called") + }, +} +`, + ) + return { marker, spec } + }, + }) + + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") + const get = spyOn(TuiConfig, "get").mockResolvedValue({ + plugin: [tmp.extra.spec], + plugin_records: [ + { + item: tmp.extra.spec, + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + ], + }) + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init(createTuiPluginApi()) + await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called") + expect(TuiPluginRuntime.list().find((item) => item.id === "demo.dir.index")?.active).toBe(true) + } finally { + await TuiPluginRuntime.dispose() + cwd.mockRestore() + get.mockRestore() + wait.mockRestore() + delete process.env.OPENCODE_PLUGIN_META_FILE + } +}) + +test("uses npm package name when tui plugin id is omitted", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const mod = path.join(dir, "mods", "acme-plugin") + const marker = path.join(dir, "name-id-called.txt") + await fs.mkdir(mod, { recursive: true }) + + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify({ + name: "acme-plugin", + type: "module", + exports: { ".": "./index.js", "./tui": "./tui.js" }, + }), + ) + await Bun.write(path.join(mod, "index.js"), "export default {}\n") + await Bun.write( + path.join(mod, "tui.js"), + `export default { + tui: async (_api, options) => { + if (!options?.marker) return + await Bun.write(options.marker, "called") + }, +} +`, + ) + + return { mod, marker, spec: "acme-plugin@1.0.0" } + }, + }) + + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") + const get = spyOn(TuiConfig, "get").mockResolvedValue({ + plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], + plugin_records: [ + { + item: [tmp.extra.spec, { marker: tmp.extra.marker }], + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + ], + }) + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod) + + try { + await TuiPluginRuntime.init(createTuiPluginApi()) + await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called") + expect(TuiPluginRuntime.list().find((item) => item.spec === tmp.extra.spec)?.id).toBe("acme-plugin") + } finally { + await TuiPluginRuntime.dispose() + install.mockRestore() + cwd.mockRestore() + get.mockRestore() + wait.mockRestore() + delete process.env.OPENCODE_PLUGIN_META_FILE + } +}) diff --git a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts index ef8f05c087..6f1899a05f 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts @@ -39,12 +39,13 @@ test("skips external tui plugins in pure mode", async () => { const get = spyOn(TuiConfig, "get").mockResolvedValue({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], - plugin_meta: { - [tmp.extra.spec]: { + plugin_records: [ + { + item: [tmp.extra.spec, { marker: tmp.extra.marker }], scope: "local", source: path.join(tmp.path, "tui.json"), }, - }, + ], }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 143c060e9c..7e1f524676 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -468,10 +468,18 @@ test("continues loading when a plugin is missing config metadata", async () => { [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }], tmp.extra.bareSpec, ], - plugin_meta: { - [tmp.extra.goodSpec]: { scope: "local", source: path.join(tmp.path, "tui.json") }, - [tmp.extra.bareSpec]: { scope: "local", source: path.join(tmp.path, "tui.json") }, - }, + plugin_records: [ + { + item: [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }], + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + { + item: tmp.extra.bareSpec, + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + ], }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) @@ -493,6 +501,84 @@ test("continues loading when a plugin is missing config metadata", async () => { } }) +test("initializes external tui plugins in config order", async () => { + const globalJson = path.join(Global.Path.config, "tui.json") + const globalJsonc = path.join(Global.Path.config, "tui.jsonc") + const backupJson = await Bun.file(globalJson) + .text() + .catch(() => undefined) + const backupJsonc = await Bun.file(globalJsonc) + .text() + .catch(() => undefined) + + await fs.rm(globalJson, { force: true }).catch(() => {}) + await fs.rm(globalJsonc, { force: true }).catch(() => {}) + + await using tmp = await tmpdir({ + init: async (dir) => { + const a = path.join(dir, "order-a.ts") + const b = path.join(dir, "order-b.ts") + const aSpec = pathToFileURL(a).href + const bSpec = pathToFileURL(b).href + const marker = path.join(dir, "tui-order.txt") + + await Bun.write( + a, + `import fs from "fs/promises" + +export default { + id: "demo.tui.order.a", + tui: async () => { + await fs.appendFile(${JSON.stringify(marker)}, "a-start\\n") + await Bun.sleep(25) + await fs.appendFile(${JSON.stringify(marker)}, "a-end\\n") + }, +} +`, + ) + await Bun.write( + b, + `import fs from "fs/promises" + +export default { + id: "demo.tui.order.b", + tui: async () => { + await fs.appendFile(${JSON.stringify(marker)}, "b\\n") + }, +} +`, + ) + await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ plugin: [aSpec, bSpec] }, null, 2)) + + return { marker } + }, + }) + + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init(createTuiPluginApi()) + const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n") + expect(lines).toEqual(["a-start", "a-end", "b"]) + } finally { + await TuiPluginRuntime.dispose() + cwd.mockRestore() + delete process.env.OPENCODE_PLUGIN_META_FILE + + if (backupJson === undefined) { + await fs.rm(globalJson, { force: true }).catch(() => {}) + } else { + await Bun.write(globalJson, backupJson) + } + if (backupJsonc === undefined) { + await fs.rm(globalJsonc, { force: true }).catch(() => {}) + } else { + await Bun.write(globalJsonc, backupJsonc) + } + } +}) + describe("tui.plugin.loader", () => { let data: Data diff --git a/packages/opencode/test/cli/tui/plugin-toggle.test.ts b/packages/opencode/test/cli/tui/plugin-toggle.test.ts index c407d11171..14ee198fc4 100644 --- a/packages/opencode/test/cli/tui/plugin-toggle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-toggle.test.ts @@ -44,12 +44,13 @@ test("toggles plugin runtime state by exported id", async () => { plugin_enabled: { "demo.toggle": false, }, - plugin_meta: { - [tmp.extra.spec]: { + plugin_records: [ + { + item: [tmp.extra.spec, { marker: tmp.extra.marker }], scope: "local", source: path.join(tmp.path, "tui.json"), }, - }, + ], }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) @@ -121,12 +122,13 @@ test("kv plugin_enabled overrides tui config on startup", async () => { plugin_enabled: { "demo.startup": false, }, - plugin_meta: { - [tmp.extra.spec]: { + plugin_records: [ + { + item: [tmp.extra.spec, { marker: tmp.extra.marker }], scope: "local", source: path.join(tmp.path, "tui.json"), }, - }, + ], }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index ea0a545200..d06bdf12a6 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1822,6 +1822,22 @@ describe("resolvePluginSpec", () => { expect(await Config.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg") }) + test("resolves windows-style relative plugin directory specs", async () => { + if (process.platform !== "win32") return + + await using tmp = await tmpdir({ + init: async (dir) => { + const plugin = path.join(dir, "plugin") + await fs.mkdir(plugin, { recursive: true }) + await Filesystem.write(path.join(plugin, "index.ts"), "export default {}") + }, + }) + + const file = path.join(tmp.path, "opencode.json") + const hit = await Config.resolvePluginSpec(".\\plugin", file) + expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href) + }) + test("resolves relative file plugin paths to file urls", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -1834,7 +1850,7 @@ describe("resolvePluginSpec", () => { expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href) }) - test("resolves plugin directory paths to package main files", async () => { + test("resolves plugin directory paths to directory urls", async () => { await using tmp = await tmpdir({ init: async (dir) => { const plugin = path.join(dir, "plugin") @@ -1848,6 +1864,20 @@ describe("resolvePluginSpec", () => { }, }) + const file = path.join(tmp.path, "opencode.json") + const hit = await Config.resolvePluginSpec("./plugin", file) + expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin")).href) + }) + + test("resolves plugin directories without package.json to index.ts", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const plugin = path.join(dir, "plugin") + await fs.mkdir(plugin, { recursive: true }) + await Filesystem.write(path.join(plugin, "index.ts"), "export default {}") + }, + }) + const file = path.join(tmp.path, "opencode.json") const hit = await Config.resolvePluginSpec("./plugin", file) expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href) diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 14f02fe30e..7fb3704e37 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -476,12 +476,13 @@ test("loads managed tui config and gives it highest precedence", async () => { const config = await TuiConfig.get() expect(config.theme).toBe("managed-theme") expect(config.plugin).toEqual(["shared-plugin@2.0.0"]) - expect(config.plugin_meta).toEqual({ - "shared-plugin@2.0.0": { + expect(config.plugin_records).toEqual([ + { + item: "shared-plugin@2.0.0", scope: "global", source: path.join(managedConfigDir, "tui.json"), }, - }) + ]) }, }) }) @@ -539,12 +540,13 @@ test("supports tuple plugin specs with options in tui.json", async () => { fn: async () => { const config = await TuiConfig.get() expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]]) - expect(config.plugin_meta).toEqual({ - "acme-plugin@1.2.3": { + expect(config.plugin_records).toEqual([ + { + item: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }], scope: "local", source: path.join(tmp.path, "tui.json"), }, - }) + ]) }, }) }) @@ -578,16 +580,18 @@ test("deduplicates tuple plugin specs by name with higher precedence winning", a ["acme-plugin@2.0.0", { source: "project" }], ["second-plugin@3.0.0", { source: "project" }], ]) - expect(config.plugin_meta).toEqual({ - "acme-plugin@2.0.0": { + expect(config.plugin_records).toEqual([ + { + item: ["acme-plugin@2.0.0", { source: "project" }], scope: "local", source: path.join(tmp.path, "tui.json"), }, - "second-plugin@3.0.0": { + { + item: ["second-plugin@3.0.0", { source: "project" }], scope: "local", source: path.join(tmp.path, "tui.json"), }, - }) + ]) }, }) }) @@ -615,16 +619,18 @@ test("tracks global and local plugin metadata in merged tui config", async () => fn: async () => { const config = await TuiConfig.get() expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"]) - expect(config.plugin_meta).toEqual({ - "global-plugin@1.0.0": { + expect(config.plugin_records).toEqual([ + { + item: "global-plugin@1.0.0", scope: "global", source: path.join(Global.Path.config, "tui.json"), }, - "local-plugin@2.0.0": { + { + item: "local-plugin@2.0.0", scope: "local", source: path.join(tmp.path, "tui.json"), }, - }) + ]) }, }) }) diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts index 67ea4b9a4c..1e2c0f2a6c 100644 --- a/packages/opencode/test/fixture/tui-runtime.ts +++ b/packages/opencode/test/fixture/tui-runtime.ts @@ -6,21 +6,14 @@ type PluginSpec = string | [string, Record] export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) { process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json") - const meta = Object.fromEntries( - plugin.map((item) => { - const spec = Array.isArray(item) ? item[0] : item - return [ - spec, - { - scope: "local" as const, - source: path.join(dir, "tui.json"), - }, - ] - }), - ) + const plugin_records = plugin.map((item) => ({ + item, + scope: "local" as const, + source: path.join(dir, "tui.json"), + })) const get = spyOn(TuiConfig, "get").mockResolvedValue({ plugin, - plugin_meta: meta, + plugin_records, }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => dir) diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index a225f66e76..d9ffa3950b 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -331,6 +331,57 @@ describe("plugin.loader.shared", () => { } }) + test("does not use npm package exports dot for server entry", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const mod = path.join(dir, "mods", "acme-plugin") + const mark = path.join(dir, "dot-server.txt") + await fs.mkdir(mod, { recursive: true }) + + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify({ + name: "acme-plugin", + type: "module", + exports: { ".": "./index.js" }, + }), + ) + await Bun.write( + path.join(mod, "index.js"), + [ + "export default {", + ' id: "demo.dot.server",', + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "called")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2)) + + return { mod, mark } + }, + }) + + const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod) + + try { + const errors = await errs(tmp.path) + const called = await Bun.file(tmp.extra.mark) + .text() + .then(() => true) + .catch(() => false) + + expect(called).toBe(false) + expect(errors.some((x) => x.includes('exports["./server"]') && x.includes("package.json main"))).toBe(true) + } finally { + install.mockRestore() + } + }) + test("rejects npm server export that resolves outside plugin directory", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -576,6 +627,55 @@ describe("plugin.loader.shared", () => { }) }) + test("initializes server plugins in config order", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const a = path.join(dir, "a-plugin.ts") + const b = path.join(dir, "b-plugin.ts") + const marker = path.join(dir, "server-order.txt") + const aSpec = pathToFileURL(a).href + const bSpec = pathToFileURL(b).href + + await Bun.write( + a, + `import fs from "fs/promises" + +export default { + id: "demo.order.a", + server: async () => { + await fs.appendFile(${JSON.stringify(marker)}, "a-start\\n") + await Bun.sleep(25) + await fs.appendFile(${JSON.stringify(marker)}, "a-end\\n") + return {} + }, +} +`, + ) + await Bun.write( + b, + `import fs from "fs/promises" + +export default { + id: "demo.order.b", + server: async () => { + await fs.appendFile(${JSON.stringify(marker)}, "b\\n") + return {} + }, +} +`, + ) + + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [aSpec, bSpec] }, null, 2)) + + return { marker } + }, + }) + + await load(tmp.path) + const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n") + expect(lines).toEqual(["a-start", "a-end", "b"]) + }) + test("skips external plugins in pure mode", async () => { await using tmp = await tmpdir({ init: async (dir) => {