align
parent
71b960f2d1
commit
d3db93a0ff
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue