From 3032b0dba1fc8b05792c8b9e0d115f7426da7723 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 6b4242a225a..304f48a7627 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -24,7 +24,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" @@ -462,23 +462,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 40ab97449fb..30ed1f7cc5f 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1622,6 +1622,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", () => { @@ -1646,6 +1699,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 b6072b0c87c23f7de4744bb8d2736874dc9f2bd4 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 304f48a7627..a9b17b9ac6a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -505,17 +505,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 83fdee0b4bebc92622ee3075274f280a63d282bc 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 30ed1f7cc5f..256689678d5 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -8,10 +8,17 @@ import fs from "fs/promises" import { pathToFileURL } from "url" import { Global } from "../../src/global" 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(() => {}) }) @@ -1602,9 +1609,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", () => { @@ -1624,18 +1631,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 () => { @@ -1691,19 +1698,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)