2
0
Эх сурвалжийг харах

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
Dax Raad 2 сар өмнө
parent
commit
3f90ffabee

+ 68 - 13
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<string, string[]>()
+
+    // 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[]) {

+ 2 - 0
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 = ["[email protected]"]

+ 30 - 18
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 = ["[email protected]"]
+  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