Refactor plugin/config loading, add theme-only plugin package support (#20556)

pull/20567/head
Sebastian 2026-04-02 01:50:22 +02:00 committed by GitHub
parent 854484babf
commit f6fd43e574
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1246 additions and 539 deletions

View File

@ -10,6 +10,7 @@ Technical reference for the current TUI plugin system.
- Package plugins can be installed from CLI or TUI. - Package plugins can be installed from CLI or TUI.
- v1 plugin modules are target-exclusive: a module can export `server` or `tui`, never both. - 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. - 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 ## 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["."]`. - 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. - 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. - `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. - 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`. - File/path plugins must export a non-empty `id`.
- npm plugins may omit `id`; package `name` is used. - npm plugins may omit `id`; package `name` is used.
@ -101,10 +103,18 @@ export default plugin
## Package manifest and install ## 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. - `server` target when `exports["./server"]` exists or `main` is set.
- `tui` target when `exports["./tui"]` exists. - `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: Example:
@ -289,9 +299,12 @@ Theme install behavior:
- Relative theme paths are resolved from the plugin root. - Relative theme paths are resolved from the plugin root.
- Theme name is the JSON basename. - 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:<dest>`.
- First install writes only when the destination file is missing. - 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`. - 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. - 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. - Global plugins persist installed themes under the global `themes` dir.
- Invalid or unreadable theme files are ignored. - 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)` 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)` 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)` 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(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(...)` 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. - `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`. - External TUI plugins load from `tuiConfig.plugin`.
- `--pure` / `OPENCODE_PURE` skips external TUI plugins only. - `--pure` / `OPENCODE_PURE` skips external TUI plugins only.
- External plugin resolution and import are parallel. - 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. - 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. - 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 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. - 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.`. - 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 })`. - 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. - 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 install reports `tui=true`, manager then calls `api.plugins.add(spec)`.
- If runtime add fails, TUI shows a warning and restart remains the fallback. - If runtime add fails, TUI shows a warning and restart remains the fallback.

View File

@ -115,7 +115,9 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps
if (manifest.code === "manifest_no_targets") { if (manifest.code === "manifest_no_targets") {
inspect.stop("No plugin targets found", 1) inspect.stop("No plugin targets found", 1)
dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`) 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 return false
} }

View File

