From f6fd43e57423a5d5767bad8894eb7803712f20b1 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 2 Apr 2026 01:50:22 +0200 Subject: [PATCH] Refactor plugin/config loading, add theme-only plugin package support (#20556) --- packages/opencode/specs/tui-plugins.md | 25 +- packages/opencode/src/cli/cmd/plug.ts | 4 +- .../src/cli/cmd/tui/plugin/runtime.ts | 397 ++++++++++-------- packages/opencode/src/config/config.ts | 144 ++++--- packages/opencode/src/config/paths.ts | 11 +- .../{migrate-tui-config.ts => tui-migrate.ts} | 0 packages/opencode/src/config/tui.ts | 71 +--- packages/opencode/src/plugin/index.ts | 129 ++---- packages/opencode/src/plugin/install.ts | 38 +- packages/opencode/src/plugin/loader.ts | 169 +++++--- packages/opencode/src/plugin/shared.ts | 46 +- packages/opencode/src/util/filesystem.ts | 33 +- .../opencode/test/cli/tui/plugin-add.test.ts | 48 ++- .../test/cli/tui/plugin-install.test.ts | 2 +- .../cli/tui/plugin-loader-entrypoint.test.ts | 32 +- .../test/cli/tui/plugin-loader-pure.test.ts | 4 +- .../test/cli/tui/plugin-loader.test.ts | 6 +- .../test/cli/tui/plugin-toggle.test.ts | 8 +- packages/opencode/test/config/config.test.ts | 83 +++- packages/opencode/test/config/tui.test.ts | 101 ++++- packages/opencode/test/fixture/tui-runtime.ts | 6 +- packages/opencode/test/plugin/install.test.ts | 39 ++ .../test/plugin/loader-shared.test.ts | 300 +++++++++++++ .../opencode/test/util/filesystem.test.ts | 89 ++++ 24 files changed, 1246 insertions(+), 539 deletions(-) rename packages/opencode/src/config/{migrate-tui-config.ts => tui-migrate.ts} (100%) diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index c1c4f53082..632f1e170f 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -10,6 +10,7 @@ Technical reference for the current TUI plugin system. - Package plugins can be installed from CLI or TUI. - v1 plugin modules are target-exclusive: a module can export `server` or `tui`, never both. - Server runtime keeps v0 legacy fallback (function exports / enumerated exports) after v1 parsing. +- npm packages can be TUI theme-only via `package.json["oc-themes"]` without a `./tui` entrypoint. ## TUI config @@ -88,7 +89,8 @@ export default plugin - 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 configured plugin has no target-specific entrypoint, it is skipped with a warning (not a load failure). +- If a configured TUI package has no `./tui` entrypoint and no valid `oc-themes`, it is skipped with a warning (not a load failure). +- If a configured TUI package has no `./tui` entrypoint but has valid `oc-themes`, runtime creates a no-op module record and still loads it for theme sync and plugin state. - 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. @@ -101,10 +103,18 @@ export default plugin ## Package manifest and install -Install target detection is inferred from `package.json` entrypoints: +Install target detection is inferred from `package.json` entrypoints and theme metadata: - `server` target when `exports["./server"]` exists or `main` is set. - `tui` target when `exports["./tui"]` exists. +- `tui` target when `oc-themes` exists and resolves to a non-empty set of valid package-relative theme paths. + +`oc-themes` rules: + +- `oc-themes` is an array of relative paths. +- Absolute paths and `file://` paths are rejected. +- Resolved theme paths must stay inside the package directory. +- Invalid `oc-themes` causes manifest read failure for install. Example: @@ -289,9 +299,12 @@ Theme install behavior: - Relative theme paths are resolved from the plugin root. - Theme name is the JSON basename. +- `api.theme.install(...)` and `oc-themes` auto-sync share the same installer path. +- Theme copy/write runs under cross-process lock key `tui-theme:`. - First install writes only when the destination file is missing. - If the theme name already exists, install is skipped unless plugin metadata state is `updated`. -- On `updated`, host only rewrites themes previously tracked for that plugin and only when source `mtime`/`size` changed. +- On `updated`, host skips rewrite when tracked `mtime`/`size` is unchanged. +- When a theme already exists and state is not `updated`, host can still persist theme metadata when destination already exists. - Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source. - Global plugins persist installed themes under the global `themes` dir. - Invalid or unreadable theme files are ignored. @@ -328,6 +341,7 @@ Slot notes: - `api.plugins.add(spec)` treats the input as the runtime plugin spec and loads it without re-reading `tui.json`. - `api.plugins.add(spec)` no-ops when that resolved spec (or resolved plugin id) is already loaded. - `api.plugins.add(spec)` assumes enabled and always attempts initialization (it does not consult config/KV enable state). +- `api.plugins.add(spec)` can load theme-only packages (`oc-themes` with no `./tui`) as runtime entries. - `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install. - `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`. - `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install. @@ -357,7 +371,11 @@ Metadata is persisted by plugin id. - External TUI plugins load from `tuiConfig.plugin`. - `--pure` / `OPENCODE_PURE` skips external TUI plugins only. - External plugin resolution and import are parallel. +- Packages with no `./tui` entrypoint and valid `oc-themes` are loaded as synthetic no-op TUI plugin modules. +- Theme-only packages loaded this way appear in `api.plugins.list()` and plugin manager rows like other external plugins. +- Packages with no `./tui` entrypoint and no valid `oc-themes` are skipped with warning. - External plugin activation is sequential to keep command, route, and side-effect order deterministic. +- Theme auto-sync from `oc-themes` runs before plugin `tui(...)` execution and only on metadata state `first` or `updated`. - File plugins that fail initially are retried once after waiting for config dependency installation. - Runtime add uses the same external loader path, including the file-plugin retry after dependency wait. - Runtime add skips duplicates by resolved spec and returns `true` when the spec is already loaded. @@ -400,6 +418,7 @@ The plugin manager is exposed as a command with title `Plugins` and value `plugi - Install is blocked until `api.state.path.directory` is available; current guard message is `Paths are still syncing. Try again in a moment.`. - Manager install uses `api.plugins.install(spec, { global })`. - If the installed package has no `tui` target (`tui=false`), manager reports that and does not expect a runtime load. +- `tui` target detection includes `exports["./tui"]` and valid `oc-themes`. - If install reports `tui=true`, manager then calls `api.plugins.add(spec)`. - If runtime add fails, TUI shows a warning and restart remains the fallback. diff --git a/packages/opencode/src/cli/cmd/plug.ts b/packages/opencode/src/cli/cmd/plug.ts index 0e24654233..692c556b24 100644 --- a/packages/opencode/src/cli/cmd/plug.ts +++ b/packages/opencode/src/cli/cmd/plug.ts @@ -115,7 +115,9 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps if (manifest.code === "manifest_no_targets") { inspect.stop("No plugin targets found", 1) dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`) - dep.log.info('Expected one of: exports["./tui"], exports["./server"], or package.json main for server.') + dep.log.info( + 'Expected one of: exports["./tui"], exports["./server"], package.json main for server, or package.json["oc-themes"] for tui themes.', + ) return false } diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 9df4e060bf..e5bc15d5c6 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -18,7 +18,14 @@ import { Log } from "@/util/log" import { errorData, errorMessage } from "@/util/error" import { isRecord } from "@/util/record" import { Instance } from "@/project/instance" -import { pluginSource, readPluginId, readV1Plugin, resolvePluginId, type PluginSource } from "@/plugin/shared" +import { + readPackageThemes, + readPluginId, + readV1Plugin, + resolvePluginId, + type PluginPackage, + type PluginSource, +} from "@/plugin/shared" import { PluginLoader } from "@/plugin/loader" import { PluginMeta } from "@/plugin/meta" import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install" @@ -26,6 +33,7 @@ import { hasTheme, upsertTheme } from "../context/theme" import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" import { Process } from "@/util/process" +import { Flock } from "@/util/flock" import { Flag } from "@/flag/flag" import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal" import { setupSlots, Slot as View } from "./slots" @@ -39,8 +47,9 @@ type PluginLoad = { source: PluginSource | "internal" id: string module: TuiPluginModule - theme_meta: TuiConfig.PluginMeta + origin: Config.PluginOrigin theme_root: string + theme_files: string[] } type Api = HostPluginApi @@ -67,12 +76,15 @@ type RuntimeState = { slots: HostSlots plugins: PluginEntry[] plugins_by_id: Map - pending: Map + pending: Map } const log = Log.create({ service: "tui.plugin" }) const DISPOSE_TIMEOUT_MS = 5000 const KV_KEY = "plugin_enabled" +const EMPTY_TUI: TuiPluginModule = { + tui: async () => {}, +} function fail(message: string, data: Record) { if (!("error" in data)) { @@ -134,7 +146,7 @@ function resolveRoot(root: string) { } function createThemeInstaller( - meta: TuiConfig.PluginMeta, + meta: Config.PluginOrigin, root: string, spec: string, plugin: PluginEntry, @@ -153,162 +165,73 @@ function createThemeInstaller( const stat = await Filesystem.statAsync(src) const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined const size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined - const exists = hasTheme(name) - const prev = plugin.themes[name] - - if (exists) { - if (plugin.meta.state !== "updated") return - if (!prev) { - if (await Filesystem.exists(dest)) { - plugin.themes[name] = { - src, - dest, - mtime, - size, - } - await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => { - log.warn("failed to track tui plugin theme", { - path: spec, - id: plugin.id, - theme: src, - dest, - error, - }) - }) - } - return - } - if (prev.dest !== dest) return - if (prev.mtime === mtime && prev.size === size) return - } - - const text = await Filesystem.readText(src).catch((error) => { - log.warn("failed to read tui plugin theme", { path: spec, theme: src, error }) - return - }) - if (text === undefined) return - - const fail = Symbol() - const data = await Promise.resolve(text) - .then((x) => JSON.parse(x)) - .catch((error) => { - log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error }) - return fail - }) - if (data === fail) return - - if (!isTheme(data)) { - log.warn("invalid tui plugin theme", { path: spec, theme: src }) - return - } - - if (exists || !(await Filesystem.exists(dest))) { - await Filesystem.write(dest, text).catch((error) => { - log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error }) - }) - } - - upsertTheme(name, data) - plugin.themes[name] = { + const info = { src, dest, mtime, size, } - await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => { - log.warn("failed to track tui plugin theme", { - path: spec, - id: plugin.id, - theme: src, - dest, - error, + + await Flock.withLock(`tui-theme:${dest}`, async () => { + const save = async () => { + plugin.themes[name] = info + await PluginMeta.setTheme(plugin.id, name, info).catch((error) => { + log.warn("failed to track tui plugin theme", { + path: spec, + id: plugin.id, + theme: src, + dest, + error, + }) + }) + } + + const exists = hasTheme(name) + const prev = plugin.themes[name] + if (exists) { + if (plugin.meta.state !== "updated") { + if (!prev && (await Filesystem.exists(dest))) { + await save() + } + return + } + if (prev?.dest === dest && prev.mtime === mtime && prev.size === size) return + } + + const text = await Filesystem.readText(src).catch((error) => { + log.warn("failed to read tui plugin theme", { path: spec, theme: src, error }) + return }) + if (text === undefined) return + + const fail = Symbol() + const data = await Promise.resolve(text) + .then((x) => JSON.parse(x)) + .catch((error) => { + log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error }) + return fail + }) + if (data === fail) return + + if (!isTheme(data)) { + log.warn("invalid tui plugin theme", { path: spec, theme: src }) + return + } + + if (exists || !(await Filesystem.exists(dest))) { + await Filesystem.write(dest, text).catch((error) => { + log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error }) + }) + } + + upsertTheme(name, data) + await save() + }).catch((error) => { + log.warn("failed to lock tui plugin theme install", { path: spec, theme: src, dest, error }) }) } } -async function loadExternalPlugin(cfg: TuiConfig.PluginRecord, retry = false): Promise { - const plan = PluginLoader.plan(cfg.item) - if (plan.deprecated) return - - log.info("loading tui plugin", { path: plan.spec, retry }) - const resolved = await PluginLoader.resolve(plan, "tui") - if (!resolved.ok) { - if (resolved.stage === "missing") { - warn("tui plugin has no entrypoint", { - path: plan.spec, - retry, - message: resolved.message, - }) - return - } - - 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 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 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: plan.spec, - target: loaded.value.entry, - retry, - error, - }) - return - }) - if (!mod) return - - 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 { - options: plan.options, - spec: plan.spec, - target: loaded.value.target, - retry, - source: loaded.value.source, - id, - module: mod, - theme_meta: { - scope: cfg.scope, - source: cfg.source, - }, - theme_root: loaded.value.pkg?.dir ?? resolveRoot(loaded.value.target), - } -} - function createMeta( source: PluginLoad["source"], spec: string, @@ -350,11 +273,38 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad { source: "internal", id: item.id, module: item, - theme_meta: { + origin: { + spec, scope: "global", source: target, }, theme_root: process.cwd(), + theme_files: [], + } +} + +async function readThemeFiles(spec: string, pkg?: PluginPackage) { + if (!pkg) return [] as string[] + return Promise.resolve() + .then(() => readPackageThemes(spec, pkg)) + .catch((error) => { + warn("invalid tui plugin oc-themes", { + path: spec, + pkg: pkg.pkg, + error, + }) + return [] as string[] + }) +} + +async function syncPluginThemes(plugin: PluginEntry) { + if (!plugin.load.theme_files.length) return + if (plugin.meta.state === "same") return + const install = createThemeInstaller(plugin.load.origin, plugin.load.theme_root, plugin.load.spec, plugin) + for (const file of plugin.load.theme_files) { + await install(file).catch((error) => { + warn("failed to sync tui plugin oc-themes", { path: plugin.load.spec, id: plugin.id, theme: file, error }) + }) } } @@ -489,6 +439,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 syncPluginThemes(plugin) await plugin.plugin(api, plugin.load.options, plugin.meta) return true }) @@ -555,7 +506,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop } const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), { - install: createThemeInstaller(load.theme_meta, load.theme_root, load.spec, plugin), + install: createThemeInstaller(load.origin, load.theme_root, load.spec, plugin), }) const event: TuiPluginApi["event"] = { @@ -637,28 +588,108 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I } } -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 - - for (let i = 0; i < list.length; i++) { - let entry = loaded[i] - if (!entry) { - const item = list[i] - if (!item) continue - if (pluginSource(Config.pluginSpecifier(item.item)) !== "file") continue - deps ??= wait().catch((error) => { +async function resolveExternalPlugins(list: Config.PluginOrigin[], wait: () => Promise) { + return PluginLoader.loadExternal({ + items: list, + kind: "tui", + wait: async () => { + await wait().catch((error) => { log.warn("failed waiting for tui plugin dependencies", { error }) }) - await deps - entry = await loadExternalPlugin(item, true) - } - if (!entry) continue - ready.push(entry) - } + }, + finish: async (loaded, origin, retry) => { + const mod = await Promise.resolve() + .then(() => readV1Plugin(loaded.mod as Record, loaded.spec, "tui") as TuiPluginModule) + .catch((error) => { + fail("failed to load tui plugin", { + path: loaded.spec, + target: loaded.entry, + retry, + error, + }) + return + }) + if (!mod) return - return ready + const id = await resolvePluginId( + loaded.source, + loaded.spec, + loaded.target, + readPluginId(mod.id, loaded.spec), + loaded.pkg, + ).catch((error) => { + fail("failed to load tui plugin", { path: loaded.spec, target: loaded.target, retry, error }) + return + }) + if (!id) return + + const theme_files = await readThemeFiles(loaded.spec, loaded.pkg) + + return { + options: loaded.options, + spec: loaded.spec, + target: loaded.target, + retry, + source: loaded.source, + id, + module: mod, + origin, + theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target), + theme_files, + } + }, + missing: async (loaded, origin, retry) => { + const theme_files = await readThemeFiles(loaded.spec, loaded.pkg) + if (!theme_files.length) return + + const name = + typeof loaded.pkg?.json.name === "string" && loaded.pkg.json.name.trim().length > 0 + ? loaded.pkg.json.name.trim() + : undefined + const id = await resolvePluginId(loaded.source, loaded.spec, loaded.target, name, loaded.pkg).catch((error) => { + fail("failed to load tui plugin", { path: loaded.spec, target: loaded.target, retry, error }) + return + }) + if (!id) return + + return { + options: loaded.options, + spec: loaded.spec, + target: loaded.target, + retry, + source: loaded.source, + id, + module: EMPTY_TUI, + origin, + theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target), + theme_files, + } + }, + report: { + start(candidate, retry) { + log.info("loading tui plugin", { path: candidate.plan.spec, retry }) + }, + missing(candidate, retry, message) { + warn("tui plugin has no entrypoint", { path: candidate.plan.spec, retry, message }) + }, + error(candidate, retry, stage, error, resolved) { + const spec = candidate.plan.spec + if (stage === "install") { + fail("failed to resolve tui plugin", { path: spec, retry, error }) + return + } + if (stage === "compatibility") { + fail("tui plugin incompatible", { path: spec, retry, error }) + return + } + if (stage === "entry") { + fail("failed to resolve tui plugin entry", { path: spec, retry, error }) + return + } + fail("failed to load tui plugin", { path: spec, target: resolved?.entry, retry, error }) + }, + }, + }) } async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]) { @@ -692,12 +723,12 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[] }) } - const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id) + const info = createMeta(entry.source, entry.spec, entry.target, hit, entry.id) const themes = hit?.entry.themes ? { ...hit.entry.themes } : {} const plugin: PluginEntry = { id: entry.id, load: entry, - meta: row, + meta: info, themes, plugin: entry.module.tui, enabled: true, @@ -712,9 +743,9 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[] return { plugins, ok } } -function defaultPluginRecord(state: RuntimeState, spec: string): TuiConfig.PluginRecord { +function defaultPluginOrigin(state: RuntimeState, spec: string): Config.PluginOrigin { return { - item: spec, + spec, scope: "local", source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"), } @@ -752,8 +783,8 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) { const spec = raw.trim() if (!spec) return false - const cfg = state.pending.get(spec) ?? defaultPluginRecord(state, spec) - const next = Config.pluginSpecifier(cfg.item) + const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec) + const next = Config.pluginSpecifier(cfg.spec) if (state.plugins.some((plugin) => plugin.load.spec === next)) { state.pending.delete(spec) return true @@ -837,7 +868,7 @@ async function installPluginBySpec( if (manifest.code === "manifest_no_targets") { return { ok: false, - message: `"${spec}" does not expose plugin entrypoints in package.json`, + message: `"${spec}" does not expose plugin entrypoints or oc-themes in package.json`, } } @@ -872,9 +903,9 @@ 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 + const next = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec state.pending.set(spec, { - item, + spec: next, scope: global ? "global" : "local", source: (file ?? dir.config) || path.join(patch.dir, "tui.json"), }) @@ -959,9 +990,9 @@ export namespace TuiPluginRuntime { directory: cwd, fn: async () => { const config = await TuiConfig.get() - 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 }) + const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? []) + if (Flag.OPENCODE_PURE && config.plugin_origins?.length) { + log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length }) } for (const item of INTERNAL_TUI_PLUGINS) { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 27618a3c36..3cae1af4bd 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -47,6 +47,12 @@ export namespace Config { export type PluginOptions = z.infer export type PluginSpec = z.infer + export type PluginScope = "global" | "local" + export type PluginOrigin = { + spec: PluginSpec + source: string + scope: PluginScope + } const log = Log.create({ service: "config" }) @@ -72,9 +78,6 @@ export namespace Config { // Custom merge function that concatenates array fields instead of replacing them function mergeConfigConcatArrays(target: Info, source: Info): Info { const merged = mergeDeep(target, source) - if (target.plugin && source.plugin) { - merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin])) - } if (target.instructions && source.instructions) { merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) } @@ -297,31 +300,19 @@ export namespace Config { return resolved } - /** - * Deduplicates plugins by name, with later entries (higher priority) winning. - * Priority order (highest to lowest): - * 1. Local plugin/ directory - * 2. Local opencode.json - * 3. Global plugin/ directory - * 4. Global opencode.json - * - * Since plugins are added in low-to-high priority order, - * we reverse, deduplicate (keeping first occurrence), then restore order. - */ - export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] { - const seenNames = new Set() - const uniqueSpecifiers: PluginSpec[] = [] + export function deduplicatePluginOrigins(plugins: PluginOrigin[]): PluginOrigin[] { + const seen = new Set() + const list: PluginOrigin[] = [] - for (const specifier of plugins.toReversed()) { - const spec = pluginSpecifier(specifier) + for (const plugin of plugins.toReversed()) { + const spec = pluginSpecifier(plugin.spec) const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg - if (!seenNames.has(name)) { - seenNames.add(name) - uniqueSpecifiers.push(specifier) - } + if (seen.has(name)) continue + seen.add(name) + list.push(plugin) } - return uniqueSpecifiers.toReversed() + return list.toReversed() } export const McpLocal = z @@ -997,7 +988,9 @@ export namespace Config { ref: "Config", }) - export type Info = z.output + export type Info = z.output & { + plugin_origins?: PluginOrigin[] + } type State = { config: Info @@ -1044,6 +1037,11 @@ export namespace Config { }, input) } + function writable(info: Info) { + const { plugin_origins, ...next } = info + return next + } + function parseConfig(text: string, filepath: string): Info { const errors: JsoncParseError[] = [] const data = parseJsonc(text, errors, { allowTrailingComma: true }) @@ -1208,6 +1206,30 @@ export namespace Config { const auth = yield* authSvc.all().pipe(Effect.orDie) let result: Info = {} + + const scope = (source: string): PluginScope => { + if (source.startsWith("http://") || source.startsWith("https://")) return "global" + if (source === "OPENCODE_CONFIG_CONTENT") return "local" + if (Instance.containsPath(source)) return "local" + return "global" + } + + const track = (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) => { + if (!list?.length) return + const hit = kind ?? scope(source) + const plugins = deduplicatePluginOrigins([ + ...(result.plugin_origins ?? []), + ...list.map((spec) => ({ spec, source, scope: hit })), + ]) + result.plugin = plugins.map((item) => item.spec) + result.plugin_origins = plugins + } + + const merge = (source: string, next: Info, kind?: PluginScope) => { + result = mergeConfigConcatArrays(result, next) + track(source, next.plugin, kind) + } + for (const [key, value] of Object.entries(auth)) { if (value.type === "wellknown") { const url = key.replace(/\/+$/, "") @@ -1220,21 +1242,21 @@ export namespace Config { const wellknown = (yield* Effect.promise(() => response.json())) as any const remoteConfig = wellknown.config ?? {} if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" - result = mergeConfigConcatArrays( - result, - yield* loadConfig(JSON.stringify(remoteConfig), { - dir: path.dirname(`${url}/.well-known/opencode`), - source: `${url}/.well-known/opencode`, - }), - ) + const source = `${url}/.well-known/opencode` + const next = yield* loadConfig(JSON.stringify(remoteConfig), { + dir: path.dirname(source), + source, + }) + merge(source, next, "global") log.debug("loaded remote config from well-known", { url }) } } - result = mergeConfigConcatArrays(result, yield* getGlobal()) + const global = yield* getGlobal() + merge(Global.Path.config, global, "global") if (Flag.OPENCODE_CONFIG) { - result = mergeConfigConcatArrays(result, yield* loadFile(Flag.OPENCODE_CONFIG)) + merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG)) log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) } @@ -1242,7 +1264,7 @@ export namespace Config { for (const file of yield* Effect.promise(() => ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree), )) { - result = mergeConfigConcatArrays(result, yield* loadFile(file)) + merge(file, yield* loadFile(file), "local") } } @@ -1260,9 +1282,10 @@ export namespace Config { for (const dir of unique(directories)) { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { - for (const file of ["opencode.jsonc", "opencode.json"]) { - log.debug(`loading config from ${path.join(dir, file)}`) - result = mergeConfigConcatArrays(result, yield* loadFile(path.join(dir, file))) + for (const file of ["opencode.json", "opencode.jsonc"]) { + const source = path.join(dir, file) + log.debug(`loading config from ${source}`) + merge(source, yield* loadFile(source)) result.agent ??= {} result.mode ??= {} result.plugin ??= [] @@ -1280,17 +1303,17 @@ export namespace Config { result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir))) result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir))) result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir))) - result.plugin.push(...(yield* Effect.promise(() => loadPlugin(dir)))) + const list = yield* Effect.promise(() => loadPlugin(dir)) + track(dir, list) } if (process.env.OPENCODE_CONFIG_CONTENT) { - result = mergeConfigConcatArrays( - result, - yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, { - dir: ctx.directory, - source: "OPENCODE_CONFIG_CONTENT", - }), - ) + const source = "OPENCODE_CONFIG_CONTENT" + const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, { + dir: ctx.directory, + source, + }) + merge(source, next, "local") log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") } @@ -1309,13 +1332,12 @@ export namespace Config { const config = Option.getOrUndefined(configOpt) if (config) { - result = mergeConfigConcatArrays( - result, - yield* loadConfig(JSON.stringify(config), { - dir: path.dirname(`${active.url}/api/config`), - source: `${active.url}/api/config`, - }), - ) + const source = `${active.url}/api/config` + const next = yield* loadConfig(JSON.stringify(config), { + dir: path.dirname(source), + source, + }) + merge(source, next, "global") } }).pipe( Effect.catch((err) => { @@ -1328,8 +1350,9 @@ export namespace Config { } if (existsSync(managedDir)) { - for (const file of ["opencode.jsonc", "opencode.json"]) { - result = mergeConfigConcatArrays(result, yield* loadFile(path.join(managedDir, file))) + for (const file of ["opencode.json", "opencode.jsonc"]) { + const source = path.join(managedDir, file) + merge(source, yield* loadFile(source), "global") } } @@ -1372,8 +1395,6 @@ export namespace Config { result.compaction = { ...result.compaction, prune: false } } - result.plugin = deduplicatePlugins(result.plugin ?? []) - return { config: result, directories, @@ -1403,7 +1424,9 @@ export namespace Config { const dir = yield* InstanceState.directory const file = path.join(dir, "config.json") const existing = yield* loadFile(file) - yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie) + yield* fs + .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) + .pipe(Effect.orDie) yield* Effect.promise(() => Instance.dispose()) }) @@ -1427,15 +1450,16 @@ export namespace Config { const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) { const file = globalConfigFile() const before = (yield* readConfigFile(file)) ?? "{}" + const input = writable(config) let next: Info if (!file.endsWith(".jsonc")) { const existing = parseConfig(before, file) - const merged = mergeDeep(existing, config) + const merged = mergeDeep(writable(existing), input) yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie) next = merged } else { - const updated = patchJsonc(before, config) + const updated = patchJsonc(before, input) next = parseConfig(updated, file) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) } diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index 396417e9a5..82ccf3945f 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -9,14 +9,7 @@ import { Global } from "@/global" export namespace ConfigPaths { export async function projectFiles(name: string, directory: string, worktree: string) { - const files: string[] = [] - for (const file of [`${name}.jsonc`, `${name}.json`]) { - const found = await Filesystem.findUp(file, directory, worktree) - for (const resolved of found.toReversed()) { - files.push(resolved) - } - } - return files + return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true }) } export async function directories(directory: string, worktree: string) { @@ -43,7 +36,7 @@ export namespace ConfigPaths { } export function fileInDirectory(dir: string, name: string) { - return [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)] + return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)] } export const JsonError = NamedError.create( diff --git a/packages/opencode/src/config/migrate-tui-config.ts b/packages/opencode/src/config/tui-migrate.ts similarity index 100% rename from packages/opencode/src/config/migrate-tui-config.ts rename to packages/opencode/src/config/tui-migrate.ts diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index 7f5d50df56..adfb3c7810 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -3,72 +3,33 @@ import z from "zod" import { mergeDeep, unique } from "remeda" import { Config } from "./config" import { ConfigPaths } from "./paths" -import { migrateTuiConfig } from "./migrate-tui-config" +import { migrateTuiConfig } from "./tui-migrate" import { TuiInfo } from "./tui-schema" import { Instance } from "@/project/instance" import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { isRecord } from "@/util/record" import { Global } from "@/global" -import { parsePluginSpecifier } from "@/plugin/shared" export namespace TuiConfig { const log = Log.create({ service: "tui.config" }) export const Info = TuiInfo - export type PluginMeta = { - scope: "global" | "local" - source: string - } - - export type PluginRecord = { - item: Config.PluginSpec - scope: PluginMeta["scope"] - source: string - } - - type PluginEntry = { - item: Config.PluginSpec - meta: PluginMeta - } - type Acc = { result: Info - entries: PluginEntry[] } export type Info = z.output & { // Internal resolved plugin list used by runtime loading. - plugin_records?: PluginRecord[] + plugin_origins?: Config.PluginOrigin[] } - function pluginScope(file: string): PluginMeta["scope"] { + function pluginScope(file: string): Config.PluginScope { if (Instance.containsPath(file)) return "local" return "global" } - function dedupePlugins(list: PluginEntry[]) { - const seen = new Set() - const result: PluginEntry[] = [] - for (const item of list.toReversed()) { - const spec = Config.pluginSpecifier(item.item) - const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg - if (seen.has(name)) continue - seen.add(name) - result.push(item) - } - return result.toReversed() - } - - function mergeInfo(target: Info, source: Info): Info { - const merged = mergeDeep(target, source) - if (target.plugin && source.plugin) { - merged.plugin = [...target.plugin, ...source.plugin] - } - return merged - } - function customPath() { return Flag.OPENCODE_TUI_CONFIG } @@ -95,19 +56,16 @@ export namespace TuiConfig { async function mergeFile(acc: Acc, file: string) { const data = await loadFile(file) - acc.result = mergeInfo(acc.result, data) + acc.result = mergeDeep(acc.result, data) if (!data.plugin?.length) return const scope = pluginScope(file) - for (const item of data.plugin) { - acc.entries.push({ - item, - meta: { - scope, - source: file, - }, - }) - } + const plugins = Config.deduplicatePluginOrigins([ + ...(acc.result.plugin_origins ?? []), + ...data.plugin.map((spec) => ({ spec, scope, source: file })), + ]) + acc.result.plugin = plugins.map((item) => item.spec) + acc.result.plugin_origins = plugins } const state = Instance.state(async () => { @@ -125,7 +83,6 @@ export namespace TuiConfig { const acc: Acc = { result: {}, - entries: [], } for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { @@ -154,15 +111,7 @@ export namespace TuiConfig { } } - const merged = dedupePlugins(acc.entries) acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {}) - 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 4f14d4d1fc..53a8741eac 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -24,10 +24,6 @@ export namespace Plugin { hooks: Hooks[] } - type Loaded = { - row: PluginLoader.Loaded - } - // Hook names that follow the (input, output) => Promise trigger pattern type TriggerName = { [K in keyof Hooks]-?: NonNullable extends (input: any, output: any) => Promise ? K : never @@ -78,22 +74,20 @@ export namespace Plugin { return result } - async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) { - const plugin = readV1Plugin(load.row.mod, load.row.spec, "server", "detect") + function publishPluginError(message: string) { + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + } + + async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) { + const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") if (plugin) { - 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)) + await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg) + hooks.push(await (plugin as PluginModule).server(input, load.options)) return } - for (const server of getLegacyPlugins(load.row.mod)) { - hooks.push(await server(input, load.row.options)) + for (const server of getLegacyPlugins(load.mod)) { + hooks.push(await server(input, load.options)) } } @@ -142,87 +136,52 @@ export namespace Plugin { if (init._tag === "Some") hooks.push(init.value) } - const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? []) - if (Flag.OPENCODE_PURE && cfg.plugin?.length) { - log.info("skipping external plugins in pure mode", { count: cfg.plugin.length }) + const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin_origins ?? []) + if (Flag.OPENCODE_PURE && cfg.plugin_origins?.length) { + log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length }) } if (plugins.length) yield* config.waitForDependencies() 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 }) + PluginLoader.loadExternal({ + items: plugins, + kind: "server", + report: { + start(candidate) { + log.info("loading plugin", { path: candidate.plan.spec }) + }, + missing(candidate, _retry, message) { + log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message }) + }, + error(candidate, _retry, stage, error, resolved) { + const spec = candidate.plan.spec + const cause = error instanceof Error ? (error.cause ?? error) : error + const message = stage === "load" ? errorMessage(error) : errorMessage(cause) - const resolved = await PluginLoader.resolve(plan, "server") - if (!resolved.ok) { - if (resolved.stage === "missing") { - log.warn("plugin has no server entrypoint", { - path: plan.spec, - message: resolved.message, - }) + if (stage === "install") { + const parsed = parsePluginSpecifier(spec) + log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message }) + publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`) return } - 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(), - }) + if (stage === "compatibility") { + log.warn("plugin incompatible", { path: spec, error: message }) + publishPluginError(`Plugin ${spec} skipped: ${message}`) 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(), - }) + if (stage === "entry") { + log.error("failed to resolve plugin server entry", { path: spec, error: message }) + publishPluginError(`Failed to load plugin ${spec}: ${message}`) 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, - } - }), - ), + log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message }) + publishPluginError(`Failed to load plugin ${spec}: ${message}`) + }, + }, + }), ) for (const load of loaded) { if (!load) continue @@ -233,14 +192,14 @@ export namespace Plugin { try: () => applyPlugin(load, input, hooks), catch: (err) => { const message = errorMessage(err) - log.error("failed to load plugin", { path: load.row.spec, error: message }) + log.error("failed to load plugin", { path: load.spec, error: message }) return message }, }).pipe( Effect.catch((message) => bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ - message: `Failed to load plugin ${load.row.spec}: ${message}`, + message: `Failed to load plugin ${load.spec}: ${message}`, }).toObject(), }), ), diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts index 1eed82624c..b6bac42a7f 100644 --- a/packages/opencode/src/plugin/install.ts +++ b/packages/opencode/src/plugin/install.ts @@ -13,7 +13,7 @@ import { Filesystem } from "@/util/filesystem" import { Flock } from "@/util/flock" import { isRecord } from "@/util/record" -import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared" +import { parsePluginSpecifier, readPackageThemes, readPluginPackage, resolvePluginTarget } from "./shared" type Mode = "noop" | "add" | "replace" type Kind = "server" | "tui" @@ -142,19 +142,26 @@ function hasMainTarget(pkg: Record) { return Boolean(main.trim()) } -function packageTargets(pkg: Record) { +function packageTargets(pkg: { json: Record; dir: string; pkg: string }) { + const spec = + typeof pkg.json.name === "string" && pkg.json.name.trim().length > 0 ? pkg.json.name.trim() : path.basename(pkg.dir) const targets: Target[] = [] - const server = exportTarget(pkg, "server") + const server = exportTarget(pkg.json, "server") if (server) { targets.push({ kind: "server", opts: server.opts }) - } else if (hasMainTarget(pkg)) { + } else if (hasMainTarget(pkg.json)) { targets.push({ kind: "server" }) } - const tui = exportTarget(pkg, "tui") + const tui = exportTarget(pkg.json, "tui") if (tui) { targets.push({ kind: "tui", opts: tui.opts }) } + + if (!targets.some((item) => item.kind === "tui") && readPackageThemes(spec, pkg).length) { + targets.push({ kind: "tui" }) + } + return targets } @@ -293,8 +300,23 @@ export async function readPluginManifest(target: string): Promise packageTargets(pkg.item)) + .then( + (item) => ({ ok: true as const, item }), + (error: unknown) => ({ ok: false as const, error }), + ) + + if (!targets.ok) { + return { + ok: false, + code: "manifest_read_failed", + file: pkg.item.pkg, + error: targets.error, + } + } + + if (!targets.item.length) { return { ok: false, code: "manifest_no_targets", @@ -304,7 +326,7 @@ export async function readPluginManifest(target: string): Promise } - export function plan(item: Config.PluginSpec): Plan { + type Candidate = { origin: Config.PluginOrigin; plan: Plan } + type Report = { + start?: (candidate: Candidate, retry: boolean) => void + missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void + error?: ( + candidate: Candidate, + retry: boolean, + stage: "install" | "entry" | "compatibility" | "load", + error: unknown, + resolved?: Resolved, + ) => void + } + + function plan(item: Config.PluginSpec): Plan { const spec = Config.pluginSpecifier(item) - return { - item, - spec, - options: Config.pluginOptions(item), - deprecated: isDeprecatedPlugin(spec), - } + return { spec, options: Config.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) } } export async function resolve( @@ -44,68 +56,44 @@ export namespace PluginLoader { kind: PluginKind, ): Promise< | { ok: true; value: Resolved } - | { ok: false; stage: "missing"; message: string } + | { ok: false; stage: "missing"; value: Missing } | { 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`), - } + 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, - } + return { ok: false, stage: "entry", error } } - - if (!base.entry) { + if (!base.entry) return { ok: false, stage: "missing", - message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`, + value: { + ...plan, + source: base.source, + target: base.target, + pkg: base.pkg, + message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`, + }, } - } if (base.source === "npm") { try { await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg) } catch (error) { - return { - ok: false, - stage: "compatibility", - error, - } + return { ok: false, stage: "compatibility", error } } } - - return { - ok: true, - value: { - ...plan, - source: base.source, - target: base.target, - entry: base.entry, - pkg: base.pkg, - }, - } + 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 }> { @@ -113,25 +101,74 @@ export namespace PluginLoader { try { mod = await import(row.entry) } catch (error) { - return { - ok: false, - 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 } } + } + + async function attempt( + candidate: Candidate, + kind: PluginKind, + retry: boolean, + finish: ((load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise) | undefined, + missing: ((value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise) | undefined, + report: Report | undefined, + ): Promise { + const plan = candidate.plan + if (plan.deprecated) return + report?.start?.(candidate, retry) + const resolved = await resolve(plan, kind) + if (!resolved.ok) { + if (resolved.stage === "missing") { + if (missing) { + const value = await missing(resolved.value, candidate.origin, retry) + if (value !== undefined) return value + } + report?.missing?.(candidate, retry, resolved.value.message, resolved.value) + return + } + report?.error?.(candidate, retry, resolved.stage, resolved.error) + return + } + const loaded = await load(resolved.value) + if (!loaded.ok) { + report?.error?.(candidate, retry, "load", loaded.error, resolved.value) + return + } + if (!finish) return loaded.value as R + return finish(loaded.value, candidate.origin, retry) + } + + type Input = { + items: Config.PluginOrigin[] + kind: PluginKind + wait?: () => Promise + finish?: (load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise + missing?: (value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise + report?: Report + } + + export async function loadExternal(input: Input): Promise { + const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) })) + const list: Array> = [] + for (const candidate of candidates) { + list.push(attempt(candidate, input.kind, false, input.finish, input.missing, input.report)) + } + const out = await Promise.all(list) + if (input.wait) { + let deps: Promise | undefined + for (let i = 0; i < candidates.length; i++) { + if (out[i] !== undefined) continue + const candidate = candidates[i] + if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue + deps ??= input.wait() + await deps + out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report) } } - - if (!mod) { - return { - ok: false, - error: new Error(`Plugin ${row.spec} module is empty`), - } - } - - return { - ok: true, - value: { - ...row, - mod, - }, - } + const ready: R[] = [] + for (const item of out) if (item !== undefined) ready.push(item) + return ready } } diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index e8cbd3ae9c..f92520d05d 100644 --- a/packages/opencode/src/plugin/shared.ts +++ b/packages/opencode/src/plugin/shared.ts @@ -50,6 +50,10 @@ function resolveExportPath(raw: string, dir: string) { return path.resolve(dir, raw) } +function isAbsolutePath(raw: string) { + return path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) +} + function extractExportValue(value: unknown): string | undefined { if (typeof value === "string") return value if (!isRecord(value)) return undefined @@ -68,14 +72,18 @@ function packageMain(pkg: PluginPackage) { return next } -function resolvePackagePath(spec: string, raw: string, kind: PluginKind, pkg: PluginPackage) { +function resolvePackageFile(spec: string, raw: string, kind: string, 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 + return next +} + +function resolvePackagePath(spec: string, raw: string, kind: PluginKind, pkg: PluginPackage) { + return pathToFileURL(resolvePackageFile(spec, raw, kind, pkg)).href } function resolvePackageEntrypoint(spec: string, kind: PluginKind, pkg: PluginPackage) { @@ -106,7 +114,7 @@ async function resolveDirectoryIndex(dir: string) { async function resolveTargetDirectory(target: string) { const file = targetPath(target) if (!file) return - const stat = Filesystem.stat(file) + const stat = await Filesystem.statAsync(file) if (!stat?.isDirectory()) return return file } @@ -147,13 +155,13 @@ async function resolvePluginEntrypoint(spec: string, target: string, kind: Plugi } export function isPathPluginSpec(spec: string) { - return spec.startsWith("file://") || spec.startsWith(".") || path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec) + return spec.startsWith("file://") || spec.startsWith(".") || isAbsolutePath(spec) } export async function resolvePathPluginTarget(spec: string) { const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec const file = path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) ? raw : path.resolve(raw) - const stat = Filesystem.stat(file) + const stat = await Filesystem.statAsync(file) if (!stat?.isDirectory()) { if (spec.startsWith("file://")) return spec return pathToFileURL(file).href @@ -190,7 +198,7 @@ export async function resolvePluginTarget(spec: string, parsed = parsePluginSpec export async function readPluginPackage(target: string): Promise { const file = target.startsWith("file://") ? fileURLToPath(target) : target - const stat = Filesystem.stat(file) + const stat = await Filesystem.statAsync(file) const dir = stat?.isDirectory() ? file : path.dirname(file) const pkg = path.join(dir, "package.json") const json = await Filesystem.readJson>(pkg) @@ -211,6 +219,32 @@ export async function createPluginEntry(spec: string, target: string, kind: Plug } } +export function readPackageThemes(spec: string, pkg: PluginPackage) { + const field = pkg.json["oc-themes"] + if (field === undefined) return [] + if (!Array.isArray(field)) { + throw new TypeError(`Plugin ${spec} has invalid oc-themes field`) + } + + const list = field.map((item) => { + if (typeof item !== "string") { + throw new TypeError(`Plugin ${spec} has invalid oc-themes entry`) + } + + const raw = item.trim() + if (!raw) { + throw new TypeError(`Plugin ${spec} has empty oc-themes entry`) + } + if (raw.startsWith("file://") || isAbsolutePath(raw)) { + throw new TypeError(`Plugin ${spec} oc-themes entry must be relative: ${item}`) + } + + return resolvePackageFile(spec, raw, "oc-themes", pkg) + }) + + return Array.from(new Set(list)) +} + 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}`) diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 29f79e9587..5f50231b03 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -166,17 +166,42 @@ export namespace Filesystem { return !relative(parent, child).startsWith("..") } - export async function findUp(target: string, start: string, stop?: string) { + export async function findUp( + target: string, + start: string, + stop?: string, + options?: { rootFirst?: boolean }, + ): Promise + export async function findUp( + target: string[], + start: string, + stop?: string, + options?: { rootFirst?: boolean }, + ): Promise + export async function findUp( + target: string | string[], + start: string, + stop?: string, + options?: { rootFirst?: boolean }, + ) { + const dirs = [start] let current = start - const result = [] while (true) { - const search = join(current, target) - if (await exists(search)) result.push(search) if (stop === current) break const parent = dirname(current) if (parent === current) break + dirs.push(parent) current = parent } + + const targets = Array.isArray(target) ? target : [target] + const result = [] + for (const dir of options?.rootFirst ? dirs.toReversed() : dirs) { + for (const item of targets) { + const search = join(dir, item) + if (await exists(search)) result.push(search) + } + } return result } diff --git a/packages/opencode/test/cli/tui/plugin-add.test.ts b/packages/opencode/test/cli/tui/plugin-add.test.ts index f42c52bb88..748f291728 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_records: undefined, + plugin_origins: undefined, }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) @@ -59,3 +59,49 @@ test("adds tui plugin at runtime from spec", async () => { delete process.env.OPENCODE_PLUGIN_META_FILE } }) + +test("retries runtime add for file plugins after dependency wait", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const mod = path.join(dir, "retry-plugin") + const spec = pathToFileURL(mod).href + const marker = path.join(dir, "retry-add.txt") + await fs.mkdir(mod, { recursive: true }) + return { mod, spec, marker } + }, + }) + + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") + const get = spyOn(TuiConfig, "get").mockResolvedValue({ + plugin: [], + plugin_origins: undefined, + }) + const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => { + await Bun.write( + path.join(tmp.extra.mod, "index.ts"), + `export default { + id: "demo.add.retry", + tui: async () => { + await Bun.write(${JSON.stringify(tmp.extra.marker)}, "called") + }, +} +`, + ) + }) + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init(createTuiPluginApi()) + + await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true) + await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called") + expect(wait).toHaveBeenCalledTimes(1) + expect(TuiPluginRuntime.list().find((item) => item.id === "demo.add.retry")?.active).toBe(true) + } finally { + await TuiPluginRuntime.dispose() + cwd.mockRestore() + get.mockRestore() + wait.mockRestore() + delete process.env.OPENCODE_PLUGIN_META_FILE + } +}) diff --git a/packages/opencode/test/cli/tui/plugin-install.test.ts b/packages/opencode/test/cli/tui/plugin-install.test.ts index c7f3615c62..290a7eea13 100644 --- a/packages/opencode/test/cli/tui/plugin-install.test.ts +++ b/packages/opencode/test/cli/tui/plugin-install.test.ts @@ -52,7 +52,7 @@ test("installs plugin without loading it", async () => { process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") const cfg: Awaited> = { plugin: [], - plugin_records: undefined, + plugin_origins: undefined, } const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() 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 1e6da59137..68c3df4475 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts @@ -46,9 +46,9 @@ 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_records: [ + plugin_origins: [ { - item: [tmp.extra.spec, { marker: tmp.extra.marker }], + spec: [tmp.extra.spec, { marker: tmp.extra.marker }], scope: "local", source: path.join(tmp.path, "tui.json"), }, @@ -108,9 +108,9 @@ test("does not use npm package exports dot for tui entry", 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_records: [ + plugin_origins: [ { - item: tmp.extra.spec, + spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json"), }, @@ -171,9 +171,9 @@ 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_records: [ + plugin_origins: [ { - item: tmp.extra.spec, + spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json"), }, @@ -234,9 +234,9 @@ 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_records: [ + plugin_origins: [ { - item: tmp.extra.spec, + spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json"), }, @@ -293,9 +293,9 @@ test("does not use npm package main for tui entry", 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_records: [ + plugin_origins: [ { - item: tmp.extra.spec, + spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json"), }, @@ -359,9 +359,9 @@ test("does not use directory package main for tui entry", 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_records: [ + plugin_origins: [ { - item: tmp.extra.spec, + spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json"), }, @@ -407,9 +407,9 @@ test("uses directory index fallback for tui when package.json is missing", 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_records: [ + plugin_origins: [ { - item: tmp.extra.spec, + spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json"), }, @@ -465,9 +465,9 @@ test("uses npm package name when tui plugin id is omitted", 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_records: [ + plugin_origins: [ { - item: [tmp.extra.spec, { marker: tmp.extra.marker }], + spec: [tmp.extra.spec, { marker: tmp.extra.marker }], scope: "local", source: path.join(tmp.path, "tui.json"), }, 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 6f1899a05f..f92d742924 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts @@ -39,9 +39,9 @@ test("skips external tui plugins in pure mode", async () => { const get = spyOn(TuiConfig, "get").mockResolvedValue({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], - plugin_records: [ + plugin_origins: [ { - item: [tmp.extra.spec, { marker: tmp.extra.marker }], + spec: [tmp.extra.spec, { marker: tmp.extra.marker }], scope: "local", source: path.join(tmp.path, "tui.json"), }, diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 7e1f524676..e9e62d2a70 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -468,14 +468,14 @@ test("continues loading when a plugin is missing config metadata", async () => { [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }], tmp.extra.bareSpec, ], - plugin_records: [ + plugin_origins: [ { - item: [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }], + spec: [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }], scope: "local", source: path.join(tmp.path, "tui.json"), }, { - item: tmp.extra.bareSpec, + spec: tmp.extra.bareSpec, scope: "local", source: path.join(tmp.path, "tui.json"), }, diff --git a/packages/opencode/test/cli/tui/plugin-toggle.test.ts b/packages/opencode/test/cli/tui/plugin-toggle.test.ts index 14ee198fc4..10ddfe8e1c 100644 --- a/packages/opencode/test/cli/tui/plugin-toggle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-toggle.test.ts @@ -44,9 +44,9 @@ test("toggles plugin runtime state by exported id", async () => { plugin_enabled: { "demo.toggle": false, }, - plugin_records: [ + plugin_origins: [ { - item: [tmp.extra.spec, { marker: tmp.extra.marker }], + spec: [tmp.extra.spec, { marker: tmp.extra.marker }], scope: "local", source: path.join(tmp.path, "tui.json"), }, @@ -122,9 +122,9 @@ test("kv plugin_enabled overrides tui config on startup", async () => { plugin_enabled: { "demo.startup": false, }, - plugin_records: [ + plugin_origins: [ { - item: [tmp.extra.spec, { marker: tmp.extra.marker }], + spec: [tmp.extra.spec, { marker: tmp.extra.marker }], scope: "local", source: path.join(tmp.path, "tui.json"), }, diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 6369ab5cee..be2a6b11be 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,4 +1,4 @@ -import { test, expect, describe, mock, afterEach, spyOn } from "bun:test" +import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:test" import { Effect, Layer, Option } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config } from "../../src/config/config" @@ -34,8 +34,13 @@ const emptyAuth = Layer.mock(Auth.Service)({ // Get managed config directory from environment (set in preload.ts) const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! +beforeEach(async () => { + await Config.invalidate(true) +}) + afterEach(async () => { await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {}) + await Config.invalidate(true) }) async function writeManagedSettings(settings: object, filename = "opencode.json") { @@ -169,7 +174,7 @@ test("loads JSONC config file", async () => { }) }) -test("merges multiple config files with correct precedence", async () => { +test("jsonc overrides json in the same directory", async () => { await using tmp = await tmpdir({ init: async (dir) => { await writeConfig( @@ -191,7 +196,7 @@ test("merges multiple config files with correct precedence", async () => { directory: tmp.path, fn: async () => { const config = await Config.get() - expect(config.model).toBe("override") + expect(config.model).toBe("base") expect(config.username).toBe("base") }, }) @@ -1174,6 +1179,51 @@ test("deduplicates duplicate plugins from global and local configs", async () => }) }) +test("keeps plugin origins aligned with merged plugin list", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const project = path.join(dir, "project") + const local = path.join(project, ".opencode") + await fs.mkdir(local, { recursive: true }) + + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"], + }), + ) + + await Filesystem.write( + path.join(local, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"], + }), + ) + }, + }) + + await Instance.provide({ + directory: path.join(tmp.path, "project"), + fn: async () => { + const cfg = await Config.get() + const plugins = cfg.plugin ?? [] + const origins = cfg.plugin_origins ?? [] + const names = plugins.map((item) => Config.pluginSpecifier(item)) + + expect(names).toContain("shared-plugin@2.0.0") + expect(names).not.toContain("shared-plugin@1.0.0") + expect(names).toContain("global-only@1.0.0") + expect(names).toContain("local-only@1.0.0") + + expect(origins.map((item) => item.spec)).toEqual(plugins) + const hit = origins.find((item) => Config.pluginSpecifier(item.spec) === "shared-plugin@2.0.0") + expect(hit?.scope).toBe("local") + }, + }) +}) + // Legacy tools migration tests test("migrates legacy tools config to permissions - allow", async () => { @@ -1550,7 +1600,7 @@ test("project config can override MCP server enabled status", async () => { init: async (dir) => { // Simulates a base config (like from remote .well-known) with disabled MCP await Filesystem.write( - path.join(dir, "opencode.jsonc"), + path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", mcp: { @@ -1569,7 +1619,7 @@ test("project config can override MCP server enabled status", async () => { ) // Project config enables just jira await Filesystem.write( - path.join(dir, "opencode.json"), + path.join(dir, "opencode.jsonc"), JSON.stringify({ $schema: "https://opencode.ai/config.json", mcp: { @@ -1608,7 +1658,7 @@ test("MCP config deep merges preserving base config properties", async () => { init: async (dir) => { // Base config with full MCP definition await Filesystem.write( - path.join(dir, "opencode.jsonc"), + path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", mcp: { @@ -1625,7 +1675,7 @@ test("MCP config deep merges preserving base config properties", async () => { ) // Override just enables it, should preserve other properties await Filesystem.write( - path.join(dir, "opencode.json"), + path.join(dir, "opencode.jsonc"), JSON.stringify({ $schema: "https://opencode.ai/config.json", mcp: { @@ -1875,11 +1925,20 @@ describe("resolvePluginSpec", () => { }) }) -describe("deduplicatePlugins", () => { +describe("deduplicatePluginOrigins", () => { + const dedupe = (plugins: Config.PluginSpec[]) => + Config.deduplicatePluginOrigins( + plugins.map((spec) => ({ + spec, + source: "", + scope: "global" as const, + })), + ).map((item) => item.spec) + test("removes duplicates keeping higher priority (later entries)", () => { const plugins = ["global-plugin@1.0.0", "shared-plugin@1.0.0", "local-plugin@2.0.0", "shared-plugin@2.0.0"] - const result = Config.deduplicatePlugins(plugins) + const result = dedupe(plugins) expect(result).toContain("global-plugin@1.0.0") expect(result).toContain("local-plugin@2.0.0") @@ -1891,7 +1950,7 @@ describe("deduplicatePlugins", () => { test("keeps path plugins separate from package plugins", () => { const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"] - const result = Config.deduplicatePlugins(plugins) + const result = dedupe(plugins) expect(result).toEqual(plugins) }) @@ -1899,7 +1958,7 @@ describe("deduplicatePlugins", () => { test("deduplicates direct path plugins by exact spec", () => { const plugins = ["file:///project/.opencode/plugin/demo.ts", "file:///project/.opencode/plugin/demo.ts"] - const result = Config.deduplicatePlugins(plugins) + const result = dedupe(plugins) expect(result).toEqual(["file:///project/.opencode/plugin/demo.ts"]) }) @@ -1907,7 +1966,7 @@ describe("deduplicatePlugins", () => { test("preserves order of remaining plugins", () => { const plugins = ["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"] - const result = Config.deduplicatePlugins(plugins) + const result = dedupe(plugins) expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"]) }) diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 7fb3704e37..a8d98b66cd 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -1,20 +1,99 @@ -import { afterEach, expect, test } from "bun:test" +import { afterEach, beforeEach, expect, test } from "bun:test" import path from "path" import fs from "fs/promises" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" +import { Config } from "../../src/config/config" import { TuiConfig } from "../../src/config/tui" import { Global } from "../../src/global" import { Filesystem } from "../../src/util/filesystem" const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! +beforeEach(async () => { + await Config.invalidate(true) +}) + afterEach(async () => { delete process.env.OPENCODE_CONFIG delete process.env.OPENCODE_TUI_CONFIG + await fs.rm(path.join(Global.Path.config, "opencode.json"), { force: true }).catch(() => {}) + await fs.rm(path.join(Global.Path.config, "opencode.jsonc"), { force: true }).catch(() => {}) await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {}) await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {}) await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {}) + await Config.invalidate(true) +}) + +test("keeps server and tui plugin merge semantics aligned", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const local = path.join(dir, ".opencode") + await fs.mkdir(local, { recursive: true }) + + await Bun.write( + path.join(Global.Path.config, "opencode.json"), + JSON.stringify( + { + plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"], + }, + null, + 2, + ), + ) + await Bun.write( + path.join(Global.Path.config, "tui.json"), + JSON.stringify( + { + plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"], + }, + null, + 2, + ), + ) + + await Bun.write( + path.join(local, "opencode.json"), + JSON.stringify( + { + plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"], + }, + null, + 2, + ), + ) + await Bun.write( + path.join(local, "tui.json"), + JSON.stringify( + { + plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"], + }, + null, + 2, + ), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const server = await Config.get() + const tui = await TuiConfig.get() + const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item)) + const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item)) + + expect(serverPlugins).toEqual(tuiPlugins) + expect(serverPlugins).toContain("shared-plugin@2.0.0") + expect(serverPlugins).not.toContain("shared-plugin@1.0.0") + + const serverOrigins = server.plugin_origins ?? [] + const tuiOrigins = tui.plugin_origins ?? [] + expect(serverOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(serverPlugins) + expect(tuiOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(tuiPlugins) + expect(serverOrigins.map((item) => item.scope)).toEqual(tuiOrigins.map((item) => item.scope)) + }, + }) }) test("loads tui config with the same precedence order as server config paths", async () => { @@ -476,9 +555,9 @@ 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_records).toEqual([ + expect(config.plugin_origins).toEqual([ { - item: "shared-plugin@2.0.0", + spec: "shared-plugin@2.0.0", scope: "global", source: path.join(managedConfigDir, "tui.json"), }, @@ -540,9 +619,9 @@ 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_records).toEqual([ + expect(config.plugin_origins).toEqual([ { - item: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }], + spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }], scope: "local", source: path.join(tmp.path, "tui.json"), }, @@ -580,14 +659,14 @@ 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_records).toEqual([ + expect(config.plugin_origins).toEqual([ { - item: ["acme-plugin@2.0.0", { source: "project" }], + spec: ["acme-plugin@2.0.0", { source: "project" }], scope: "local", source: path.join(tmp.path, "tui.json"), }, { - item: ["second-plugin@3.0.0", { source: "project" }], + spec: ["second-plugin@3.0.0", { source: "project" }], scope: "local", source: path.join(tmp.path, "tui.json"), }, @@ -619,14 +698,14 @@ 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_records).toEqual([ + expect(config.plugin_origins).toEqual([ { - item: "global-plugin@1.0.0", + spec: "global-plugin@1.0.0", scope: "global", source: path.join(Global.Path.config, "tui.json"), }, { - item: "local-plugin@2.0.0", + spec: "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 1e2c0f2a6c..fdd3b6cfff 100644 --- a/packages/opencode/test/fixture/tui-runtime.ts +++ b/packages/opencode/test/fixture/tui-runtime.ts @@ -6,14 +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 plugin_records = plugin.map((item) => ({ - item, + const plugin_origins = plugin.map((spec) => ({ + spec, scope: "local" as const, source: path.join(dir, "tui.json"), })) const get = spyOn(TuiConfig, "get").mockResolvedValue({ plugin, - plugin_records, + plugin_origins, }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => dir) diff --git a/packages/opencode/test/plugin/install.test.ts b/packages/opencode/test/plugin/install.test.ts index 20d71d3e18..5ce21c4cf4 100644 --- a/packages/opencode/test/plugin/install.test.ts +++ b/packages/opencode/test/plugin/install.test.ts @@ -62,6 +62,7 @@ async function plugin( server?: Record tui?: Record }, + themes?: string[], ) { const p = path.join(dir, "plugin") const server = kinds?.includes("server") ?? false @@ -92,6 +93,7 @@ async function plugin( version: "1.0.0", ...(server ? { main: "./server.js" } : {}), ...(Object.keys(exports).length ? { exports } : {}), + ...(themes?.length ? { "oc-themes": themes } : {}), }, null, 2, @@ -438,6 +440,43 @@ describe("plugin.install.task", () => { expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false) }) + test("writes tui config for oc-themes-only packages", async () => { + await using tmp = await tmpdir() + const target = await plugin(tmp.path, undefined, undefined, ["themes/forest.json"]) + await fs.mkdir(path.join(target, "themes"), { recursive: true }) + await Bun.write(path.join(target, "themes", "forest.json"), JSON.stringify({ theme: { text: "#fff" } }, null, 2)) + const run = createPlugTask( + { + mod: "acme@1.2.3", + }, + deps(path.join(tmp.path, "global"), target), + ) + + const ok = await run(ctx(tmp.path)) + expect(ok).toBe(true) + expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(true) + expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false) + + const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc")) + expect(tui.plugin).toEqual(["acme@1.2.3"]) + }) + + test("returns false for oc-themes outside plugin directory", async () => { + await using tmp = await tmpdir() + const target = await plugin(tmp.path, undefined, undefined, ["../outside.json"]) + const run = createPlugTask( + { + mod: "acme@1.2.3", + }, + deps(path.join(tmp.path, "global"), target), + ) + + const ok = await run(ctx(tmp.path)) + expect(ok).toBe(false) + expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(false) + expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false) + }) + test("force replaces version in both server and tui configs", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, ["server", "tui"]) diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 7830ac0da1..c01a02ef41 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -9,6 +9,8 @@ const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" const { Plugin } = await import("../../src/plugin/index") +const { PluginLoader } = await import("../../src/plugin/loader") +const { readPackageThemes } = await import("../../src/plugin/shared") const { Instance } = await import("../../src/project/instance") const { Npm } = await import("../../src/npm") const { Bus } = await import("../../src/bus") @@ -833,4 +835,302 @@ export default { } } }) + + test("reads oc-themes from package manifest", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const mod = path.join(dir, "mod") + await fs.mkdir(path.join(mod, "themes"), { recursive: true }) + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify( + { + name: "acme-plugin", + version: "1.0.0", + "oc-themes": ["themes/one.json", "./themes/one.json", "themes/two.json"], + }, + null, + 2, + ), + ) + + return { mod } + }, + }) + + const file = path.join(tmp.extra.mod, "package.json") + const json = await Filesystem.readJson>(file) + const list = readPackageThemes("acme-plugin", { + dir: tmp.extra.mod, + pkg: file, + json, + }) + + expect(list).toEqual([ + Filesystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")), + Filesystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")), + ]) + }) + + test("handles no-entrypoint tui packages via missing callback", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const mod = path.join(dir, "mods", "acme-plugin") + await fs.mkdir(path.join(mod, "themes"), { recursive: true }) + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify( + { + name: "acme-plugin", + version: "1.0.0", + "oc-themes": ["themes/night.json"], + }, + null, + 2, + ), + ) + await Bun.write(path.join(mod, "themes", "night.json"), "{}\n") + return { mod } + }, + }) + + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const missing: string[] = [] + + try { + const loaded = await PluginLoader.loadExternal({ + items: [ + { + spec: "acme-plugin@1.0.0", + scope: "local" as const, + source: tmp.path, + }, + ], + kind: "tui", + missing: async (item) => { + if (!item.pkg) return + const themes = readPackageThemes(item.spec, item.pkg) + if (!themes.length) return + return { + spec: item.spec, + target: item.target, + themes, + } + }, + report: { + missing(_candidate, _retry, message) { + missing.push(message) + }, + }, + }) + + expect(loaded).toEqual([ + { + spec: "acme-plugin@1.0.0", + target: tmp.extra.mod, + themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))], + }, + ]) + expect(missing).toHaveLength(0) + } finally { + install.mockRestore() + } + }) + + test("passes package metadata for entrypoint tui plugins", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const mod = path.join(dir, "mods", "acme-plugin") + await fs.mkdir(path.join(mod, "themes"), { recursive: true }) + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify( + { + name: "acme-plugin", + version: "1.0.0", + exports: { + "./tui": "./tui.js", + }, + "oc-themes": ["themes/night.json"], + }, + null, + 2, + ), + ) + await Bun.write(path.join(mod, "tui.js"), 'export default { id: "demo", tui: async () => {} }\n') + await Bun.write(path.join(mod, "themes", "night.json"), "{}\n") + return { mod } + }, + }) + + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + + try { + const loaded = await PluginLoader.loadExternal({ + items: [ + { + spec: "acme-plugin@1.0.0", + scope: "local" as const, + source: tmp.path, + }, + ], + kind: "tui", + finish: async (item) => { + if (!item.pkg) return + return { + spec: item.spec, + themes: readPackageThemes(item.spec, item.pkg), + } + }, + }) + + expect(loaded).toEqual([ + { + spec: "acme-plugin@1.0.0", + themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))], + }, + ]) + } finally { + install.mockRestore() + } + }) + + test("rejects oc-themes path traversal", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const mod = path.join(dir, "mod") + await fs.mkdir(mod, { recursive: true }) + const file = path.join(mod, "package.json") + await Bun.write(file, JSON.stringify({ name: "acme", "oc-themes": ["../escape.json"] }, null, 2)) + return { mod, file } + }, + }) + + const json = await Filesystem.readJson>(tmp.extra.file) + expect(() => + readPackageThemes("acme", { + dir: tmp.extra.mod, + pkg: tmp.extra.file, + json, + }), + ).toThrow("outside plugin directory") + }) + + test("retries failed file plugins once after wait and keeps order", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const a = path.join(dir, "a") + const b = path.join(dir, "b") + const aSpec = pathToFileURL(a).href + const bSpec = pathToFileURL(b).href + await fs.mkdir(a, { recursive: true }) + await fs.mkdir(b, { recursive: true }) + return { a, b, aSpec, bSpec } + }, + }) + + let wait = 0 + const calls: Array<[string, boolean]> = [] + + const loaded = await PluginLoader.loadExternal({ + items: [tmp.extra.aSpec, tmp.extra.bSpec].map((spec) => ({ + spec, + scope: "local" as const, + source: tmp.path, + })), + kind: "tui", + wait: async () => { + wait += 1 + await Bun.write(path.join(tmp.extra.a, "index.ts"), "export default {}\n") + await Bun.write(path.join(tmp.extra.b, "index.ts"), "export default {}\n") + }, + report: { + start(candidate, retry) { + calls.push([candidate.plan.spec, retry]) + }, + }, + }) + + expect(wait).toBe(1) + expect(calls).toEqual([ + [tmp.extra.aSpec, false], + [tmp.extra.bSpec, false], + [tmp.extra.aSpec, true], + [tmp.extra.bSpec, true], + ]) + expect(loaded.map((item) => item.spec)).toEqual([tmp.extra.aSpec, tmp.extra.bSpec]) + }) + + test("retries file plugins when finish returns undefined", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "plugin.ts") + const spec = pathToFileURL(file).href + await Bun.write(file, "export default {}\n") + return { spec } + }, + }) + + let wait = 0 + let count = 0 + + const loaded = await PluginLoader.loadExternal({ + items: [ + { + spec: tmp.extra.spec, + scope: "local" as const, + source: tmp.path, + }, + ], + kind: "tui", + wait: async () => { + wait += 1 + }, + finish: async (load, _item, retry) => { + count += 1 + if (!retry) return + return { + retry, + spec: load.spec, + } + }, + }) + + expect(wait).toBe(1) + expect(count).toBe(2) + expect(loaded).toEqual([{ retry: true, spec: tmp.extra.spec }]) + }) + + test("does not wait or retry npm plugin failures", async () => { + const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom")) + let wait = 0 + const errors: Array<[string, boolean]> = [] + + try { + const loaded = await PluginLoader.loadExternal({ + items: [ + { + spec: "acme-plugin@1.0.0", + scope: "local" as const, + source: "test", + }, + ], + kind: "tui", + wait: async () => { + wait += 1 + }, + report: { + error(_candidate, retry, stage) { + errors.push([stage, retry]) + }, + }, + }) + + expect(loaded).toEqual([]) + expect(wait).toBe(0) + expect(errors).toEqual([["install", false]]) + } finally { + install.mockRestore() + } + }) }) diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index e6ace9c722..3abcf011bc 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -83,6 +83,95 @@ describe("filesystem", () => { }) }) + describe("findUp()", () => { + test("keeps previous nearest-first behavior for single target", async () => { + await using tmp = await tmpdir() + const parent = path.join(tmp.path, "parent") + const child = path.join(parent, "child") + await fs.mkdir(child, { recursive: true }) + await fs.writeFile(path.join(tmp.path, "marker"), "root", "utf-8") + await fs.writeFile(path.join(parent, "marker"), "parent", "utf-8") + + const result = await Filesystem.findUp("marker", child, tmp.path) + + expect(result).toEqual([path.join(parent, "marker"), path.join(tmp.path, "marker")]) + }) + + test("respects stop boundary", async () => { + await using tmp = await tmpdir() + const parent = path.join(tmp.path, "parent") + const child = path.join(parent, "child") + await fs.mkdir(child, { recursive: true }) + await fs.writeFile(path.join(tmp.path, "marker"), "root", "utf-8") + await fs.writeFile(path.join(parent, "marker"), "parent", "utf-8") + + const result = await Filesystem.findUp("marker", child, parent) + + expect(result).toEqual([path.join(parent, "marker")]) + }) + + test("supports multiple targets with nearest-first default ordering", async () => { + await using tmp = await tmpdir() + const parent = path.join(tmp.path, "parent") + const child = path.join(parent, "child") + await fs.mkdir(child, { recursive: true }) + + await fs.writeFile(path.join(parent, "cfg.jsonc"), "{}", "utf-8") + await fs.writeFile(path.join(tmp.path, "cfg.json"), "{}", "utf-8") + await fs.writeFile(path.join(tmp.path, "cfg.jsonc"), "{}", "utf-8") + + const result = await Filesystem.findUp(["cfg.json", "cfg.jsonc"], child, tmp.path) + + expect(result).toEqual([ + path.join(parent, "cfg.jsonc"), + path.join(tmp.path, "cfg.json"), + path.join(tmp.path, "cfg.jsonc"), + ]) + }) + + test("supports rootFirst ordering for multiple targets", async () => { + await using tmp = await tmpdir() + const parent = path.join(tmp.path, "parent") + const child = path.join(parent, "child") + await fs.mkdir(child, { recursive: true }) + + await fs.writeFile(path.join(parent, "cfg.jsonc"), "{}", "utf-8") + await fs.writeFile(path.join(tmp.path, "cfg.json"), "{}", "utf-8") + await fs.writeFile(path.join(tmp.path, "cfg.jsonc"), "{}", "utf-8") + + const result = await Filesystem.findUp(["cfg.json", "cfg.jsonc"], child, tmp.path, { rootFirst: true }) + + expect(result).toEqual([ + path.join(tmp.path, "cfg.json"), + path.join(tmp.path, "cfg.jsonc"), + path.join(parent, "cfg.jsonc"), + ]) + }) + + test("rootFirst preserves json then jsonc order per directory", async () => { + await using tmp = await tmpdir() + const project = path.join(tmp.path, "project") + const nested = path.join(project, "nested") + await fs.mkdir(nested, { recursive: true }) + + await fs.writeFile(path.join(tmp.path, "opencode.json"), "{}", "utf-8") + await fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "{}", "utf-8") + await fs.writeFile(path.join(project, "opencode.json"), "{}", "utf-8") + await fs.writeFile(path.join(project, "opencode.jsonc"), "{}", "utf-8") + + const result = await Filesystem.findUp(["opencode.json", "opencode.jsonc"], nested, tmp.path, { + rootFirst: true, + }) + + expect(result).toEqual([ + path.join(tmp.path, "opencode.json"), + path.join(tmp.path, "opencode.jsonc"), + path.join(project, "opencode.json"), + path.join(project, "opencode.jsonc"), + ]) + }) + }) + describe("readText()", () => { test("reads file content", async () => { await using tmp = await tmpdir()