| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136 |
- import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test"
- import fs from "fs/promises"
- import path from "path"
- import { pathToFileURL } from "url"
- import { tmpdir } from "../fixture/fixture"
- import { Filesystem } from "../../src/util/filesystem"
- const disableDefault = process.env.KILO_DISABLE_DEFAULT_PLUGINS
- process.env.KILO_DISABLE_DEFAULT_PLUGINS = "1"
- const { Plugin } = await import("../../src/plugin/index")
- const { PluginLoader } = await import("../../src/plugin/loader")
- const { readPackageThemes } = await import("../../src/plugin/shared")
- const { Instance } = await import("../../src/project/instance")
- const { Npm } = await import("../../src/npm")
- const { Bus } = await import("../../src/bus")
- const { Session } = await import("../../src/session")
- afterAll(() => {
- if (disableDefault === undefined) {
- delete process.env.KILO_DISABLE_DEFAULT_PLUGINS
- return
- }
- process.env.KILO_DISABLE_DEFAULT_PLUGINS = disableDefault
- })
- afterEach(async () => {
- 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(mark, "")
- 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("uses only default v1 server plugin when present", 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,
- [
- "export default {",
- ' id: "demo.v1-default",',
- " server: async () => {",
- ` await Bun.write(${JSON.stringify(mark)}, "default")`,
- " return {}",
- " },",
- "}",
- "export const named = async () => {",
- ` await Bun.write(${JSON.stringify(mark)}, "named")`,
- " 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 Bun.file(tmp.extra.mark).text()).toBe("default")
- })
- test("rejects v1 file server plugin without id", 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 {",
- " server: 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 }
- },
- })
- const errors = await errs(tmp.path)
- const called = await Bun.file(tmp.extra.mark)
- .text()
- .then(() => true)
- .catch(() => false)
- expect(called).toBe(false)
- expect(errors.some((x) => x.includes("must export id"))).toBe(true)
- })
- test("rejects v1 plugin that exports server and tui together", 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 {",
- ' id: "demo.mixed",',
- " server: async () => {",
- ` await Bun.write(${JSON.stringify(mark)}, "server")`,
- " return {}",
- " },",
- " tui: async () => {},",
- "}",
- "",
- ].join("\n"),
- )
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
- )
- return { mark }
- },
- })
- const errors = await errs(tmp.path)
- const called = await Bun.file(tmp.extra.mark)
- .text()
- .then(() => true)
- .catch(() => false)
- expect(called).toBe(false)
- expect(errors.some((x) => x.includes("either server() or tui(), not both"))).toBe(true)
- })
- test("resolves npm plugin specs with explicit and default versions", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const acme = path.join(dir, "node_modules", "acme-plugin")
- const scope = path.join(dir, "node_modules", "scope-plugin")
- await fs.mkdir(acme, { recursive: true })
- await fs.mkdir(scope, { recursive: true })
- await Bun.write(
- path.join(acme, "package.json"),
- JSON.stringify({ name: "acme-plugin", type: "module", main: "./index.js" }, null, 2),
- )
- await Bun.write(path.join(acme, "index.js"), "export default { server: async () => ({}) }\n")
- await Bun.write(
- path.join(scope, "package.json"),
- JSON.stringify({ name: "scope-plugin", type: "module", main: "./index.js" }, null, 2),
- )
- await Bun.write(path.join(scope, "index.js"), "export default { server: async () => ({}) }\n")
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({ plugin: ["acme-plugin", "[email protected]"] }, null, 2),
- )
- return { acme, scope }
- },
- })
- const add = spyOn(Npm, "add").mockImplementation(async (pkg) => {
- if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: tmp.extra.acme }
- return { directory: tmp.extra.scope, entrypoint: tmp.extra.scope }
- })
- try {
- await load(tmp.path)
- expect(add.mock.calls).toContainEqual(["acme-plugin@latest"])
- expect(add.mock.calls).toContainEqual(["[email protected]"])
- } finally {
- add.mockRestore()
- }
- })
- test("loads npm server plugin from package ./server export", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const mod = path.join(dir, "mods", "acme-plugin")
- const mark = path.join(dir, "server-called.txt")
- await fs.mkdir(mod, { recursive: true })
- await Bun.write(
- path.join(mod, "package.json"),
- JSON.stringify(
- {
- name: "acme-plugin",
- type: "module",
- exports: {
- ".": "./index.js",
- "./server": "./server.js",
- "./tui": "./tui.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(mod, "server.js"),
- [
- "export default {",
- " server: async () => {",
- ` await Bun.write(${JSON.stringify(mark)}, "called")`,
- " return {}",
- " },",
- "}",
- "",
- ].join("\n"),
- )
- await Bun.write(path.join(mod, "tui.js"), "export default {}\n")
- await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
- return {
- mod,
- mark,
- }
- },
- })
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
- try {
- await load(tmp.path)
- expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
- } finally {
- install.mockRestore()
- }
- })
- 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: ["[email protected]"] }, null, 2))
- return {
- mod,
- mark,
- }
- },
- })
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: 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: ["[email protected]"] }, null, 2))
- return {
- mod,
- mark,
- }
- },
- })
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: 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) => {
- const mod = path.join(dir, "mods", "acme-plugin")
- const mark = path.join(dir, "dot-server.txt")
- await fs.mkdir(mod, { recursive: true })
- await Bun.write(
- path.join(mod, "package.json"),
- JSON.stringify({
- name: "acme-plugin",
- type: "module",
- exports: { ".": "./index.js" },
- }),
- )
- await Bun.write(
- path.join(mod, "index.js"),
- [
- "export default {",
- ' id: "demo.dot.server",',
- " server: async () => {",
- ` await Bun.write(${JSON.stringify(mark)}, "called")`,
- " return {}",
- " },",
- "}",
- "",
- ].join("\n"),
- )
- await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
- return { mod, mark }
- },
- })
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
- try {
- const errors = await errs(tmp.path)
- const called = await Bun.file(tmp.extra.mark)
- .text()
- .then(() => true)
- .catch(() => false)
- expect(called).toBe(false)
- expect(errors).toHaveLength(0)
- } finally {
- install.mockRestore()
- }
- })
- test("rejects npm server export that resolves outside plugin directory", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const mod = path.join(dir, "mods", "acme-plugin")
- const outside = path.join(dir, "outside")
- const mark = path.join(dir, "outside-server.txt")
- await fs.mkdir(mod, { recursive: true })
- await fs.mkdir(outside, { recursive: true })
- await Bun.write(
- path.join(mod, "package.json"),
- JSON.stringify(
- {
- name: "acme-plugin",
- type: "module",
- exports: {
- ".": "./index.js",
- "./server": "./escape/server.js",
- },
- },
- null,
- 2,
- ),
- )
- await Bun.write(path.join(mod, "index.js"), "export default {}\n")
- await Bun.write(
- path.join(outside, "server.js"),
- [
- "export default {",
- " server: async () => {",
- ` await Bun.write(${JSON.stringify(mark)}, "outside")`,
- " return {}",
- " },",
- "}",
- "",
- ].join("\n"),
- )
- await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir")
- await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin"] }, null, 2))
- return {
- mod,
- mark,
- }
- },
- })
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
- try {
- const errors = await errs(tmp.path)
- const called = await Bun.file(tmp.extra.mark)
- .text()
- .then(() => true)
- .catch(() => false)
- expect(called).toBe(false)
- expect(errors.some((x) => x.includes("outside plugin directory"))).toBe(true)
- } finally {
- install.mockRestore()
- }
- })
- 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: ["[email protected]", "[email protected]", "[email protected]"],
- },
- null,
- 2,
- ),
- )
- },
- })
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: "" })
- try {
- await load(tmp.path)
- const pkgs = install.mock.calls.map((call) => call[0])
- expect(pkgs).toContain("[email protected]")
- expect(pkgs).not.toContain("[email protected]")
- expect(pkgs).not.toContain("[email protected]")
- } finally {
- install.mockRestore()
- }
- })
- 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: ["[email protected]"] }, null, 2))
- },
- })
- const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
- try {
- const errors = await errs(tmp.path)
- expect(errors.some((x) => x.includes("Failed to install plugin [email protected]") && x.includes("boom"))).toBe(
- true,
- )
- } finally {
- install.mockRestore()
- }
- })
- 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 {",
- ' id: "demo.throws",',
- " server: 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 {", ' id: "demo.invalid",', " nope: true,", "}", ""].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 = {",
- ' id: "demo.object",',
- " 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 = {",
- ' id: "demo.options",',
- " 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(await Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark)).toEqual({
- source: "tuple",
- enabled: true,
- })
- })
- test("initializes server plugins in config order", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const a = path.join(dir, "a-plugin.ts")
- const b = path.join(dir, "b-plugin.ts")
- const marker = path.join(dir, "server-order.txt")
- const aSpec = pathToFileURL(a).href
- const bSpec = pathToFileURL(b).href
- await Bun.write(
- a,
- `import fs from "fs/promises"
- export default {
- id: "demo.order.a",
- server: async () => {
- await fs.appendFile(${JSON.stringify(marker)}, "a-start\\n")
- await Bun.sleep(25)
- await fs.appendFile(${JSON.stringify(marker)}, "a-end\\n")
- return {}
- },
- }
- `,
- )
- await Bun.write(
- b,
- `import fs from "fs/promises"
- export default {
- id: "demo.order.b",
- server: async () => {
- await fs.appendFile(${JSON.stringify(marker)}, "b\\n")
- return {}
- },
- }
- `,
- )
- await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [aSpec, bSpec] }, null, 2))
- return { marker }
- },
- })
- await load(tmp.path)
- const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
- expect(lines).toEqual(["a-start", "a-end", "b"])
- })
- test("skips external plugins in pure mode", 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 {",
- ' id: "demo.pure",',
- " server: 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 }
- },
- })
- const pure = process.env.KILO_PURE
- process.env.KILO_PURE = "1"
- try {
- await load(tmp.path)
- const called = await fs
- .readFile(tmp.extra.mark, "utf8")
- .then(() => true)
- .catch(() => false)
- expect(called).toBe(false)
- } finally {
- if (pure === undefined) {
- delete process.env.KILO_PURE
- } else {
- process.env.KILO_PURE = pure
- }
- }
- })
- test("reads oc-themes from package manifest", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const mod = path.join(dir, "mod")
- await fs.mkdir(path.join(mod, "themes"), { recursive: true })
- await Bun.write(
- path.join(mod, "package.json"),
- JSON.stringify(
- {
- name: "acme-plugin",
- version: "1.0.0",
- "oc-themes": ["themes/one.json", "./themes/one.json", "themes/two.json"],
- },
- null,
- 2,
- ),
- )
- return { mod }
- },
- })
- const file = path.join(tmp.extra.mod, "package.json")
- const json = await Filesystem.readJson<Record<string, unknown>>(file)
- const list = readPackageThemes("acme-plugin", {
- dir: tmp.extra.mod,
- pkg: file,
- json,
- })
- expect(list).toEqual([
- Filesystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")),
- Filesystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")),
- ])
- })
- test("handles no-entrypoint tui packages via missing callback", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const mod = path.join(dir, "mods", "acme-plugin")
- await fs.mkdir(path.join(mod, "themes"), { recursive: true })
- await Bun.write(
- path.join(mod, "package.json"),
- JSON.stringify(
- {
- name: "acme-plugin",
- version: "1.0.0",
- "oc-themes": ["themes/night.json"],
- },
- null,
- 2,
- ),
- )
- await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
- return { mod }
- },
- })
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
- const missing: string[] = []
- try {
- const loaded = await PluginLoader.loadExternal({
- items: [
- {
- spec: "[email protected]",
- scope: "local" as const,
- source: tmp.path,
- },
- ],
- kind: "tui",
- missing: async (item) => {
- if (!item.pkg) return
- const themes = readPackageThemes(item.spec, item.pkg)
- if (!themes.length) return
- return {
- spec: item.spec,
- target: item.target,
- themes,
- }
- },
- report: {
- missing(_candidate, _retry, message) {
- missing.push(message)
- },
- },
- })
- expect(loaded).toEqual([
- {
- spec: "[email protected]",
- target: tmp.extra.mod,
- themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
- },
- ])
- expect(missing).toHaveLength(0)
- } finally {
- install.mockRestore()
- }
- })
- test("passes package metadata for entrypoint tui plugins", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const mod = path.join(dir, "mods", "acme-plugin")
- await fs.mkdir(path.join(mod, "themes"), { recursive: true })
- await Bun.write(
- path.join(mod, "package.json"),
- JSON.stringify(
- {
- name: "acme-plugin",
- version: "1.0.0",
- exports: {
- "./tui": "./tui.js",
- },
- "oc-themes": ["themes/night.json"],
- },
- null,
- 2,
- ),
- )
- await Bun.write(path.join(mod, "tui.js"), 'export default { id: "demo", tui: async () => {} }\n')
- await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
- return { mod }
- },
- })
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
- try {
- const loaded = await PluginLoader.loadExternal({
- items: [
- {
- spec: "[email protected]",
- scope: "local" as const,
- source: tmp.path,
- },
- ],
- kind: "tui",
- finish: async (item) => {
- if (!item.pkg) return
- return {
- spec: item.spec,
- themes: readPackageThemes(item.spec, item.pkg),
- }
- },
- })
- expect(loaded).toEqual([
- {
- spec: "[email protected]",
- themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
- },
- ])
- } finally {
- install.mockRestore()
- }
- })
- test("rejects oc-themes path traversal", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const mod = path.join(dir, "mod")
- await fs.mkdir(mod, { recursive: true })
- const file = path.join(mod, "package.json")
- await Bun.write(file, JSON.stringify({ name: "acme", "oc-themes": ["../escape.json"] }, null, 2))
- return { mod, file }
- },
- })
- const json = await Filesystem.readJson<Record<string, unknown>>(tmp.extra.file)
- expect(() =>
- readPackageThemes("acme", {
- dir: tmp.extra.mod,
- pkg: tmp.extra.file,
- json,
- }),
- ).toThrow("outside plugin directory")
- })
- test("retries failed file plugins once after wait and keeps order", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const a = path.join(dir, "a")
- const b = path.join(dir, "b")
- const aSpec = pathToFileURL(a).href
- const bSpec = pathToFileURL(b).href
- await fs.mkdir(a, { recursive: true })
- await fs.mkdir(b, { recursive: true })
- return { a, b, aSpec, bSpec }
- },
- })
- let wait = 0
- const calls: Array<[string, boolean]> = []
- const loaded = await PluginLoader.loadExternal({
- items: [tmp.extra.aSpec, tmp.extra.bSpec].map((spec) => ({
- spec,
- scope: "local" as const,
- source: tmp.path,
- })),
- kind: "tui",
- wait: async () => {
- wait += 1
- await Bun.write(path.join(tmp.extra.a, "index.ts"), "export default {}\n")
- await Bun.write(path.join(tmp.extra.b, "index.ts"), "export default {}\n")
- },
- report: {
- start(candidate, retry) {
- calls.push([candidate.plan.spec, retry])
- },
- },
- })
- expect(wait).toBe(1)
- expect(calls).toEqual([
- [tmp.extra.aSpec, false],
- [tmp.extra.bSpec, false],
- [tmp.extra.aSpec, true],
- [tmp.extra.bSpec, true],
- ])
- expect(loaded.map((item) => item.spec)).toEqual([tmp.extra.aSpec, tmp.extra.bSpec])
- })
- test("retries file plugins when finish returns undefined", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const file = path.join(dir, "plugin.ts")
- const spec = pathToFileURL(file).href
- await Bun.write(file, "export default {}\n")
- return { spec }
- },
- })
- let wait = 0
- let count = 0
- const loaded = await PluginLoader.loadExternal({
- items: [
- {
- spec: tmp.extra.spec,
- scope: "local" as const,
- source: tmp.path,
- },
- ],
- kind: "tui",
- wait: async () => {
- wait += 1
- },
- finish: async (load, _item, retry) => {
- count += 1
- if (!retry) return
- return {
- retry,
- spec: load.spec,
- }
- },
- })
- expect(wait).toBe(1)
- expect(count).toBe(2)
- expect(loaded).toEqual([{ retry: true, spec: tmp.extra.spec }])
- })
- test("does not wait or retry npm plugin failures", async () => {
- const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
- let wait = 0
- const errors: Array<[string, boolean]> = []
- try {
- const loaded = await PluginLoader.loadExternal({
- items: [
- {
- spec: "[email protected]",
- scope: "local" as const,
- source: "test",
- },
- ],
- kind: "tui",
- wait: async () => {
- wait += 1
- },
- report: {
- error(_candidate, retry, stage) {
- errors.push([stage, retry])
- },
- },
- })
- expect(loaded).toEqual([])
- expect(wait).toBe(0)
- expect(errors).toEqual([["install", false]])
- } finally {
- install.mockRestore()
- }
- })
- })
|