From da399365b2e4061c20fd4985ac20e9bdfeb7155c Mon Sep 17 00:00:00 2001 From: SANGWOO PARK Date: Mon, 2 Mar 2026 01:20:04 +0900 Subject: [PATCH 1/2] fix(config): avoid file:// plugin dedup collisions in node_modules Normalize file:// plugin names from node_modules paths to package names so multiple plugins with index.js entrypoints can co-exist. Adds regression tests for package-name extraction and multi-plugin dedup behavior. --- packages/opencode/src/config/config.ts | 13 ++++++++++++- packages/opencode/test/config/config.test.ts | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 141f6156985..26c6403f0ce 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -473,7 +473,18 @@ export namespace Config { */ export function getPluginName(plugin: string): string { if (plugin.startsWith("file://")) { - return path.parse(new URL(plugin).pathname).name + const file = fileURLToPath(plugin) + const parts = file.split(path.sep) + const nodeModules = parts.lastIndexOf("node_modules") + if (nodeModules > -1) { + const name = parts[nodeModules + 1] + if (name?.startsWith("@")) { + const scoped = parts[nodeModules + 2] + if (scoped) return `${name}/${scoped}` + } + if (name) return name + } + return path.parse(file).name } const lastAt = plugin.lastIndexOf("@") if (lastAt > 0) { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index f245dc3493d..ed7cb5ae52e 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1542,6 +1542,14 @@ describe("getPluginName", () => { expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin") }) + test("extracts package name from node_modules file:// URL", () => { + const basic = pathToFileURL(path.join("/tmp", "project", "node_modules", "foo", "index.js")).href + const scoped = pathToFileURL(path.join("/tmp", "project", "node_modules", "@scope", "pkg", "dist", "index.js")).href + + expect(Config.getPluginName(basic)).toBe("foo") + expect(Config.getPluginName(scoped)).toBe("@scope/pkg") + }) + test("extracts name from npm package with version", () => { expect(Config.getPluginName("oh-my-opencode@2.4.3")).toBe("oh-my-opencode") expect(Config.getPluginName("some-plugin@1.0.0")).toBe("some-plugin") @@ -1589,6 +1597,17 @@ describe("deduplicatePlugins", () => { expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"]) }) + test("keeps multiple npm plugins resolved as file:// URLs", () => { + const plugins = [ + pathToFileURL(path.join("/tmp", "project", "node_modules", "foo", "index.js")).href, + pathToFileURL(path.join("/tmp", "project", "node_modules", "bar", "index.js")).href, + ] + + const result = Config.deduplicatePlugins(plugins) + + expect(result).toEqual(plugins) + }) + test("local plugin directory overrides global opencode.json plugin", async () => { await using tmp = await tmpdir({ init: async (dir) => { From 0ee3a2e64ea148dc74b5d6cdd79bfd7b7913ab55 Mon Sep 17 00:00:00 2001 From: SANGWOO PARK Date: Mon, 2 Mar 2026 01:24:37 +0900 Subject: [PATCH 2/2] fix(config): handle file:// plugin names cross-platform Avoid fileURLToPath crashes on Windows for synthetic POSIX-style file URLs used in tests and fallback parsing. Keep node_modules package-name extraction for deduping plugins with index.js entrypoints. --- packages/opencode/src/config/config.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 26c6403f0ce..70b51676e7c 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1,6 +1,6 @@ import { Log } from "../util/log" import path from "path" -import { pathToFileURL, fileURLToPath } from "url" +import { pathToFileURL } from "url" import { createRequire } from "module" import os from "os" import z from "zod" @@ -473,8 +473,8 @@ export namespace Config { */ export function getPluginName(plugin: string): string { if (plugin.startsWith("file://")) { - const file = fileURLToPath(plugin) - const parts = file.split(path.sep) + const pathname = decodeURIComponent(new URL(plugin).pathname) + const parts = pathname.split("/") const nodeModules = parts.lastIndexOf("node_modules") if (nodeModules > -1) { const name = parts[nodeModules + 1] @@ -484,7 +484,7 @@ export namespace Config { } if (name) return name } - return path.parse(file).name + return path.posix.parse(pathname).name } const lastAt = plugin.lastIndexOf("@") if (lastAt > 0) {