Просмотр исходного кода

feat(config): deduplicate plugins by name with priority-based resolution (#5957)

Jeon Suyeol 3 месяцев назад
Родитель
Сommit
8e3ab4afa7
2 измененных файлов с 143 добавлено и 1 удалено
  1. 54 0
      packages/opencode/src/config/config.ts
  2. 89 1
      packages/opencode/test/config/config.test.ts

+ 54 - 0
packages/opencode/src/config/config.ts

@@ -178,6 +178,8 @@ export namespace Config {
       result.compaction = { ...result.compaction, prune: false }
     }
 
+    result.plugin = deduplicatePlugins(result.plugin ?? [])
+
     return {
       config: result,
       directories,
@@ -332,6 +334,58 @@ export namespace Config {
     return plugins
   }
 
+  /**
+   * Extracts a canonical plugin name from a plugin specifier.
+   * - For file:// URLs: extracts filename without extension
+   * - For npm packages: extracts package name without version
+   *
+   * @example
+   * getPluginName("file:///path/to/plugin/foo.js") // "foo"
+   * getPluginName("[email protected]") // "oh-my-opencode"
+   * getPluginName("@scope/[email protected]") // "@scope/pkg"
+   */
+  export function getPluginName(plugin: string): string {
+    if (plugin.startsWith("file://")) {
+      return path.parse(new URL(plugin).pathname).name
+    }
+    const lastAt = plugin.lastIndexOf("@")
+    if (lastAt > 0) {
+      return plugin.substring(0, lastAt)
+    }
+    return plugin
+  }
+
+  /**
+   * Deduplicates plugins by name, with later entries (higher priority) winning.
+   * Priority order (highest to lowest):
+   * 1. Local plugin/ directory
+   * 2. Local opencode.json
+   * 3. Global plugin/ directory
+   * 4. Global opencode.json
+   *
+   * Since plugins are added in low-to-high priority order,
+   * we reverse, deduplicate (keeping first occurrence), then restore order.
+   */
+  export function deduplicatePlugins(plugins: string[]): string[] {
+    // seenNames: canonical plugin names for duplicate detection
+    // e.g., "oh-my-opencode", "@scope/pkg"
+    const seenNames = new Set<string>()
+
+    // uniqueSpecifiers: full plugin specifiers to return
+    // e.g., "[email protected]", "file:///path/to/plugin.js"
+    const uniqueSpecifiers: string[] = []
+
+    for (const specifier of plugins.toReversed()) {
+      const name = getPluginName(specifier)
+      if (!seenNames.has(name)) {
+        seenNames.add(name)
+        uniqueSpecifiers.push(specifier)
+      }
+    }
+
+    return uniqueSpecifiers.toReversed()
+  }
+
   export const McpLocal = z
     .object({
       type: z.literal("local").describe("Type of MCP server connection"),

+ 89 - 1
packages/opencode/test/config/config.test.ts

@@ -1,4 +1,4 @@
-import { test, expect, mock, afterEach } from "bun:test"
+import { test, expect, describe, mock } from "bun:test"
 import { Config } from "../../src/config/config"
 import { Instance } from "../../src/project/instance"
 import { Auth } from "../../src/auth"
@@ -1145,3 +1145,91 @@ test("project config overrides remote well-known config", async () => {
     Auth.all = originalAuthAll
   }
 })
+
+describe("getPluginName", () => {
+  test("extracts name from file:// URL", () => {
+    expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo")
+    expect(Config.getPluginName("file:///path/to/plugin/bar.ts")).toBe("bar")
+    expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin")
+  })
+
+  test("extracts name from npm package with version", () => {
+    expect(Config.getPluginName("[email protected]")).toBe("oh-my-opencode")
+    expect(Config.getPluginName("[email protected]")).toBe("some-plugin")
+    expect(Config.getPluginName("plugin@latest")).toBe("plugin")
+  })
+
+  test("extracts name from scoped npm package", () => {
+    expect(Config.getPluginName("@scope/[email protected]")).toBe("@scope/pkg")
+    expect(Config.getPluginName("@opencode/[email protected]")).toBe("@opencode/plugin")
+  })
+
+  test("returns full string for package without version", () => {
+    expect(Config.getPluginName("some-plugin")).toBe("some-plugin")
+    expect(Config.getPluginName("@scope/pkg")).toBe("@scope/pkg")
+  })
+})
+
+describe("deduplicatePlugins", () => {
+  test("removes duplicates keeping higher priority (later entries)", () => {
+    const plugins = ["[email protected]", "[email protected]", "[email protected]", "[email protected]"]
+
+    const result = Config.deduplicatePlugins(plugins)
+
+    expect(result).toContain("[email protected]")
+    expect(result).toContain("[email protected]")
+    expect(result).toContain("[email protected]")
+    expect(result).not.toContain("[email protected]")
+    expect(result.length).toBe(3)
+  })
+
+  test("prefers local file over npm package with same name", () => {
+    const plugins = ["[email protected]", "file:///project/.opencode/plugin/oh-my-opencode.js"]
+
+    const result = Config.deduplicatePlugins(plugins)
+
+    expect(result.length).toBe(1)
+    expect(result[0]).toBe("file:///project/.opencode/plugin/oh-my-opencode.js")
+  })
+
+  test("preserves order of remaining plugins", () => {
+    const plugins = ["[email protected]", "[email protected]", "[email protected]"]
+
+    const result = Config.deduplicatePlugins(plugins)
+
+    expect(result).toEqual(["[email protected]", "[email protected]", "[email protected]"])
+  })
+
+  test("local plugin directory overrides global opencode.json plugin", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const projectDir = path.join(dir, "project")
+        const opencodeDir = path.join(projectDir, ".opencode")
+        const pluginDir = path.join(opencodeDir, "plugin")
+        await fs.mkdir(pluginDir, { recursive: true })
+
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({
+            $schema: "https://opencode.ai/config.json",
+            plugin: ["[email protected]"],
+          }),
+        )
+
+        await Bun.write(path.join(pluginDir, "my-plugin.js"), "export default {}")
+      },
+    })
+
+    await Instance.provide({
+      directory: path.join(tmp.path, "project"),
+      fn: async () => {
+        const config = await Config.get()
+        const plugins = config.plugin ?? []
+
+        const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin")
+        expect(myPlugins.length).toBe(1)
+        expect(myPlugins[0].startsWith("file://")).toBe(true)
+      },
+    })
+  })
+})