From d3db93a0ffa271ffa012122d90daaeecfeac2ba6 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Wed, 4 Mar 2026 22:39:14 +0100 Subject: [PATCH] align --- packages/opencode/src/plugin/index.ts | 33 +- .../test/plugin/loader-shared.test.ts | 306 ++++++++++++++++++ 2 files changed, 321 insertions(+), 18 deletions(-) create mode 100644 packages/opencode/test/plugin/loader-shared.test.ts diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 69c3e39754..5ce2481550 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -56,15 +56,13 @@ export namespace Plugin { async function resolve(spec: string) { const parsed = parsePluginSpecifier(spec) - const builtIn = BUILTIN.some((x) => x.startsWith(parsed.pkg + "@")) const target = await resolvePluginTarget(spec, parsed).catch((err) => { const cause = err instanceof Error ? err.cause : err const detail = cause instanceof Error ? cause.message : String(cause ?? err) log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail }) - const label = builtIn ? "built-in plugin" : "plugin" Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ - message: `Failed to install ${label} ${parsed.pkg}@${parsed.version}: ${detail}`, + message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${detail}`, }).toObject(), }) return "" @@ -106,22 +104,21 @@ export namespace Plugin { // Prevent duplicate initialization when plugins export the same function // as both a named export and default export (e.g., `export const X` and `export default X`). // uniqueModuleEntries keeps only the first export for each shared value reference. - for (const [, entry] of uniqueModuleEntries(mod)) { - const server = getServerPlugin(entry) - if (!server) continue - const init = await server(input, Config.pluginOptions(item)).catch((err) => { - const message = err instanceof Error ? err.message : String(err) - log.error("failed to initialize plugin", { path: spec, error: message }) - Bus.publish(Session.Event.Error, { - error: new NamedError.Unknown({ - message: `Failed to initialize plugin ${spec}: ${message}`, - }).toObject(), - }) - return + await (async () => { + for (const [, entry] of uniqueModuleEntries(mod)) { + const server = getServerPlugin(entry) + if (!server) throw new TypeError("Plugin export is not a function") + hooks.push(await server(input, Config.pluginOptions(item))) + } + })().catch((err) => { + const message = err instanceof Error ? err.message : String(err) + log.error("failed to load plugin", { path: spec, error: message }) + Bus.publish(Session.Event.Error, { + error: new NamedError.Unknown({ + message: `Failed to load plugin ${spec}: ${message}`, + }).toObject(), }) - if (!init) continue - hooks.push(init) - } + }) } return { diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts new file mode 100644 index 0000000000..8e144db73d --- /dev/null +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -0,0 +1,306 @@ +import { afterAll, afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { pathToFileURL } from "url" +import { tmpdir } from "../fixture/fixture" + +const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS +process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" + +const { Plugin } = await import("../../src/plugin/index") +const { Instance } = await import("../../src/project/instance") +const { BunProc } = await import("../../src/bun") +const { Bus } = await import("../../src/bus") +const { Session } = await import("../../src/session") + +afterAll(() => { + if (disableDefault === undefined) { + delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS + return + } + process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault +}) + +afterEach(async () => { + mock.restore() + await Instance.disposeAll() +}) + +async function load(dir: string) { + return Instance.provide({ + directory: dir, + fn: async () => { + await Plugin.list() + }, + }) +} + +async function errs(dir: string) { + return Instance.provide({ + directory: dir, + fn: async () => { + const errors: string[] = [] + const off = Bus.subscribe(Session.Event.Error, (evt) => { + const error = evt.properties.error + if (!error || typeof error !== "object") return + if (!("data" in error)) return + if (!error.data || typeof error.data !== "object") return + if (!("message" in error.data)) return + if (typeof error.data.message !== "string") return + errors.push(error.data.message) + }) + await Plugin.list() + off() + return errors + }, + }) +} + +describe("plugin.loader.shared", () => { + test("loads a file:// plugin function export", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "plugin.ts") + const mark = path.join(dir, "called.txt") + await Bun.write( + file, + [ + "export default async () => {", + ` await Bun.write(${JSON.stringify(mark)}, \"called\")`, + " return {}", + "}", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2), + ) + + return { mark } + }, + }) + + await load(tmp.path) + expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called") + }) + + test("deduplicates same function exported as default and named", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "plugin.ts") + const mark = path.join(dir, "count.txt") + await Bun.write( + file, + [ + "const run = async () => {", + ` const text = await Bun.file(${JSON.stringify(mark)}).text().catch(() => \"\")`, + ` await Bun.write(${JSON.stringify(mark)}, text + \"1\")`, + " return {}", + "}", + "export default run", + "export const named = run", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2), + ) + + return { mark } + }, + }) + + await load(tmp.path) + expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("1") + }) + + test("resolves npm plugin specs with explicit and default versions", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "plugin.ts") + await Bun.write(file, ["export default async () => {", " return {}", "}", ""].join("\n")) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: ["acme-plugin", "scope-plugin@2.3.4"] }, null, 2), + ) + + return { file } + }, + }) + + const install = spyOn(BunProc, "install").mockImplementation(async () => pathToFileURL(tmp.extra.file).href) + + await load(tmp.path) + + expect(install.mock.calls).toContainEqual(["acme-plugin", "latest"]) + expect(install.mock.calls).toContainEqual(["scope-plugin", "2.3.4"]) + }) + + test("skips legacy codex and copilot auth plugin specs", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + plugin: ["opencode-openai-codex-auth@1.0.0", "opencode-copilot-auth@1.0.0", "regular-plugin@1.0.0"], + }, + null, + 2, + ), + ) + }, + }) + + const install = spyOn(BunProc, "install").mockResolvedValue("") + + await load(tmp.path) + + const pkgs = install.mock.calls.map((call) => call[0]) + expect(pkgs).toContain("regular-plugin") + expect(pkgs).not.toContain("opencode-openai-codex-auth") + expect(pkgs).not.toContain("opencode-copilot-auth") + }) + + test("publishes session.error when install fails", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["broken-plugin@9.9.9"] }, null, 2)) + }, + }) + + spyOn(BunProc, "install").mockRejectedValue(new Error("boom")) + + const errors = await errs(tmp.path) + + expect(errors.some((x) => x.includes("Failed to install plugin broken-plugin@9.9.9") && x.includes("boom"))).toBe( + true, + ) + }) + + test("publishes session.error when plugin init throws", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = pathToFileURL(path.join(dir, "throws.ts")).href + await Bun.write( + path.join(dir, "throws.ts"), + ["export default async () => {", ' throw new Error("explode")', "}", ""].join("\n"), + ) + + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2)) + + return { file } + }, + }) + + const errors = await errs(tmp.path) + + expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}: explode`))).toBe(true) + }) + + test("publishes session.error when plugin module has invalid export", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = pathToFileURL(path.join(dir, "invalid.ts")).href + await Bun.write( + path.join(dir, "invalid.ts"), + ["export default async () => {", " return {}", "}", 'export const meta = { name: "invalid" }', ""].join( + "\n", + ), + ) + + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2)) + + return { file } + }, + }) + + const errors = await errs(tmp.path) + + expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}`))).toBe(true) + }) + + test("publishes session.error when plugin import fails", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing] }, null, 2)) + + return { missing } + }, + }) + + const errors = await errs(tmp.path) + + expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.missing}`))).toBe(true) + }) + + test("loads object plugin via plugin.server", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "object-plugin.ts") + const mark = path.join(dir, "object-called.txt") + await Bun.write( + file, + [ + "const plugin = {", + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, \"called\")`, + " return {}", + " },", + "}", + "export default plugin", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2), + ) + + return { mark } + }, + }) + + await load(tmp.path) + expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called") + }) + + test("passes tuple plugin options into server plugin", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "options-plugin.ts") + const mark = path.join(dir, "options.json") + await Bun.write( + file, + [ + "const plugin = {", + " server: async (_input, options) => {", + ` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(options ?? null))`, + " return {}", + " },", + "}", + "export default plugin", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: [[pathToFileURL(file).href, { source: "tuple", enabled: true }]] }, null, 2), + ) + + return { mark } + }, + }) + + await load(tmp.path) + expect(JSON.parse(await fs.readFile(tmp.extra.mark, "utf8"))).toEqual({ source: "tuple", enabled: true }) + }) +})