From 6f0028644e12686f1c7282e08126c9da0d0752fe Mon Sep 17 00:00:00 2001 From: Err Date: Tue, 4 Nov 2025 09:15:01 -0600 Subject: [PATCH] fix: support scoped npm plugins (#3785) Co-authored-by: Aiden Cline --- .gitignore | 2 + packages/opencode/src/plugin/index.ts | 6 ++- packages/opencode/test/config/config.test.ts | 57 ++++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 81bdb992..f69a7079 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ playground tmp dist .turbo +**/.serena +.serena/ diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 0d66b469..1d433628 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -34,8 +34,10 @@ export namespace Plugin { for (let plugin of plugins) { log.info("loading plugin", { path: plugin }) if (!plugin.startsWith("file://")) { - const [pkg, version] = plugin.split("@") - plugin = await BunProc.install(pkg, version ?? "latest") + const lastAtIndex = plugin.lastIndexOf("@") + const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin + const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest" + plugin = await BunProc.install(pkg, version) } const mod = await import(plugin) for (const [_name, fn] of Object.entries(mod)) { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 1c1755a8..75b41fc0 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -4,6 +4,7 @@ import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import path from "path" import fs from "fs/promises" +import { pathToFileURL } from "url" test("loads config with defaults when no files exist", async () => { await using tmp = await tmpdir() @@ -350,3 +351,59 @@ test("gets config directories", async () => { }, }) }) + +test("resolves scoped npm plugins in config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginDir = path.join(dir, "node_modules", "@scope", "plugin") + await fs.mkdir(pluginDir, { recursive: true }) + + await Bun.write( + path.join(dir, "package.json"), + JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2), + ) + + await Bun.write( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@scope/plugin", + version: "1.0.0", + type: "module", + main: "./index.js", + }, + null, + 2, + ), + ) + + await Bun.write(path.join(pluginDir, "index.js"), "export default {}\n") + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] }, + null, + 2, + ), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const pluginEntries = config.plugin ?? [] + + const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href + const expected = import.meta.resolve("@scope/plugin", baseUrl) + + expect(pluginEntries.includes(expected)).toBe(true) + + const scopedEntry = pluginEntries.find((entry) => entry === expected) + expect(scopedEntry).toBeDefined() + expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true) + }, + }) +})