From 1a946994ebf9e70971ef565f06a61f7924360134 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 29 Jan 2026 23:52:58 +0800 Subject: [PATCH 1/3] fix(opencode): prevent plugin deduplication collision for index.js entry points getPluginName() now uses a 3-tier resolution for file:// URLs: 1. Walk up to find package.json name (stops at .opencode boundary) 2. Use filename if not "index" 3. For index entry points, walk up skipping generic dirs (src/dist/lib/build/out/esm/cjs) Also uses fileURLToPath() for cross-platform correctness. Fixes #11159 --- packages/opencode/src/config/config.ts | 56 +++++++++++++++-- packages/opencode/test/config/config.test.ts | 66 ++++++++++++++++++++ 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 2b8aa9e03010..35557a1544fb 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -25,7 +25,7 @@ import { LSPServer } from "../lsp/server" import { BunProc } from "@/bun" import { Installation } from "@/installation" import { ConfigMarkdown } from "./markdown" -import { constants, existsSync } from "fs" +import { constants, existsSync, readFileSync } from "fs" import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global" import { Event } from "../server/event" @@ -485,23 +485,67 @@ export namespace Config { return plugins } + function findPackageName(fp: string): string | undefined { + let dir = path.dirname(fp) + const root = path.parse(dir).root + for (let i = 0; i < 5 && dir !== root; i++) { + // Don't escape .opencode boundary — host project's package.json is irrelevant + if (path.basename(dir) === ".opencode") return undefined + const pkg = path.join(dir, "package.json") + if (existsSync(pkg)) { + try { + const data = JSON.parse(readFileSync(pkg, "utf-8")) + if (typeof data.name === "string") return data.name + } catch {} + } + dir = path.dirname(dir) + } + return undefined + } + /** * Extracts a canonical plugin name from a plugin specifier. - * - For file:// URLs: extracts filename without extension + * - For file:// URLs: + * 1. Reads nearest package.json `name` field (walks up max 5 levels) + * 2. Falls back to filename if not "index" + * 3. For "index" entry points, walks up directories skipping + * src/dist/lib/build/out/esm/cjs to find a meaningful name * - For npm packages: extracts package name without version * * @example * getPluginName("file:///path/to/plugin/foo.js") // "foo" + * getPluginName("file:///path/to/my-plugin/src/index.ts") // "my-plugin" (via package.json or dir heuristic) + * getPluginName("file:///path/to/oh-my-opencode/dist/index.js") // "oh-my-opencode" * getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode" * getPluginName("@scope/pkg@1.0.0") // "@scope/pkg" */ export function getPluginName(plugin: string): string { if (plugin.startsWith("file://")) { - return path.parse(new URL(plugin).pathname).name + const fp = fileURLToPath(plugin) + + // Best: use package.json name + const pkg = findPackageName(fp) + if (pkg) return pkg + + // Fallback: use filename, skip generic names + const parsed = path.parse(fp) + if (parsed.name !== "index") return parsed.name + + // Walk up to find a meaningful directory name + const skip = new Set(["src", "dist", "lib", "build", "out", "esm", "cjs"]) + let dir = parsed.dir + const root = path.parse(dir).root + for (let i = 0; i < 5 && dir !== root; i++) { + const name = path.basename(dir) + if (!skip.has(name)) return name + dir = path.dirname(dir) + } + + return parsed.name } - const lastAt = plugin.lastIndexOf("@") - if (lastAt > 0) { - return plugin.substring(0, lastAt) + const last = plugin.lastIndexOf("@") + if (last > 0) { + return plugin.substring(0, last) } return plugin } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 90727cf8a083..e77babd25d3d 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1715,6 +1715,59 @@ describe("getPluginName", () => { expect(Config.getPluginName("some-plugin")).toBe("some-plugin") expect(Config.getPluginName("@scope/pkg")).toBe("@scope/pkg") }) + + test("uses directory heuristic for index.ts in src/", () => { + expect(Config.getPluginName("file:///path/to/my-plugin/src/index.ts")).toBe("my-plugin") + }) + + test("skips dist/ for index.js entry points", () => { + expect(Config.getPluginName("file:///path/to/plugin/dist/index.js")).toBe("plugin") + }) + + test("skips build/out/esm/cjs for index entry points", () => { + expect(Config.getPluginName("file:///path/to/mypkg/build/index.js")).toBe("mypkg") + expect(Config.getPluginName("file:///path/to/mypkg/out/index.js")).toBe("mypkg") + expect(Config.getPluginName("file:///path/to/mypkg/esm/index.js")).toBe("mypkg") + expect(Config.getPluginName("file:///path/to/mypkg/cjs/index.js")).toBe("mypkg") + }) + + test(".opencode/plugin scripts use filename, not host package.json", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, "package.json"), JSON.stringify({ name: "my-app" })) + const pluginDir = path.join(dir, ".opencode", "plugin") + await fs.mkdir(pluginDir, { recursive: true }) + await Filesystem.write(path.join(pluginDir, "a.js"), "export default {}") + await Filesystem.write(path.join(pluginDir, "b.js"), "export default {}") + }, + }) + + const urlA = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "a.js")).href + const urlB = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "b.js")).href + + expect(Config.getPluginName(urlA)).toBe("a") + expect(Config.getPluginName(urlB)).toBe("b") + + const result = Config.deduplicatePlugins([urlA, urlB]) + expect(result.length).toBe(2) + }) + + test("real plugin with package.json resolves to package name", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginDir = path.join(dir, "my-plugin", "src") + await fs.mkdir(pluginDir, { recursive: true }) + await Filesystem.write( + path.join(dir, "my-plugin", "package.json"), + JSON.stringify({ name: "my-plugin" }), + ) + await Filesystem.write(path.join(pluginDir, "index.ts"), "export default {}") + }, + }) + + const url = pathToFileURL(path.join(tmp.path, "my-plugin", "src", "index.ts")).href + expect(Config.getPluginName(url)).toBe("my-plugin") + }) }) describe("deduplicatePlugins", () => { @@ -1739,6 +1792,19 @@ describe("deduplicatePlugins", () => { expect(result[0]).toBe("file:///project/.opencode/plugin/oh-my-opencode.js") }) + test("keeps all index.js plugins from different directories", () => { + const plugins = [ + "file:///path/to/alpha/src/index.ts", + "file:///path/to/beta/dist/index.js", + "file:///path/to/gamma/lib/index.js", + ] + + const result = Config.deduplicatePlugins(plugins) + + // Each has a distinct directory name, so all 3 should survive + expect(result.length).toBe(3) + }) + test("preserves order of remaining plugins", () => { const plugins = ["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"] From 62127c6fb58fad655ab99e4292e149247f3d0b40 Mon Sep 17 00:00:00 2001 From: guazi04 Date: Wed, 4 Mar 2026 21:03:51 +0800 Subject: [PATCH 2/3] fix(opencode): use path.posix for URL pathname parsing in getPluginName file:// URLs always use forward slashes, but fileURLToPath() converts them to backslashes on Windows. Use path.posix for URL pathname parsing (directory walking heuristic) while keeping fileURLToPath() for actual filesystem operations in findPackageName(). --- packages/opencode/src/config/config.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 35557a1544fb..a3625bfd2969 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -528,17 +528,19 @@ export namespace Config { if (pkg) return pkg // Fallback: use filename, skip generic names - const parsed = path.parse(fp) + // Use URL pathname for name extraction (always posix-style /) + const pathname = new URL(plugin).pathname + const parsed = path.posix.parse(pathname) if (parsed.name !== "index") return parsed.name // Walk up to find a meaningful directory name const skip = new Set(["src", "dist", "lib", "build", "out", "esm", "cjs"]) let dir = parsed.dir - const root = path.parse(dir).root + const root = path.posix.parse(dir).root for (let i = 0; i < 5 && dir !== root; i++) { - const name = path.basename(dir) + const name = path.posix.basename(dir) if (!skip.has(name)) return name - dir = path.dirname(dir) + dir = path.posix.dirname(dir) } return parsed.name From 9839adc29d710a3dbbd3d325e5c4b391c3ae1a0c Mon Sep 17 00:00:00 2001 From: guazi04 Date: Wed, 4 Mar 2026 21:16:29 +0800 Subject: [PATCH 3/3] test(opencode): make getPluginName tests cross-platform with testFileURL helper Replace hardcoded Unix file:// URLs with pathToFileURL(path.resolve()) so fileURLToPath() receives valid absolute paths on Windows. --- packages/opencode/test/config/config.test.ts | 37 ++++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index e77babd25d3d..2e8b559e57e1 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -10,10 +10,17 @@ import { pathToFileURL } from "url" import { Global } from "../../src/global" import { ProjectID } from "../../src/project/schema" import { Filesystem } from "../../src/util/filesystem" - // Get managed config directory from environment (set in preload.ts) const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! +// Generate a platform-correct file:// URL for testing +// On Windows: file:///C:/path/to/... On Unix: file:///path/to/... +function testFileURL(...segments: string[]): string { + return pathToFileURL(path.resolve(path.sep, ...segments)).href +} + + + afterEach(async () => { await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {}) }) @@ -1695,9 +1702,9 @@ test("wellknown URL with trailing slash is normalized", async () => { 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") + expect(Config.getPluginName(testFileURL("path", "to", "plugin", "foo.js"))).toBe("foo") + expect(Config.getPluginName(testFileURL("path", "to", "plugin", "bar.ts"))).toBe("bar") + expect(Config.getPluginName(testFileURL("some", "path", "my-plugin.js"))).toBe("my-plugin") }) test("extracts name from npm package with version", () => { @@ -1717,18 +1724,18 @@ describe("getPluginName", () => { }) test("uses directory heuristic for index.ts in src/", () => { - expect(Config.getPluginName("file:///path/to/my-plugin/src/index.ts")).toBe("my-plugin") + expect(Config.getPluginName(testFileURL("path", "to", "my-plugin", "src", "index.ts"))).toBe("my-plugin") }) test("skips dist/ for index.js entry points", () => { - expect(Config.getPluginName("file:///path/to/plugin/dist/index.js")).toBe("plugin") + expect(Config.getPluginName(testFileURL("path", "to", "plugin", "dist", "index.js"))).toBe("plugin") }) test("skips build/out/esm/cjs for index entry points", () => { - expect(Config.getPluginName("file:///path/to/mypkg/build/index.js")).toBe("mypkg") - expect(Config.getPluginName("file:///path/to/mypkg/out/index.js")).toBe("mypkg") - expect(Config.getPluginName("file:///path/to/mypkg/esm/index.js")).toBe("mypkg") - expect(Config.getPluginName("file:///path/to/mypkg/cjs/index.js")).toBe("mypkg") + expect(Config.getPluginName(testFileURL("path", "to", "mypkg", "build", "index.js"))).toBe("mypkg") + expect(Config.getPluginName(testFileURL("path", "to", "mypkg", "out", "index.js"))).toBe("mypkg") + expect(Config.getPluginName(testFileURL("path", "to", "mypkg", "esm", "index.js"))).toBe("mypkg") + expect(Config.getPluginName(testFileURL("path", "to", "mypkg", "cjs", "index.js"))).toBe("mypkg") }) test(".opencode/plugin scripts use filename, not host package.json", async () => { @@ -1784,19 +1791,19 @@ describe("deduplicatePlugins", () => { }) test("prefers local file over npm package with same name", () => { - const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"] + const plugins = ["oh-my-opencode@2.4.3", testFileURL("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") + expect(result[0]).toBe(testFileURL("project", ".opencode", "plugin", "oh-my-opencode.js")) }) test("keeps all index.js plugins from different directories", () => { const plugins = [ - "file:///path/to/alpha/src/index.ts", - "file:///path/to/beta/dist/index.js", - "file:///path/to/gamma/lib/index.js", + testFileURL("path", "to", "alpha", "src", "index.ts"), + testFileURL("path", "to", "beta", "dist", "index.js"), + testFileURL("path", "to", "gamma", "lib", "index.js"), ] const result = Config.deduplicatePlugins(plugins)