From 3f90ffabee0453241df2f1f23592e7aac9bf559a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 5 Feb 2026 16:17:27 -0500 Subject: [PATCH] fix(core): install npm plugins into config directories instead of cache Plugins declared in config files are now installed into the config directory that declared them (e.g. ~/.config/opencode/ or .opencode/) instead of ~/.cache/opencode/. This prevents plugin data loss on cache version bumps and ensures plugins can reliably locate their data files relative to the config directory. Fixes #12222 --- packages/opencode/src/config/config.ts | 81 +++++++++++++++++++++---- packages/opencode/src/plugin/builtin.ts | 2 + packages/opencode/src/plugin/index.ts | 48 +++++++++------ 3 files changed, 100 insertions(+), 31 deletions(-) create mode 100644 packages/opencode/src/plugin/builtin.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ed1b155003..eb82c218c1 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -31,6 +31,7 @@ import { Event } from "../server/event" import { PackageRegistry } from "@/bun/registry" import { proxied } from "@/util/proxied" import { iife } from "@/util/iife" +import { BUILTIN_PLUGINS } from "@/plugin/builtin" export namespace Config { const log = Log.create({ service: "config" }) @@ -147,7 +148,21 @@ export namespace Config { const deps = [] + // Collect npm plugins declared in each directory's config so we can + // install them into that directory (instead of ~/.cache/opencode/) + const dirPlugins = new Map() + + // Plugins from global/project configs that were loaded before the directory + // loop get assigned to the global config directory. + // Built-in plugins also get installed in the global config directory. + const preloopPlugins = [ + ...npmPlugins(result.plugin ?? []), + ...(!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS ? BUILTIN_PLUGINS : []), + ] + for (const dir of unique(directories)) { + const pluginsBefore = [...(result.plugin ?? [])] + if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { for (const file of ["opencode.jsonc", "opencode.json"]) { log.debug(`loading config from ${path.join(dir, file)}`) @@ -159,10 +174,17 @@ export namespace Config { } } + // Determine which npm plugins this directory's config added + const added = npmPlugins((result.plugin ?? []).filter((p) => !pluginsBefore.includes(p))) + + // First directory (global config) also gets the pre-loop plugins + const plugins = dir === Global.Path.config ? [...preloopPlugins, ...added] : added + if (plugins.length) dirPlugins.set(dir, plugins) + deps.push( iife(async () => { - const shouldInstall = await needsInstall(dir) - if (shouldInstall) await installDependencies(dir) + const shouldInstall = await needsInstall(dir, dirPlugins.get(dir)) + if (shouldInstall) await installDependencies(dir, dirPlugins.get(dir)) }), ) @@ -247,7 +269,19 @@ export namespace Config { await Promise.all(deps) } - export async function installDependencies(dir: string) { + /** Extract npm plugin specifiers (non-file:// plugins) */ + function npmPlugins(plugins: string[]): string[] { + return plugins.filter((p) => !p.startsWith("file://")) + } + + /** Parse a plugin specifier into package name and version */ + function parsePlugin(specifier: string): { pkg: string; version: string } { + const lastAt = specifier.lastIndexOf("@") + if (lastAt > 0) return { pkg: specifier.substring(0, lastAt), version: specifier.substring(lastAt + 1) } + return { pkg: specifier, version: "latest" } + } + + export async function installDependencies(dir: string, plugins?: string[]) { const pkg = path.join(dir, "package.json") const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION @@ -258,6 +292,16 @@ export namespace Config { ...json.dependencies, "@opencode-ai/plugin": targetVersion, } + + // Add declared npm plugins to this directory's package.json + // so they get installed here instead of in ~/.cache/opencode/ + if (plugins) { + for (const specifier of plugins) { + const parsed = parsePlugin(specifier) + json.dependencies[parsed.pkg] = parsed.version + } + } + await Bun.write(pkg, JSON.stringify(json, null, 2)) await new Promise((resolve) => setTimeout(resolve, 3000)) @@ -265,8 +309,7 @@ export namespace Config { const hasGitIgnore = await Bun.file(gitignore).exists() if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n")) - // Install any additional dependencies defined in the package.json - // This allows local plugins and custom tools to use external packages + // Install all dependencies (including npm plugins) in this config directory await BunProc.run( [ "install", @@ -286,7 +329,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) @@ -311,15 +354,27 @@ export namespace Config { const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION if (targetVersion === "latest") { const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir) - if (!isOutdated) return false - log.info("Cached version is outdated, proceeding with install", { - pkg: "@opencode-ai/plugin", - cachedVersion: depVersion, - }) + if (isOutdated) { + log.info("Cached version is outdated, proceeding with install", { + pkg: "@opencode-ai/plugin", + cachedVersion: depVersion, + }) + return true + } + } else if (depVersion !== targetVersion) { return true } - if (depVersion === targetVersion) return false - return true + + // Check if any declared plugins are missing from the installed dependencies + if (plugins) { + for (const specifier of plugins) { + const parsed = parsePlugin(specifier) + if (!dependencies[parsed.pkg]) return true + if (!existsSync(path.join(nodeModules, ...parsed.pkg.split("/")))) return true + } + } + + return false } function rel(item: string, patterns: string[]) { diff --git a/packages/opencode/src/plugin/builtin.ts b/packages/opencode/src/plugin/builtin.ts new file mode 100644 index 0000000000..33298aaba5 --- /dev/null +++ b/packages/opencode/src/plugin/builtin.ts @@ -0,0 +1,2 @@ +/** Built-in npm plugins that are installed by default (unless OPENCODE_DISABLE_DEFAULT_PLUGINS is set) */ +export const BUILTIN_PLUGINS = ["opencode-anthropic-auth@0.0.13"] diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 7c55970cd0..b2582d930d 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -4,7 +4,6 @@ import { Bus } from "../bus" import { Log } from "../util/log" import { createOpencodeClient } from "@opencode-ai/sdk" import { Server } from "../server/server" -import { BunProc } from "../bun" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" import { CodexAuthPlugin } from "./codex" @@ -12,15 +11,26 @@ import { Session } from "../session" import { NamedError } from "@opencode-ai/util/error" import { CopilotAuthPlugin } from "./copilot" import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth" +import path from "path" +import { existsSync } from "fs" +import { BUILTIN_PLUGINS } from "./builtin" export namespace Plugin { const log = Log.create({ service: "plugin" }) - const BUILTIN = ["opencode-anthropic-auth@0.0.13"] + export const BUILTIN = BUILTIN_PLUGINS // Built-in plugins that are directly imported (not installed from npm) const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin] + /** Resolve an npm plugin from the config directories' node_modules */ + function resolve(pkg: string, directories: string[]): string | undefined { + for (const dir of directories) { + const mod = path.join(dir, "node_modules", ...pkg.split("/")) + if (existsSync(mod)) return mod + } + } + const state = Instance.state(async () => { const client = createOpencodeClient({ baseUrl: "http://localhost:4096", @@ -50,6 +60,10 @@ export namespace Plugin { plugins.push(...BUILTIN) } + // Wait for dependencies so npm plugins are installed in their config directories + await Config.waitForDependencies() + const directories = await Config.directories() + for (let plugin of plugins) { // ignore old codex plugin since it is supported first party now if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue @@ -57,26 +71,24 @@ export namespace Plugin { if (!plugin.startsWith("file://")) { const lastAtIndex = plugin.lastIndexOf("@") const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin - const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest" const builtin = BUILTIN.some((x) => x.startsWith(pkg + "@")) - plugin = await BunProc.install(pkg, version).catch((err) => { - if (!builtin) throw err - const message = err instanceof Error ? err.message : String(err) - log.error("failed to install builtin plugin", { - pkg, - version, - error: message, - }) + // Resolve the plugin from config directories' node_modules + const resolved = resolve(pkg, directories) + if (!resolved) { + const message = `Plugin ${plugin} not found in any config directory` + if (!builtin) { + log.error("plugin not found", { plugin, directories }) + throw new Error(message) + } + + log.error("failed to resolve builtin plugin", { plugin }) Bus.publish(Session.Event.Error, { - error: new NamedError.Unknown({ - message: `Failed to install built-in plugin ${pkg}@${version}: ${message}`, - }).toObject(), + error: new NamedError.Unknown({ message }).toObject(), }) - - return "" - }) - if (!plugin) continue + continue + } + plugin = resolved } const mod = await import(plugin) // Prevent duplicate initialization when plugins export the same function