@ -18,7 +18,14 @@ import { Log } from "@/util/log"
import { errorData, errorMessage } from "@/util/error" import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance" 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 { PluginLoader } from "@/plugin/loader"
import { PluginMeta } from "@/plugin/meta" import { PluginMeta } from "@/plugin/meta"
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install" import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
@ -26,6 +33,7 @@ import { hasTheme, upsertTheme } from "../context/theme"
import { Global } from "@/global" import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process" import { Process } from "@/util/process"
import { Flock } from "@/util/flock"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal" import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
import { setupSlots, Slot as View } from "./slots" import { setupSlots, Slot as View } from "./slots"
@ -39,8 +47,9 @@ type PluginLoad = {
source: PluginSource | "internal" source: PluginSource | "internal"
id: string id: string
module: TuiPluginModule module: TuiPluginModule
theme_meta: TuiConfig.PluginMeta origin: Config.PluginOrigin
theme_root: string theme_root: string
theme_files: string[]
} }
type Api = HostPluginApi type Api = HostPluginApi
@ -67,12 +76,15 @@ type RuntimeState = {
slots: HostSlots slots: HostSlots
plugins: PluginEntry[] plugins: PluginEntry[]
plugins_by_id: Map<string, PluginEntry> plugins_by_id: Map<string, PluginEntry>
pending: Map<string, TuiConfig.PluginRecord> pending: Map<string, Config.PluginOrigin>
} }
const log = Log.create({ service: "tui.plugin" }) const log = Log.create({ service: "tui.plugin" })
const DISPOSE_TIMEOUT_MS = 5000 const DISPOSE_TIMEOUT_MS = 5000
const KV_KEY = "plugin_enabled" const KV_KEY = "plugin_enabled"
const EMPTY_TUI: TuiPluginModule = {
tui: async () => {},
}
function fail(message: string, data: Record<string, unknown>) { function fail(message: string, data: Record<string, unknown>) {
if (!("error" in data)) { if (!("error" in data)) {
@ -134,7 +146,7 @@ function resolveRoot(root: string) {
} }
function createThemeInstaller( function createThemeInstaller(
meta: TuiConfig.PluginMeta, meta: Config.PluginOrigin,
root: string, root: string,
spec: string, spec: string,
plugin: PluginEntry, plugin: PluginEntry,
@ -153,162 +165,73 @@ function createThemeInstaller(
const stat = await Filesystem.statAsync(src) const stat = await Filesystem.statAsync(src)
const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined 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 size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined
const exists = hasTheme(name) const info = {
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] = {
src, src,
dest, dest,
mtime, mtime,
size, size,
} }
await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
log.warn("failed to track tui plugin theme", { await Flock.withLock(`tui-theme:${dest}`, async () => {
path: spec, const save = async () => {
id: plugin.id, plugin.themes[name] = info
theme: src, await PluginMeta.setTheme(plugin.id, name, info).catch((error) => {
dest, log.warn("failed to track tui plugin theme", {
error, 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<PluginLoad | undefined> {
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<string, unknown>, 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( function createMeta(
source: PluginLoad["source"], source: PluginLoad["source"],
spec: string, spec: string,
@ -350,11 +273,38 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
source: "internal", source: "internal",
id: item.id, id: item.id,
module: item, module: item,
theme_meta: { origin: {
spec,
scope: "global", scope: "global",
source: target, source: target,
}, },
theme_root: process.cwd(), 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 api = pluginApi(state, plugin, scope, plugin.id)
const ok = await Promise.resolve() const ok = await Promise.resolve()
.then(async () => { .then(async () => {
await syncPluginThemes(plugin)
await plugin.plugin(api, plugin.load.options, plugin.meta) await plugin.plugin(api, plugin.load.options, plugin.meta)
return true return true
}) })
@ -555,7 +506,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
} }
const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), { 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"] = { const event: TuiPluginApi["event"] = {
@ -637,28 +588,108 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I
} }
} }
async function resolveExternalPlugins(list: TuiConfig.PluginRecord[], wait: () => Promise<void>) { async function resolveExternalPlugins(list: Config.PluginOrigin[], wait: () => Promise<void>) {
const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item))) return PluginLoader.loadExternal({
const ready: PluginLoad[] = [] items: list,
let deps: Promise<void> | undefined kind: "tui",
wait: async () => {
for (let i = 0; i < list.length; i++) { await wait().catch((error) => {
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) => {
log.warn("failed waiting for tui plugin dependencies", { error }) log.warn("failed waiting for tui plugin dependencies", { error })
}) })
await deps },
entry = await loadExternalPlugin(item, true) finish: async (loaded, origin, retry) => {
} const mod = await Promise.resolve()
if (!entry) continue .then(() => readV1Plugin(loaded.mod as Record<string, unknown>, loaded.spec, "tui") as TuiPluginModule)
ready.push(entry) .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[]) { 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 themes = hit?.entry.themes ? { ...hit.entry.themes } : {}
const plugin: PluginEntry = { const plugin: PluginEntry = {
id: entry.id, id: entry.id,
load: entry, load: entry,
meta: row, meta: info,
themes, themes,
plugin: entry.module.tui, plugin: entry.module.tui,
enabled: true, enabled: true,
@ -712,9 +743,9 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
return { plugins, ok } return { plugins, ok }
} }
function defaultPluginRecord(state: RuntimeState, spec: string): TuiConfig.PluginRecord { function defaultPluginOrigin(state: RuntimeState, spec: string): Config.PluginOrigin {
return { return {
item: spec, spec,
scope: "local", scope: "local",
source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"), 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() const spec = raw.trim()
if (!spec) return false if (!spec) return false
const cfg = state.pending.get(spec) ?? defaultPluginRecord(state, spec) const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec)
const next = Config.pluginSpecifier(cfg.item) const next = Config.pluginSpecifier(cfg.spec)
if (state.plugins.some((plugin) => plugin.load.spec === next)) { if (state.plugins.some((plugin) => plugin.load.spec === next)) {
state.pending.delete(spec) state.pending.delete(spec)
return true return true
@ -837,7 +868,7 @@ async function installPluginBySpec(
if (manifest.code === "manifest_no_targets") { if (manifest.code === "manifest_no_targets") {
return { return {
ok: false, 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") const tui = manifest.targets.find((item) => item.kind === "tui")
if (tui) { if (tui) {
const file = patch.items.find((item) => item.kind === "tui")?.file 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, { state.pending.set(spec, {
item, spec: next,
scope: global ? "global" : "local", scope: global ? "global" : "local",
source: (file ?? dir.config) || path.join(patch.dir, "tui.json"), source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
}) })
@ -959,9 +990,9 @@ export namespace TuiPluginRuntime {
directory: cwd, directory: cwd,
fn: async () => { fn: async () => {
const config = await TuiConfig.get() const config = await TuiConfig.get()
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_records ?? []) const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && config.plugin_records?.length) { if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_records.length }) log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
} }
for (const item of INTERNAL_TUI_PLUGINS) { for (const item of INTERNAL_TUI_PLUGINS) {

View File

@ -47,6 +47,12 @@ export namespace Config {
export type PluginOptions = z.infer<typeof PluginOptions> export type PluginOptions = z.infer<typeof PluginOptions>
export type PluginSpec = z.infer<typeof PluginSpec> export type PluginSpec = z.infer<typeof PluginSpec>
export type PluginScope = "global" | "local"
export type PluginOrigin = {
spec: PluginSpec
source: string
scope: PluginScope
}
const log = Log.create({ service: "config" }) const log = Log.create({ service: "config" })
@ -72,9 +78,6 @@ export namespace Config {
// Custom merge function that concatenates array fields instead of replacing them // Custom merge function that concatenates array fields instead of replacing them
function mergeConfigConcatArrays(target: Info, source: Info): Info { function mergeConfigConcatArrays(target: Info, source: Info): Info {
const merged = mergeDeep(target, source) 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) { if (target.instructions && source.instructions) {
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
} }
@ -297,31 +300,19 @@ export namespace Config {
return resolved return resolved
} }
/** export function deduplicatePluginOrigins(plugins: PluginOrigin[]): PluginOrigin[] {
* Deduplicates plugins by name, with later entries (higher priority) winning. const seen = new Set<string>()
* Priority order (highest to lowest): const list: PluginOrigin[] = []
* 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<string>()
const uniqueSpecifiers: PluginSpec[] = []
for (const specifier of plugins.toReversed()) { for (const plugin of plugins.toReversed()) {
const spec = pluginSpecifier(specifier) const spec = pluginSpecifier(plugin.spec)
const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
if (!seenNames.has(name)) { if (seen.has(name)) continue
seenNames.add(name) seen.add(name)
uniqueSpecifiers.push(specifier) list.push(plugin)
}
} }
return uniqueSpecifiers.toReversed() return list.toReversed()
} }
export const McpLocal = z export const McpLocal = z
@ -997,7 +988,9 @@ export namespace Config {
ref: "Config", ref: "Config",
}) })
export type Info = z.output<typeof Info> export type Info = z.output<typeof Info> & {
plugin_origins?: PluginOrigin[]
}
type State = { type State = {
config: Info config: Info
@ -1044,6 +1037,11 @@ export namespace Config {
}, input) }, input)
} }
function writable(info: Info) {
const { plugin_origins, ...next } = info
return next
}
function parseConfig(text: string, filepath: string): Info { function parseConfig(text: string, filepath: string): Info {
const errors: JsoncParseError[] = [] const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true }) const data = parseJsonc(text, errors, { allowTrailingComma: true })
@ -1208,6 +1206,30 @@ export namespace Config {
const auth = yield* authSvc.all().pipe(Effect.orDie) const auth = yield* authSvc.all().pipe(Effect.orDie)
let result: Info = {} 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)) { for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") { if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "") const url = key.replace(/\/+$/, "")
@ -1220,21 +1242,21 @@ export namespace Config {
const wellknown = (yield* Effect.promise(() => response.json())) as any const wellknown = (yield* Effect.promise(() => response.json())) as any
const remoteConfig = wellknown.config ?? {} const remoteConfig = wellknown.config ?? {}
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
result = mergeConfigConcatArrays( const source = `${url}/.well-known/opencode`
result, const next = yield* loadConfig(JSON.stringify(remoteConfig), {
yield* loadConfig(JSON.stringify(remoteConfig), { dir: path.dirname(source),
dir: path.dirname(`${url}/.well-known/opencode`), source,
source: `${url}/.well-known/opencode`, })
}), merge(source, next, "global")
)
log.debug("loaded remote config from well-known", { url }) 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) { 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 }) log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
} }
@ -1242,7 +1264,7 @@ export namespace Config {
for (const file of yield* Effect.promise(() => for (const file of yield* Effect.promise(() =>
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree), 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)) { for (const dir of unique(directories)) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) { for (const file of ["opencode.json", "opencode.jsonc"]) {
log.debug(`loading config from ${path.join(dir, file)}`) const source = path.join(dir, file)
result = mergeConfigConcatArrays(result, yield* loadFile(path.join(dir, file))) log.debug(`loading config from ${source}`)
merge(source, yield* loadFile(source))
result.agent ??= {} result.agent ??= {}
result.mode ??= {} result.mode ??= {}
result.plugin ??= [] result.plugin ??= []
@ -1280,17 +1303,17 @@ export namespace Config {
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir))) 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(() => loadAgent(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(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) { if (process.env.OPENCODE_CONFIG_CONTENT) {
result = mergeConfigConcatArrays( const source = "OPENCODE_CONFIG_CONTENT"
result, const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, { dir: ctx.directory,
dir: ctx.directory, source,
source: "OPENCODE_CONFIG_CONTENT", })
}), merge(source, next, "local")
)
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
} }
@ -1309,13 +1332,12 @@ export namespace Config {
const config = Option.getOrUndefined(configOpt) const config = Option.getOrUndefined(configOpt)
if (config) { if (config) {
result = mergeConfigConcatArrays( const source = `${active.url}/api/config`
result, const next = yield* loadConfig(JSON.stringify(config), {
yield* loadConfig(JSON.stringify(config), { dir: path.dirname(source),
dir: path.dirname(`${active.url}/api/config`), source,
source: `${active.url}/api/config`, })
}), merge(source, next, "global")
)
} }
}).pipe( }).pipe(
Effect.catch((err) => { Effect.catch((err) => {
@ -1328,8 +1350,9 @@ export namespace Config {
} }
if (existsSync(managedDir)) { if (existsSync(managedDir)) {
for (const file of ["opencode.jsonc", "opencode.json"]) { for (const file of ["opencode.json", "opencode.jsonc"]) {
result = mergeConfigConcatArrays(result, yield* loadFile(path.join(managedDir, file))) 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.compaction = { ...result.compaction, prune: false }
} }
result.plugin = deduplicatePlugins(result.plugin ?? [])
return { return {
config: result, config: result,
directories, directories,
@ -1403,7 +1424,9 @@ export namespace Config {
const dir = yield* InstanceState.directory const dir = yield* InstanceState.directory
const file = path.join(dir, "config.json") const file = path.join(dir, "config.json")
const existing = yield* loadFile(file) 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()) yield* Effect.promise(() => Instance.dispose())
}) })
@ -1427,15 +1450,16 @@ export namespace Config {
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) { const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
const file = globalConfigFile() const file = globalConfigFile()
const before = (yield* readConfigFile(file)) ?? "{}" const before = (yield* readConfigFile(file)) ?? "{}"
const input = writable(config)
let next: Info let next: Info
if (!file.endsWith(".jsonc")) { if (!file.endsWith(".jsonc")) {
const existing = parseConfig(before, file) 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) yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged next = merged
} else { } else {
const updated = patchJsonc(before, config) const updated = patchJsonc(before, input)
next = parseConfig(updated, file) next = parseConfig(updated, file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie) yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
} }

View File

@ -9,14 +9,7 @@ import { Global } from "@/global"
export namespace ConfigPaths { export namespace ConfigPaths {
export async function projectFiles(name: string, directory: string, worktree: string) { export async function projectFiles(name: string, directory: string, worktree: string) {
const files: string[] = [] return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
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
} }
export async function directories(directory: string, worktree: string) { export async function directories(directory: string, worktree: string) {
@ -43,7 +36,7 @@ export namespace ConfigPaths {
} }
export function fileInDirectory(dir: string, name: string) { 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( export const JsonError = NamedError.create(

View File

@ -3,72 +3,33 @@ import z from "zod"
import { mergeDeep, unique } from "remeda" import { mergeDeep, unique } from "remeda"
import { Config } from "./config" import { Config } from "./config"
import { ConfigPaths } from "./paths" import { ConfigPaths } from "./paths"
import { migrateTuiConfig } from "./migrate-tui-config" import { migrateTuiConfig } from "./tui-migrate"
import { TuiInfo } from "./tui-schema" import { TuiInfo } from "./tui-schema"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { Global } from "@/global" import { Global } from "@/global"
import { parsePluginSpecifier } from "@/plugin/shared"
export namespace TuiConfig { export namespace TuiConfig {
const log = Log.create({ service: "tui.config" }) const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo 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 = { type Acc = {
result: Info result: Info
entries: PluginEntry[]
} }
export type Info = z.output<typeof Info> & { export type Info = z.output<typeof Info> & {
// Internal resolved plugin list used by runtime loading. // 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" if (Instance.containsPath(file)) return "local"
return "global" return "global"
} }
function dedupePlugins(list: PluginEntry[]) {
const seen = new Set<string>()
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() { function customPath() {
return Flag.OPENCODE_TUI_CONFIG return Flag.OPENCODE_TUI_CONFIG
} }
@ -95,19 +56,16 @@ export namespace TuiConfig {
async function mergeFile(acc: Acc, file: string) { async function mergeFile(acc: Acc, file: string) {
const data = await loadFile(file) const data = await loadFile(file)
acc.result = mergeInfo(acc.result, data) acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return if (!data.plugin?.length) return
const scope = pluginScope(file) const scope = pluginScope(file)
for (const item of data.plugin) { const plugins = Config.deduplicatePluginOrigins([
acc.entries.push({ ...(acc.result.plugin_origins ?? []),
item, ...data.plugin.map((spec) => ({ spec, scope, source: file })),
meta: { ])
scope, acc.result.plugin = plugins.map((item) => item.spec)
source: file, acc.result.plugin_origins = plugins
},
})
}
} }
const state = Instance.state(async () => { const state = Instance.state(async () => {
@ -125,7 +83,6 @@ export namespace TuiConfig {
const acc: Acc = { const acc: Acc = {
result: {}, result: {},
entries: [],
} }
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { 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 ?? {}) 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<void>[] = [] const deps: Promise<void>[] = []
if (acc.result.plugin?.length) { if (acc.result.plugin?.length) {

View File

@ -24,10 +24,6 @@ export namespace Plugin {
hooks: Hooks[] hooks: Hooks[]
} }
type Loaded = {
row: PluginLoader.Loaded
}
// Hook names that follow the (input, output) => Promise<void> trigger pattern // Hook names that follow the (input, output) => Promise<void> trigger pattern
type TriggerName = { type TriggerName = {
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never [K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
@ -78,22 +74,20 @@ export namespace Plugin {
return result return result
} }
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) { function publishPluginError(message: string) {
const plugin = readV1Plugin(load.row.mod, load.row.spec, "server", "detect") 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) { if (plugin) {
await resolvePluginId( await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg)
load.row.source, hooks.push(await (plugin as PluginModule).server(input, load.options))
load.row.spec,
load.row.target,
readPluginId(plugin.id, load.row.spec),
load.row.pkg,
)
hooks.push(await (plugin as PluginModule).server(input, load.row.options))
return return
} }
for (const server of getLegacyPlugins(load.row.mod)) { for (const server of getLegacyPlugins(load.mod)) {
hooks.push(await server(input, load.row.options)) hooks.push(await server(input, load.options))
} }
} }
@ -142,87 +136,52 @@ export namespace Plugin {
if (init._tag === "Some") hooks.push(init.value) if (init._tag === "Some") hooks.push(init.value)
} }
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? []) const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && cfg.plugin?.length) { if (Flag.OPENCODE_PURE && cfg.plugin_origins?.length) {
log.info("skipping external plugins in pure mode", { count: cfg.plugin.length }) log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length })
} }
if (plugins.length) yield* config.waitForDependencies() if (plugins.length) yield* config.waitForDependencies()
const loaded = yield* Effect.promise(() => const loaded = yield* Effect.promise(() =>
Promise.all( PluginLoader.loadExternal({
plugins.map(async (item) => { items: plugins,
const plan = PluginLoader.plan(item) kind: "server",
if (plan.deprecated) return report: {
log.info("loading plugin", { path: plan.spec }) 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 (stage === "install") {
if (!resolved.ok) { const parsed = parsePluginSpecifier(spec)
if (resolved.stage === "missing") { log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message })
log.warn("plugin has no server entrypoint", { publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`)
path: plan.spec,
message: resolved.message,
})
return return
} }
const cause = if (stage === "compatibility") {
resolved.error instanceof Error ? (resolved.error.cause ?? resolved.error) : resolved.error log.warn("plugin incompatible", { path: spec, error: message })
const message = errorMessage(cause) publishPluginError(`Plugin ${spec} skipped: ${message}`)
if (resolved.stage === "install") {
const parsed = parsePluginSpecifier(plan.spec)
log.error("failed to install plugin", {
pkg: parsed.pkg,
version: parsed.version,
error: message,
})
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`,
}).toObject(),
})
return return
} }
if (resolved.stage === "compatibility") { if (stage === "entry") {
log.warn("plugin incompatible", { path: plan.spec, error: message }) log.error("failed to resolve plugin server entry", { path: spec, error: message })
Bus.publish(Session.Event.Error, { publishPluginError(`Failed to load plugin ${spec}: ${message}`)
error: new NamedError.Unknown({
message: `Plugin ${plan.spec} skipped: ${message}`,
}).toObject(),
})
return return
} }
log.error("failed to resolve plugin server entry", { log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message })
path: plan.spec, publishPluginError(`Failed to load plugin ${spec}: ${message}`)
error: message, },
}) },
Bus.publish(Session.Event.Error, { }),
error: new NamedError.Unknown({
message: `Failed to load plugin ${plan.spec}: ${message}`,
}).toObject(),
})
return
}
const mod = await PluginLoader.load(resolved.value)
if (!mod.ok) {
const message = errorMessage(mod.error)
log.error("failed to load plugin", { path: plan.spec, target: resolved.value.entry, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${plan.spec}: ${message}`,
}).toObject(),
})
return
}
return {
row: mod.value,
}
}),
),
) )
for (const load of loaded) { for (const load of loaded) {
if (!load) continue if (!load) continue
@ -233,14 +192,14 @@ export namespace Plugin {
try: () => applyPlugin(load, input, hooks), try: () => applyPlugin(load, input, hooks),
catch: (err) => { catch: (err) => {
const message = errorMessage(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 return message
}, },
}).pipe( }).pipe(
Effect.catch((message) => Effect.catch((message) =>
bus.publish(Session.Event.Error, { bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({ error: new NamedError.Unknown({
message: `Failed to load plugin ${load.row.spec}: ${message}`, message: `Failed to load plugin ${load.spec}: ${message}`,
}).toObject(), }).toObject(),
}), }),
), ),

View File

@ -13,7 +13,7 @@ import { Filesystem } from "@/util/filesystem"
import { Flock } from "@/util/flock" import { Flock } from "@/util/flock"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared" import { parsePluginSpecifier, readPackageThemes, readPluginPackage, resolvePluginTarget } from "./shared"
type Mode = "noop" | "add" | "replace" type Mode = "noop" | "add" | "replace"
type Kind = "server" | "tui" type Kind = "server" | "tui"
@ -142,19 +142,26 @@ function hasMainTarget(pkg: Record<string, unknown>) {
return Boolean(main.trim()) return Boolean(main.trim())
} }
function packageTargets(pkg: Record<string, unknown>) { function packageTargets(pkg: { json: Record<string, unknown>; 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 targets: Target[] = []
const server = exportTarget(pkg, "server") const server = exportTarget(pkg.json, "server")
if (server) { if (server) {
targets.push({ kind: "server", opts: server.opts }) targets.push({ kind: "server", opts: server.opts })
} else if (hasMainTarget(pkg)) { } else if (hasMainTarget(pkg.json)) {
targets.push({ kind: "server" }) targets.push({ kind: "server" })
} }
const tui = exportTarget(pkg, "tui") const tui = exportTarget(pkg.json, "tui")
if (tui) { if (tui) {
targets.push({ kind: "tui", opts: tui.opts }) targets.push({ kind: "tui", opts: tui.opts })
} }
if (!targets.some((item) => item.kind === "tui") && readPackageThemes(spec, pkg).length) {
targets.push({ kind: "tui" })
}
return targets return targets
} }
@ -293,8 +300,23 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
} }
} }
const targets = packageTargets(pkg.item.json) const targets = await Promise.resolve()
if (!targets.length) { .then(() => 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 { return {
ok: false, ok: false,
code: "manifest_no_targets", code: "manifest_no_targets",
@ -304,7 +326,7 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
return { return {
ok: true, ok: true,
targets, targets: targets.item,
} }
} }

View File

@ -4,6 +4,7 @@ import {
checkPluginCompatibility, checkPluginCompatibility,
createPluginEntry, createPluginEntry,
isDeprecatedPlugin, isDeprecatedPlugin,
pluginSource,
resolvePluginTarget, resolvePluginTarget,
type PluginKind, type PluginKind,
type PluginPackage, type PluginPackage,
@ -12,31 +13,42 @@ import {
export namespace PluginLoader { export namespace PluginLoader {
export type Plan = { export type Plan = {
item: Config.PluginSpec
spec: string spec: string
options: Config.PluginOptions | undefined options: Config.PluginOptions | undefined
deprecated: boolean deprecated: boolean
} }
export type Resolved = Plan & { export type Resolved = Plan & {
source: PluginSource source: PluginSource
target: string target: string
entry: string entry: string
pkg?: PluginPackage pkg?: PluginPackage
} }
export type Missing = Plan & {
source: PluginSource
target: string
pkg?: PluginPackage
message: string
}
export type Loaded = Resolved & { export type Loaded = Resolved & {
mod: Record<string, unknown> mod: Record<string, unknown>
} }
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) const spec = Config.pluginSpecifier(item)
return { return { spec, options: Config.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
item,
spec,
options: Config.pluginOptions(item),
deprecated: isDeprecatedPlugin(spec),
}
} }
export async function resolve( export async function resolve(
@ -44,68 +56,44 @@ export namespace PluginLoader {
kind: PluginKind, kind: PluginKind,
): Promise< ): Promise<
| { ok: true; value: Resolved } | { ok: true; value: Resolved }
| { ok: false; stage: "missing"; message: string } | { ok: false; stage: "missing"; value: Missing }
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown } | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
> { > {
let target = "" let target = ""
try { try {
target = await resolvePluginTarget(plan.spec) target = await resolvePluginTarget(plan.spec)
} catch (error) { } catch (error) {
return { return { ok: false, stage: "install", error }
ok: false,
stage: "install",
error,
}
}
if (!target) {
return {
ok: false,
stage: "install",
error: new Error(`Plugin ${plan.spec} target is empty`),
}
} }
if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
let base let base
try { try {
base = await createPluginEntry(plan.spec, target, kind) base = await createPluginEntry(plan.spec, target, kind)
} catch (error) { } catch (error) {
return { return { ok: false, stage: "entry", error }
ok: false,
stage: "entry",
error,
}
} }
if (!base.entry)
if (!base.entry) {
return { return {
ok: false, ok: false,
stage: "missing", 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") { if (base.source === "npm") {
try { try {
await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg) await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg)
} catch (error) { } catch (error) {
return { return { ok: false, stage: "compatibility", error }
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 }> { export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
@ -113,25 +101,74 @@ export namespace PluginLoader {
try { try {
mod = await import(row.entry) mod = await import(row.entry)
} catch (error) { } catch (error) {
return { return { ok: false, error }
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<R>(
candidate: Candidate,
kind: PluginKind,
retry: boolean,
finish: ((load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>) | undefined,
missing: ((value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>) | undefined,
report: Report | undefined,
): Promise<R | undefined> {
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<R> = {
items: Config.PluginOrigin[]
kind: PluginKind
wait?: () => Promise<void>
finish?: (load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>
missing?: (value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>
report?: Report
}
export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
const list: Array<Promise<R | undefined>> = []
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<void> | 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)
} }
} }
const ready: R[] = []
if (!mod) { for (const item of out) if (item !== undefined) ready.push(item)
return { return ready
ok: false,
error: new Error(`Plugin ${row.spec} module is empty`),
}
}
return {
ok: true,
value: {
...row,
mod,
},
}
} }
} }

View File

@ -50,6 +50,10 @@ function resolveExportPath(raw: string, dir: string) {
return path.resolve(dir, raw) return path.resolve(dir, raw)
} }
function isAbsolutePath(raw: string) {
return path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw)
}
function extractExportValue(value: unknown): string | undefined { function extractExportValue(value: unknown): string | undefined {
if (typeof value === "string") return value if (typeof value === "string") return value
if (!isRecord(value)) return undefined if (!isRecord(value)) return undefined
@ -68,14 +72,18 @@ function packageMain(pkg: PluginPackage) {
return next 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 resolved = resolveExportPath(raw, pkg.dir)
const root = Filesystem.resolve(pkg.dir) const root = Filesystem.resolve(pkg.dir)
const next = Filesystem.resolve(resolved) const next = Filesystem.resolve(resolved)
if (!Filesystem.contains(root, next)) { if (!Filesystem.contains(root, next)) {
throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`) 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) { function resolvePackageEntrypoint(spec: string, kind: PluginKind, pkg: PluginPackage) {
@ -106,7 +114,7 @@ async function resolveDirectoryIndex(dir: string) {
async function resolveTargetDirectory(target: string) { async function resolveTargetDirectory(target: string) {
const file = targetPath(target) const file = targetPath(target)
if (!file) return if (!file) return
const stat = Filesystem.stat(file) const stat = await Filesystem.statAsync(file)
if (!stat?.isDirectory()) return if (!stat?.isDirectory()) return
return file return file
} }
@ -147,13 +155,13 @@ async function resolvePluginEntrypoint(spec: string, target: string, kind: Plugi
} }
export function isPathPluginSpec(spec: string) { 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) { export async function resolvePathPluginTarget(spec: string) {
const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec
const file = path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) ? raw : path.resolve(raw) 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 (!stat?.isDirectory()) {
if (spec.startsWith("file://")) return spec if (spec.startsWith("file://")) return spec
return pathToFileURL(file).href return pathToFileURL(file).href
@ -190,7 +198,7 @@ export async function resolvePluginTarget(spec: string, parsed = parsePluginSpec
export async function readPluginPackage(target: string): Promise<PluginPackage> { export async function readPluginPackage(target: string): Promise<PluginPackage> {
const file = target.startsWith("file://") ? fileURLToPath(target) : target 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 dir = stat?.isDirectory() ? file : path.dirname(file)
const pkg = path.join(dir, "package.json") const pkg = path.join(dir, "package.json")
const json = await Filesystem.readJson<Record<string, unknown>>(pkg) const json = await Filesystem.readJson<Record<string, unknown>>(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) { export function readPluginId(id: unknown, spec: string) {
if (id === undefined) return if (id === undefined) return
if (typeof id !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof id}`) if (typeof id !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof id}`)

View File

@ -166,17 +166,42 @@ export namespace Filesystem {
return !relative(parent, child).startsWith("..") 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<string[]>
export async function findUp(
target: string[],
start: string,
stop?: string,
options?: { rootFirst?: boolean },
): Promise<string[]>
export async function findUp(
target: string | string[],
start: string,
stop?: string,
options?: { rootFirst?: boolean },
) {
const dirs = [start]
let current = start let current = start
const result = []
while (true) { while (true) {
const search = join(current, target)
if (await exists(search)) result.push(search)
if (stop === current) break if (stop === current) break
const parent = dirname(current) const parent = dirname(current)
if (parent === current) break if (parent === current) break
dirs.push(parent)
current = 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 return result
} }

View File

@ -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") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const get = spyOn(TuiConfig, "get").mockResolvedValue({ const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [], plugin: [],
plugin_records: undefined, plugin_origins: undefined,
}) })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) 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 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
}
})

View File

@ -52,7 +52,7 @@ test("installs plugin without loading it", async () => {
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const cfg: Awaited<ReturnType<typeof TuiConfig.get>> = { const cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
plugin: [], plugin: [],
plugin_records: undefined, plugin_origins: undefined,
} }
const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg) const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg)
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()

View File

@ -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") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const get = spyOn(TuiConfig, "get").mockResolvedValue({ const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], 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", scope: "local",
source: path.join(tmp.path, "tui.json"), 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") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const get = spyOn(TuiConfig, "get").mockResolvedValue({ const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [tmp.extra.spec], plugin: [tmp.extra.spec],
plugin_records: [ plugin_origins: [
{ {
item: tmp.extra.spec, spec: tmp.extra.spec,
scope: "local", scope: "local",
source: path.join(tmp.path, "tui.json"), 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") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const get = spyOn(TuiConfig, "get").mockResolvedValue({ const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [tmp.extra.spec], plugin: [tmp.extra.spec],
plugin_records: [ plugin_origins: [
{ {
item: tmp.extra.spec, spec: tmp.extra.spec,
scope: "local", scope: "local",
source: path.join(tmp.path, "tui.json"), 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") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const get = spyOn(TuiConfig, "get").mockResolvedValue({ const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [tmp.extra.spec], plugin: [tmp.extra.spec],
plugin_records: [ plugin_origins: [
{ {
item: tmp.extra.spec, spec: tmp.extra.spec,
scope: "local", scope: "local",
source: path.join(tmp.path, "tui.json"), 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") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const get = spyOn(TuiConfig, "get").mockResolvedValue({ const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [tmp.extra.spec], plugin: [tmp.extra.spec],
plugin_records: [ plugin_origins: [
{ {
item: tmp.extra.spec, spec: tmp.extra.spec,
scope: "local", scope: "local",
source: path.join(tmp.path, "tui.json"), 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") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const get = spyOn(TuiConfig, "get").mockResolvedValue({ const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [tmp.extra.spec], plugin: [tmp.extra.spec],
plugin_records: [ plugin_origins: [
{ {
item: tmp.extra.spec, spec: tmp.extra.spec,
scope: "local", scope: "local",
source: path.join(tmp.path, "tui.json"), 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") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const get = spyOn(TuiConfig, "get").mockResolvedValue({ const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [tmp.extra.spec], plugin: [tmp.extra.spec],
plugin_records: [ plugin_origins: [
{ {
item: tmp.extra.spec, spec: tmp.extra.spec,
scope: "local", scope: "local",
source: path.join(tmp.path, "tui.json"), 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") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const get = spyOn(TuiConfig, "get").mockResolvedValue({ const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], 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", scope: "local",
source: path.join(tmp.path, "tui.json"), source: path.join(tmp.path, "tui.json"),
}, },

View File

@ -39,9 +39,9 @@ test("skips external tui plugins in pure mode", async () => {
const get = spyOn(TuiConfig, "get").mockResolvedValue({ const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], 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", scope: "local",
source: path.join(tmp.path, "tui.json"), source: path.join(tmp.path, "tui.json"),
}, },

View File

@ -468,14 +468,14 @@ test("continues loading when a plugin is missing config metadata", async () => {
[tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }], [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
tmp.extra.bareSpec, 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", scope: "local",
source: path.join(tmp.path, "tui.json"), source: path.join(tmp.path, "tui.json"),
}, },
{ {
item: tmp.extra.bareSpec, spec: tmp.extra.bareSpec,
scope: "local", scope: "local",
source: path.join(tmp.path, "tui.json"), source: path.join(tmp.path, "tui.json"),
}, },

View File

@ -44,9 +44,9 @@ test("toggles plugin runtime state by exported id", async () => {
plugin_enabled: { plugin_enabled: {
"demo.toggle": false, "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", scope: "local",
source: path.join(tmp.path, "tui.json"), source: path.join(tmp.path, "tui.json"),
}, },
@ -122,9 +122,9 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
plugin_enabled: { plugin_enabled: {
"demo.startup": false, "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", scope: "local",
source: path.join(tmp.path, "tui.json"), source: path.join(tmp.path, "tui.json"),
}, },

View File

@ -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 { Effect, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node" import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config } from "../../src/config/config" 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) // Get managed config directory from environment (set in preload.ts)
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
beforeEach(async () => {
await Config.invalidate(true)
})
afterEach(async () => { afterEach(async () => {
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {}) await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
await Config.invalidate(true)
}) })
async function writeManagedSettings(settings: object, filename = "opencode.json") { 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({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
await writeConfig( await writeConfig(
@ -191,7 +196,7 @@ test("merges multiple config files with correct precedence", async () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await Config.get()
expect(config.model).toBe("override") expect(config.model).toBe("base")
expect(config.username).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 // Legacy tools migration tests
test("migrates legacy tools config to permissions - allow", async () => { 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) => { init: async (dir) => {
// Simulates a base config (like from remote .well-known) with disabled MCP // Simulates a base config (like from remote .well-known) with disabled MCP
await Filesystem.write( await Filesystem.write(
path.join(dir, "opencode.jsonc"), path.join(dir, "opencode.json"),
JSON.stringify({ JSON.stringify({
$schema: "https://opencode.ai/config.json", $schema: "https://opencode.ai/config.json",
mcp: { mcp: {
@ -1569,7 +1619,7 @@ test("project config can override MCP server enabled status", async () => {
) )
// Project config enables just jira // Project config enables just jira
await Filesystem.write( await Filesystem.write(
path.join(dir, "opencode.json"), path.join(dir, "opencode.jsonc"),
JSON.stringify({ JSON.stringify({
$schema: "https://opencode.ai/config.json", $schema: "https://opencode.ai/config.json",
mcp: { mcp: {
@ -1608,7 +1658,7 @@ test("MCP config deep merges preserving base config properties", async () => {
init: async (dir) => { init: async (dir) => {
// Base config with full MCP definition // Base config with full MCP definition
await Filesystem.write( await Filesystem.write(
path.join(dir, "opencode.jsonc"), path.join(dir, "opencode.json"),
JSON.stringify({ JSON.stringify({
$schema: "https://opencode.ai/config.json", $schema: "https://opencode.ai/config.json",
mcp: { mcp: {
@ -1625,7 +1675,7 @@ test("MCP config deep merges preserving base config properties", async () => {
) )
// Override just enables it, should preserve other properties // Override just enables it, should preserve other properties
await Filesystem.write( await Filesystem.write(
path.join(dir, "opencode.json"), path.join(dir, "opencode.jsonc"),
JSON.stringify({ JSON.stringify({
$schema: "https://opencode.ai/config.json", $schema: "https://opencode.ai/config.json",
mcp: { 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)", () => { 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 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("global-plugin@1.0.0")
expect(result).toContain("local-plugin@2.0.0") expect(result).toContain("local-plugin@2.0.0")
@ -1891,7 +1950,7 @@ describe("deduplicatePlugins", () => {
test("keeps path plugins separate from package plugins", () => { 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 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) expect(result).toEqual(plugins)
}) })
@ -1899,7 +1958,7 @@ describe("deduplicatePlugins", () => {
test("deduplicates direct path plugins by exact spec", () => { test("deduplicates direct path plugins by exact spec", () => {
const plugins = ["file:///project/.opencode/plugin/demo.ts", "file:///project/.opencode/plugin/demo.ts"] 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"]) expect(result).toEqual(["file:///project/.opencode/plugin/demo.ts"])
}) })
@ -1907,7 +1966,7 @@ describe("deduplicatePlugins", () => {
test("preserves order of remaining plugins", () => { test("preserves order of remaining plugins", () => {
const plugins = ["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"] 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"]) expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"])
}) })

View File

@ -1,20 +1,99 @@
import { afterEach, expect, test } from "bun:test" import { afterEach, beforeEach, expect, test } from "bun:test"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Config } from "../../src/config/config"
import { TuiConfig } from "../../src/config/tui" import { TuiConfig } from "../../src/config/tui"
import { Global } from "../../src/global" import { Global } from "../../src/global"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
beforeEach(async () => {
await Config.invalidate(true)
})
afterEach(async () => { afterEach(async () => {
delete process.env.OPENCODE_CONFIG delete process.env.OPENCODE_CONFIG
delete process.env.OPENCODE_TUI_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.json"), { force: true }).catch(() => {})
await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { 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 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 () => { 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() const config = await TuiConfig.get()
expect(config.theme).toBe("managed-theme") expect(config.theme).toBe("managed-theme")
expect(config.plugin).toEqual(["shared-plugin@2.0.0"]) 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", scope: "global",
source: path.join(managedConfigDir, "tui.json"), source: path.join(managedConfigDir, "tui.json"),
}, },
@ -540,9 +619,9 @@ test("supports tuple plugin specs with options in tui.json", async () => {
fn: async () => { fn: async () => {
const config = await TuiConfig.get() const config = await TuiConfig.get()
expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]]) 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", scope: "local",
source: path.join(tmp.path, "tui.json"), 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" }], ["acme-plugin@2.0.0", { source: "project" }],
["second-plugin@3.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", scope: "local",
source: path.join(tmp.path, "tui.json"), 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", scope: "local",
source: path.join(tmp.path, "tui.json"), 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 () => { fn: async () => {
const config = await TuiConfig.get() const config = await TuiConfig.get()
expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"]) 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", scope: "global",
source: path.join(Global.Path.config, "tui.json"), source: path.join(Global.Path.config, "tui.json"),
}, },
{ {
item: "local-plugin@2.0.0", spec: "local-plugin@2.0.0",
scope: "local", scope: "local",
source: path.join(tmp.path, "tui.json"), source: path.join(tmp.path, "tui.json"),
}, },

View File

@ -6,14 +6,14 @@ type PluginSpec = string | [string, Record<string, unknown>]
export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) { export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) {
process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
const plugin_records = plugin.map((item) => ({ const plugin_origins = plugin.map((spec) => ({
item, spec,
scope: "local" as const, scope: "local" as const,
source: path.join(dir, "tui.json"), source: path.join(dir, "tui.json"),
})) }))
const get = spyOn(TuiConfig, "get").mockResolvedValue({ const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin, plugin,
plugin_records, plugin_origins,
}) })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => dir) const cwd = spyOn(process, "cwd").mockImplementation(() => dir)

View File

@ -62,6 +62,7 @@ async function plugin(
server?: Record<string, unknown> server?: Record<string, unknown>
tui?: Record<string, unknown> tui?: Record<string, unknown>
}, },
themes?: string[],
) { ) {
const p = path.join(dir, "plugin") const p = path.join(dir, "plugin")
const server = kinds?.includes("server") ?? false const server = kinds?.includes("server") ?? false
@ -92,6 +93,7 @@ async function plugin(
version: "1.0.0", version: "1.0.0",
...(server ? { main: "./server.js" } : {}), ...(server ? { main: "./server.js" } : {}),
...(Object.keys(exports).length ? { exports } : {}), ...(Object.keys(exports).length ? { exports } : {}),
...(themes?.length ? { "oc-themes": themes } : {}),
}, },
null, null,
2, 2,
@ -438,6 +440,43 @@ describe("plugin.install.task", () => {
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false) 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 () => { test("force replaces version in both server and tui configs", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server", "tui"]) const target = await plugin(tmp.path, ["server", "tui"])

View File

@ -9,6 +9,8 @@ const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
const { Plugin } = await import("../../src/plugin/index") 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 { Instance } = await import("../../src/project/instance")
const { Npm } = await import("../../src/npm") const { Npm } = await import("../../src/npm")
const { Bus } = await import("../../src/bus") 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<Record<string, unknown>>(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<Record<string, unknown>>(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()
}
})
}) })

View File

@ -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()", () => { describe("readText()", () => {
test("reads file content", async () => { test("reads file content", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()