From 440eb76e52538e3a521073b7925bb39fedbab070 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 5 Feb 2026 16:22:13 -0500 Subject: [PATCH] fix(config): install plugin deps per config directory --- packages/opencode/src/config/config.ts | 61 ++++++++++++++++++-------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ed1b155003..d82287c214 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -145,11 +145,20 @@ export namespace Config { log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) } - const deps = [] + const deps: Promise[] = [] for (const dir of unique(directories)) { - if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { - for (const file of ["opencode.jsonc", "opencode.json"]) { + const files = ["opencode.jsonc", "opencode.json"] + const isConfigDir = dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR || dir === Global.Path.config + const configs = isConfigDir + ? await Promise.all(files.map((file) => loadFile(path.join(dir, file), { resolvePlugins: false }))) + : [] + const plugins = configs.flatMap((config) => config.plugin ?? []) + const shouldInstall = await needsInstall(dir, plugins) + if (shouldInstall) await installDependencies(dir, plugins) + + if (isConfigDir) { + for (const file of files) { log.debug(`loading config from ${path.join(dir, file)}`) result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file))) // to satisfy the type checker @@ -159,13 +168,6 @@ export namespace Config { } } - deps.push( - iife(async () => { - const shouldInstall = await needsInstall(dir) - if (shouldInstall) await installDependencies(dir) - }), - ) - result.command = mergeDeep(result.command ?? {}, await loadCommand(dir)) result.agent = mergeDeep(result.agent, await loadAgent(dir)) result.agent = mergeDeep(result.agent, await loadMode(dir)) @@ -247,7 +249,7 @@ export namespace Config { await Promise.all(deps) } - export async function installDependencies(dir: string) { + export async function installDependencies(dir: string, plugins: string[] = []) { const pkg = path.join(dir, "package.json") const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION @@ -258,6 +260,10 @@ export namespace Config { ...json.dependencies, "@opencode-ai/plugin": targetVersion, } + const pluginDeps = deps(plugins) + for (const [name, version] of Object.entries(pluginDeps)) { + json.dependencies[name] = version + } await Bun.write(pkg, JSON.stringify(json, null, 2)) await new Promise((resolve) => setTimeout(resolve, 3000)) @@ -286,7 +292,7 @@ export namespace Config { } } - async function needsInstall(dir: string) { + async function needsInstall(dir: string, plugins: string[] = []) { // Some config dirs may be read-only. // Installing deps there will fail; skip installation in that case. const writable = await isWritable(dir) @@ -318,8 +324,27 @@ export namespace Config { }) return true } - if (depVersion === targetVersion) return false - return true + if (depVersion !== targetVersion) return true + + const pluginDeps = deps(plugins) + for (const [name, version] of Object.entries(pluginDeps)) { + if (dependencies[name] !== version) return true + } + return false + } + + function deps(items: string[]) { + const result: Record = {} + for (const item of items) { + if (!item) continue + if (item.startsWith("file://")) continue + if (item.startsWith("./") || item.startsWith("../") || item.startsWith("/") || item.startsWith("~")) continue + const lastAt = item.lastIndexOf("@") + const pkg = lastAt > 0 ? item.substring(0, lastAt) : item + const version = lastAt > 0 ? item.substring(lastAt + 1) : "latest" + result[pkg] = version + } + return result } function rel(item: string, patterns: string[]) { @@ -1216,7 +1241,7 @@ export namespace Config { return result }) - async function loadFile(filepath: string): Promise { + async function loadFile(filepath: string, options: { resolvePlugins?: boolean } = {}): Promise { log.info("loading", { path: filepath }) let text = await Bun.file(filepath) .text() @@ -1225,10 +1250,10 @@ export namespace Config { throw new JsonError({ path: filepath }, { cause: err }) }) if (!text) return {} - return load(text, filepath) + return load(text, filepath, options) } - async function load(text: string, configFilepath: string) { + async function load(text: string, configFilepath: string, options: { resolvePlugins?: boolean } = {}) { const original = text text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => { return process.env[varName] || "" @@ -1304,7 +1329,7 @@ export namespace Config { await Bun.write(configFilepath, updated).catch(() => {}) } const data = parsed.data - if (data.plugin) { + if (data.plugin && options.resolvePlugins !== false) { for (let i = 0; i < data.plugin.length; i++) { const plugin = data.plugin[i] try {