From 1de06452d39872e980ca3992a1aca9b87b4469ac Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:21:17 +1000 Subject: [PATCH] fix(plugin): properly resolve entrypoints without leading dot (#20140) --- packages/opencode/src/plugin/shared.ts | 6 +- .../test/plugin/loader-shared.test.ts | 111 ++++++++++++++++++ 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index 190d73301b..1165191436 100644 --- a/packages/opencode/src/plugin/shared.ts +++ b/packages/opencode/src/plugin/shared.ts @@ -45,9 +45,9 @@ export function pluginSource(spec: string): PluginSource { } function resolveExportPath(raw: string, dir: string) { - if (raw.startsWith("./") || raw.startsWith("../")) return path.resolve(dir, raw) if (raw.startsWith("file://")) return fileURLToPath(raw) - return raw + if (path.isAbsolute(raw)) return raw + return path.resolve(dir, raw) } function extractExportValue(value: unknown): string | undefined { @@ -93,7 +93,7 @@ function resolvePackageEntrypoint(spec: string, kind: PluginKind, pkg: PluginPac function targetPath(target: string) { if (target.startsWith("file://")) return fileURLToPath(target) - if (path.isAbsolute(target) || /^[A-Za-z]:[\\/]/.test(target)) return target + if (path.isAbsolute(target)) return target } async function resolveDirectoryIndex(dir: string) { diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index d9ffa3950b..ebc8daa24e 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -331,6 +331,117 @@ describe("plugin.loader.shared", () => { } }) + test("loads npm server plugin from package server export without leading dot", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const mod = path.join(dir, "mods", "acme-plugin") + const dist = path.join(mod, "dist") + const mark = path.join(dir, "server-called.txt") + await fs.mkdir(dist, { recursive: true }) + + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify( + { + name: "acme-plugin", + type: "module", + exports: { + ".": "./index.js", + "./server": "dist/server.js", + }, + }, + null, + 2, + ), + ) + await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n') + await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n') + await Bun.write( + path.join(dist, "server.js"), + [ + "export default {", + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "called")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2)) + + return { + mod, + mark, + } + }, + }) + + const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod) + + try { + const errors = await errs(tmp.path) + expect(errors).toHaveLength(0) + expect(await Bun.file(tmp.extra.mark).text()).toBe("called") + } finally { + install.mockRestore() + } + }) + + test("loads npm server plugin from package main without leading dot", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const mod = path.join(dir, "mods", "acme-plugin") + const dist = path.join(mod, "dist") + const mark = path.join(dir, "main-called.txt") + await fs.mkdir(dist, { recursive: true }) + + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify( + { + name: "acme-plugin", + type: "module", + main: "dist/index.js", + }, + null, + 2, + ), + ) + await Bun.write( + path.join(dist, "index.js"), + [ + "export default {", + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "called")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2)) + + return { + mod, + mark, + } + }, + }) + + const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod) + + try { + const errors = await errs(tmp.path) + expect(errors).toHaveLength(0) + expect(await Bun.file(tmp.extra.mark).text()).toBe("called") + } finally { + install.mockRestore() + } + }) + test("does not use npm package exports dot for server entry", async () => { await using tmp = await tmpdir({ init: async (dir) => {