loader-shared.test.ts 32 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136
  1. import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test"
  2. import fs from "fs/promises"
  3. import path from "path"
  4. import { pathToFileURL } from "url"
  5. import { tmpdir } from "../fixture/fixture"
  6. import { Filesystem } from "../../src/util/filesystem"
  7. const disableDefault = process.env.KILO_DISABLE_DEFAULT_PLUGINS
  8. process.env.KILO_DISABLE_DEFAULT_PLUGINS = "1"
  9. const { Plugin } = await import("../../src/plugin/index")
  10. const { PluginLoader } = await import("../../src/plugin/loader")
  11. const { readPackageThemes } = await import("../../src/plugin/shared")
  12. const { Instance } = await import("../../src/project/instance")
  13. const { Npm } = await import("../../src/npm")
  14. const { Bus } = await import("../../src/bus")
  15. const { Session } = await import("../../src/session")
  16. afterAll(() => {
  17. if (disableDefault === undefined) {
  18. delete process.env.KILO_DISABLE_DEFAULT_PLUGINS
  19. return
  20. }
  21. process.env.KILO_DISABLE_DEFAULT_PLUGINS = disableDefault
  22. })
  23. afterEach(async () => {
  24. await Instance.disposeAll()
  25. })
  26. async function load(dir: string) {
  27. return Instance.provide({
  28. directory: dir,
  29. fn: async () => {
  30. await Plugin.list()
  31. },
  32. })
  33. }
  34. async function errs(dir: string) {
  35. return Instance.provide({
  36. directory: dir,
  37. fn: async () => {
  38. const errors: string[] = []
  39. const off = Bus.subscribe(Session.Event.Error, (evt) => {
  40. const error = evt.properties.error
  41. if (!error || typeof error !== "object") return
  42. if (!("data" in error)) return
  43. if (!error.data || typeof error.data !== "object") return
  44. if (!("message" in error.data)) return
  45. if (typeof error.data.message !== "string") return
  46. errors.push(error.data.message)
  47. })
  48. await Plugin.list()
  49. off()
  50. return errors
  51. },
  52. })
  53. }
  54. describe("plugin.loader.shared", () => {
  55. test("loads a file:// plugin function export", async () => {
  56. await using tmp = await tmpdir({
  57. init: async (dir) => {
  58. const file = path.join(dir, "plugin.ts")
  59. const mark = path.join(dir, "called.txt")
  60. await Bun.write(
  61. file,
  62. [
  63. "export default async () => {",
  64. ` await Bun.write(${JSON.stringify(mark)}, \"called\")`,
  65. " return {}",
  66. "}",
  67. "",
  68. ].join("\n"),
  69. )
  70. await Bun.write(
  71. path.join(dir, "opencode.json"),
  72. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  73. )
  74. return { mark }
  75. },
  76. })
  77. await load(tmp.path)
  78. expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called")
  79. })
  80. test("deduplicates same function exported as default and named", async () => {
  81. await using tmp = await tmpdir({
  82. init: async (dir) => {
  83. const file = path.join(dir, "plugin.ts")
  84. const mark = path.join(dir, "count.txt")
  85. await Bun.write(mark, "")
  86. await Bun.write(
  87. file,
  88. [
  89. "const run = async () => {",
  90. ` const text = await Bun.file(${JSON.stringify(mark)}).text().catch(() => \"\")`,
  91. ` await Bun.write(${JSON.stringify(mark)}, text + \"1\")`,
  92. " return {}",
  93. "}",
  94. "export default run",
  95. "export const named = run",
  96. "",
  97. ].join("\n"),
  98. )
  99. await Bun.write(
  100. path.join(dir, "opencode.json"),
  101. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  102. )
  103. return { mark }
  104. },
  105. })
  106. await load(tmp.path)
  107. expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("1")
  108. })
  109. test("uses only default v1 server plugin when present", async () => {
  110. await using tmp = await tmpdir({
  111. init: async (dir) => {
  112. const file = path.join(dir, "plugin.ts")
  113. const mark = path.join(dir, "count.txt")
  114. await Bun.write(
  115. file,
  116. [
  117. "export default {",
  118. ' id: "demo.v1-default",',
  119. " server: async () => {",
  120. ` await Bun.write(${JSON.stringify(mark)}, "default")`,
  121. " return {}",
  122. " },",
  123. "}",
  124. "export const named = async () => {",
  125. ` await Bun.write(${JSON.stringify(mark)}, "named")`,
  126. " return {}",
  127. "}",
  128. "",
  129. ].join("\n"),
  130. )
  131. await Bun.write(
  132. path.join(dir, "opencode.json"),
  133. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  134. )
  135. return { mark }
  136. },
  137. })
  138. await load(tmp.path)
  139. expect(await Bun.file(tmp.extra.mark).text()).toBe("default")
  140. })
  141. test("rejects v1 file server plugin without id", async () => {
  142. await using tmp = await tmpdir({
  143. init: async (dir) => {
  144. const file = path.join(dir, "plugin.ts")
  145. const mark = path.join(dir, "called.txt")
  146. await Bun.write(
  147. file,
  148. [
  149. "export default {",
  150. " server: async () => {",
  151. ` await Bun.write(${JSON.stringify(mark)}, "called")`,
  152. " return {}",
  153. " },",
  154. "}",
  155. "",
  156. ].join("\n"),
  157. )
  158. await Bun.write(
  159. path.join(dir, "opencode.json"),
  160. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  161. )
  162. return { mark }
  163. },
  164. })
  165. const errors = await errs(tmp.path)
  166. const called = await Bun.file(tmp.extra.mark)
  167. .text()
  168. .then(() => true)
  169. .catch(() => false)
  170. expect(called).toBe(false)
  171. expect(errors.some((x) => x.includes("must export id"))).toBe(true)
  172. })
  173. test("rejects v1 plugin that exports server and tui together", async () => {
  174. await using tmp = await tmpdir({
  175. init: async (dir) => {
  176. const file = path.join(dir, "plugin.ts")
  177. const mark = path.join(dir, "called.txt")
  178. await Bun.write(
  179. file,
  180. [
  181. "export default {",
  182. ' id: "demo.mixed",',
  183. " server: async () => {",
  184. ` await Bun.write(${JSON.stringify(mark)}, "server")`,
  185. " return {}",
  186. " },",
  187. " tui: async () => {},",
  188. "}",
  189. "",
  190. ].join("\n"),
  191. )
  192. await Bun.write(
  193. path.join(dir, "opencode.json"),
  194. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  195. )
  196. return { mark }
  197. },
  198. })
  199. const errors = await errs(tmp.path)
  200. const called = await Bun.file(tmp.extra.mark)
  201. .text()
  202. .then(() => true)
  203. .catch(() => false)
  204. expect(called).toBe(false)
  205. expect(errors.some((x) => x.includes("either server() or tui(), not both"))).toBe(true)
  206. })
  207. test("resolves npm plugin specs with explicit and default versions", async () => {
  208. await using tmp = await tmpdir({
  209. init: async (dir) => {
  210. const acme = path.join(dir, "node_modules", "acme-plugin")
  211. const scope = path.join(dir, "node_modules", "scope-plugin")
  212. await fs.mkdir(acme, { recursive: true })
  213. await fs.mkdir(scope, { recursive: true })
  214. await Bun.write(
  215. path.join(acme, "package.json"),
  216. JSON.stringify({ name: "acme-plugin", type: "module", main: "./index.js" }, null, 2),
  217. )
  218. await Bun.write(path.join(acme, "index.js"), "export default { server: async () => ({}) }\n")
  219. await Bun.write(
  220. path.join(scope, "package.json"),
  221. JSON.stringify({ name: "scope-plugin", type: "module", main: "./index.js" }, null, 2),
  222. )
  223. await Bun.write(path.join(scope, "index.js"), "export default { server: async () => ({}) }\n")
  224. await Bun.write(
  225. path.join(dir, "opencode.json"),
  226. JSON.stringify({ plugin: ["acme-plugin", "[email protected]"] }, null, 2),
  227. )
  228. return { acme, scope }
  229. },
  230. })
  231. const add = spyOn(Npm, "add").mockImplementation(async (pkg) => {
  232. if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: tmp.extra.acme }
  233. return { directory: tmp.extra.scope, entrypoint: tmp.extra.scope }
  234. })
  235. try {
  236. await load(tmp.path)
  237. expect(add.mock.calls).toContainEqual(["acme-plugin@latest"])
  238. expect(add.mock.calls).toContainEqual(["[email protected]"])
  239. } finally {
  240. add.mockRestore()
  241. }
  242. })
  243. test("loads npm server plugin from package ./server export", async () => {
  244. await using tmp = await tmpdir({
  245. init: async (dir) => {
  246. const mod = path.join(dir, "mods", "acme-plugin")
  247. const mark = path.join(dir, "server-called.txt")
  248. await fs.mkdir(mod, { recursive: true })
  249. await Bun.write(
  250. path.join(mod, "package.json"),
  251. JSON.stringify(
  252. {
  253. name: "acme-plugin",
  254. type: "module",
  255. exports: {
  256. ".": "./index.js",
  257. "./server": "./server.js",
  258. "./tui": "./tui.js",
  259. },
  260. },
  261. null,
  262. 2,
  263. ),
  264. )
  265. await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
  266. await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
  267. await Bun.write(
  268. path.join(mod, "server.js"),
  269. [
  270. "export default {",
  271. " server: async () => {",
  272. ` await Bun.write(${JSON.stringify(mark)}, "called")`,
  273. " return {}",
  274. " },",
  275. "}",
  276. "",
  277. ].join("\n"),
  278. )
  279. await Bun.write(path.join(mod, "tui.js"), "export default {}\n")
  280. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
  281. return {
  282. mod,
  283. mark,
  284. }
  285. },
  286. })
  287. const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
  288. try {
  289. await load(tmp.path)
  290. expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
  291. } finally {
  292. install.mockRestore()
  293. }
  294. })
  295. test("loads npm server plugin from package server export without leading dot", async () => {
  296. await using tmp = await tmpdir({
  297. init: async (dir) => {
  298. const mod = path.join(dir, "mods", "acme-plugin")
  299. const dist = path.join(mod, "dist")
  300. const mark = path.join(dir, "server-called.txt")
  301. await fs.mkdir(dist, { recursive: true })
  302. await Bun.write(
  303. path.join(mod, "package.json"),
  304. JSON.stringify(
  305. {
  306. name: "acme-plugin",
  307. type: "module",
  308. exports: {
  309. ".": "./index.js",
  310. "./server": "dist/server.js",
  311. },
  312. },
  313. null,
  314. 2,
  315. ),
  316. )
  317. await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
  318. await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
  319. await Bun.write(
  320. path.join(dist, "server.js"),
  321. [
  322. "export default {",
  323. " server: async () => {",
  324. ` await Bun.write(${JSON.stringify(mark)}, "called")`,
  325. " return {}",
  326. " },",
  327. "}",
  328. "",
  329. ].join("\n"),
  330. )
  331. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
  332. return {
  333. mod,
  334. mark,
  335. }
  336. },
  337. })
  338. const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
  339. try {
  340. const errors = await errs(tmp.path)
  341. expect(errors).toHaveLength(0)
  342. expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
  343. } finally {
  344. install.mockRestore()
  345. }
  346. })
  347. test("loads npm server plugin from package main without leading dot", async () => {
  348. await using tmp = await tmpdir({
  349. init: async (dir) => {
  350. const mod = path.join(dir, "mods", "acme-plugin")
  351. const dist = path.join(mod, "dist")
  352. const mark = path.join(dir, "main-called.txt")
  353. await fs.mkdir(dist, { recursive: true })
  354. await Bun.write(
  355. path.join(mod, "package.json"),
  356. JSON.stringify(
  357. {
  358. name: "acme-plugin",
  359. type: "module",
  360. main: "dist/index.js",
  361. },
  362. null,
  363. 2,
  364. ),
  365. )
  366. await Bun.write(
  367. path.join(dist, "index.js"),
  368. [
  369. "export default {",
  370. " server: async () => {",
  371. ` await Bun.write(${JSON.stringify(mark)}, "called")`,
  372. " return {}",
  373. " },",
  374. "}",
  375. "",
  376. ].join("\n"),
  377. )
  378. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
  379. return {
  380. mod,
  381. mark,
  382. }
  383. },
  384. })
  385. const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
  386. try {
  387. const errors = await errs(tmp.path)
  388. expect(errors).toHaveLength(0)
  389. expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
  390. } finally {
  391. install.mockRestore()
  392. }
  393. })
  394. test("does not use npm package exports dot for server entry", async () => {
  395. await using tmp = await tmpdir({
  396. init: async (dir) => {
  397. const mod = path.join(dir, "mods", "acme-plugin")
  398. const mark = path.join(dir, "dot-server.txt")
  399. await fs.mkdir(mod, { recursive: true })
  400. await Bun.write(
  401. path.join(mod, "package.json"),
  402. JSON.stringify({
  403. name: "acme-plugin",
  404. type: "module",
  405. exports: { ".": "./index.js" },
  406. }),
  407. )
  408. await Bun.write(
  409. path.join(mod, "index.js"),
  410. [
  411. "export default {",
  412. ' id: "demo.dot.server",',
  413. " server: async () => {",
  414. ` await Bun.write(${JSON.stringify(mark)}, "called")`,
  415. " return {}",
  416. " },",
  417. "}",
  418. "",
  419. ].join("\n"),
  420. )
  421. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
  422. return { mod, mark }
  423. },
  424. })
  425. const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
  426. try {
  427. const errors = await errs(tmp.path)
  428. const called = await Bun.file(tmp.extra.mark)
  429. .text()
  430. .then(() => true)
  431. .catch(() => false)
  432. expect(called).toBe(false)
  433. expect(errors).toHaveLength(0)
  434. } finally {
  435. install.mockRestore()
  436. }
  437. })
  438. test("rejects npm server export that resolves outside plugin directory", async () => {
  439. await using tmp = await tmpdir({
  440. init: async (dir) => {
  441. const mod = path.join(dir, "mods", "acme-plugin")
  442. const outside = path.join(dir, "outside")
  443. const mark = path.join(dir, "outside-server.txt")
  444. await fs.mkdir(mod, { recursive: true })
  445. await fs.mkdir(outside, { recursive: true })
  446. await Bun.write(
  447. path.join(mod, "package.json"),
  448. JSON.stringify(
  449. {
  450. name: "acme-plugin",
  451. type: "module",
  452. exports: {
  453. ".": "./index.js",
  454. "./server": "./escape/server.js",
  455. },
  456. },
  457. null,
  458. 2,
  459. ),
  460. )
  461. await Bun.write(path.join(mod, "index.js"), "export default {}\n")
  462. await Bun.write(
  463. path.join(outside, "server.js"),
  464. [
  465. "export default {",
  466. " server: async () => {",
  467. ` await Bun.write(${JSON.stringify(mark)}, "outside")`,
  468. " return {}",
  469. " },",
  470. "}",
  471. "",
  472. ].join("\n"),
  473. )
  474. await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir")
  475. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin"] }, null, 2))
  476. return {
  477. mod,
  478. mark,
  479. }
  480. },
  481. })
  482. const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
  483. try {
  484. const errors = await errs(tmp.path)
  485. const called = await Bun.file(tmp.extra.mark)
  486. .text()
  487. .then(() => true)
  488. .catch(() => false)
  489. expect(called).toBe(false)
  490. expect(errors.some((x) => x.includes("outside plugin directory"))).toBe(true)
  491. } finally {
  492. install.mockRestore()
  493. }
  494. })
  495. test("skips legacy codex and copilot auth plugin specs", async () => {
  496. await using tmp = await tmpdir({
  497. init: async (dir) => {
  498. await Bun.write(
  499. path.join(dir, "opencode.json"),
  500. JSON.stringify(
  501. {
  502. plugin: ["[email protected]", "[email protected]", "[email protected]"],
  503. },
  504. null,
  505. 2,
  506. ),
  507. )
  508. },
  509. })
  510. const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: "" })
  511. try {
  512. await load(tmp.path)
  513. const pkgs = install.mock.calls.map((call) => call[0])
  514. expect(pkgs).toContain("[email protected]")
  515. expect(pkgs).not.toContain("[email protected]")
  516. expect(pkgs).not.toContain("[email protected]")
  517. } finally {
  518. install.mockRestore()
  519. }
  520. })
  521. test("publishes session.error when install fails", async () => {
  522. await using tmp = await tmpdir({
  523. init: async (dir) => {
  524. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
  525. },
  526. })
  527. const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
  528. try {
  529. const errors = await errs(tmp.path)
  530. expect(errors.some((x) => x.includes("Failed to install plugin [email protected]") && x.includes("boom"))).toBe(
  531. true,
  532. )
  533. } finally {
  534. install.mockRestore()
  535. }
  536. })
  537. test("publishes session.error when plugin init throws", async () => {
  538. await using tmp = await tmpdir({
  539. init: async (dir) => {
  540. const file = pathToFileURL(path.join(dir, "throws.ts")).href
  541. await Bun.write(
  542. path.join(dir, "throws.ts"),
  543. [
  544. "export default {",
  545. ' id: "demo.throws",',
  546. " server: async () => {",
  547. ' throw new Error("explode")',
  548. " },",
  549. "}",
  550. "",
  551. ].join("\n"),
  552. )
  553. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
  554. return { file }
  555. },
  556. })
  557. const errors = await errs(tmp.path)
  558. expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}: explode`))).toBe(true)
  559. })
  560. test("publishes session.error when plugin module has invalid export", async () => {
  561. await using tmp = await tmpdir({
  562. init: async (dir) => {
  563. const file = pathToFileURL(path.join(dir, "invalid.ts")).href
  564. await Bun.write(
  565. path.join(dir, "invalid.ts"),
  566. ["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"),
  567. )
  568. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
  569. return { file }
  570. },
  571. })
  572. const errors = await errs(tmp.path)
  573. expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}`))).toBe(true)
  574. })
  575. test("publishes session.error when plugin import fails", async () => {
  576. await using tmp = await tmpdir({
  577. init: async (dir) => {
  578. const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
  579. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing] }, null, 2))
  580. return { missing }
  581. },
  582. })
  583. const errors = await errs(tmp.path)
  584. expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.missing}`))).toBe(true)
  585. })
  586. test("loads object plugin via plugin.server", async () => {
  587. await using tmp = await tmpdir({
  588. init: async (dir) => {
  589. const file = path.join(dir, "object-plugin.ts")
  590. const mark = path.join(dir, "object-called.txt")
  591. await Bun.write(
  592. file,
  593. [
  594. "const plugin = {",
  595. ' id: "demo.object",',
  596. " server: async () => {",
  597. ` await Bun.write(${JSON.stringify(mark)}, \"called\")`,
  598. " return {}",
  599. " },",
  600. "}",
  601. "export default plugin",
  602. "",
  603. ].join("\n"),
  604. )
  605. await Bun.write(
  606. path.join(dir, "opencode.json"),
  607. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  608. )
  609. return { mark }
  610. },
  611. })
  612. await load(tmp.path)
  613. expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called")
  614. })
  615. test("passes tuple plugin options into server plugin", async () => {
  616. await using tmp = await tmpdir({
  617. init: async (dir) => {
  618. const file = path.join(dir, "options-plugin.ts")
  619. const mark = path.join(dir, "options.json")
  620. await Bun.write(
  621. file,
  622. [
  623. "const plugin = {",
  624. ' id: "demo.options",',
  625. " server: async (_input, options) => {",
  626. ` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(options ?? null))`,
  627. " return {}",
  628. " },",
  629. "}",
  630. "export default plugin",
  631. "",
  632. ].join("\n"),
  633. )
  634. await Bun.write(
  635. path.join(dir, "opencode.json"),
  636. JSON.stringify({ plugin: [[pathToFileURL(file).href, { source: "tuple", enabled: true }]] }, null, 2),
  637. )
  638. return { mark }
  639. },
  640. })
  641. await load(tmp.path)
  642. expect(await Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark)).toEqual({
  643. source: "tuple",
  644. enabled: true,
  645. })
  646. })
  647. test("initializes server plugins in config order", async () => {
  648. await using tmp = await tmpdir({
  649. init: async (dir) => {
  650. const a = path.join(dir, "a-plugin.ts")
  651. const b = path.join(dir, "b-plugin.ts")
  652. const marker = path.join(dir, "server-order.txt")
  653. const aSpec = pathToFileURL(a).href
  654. const bSpec = pathToFileURL(b).href
  655. await Bun.write(
  656. a,
  657. `import fs from "fs/promises"
  658. export default {
  659. id: "demo.order.a",
  660. server: async () => {
  661. await fs.appendFile(${JSON.stringify(marker)}, "a-start\\n")
  662. await Bun.sleep(25)
  663. await fs.appendFile(${JSON.stringify(marker)}, "a-end\\n")
  664. return {}
  665. },
  666. }
  667. `,
  668. )
  669. await Bun.write(
  670. b,
  671. `import fs from "fs/promises"
  672. export default {
  673. id: "demo.order.b",
  674. server: async () => {
  675. await fs.appendFile(${JSON.stringify(marker)}, "b\\n")
  676. return {}
  677. },
  678. }
  679. `,
  680. )
  681. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [aSpec, bSpec] }, null, 2))
  682. return { marker }
  683. },
  684. })
  685. await load(tmp.path)
  686. const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
  687. expect(lines).toEqual(["a-start", "a-end", "b"])
  688. })
  689. test("skips external plugins in pure mode", async () => {
  690. await using tmp = await tmpdir({
  691. init: async (dir) => {
  692. const file = path.join(dir, "plugin.ts")
  693. const mark = path.join(dir, "called.txt")
  694. await Bun.write(
  695. file,
  696. [
  697. "export default {",
  698. ' id: "demo.pure",',
  699. " server: async () => {",
  700. ` await Bun.write(${JSON.stringify(mark)}, \"called\")`,
  701. " return {}",
  702. " },",
  703. "}",
  704. "",
  705. ].join("\n"),
  706. )
  707. await Bun.write(
  708. path.join(dir, "opencode.json"),
  709. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  710. )
  711. return { mark }
  712. },
  713. })
  714. const pure = process.env.KILO_PURE
  715. process.env.KILO_PURE = "1"
  716. try {
  717. await load(tmp.path)
  718. const called = await fs
  719. .readFile(tmp.extra.mark, "utf8")
  720. .then(() => true)
  721. .catch(() => false)
  722. expect(called).toBe(false)
  723. } finally {
  724. if (pure === undefined) {
  725. delete process.env.KILO_PURE
  726. } else {
  727. process.env.KILO_PURE = pure
  728. }
  729. }
  730. })
  731. test("reads oc-themes from package manifest", async () => {
  732. await using tmp = await tmpdir({
  733. init: async (dir) => {
  734. const mod = path.join(dir, "mod")
  735. await fs.mkdir(path.join(mod, "themes"), { recursive: true })
  736. await Bun.write(
  737. path.join(mod, "package.json"),
  738. JSON.stringify(
  739. {
  740. name: "acme-plugin",
  741. version: "1.0.0",
  742. "oc-themes": ["themes/one.json", "./themes/one.json", "themes/two.json"],
  743. },
  744. null,
  745. 2,
  746. ),
  747. )
  748. return { mod }
  749. },
  750. })
  751. const file = path.join(tmp.extra.mod, "package.json")
  752. const json = await Filesystem.readJson<Record<string, unknown>>(file)
  753. const list = readPackageThemes("acme-plugin", {
  754. dir: tmp.extra.mod,
  755. pkg: file,
  756. json,
  757. })
  758. expect(list).toEqual([
  759. Filesystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")),
  760. Filesystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")),
  761. ])
  762. })
  763. test("handles no-entrypoint tui packages via missing callback", async () => {
  764. await using tmp = await tmpdir({
  765. init: async (dir) => {
  766. const mod = path.join(dir, "mods", "acme-plugin")
  767. await fs.mkdir(path.join(mod, "themes"), { recursive: true })
  768. await Bun.write(
  769. path.join(mod, "package.json"),
  770. JSON.stringify(
  771. {
  772. name: "acme-plugin",
  773. version: "1.0.0",
  774. "oc-themes": ["themes/night.json"],
  775. },
  776. null,
  777. 2,
  778. ),
  779. )
  780. await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
  781. return { mod }
  782. },
  783. })
  784. const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
  785. const missing: string[] = []
  786. try {
  787. const loaded = await PluginLoader.loadExternal({
  788. items: [
  789. {
  790. spec: "[email protected]",
  791. scope: "local" as const,
  792. source: tmp.path,
  793. },
  794. ],
  795. kind: "tui",
  796. missing: async (item) => {
  797. if (!item.pkg) return
  798. const themes = readPackageThemes(item.spec, item.pkg)
  799. if (!themes.length) return
  800. return {
  801. spec: item.spec,
  802. target: item.target,
  803. themes,
  804. }
  805. },
  806. report: {
  807. missing(_candidate, _retry, message) {
  808. missing.push(message)
  809. },
  810. },
  811. })
  812. expect(loaded).toEqual([
  813. {
  814. spec: "[email protected]",
  815. target: tmp.extra.mod,
  816. themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
  817. },
  818. ])
  819. expect(missing).toHaveLength(0)
  820. } finally {
  821. install.mockRestore()
  822. }
  823. })
  824. test("passes package metadata for entrypoint tui plugins", async () => {
  825. await using tmp = await tmpdir({
  826. init: async (dir) => {
  827. const mod = path.join(dir, "mods", "acme-plugin")
  828. await fs.mkdir(path.join(mod, "themes"), { recursive: true })
  829. await Bun.write(
  830. path.join(mod, "package.json"),
  831. JSON.stringify(
  832. {
  833. name: "acme-plugin",
  834. version: "1.0.0",
  835. exports: {
  836. "./tui": "./tui.js",
  837. },
  838. "oc-themes": ["themes/night.json"],
  839. },
  840. null,
  841. 2,
  842. ),
  843. )
  844. await Bun.write(path.join(mod, "tui.js"), 'export default { id: "demo", tui: async () => {} }\n')
  845. await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
  846. return { mod }
  847. },
  848. })
  849. const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
  850. try {
  851. const loaded = await PluginLoader.loadExternal({
  852. items: [
  853. {
  854. spec: "[email protected]",
  855. scope: "local" as const,
  856. source: tmp.path,
  857. },
  858. ],
  859. kind: "tui",
  860. finish: async (item) => {
  861. if (!item.pkg) return
  862. return {
  863. spec: item.spec,
  864. themes: readPackageThemes(item.spec, item.pkg),
  865. }
  866. },
  867. })
  868. expect(loaded).toEqual([
  869. {
  870. spec: "[email protected]",
  871. themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
  872. },
  873. ])
  874. } finally {
  875. install.mockRestore()
  876. }
  877. })
  878. test("rejects oc-themes path traversal", async () => {
  879. await using tmp = await tmpdir({
  880. init: async (dir) => {
  881. const mod = path.join(dir, "mod")
  882. await fs.mkdir(mod, { recursive: true })
  883. const file = path.join(mod, "package.json")
  884. await Bun.write(file, JSON.stringify({ name: "acme", "oc-themes": ["../escape.json"] }, null, 2))
  885. return { mod, file }
  886. },
  887. })
  888. const json = await Filesystem.readJson<Record<string, unknown>>(tmp.extra.file)
  889. expect(() =>
  890. readPackageThemes("acme", {
  891. dir: tmp.extra.mod,
  892. pkg: tmp.extra.file,
  893. json,
  894. }),
  895. ).toThrow("outside plugin directory")
  896. })
  897. test("retries failed file plugins once after wait and keeps order", async () => {
  898. await using tmp = await tmpdir({
  899. init: async (dir) => {
  900. const a = path.join(dir, "a")
  901. const b = path.join(dir, "b")
  902. const aSpec = pathToFileURL(a).href
  903. const bSpec = pathToFileURL(b).href
  904. await fs.mkdir(a, { recursive: true })
  905. await fs.mkdir(b, { recursive: true })
  906. return { a, b, aSpec, bSpec }
  907. },
  908. })
  909. let wait = 0
  910. const calls: Array<[string, boolean]> = []
  911. const loaded = await PluginLoader.loadExternal({
  912. items: [tmp.extra.aSpec, tmp.extra.bSpec].map((spec) => ({
  913. spec,
  914. scope: "local" as const,
  915. source: tmp.path,
  916. })),
  917. kind: "tui",
  918. wait: async () => {
  919. wait += 1
  920. await Bun.write(path.join(tmp.extra.a, "index.ts"), "export default {}\n")
  921. await Bun.write(path.join(tmp.extra.b, "index.ts"), "export default {}\n")
  922. },
  923. report: {
  924. start(candidate, retry) {
  925. calls.push([candidate.plan.spec, retry])
  926. },
  927. },
  928. })
  929. expect(wait).toBe(1)
  930. expect(calls).toEqual([
  931. [tmp.extra.aSpec, false],
  932. [tmp.extra.bSpec, false],
  933. [tmp.extra.aSpec, true],
  934. [tmp.extra.bSpec, true],
  935. ])
  936. expect(loaded.map((item) => item.spec)).toEqual([tmp.extra.aSpec, tmp.extra.bSpec])
  937. })
  938. test("retries file plugins when finish returns undefined", async () => {
  939. await using tmp = await tmpdir({
  940. init: async (dir) => {
  941. const file = path.join(dir, "plugin.ts")
  942. const spec = pathToFileURL(file).href
  943. await Bun.write(file, "export default {}\n")
  944. return { spec }
  945. },
  946. })
  947. let wait = 0
  948. let count = 0
  949. const loaded = await PluginLoader.loadExternal({
  950. items: [
  951. {
  952. spec: tmp.extra.spec,
  953. scope: "local" as const,
  954. source: tmp.path,
  955. },
  956. ],
  957. kind: "tui",
  958. wait: async () => {
  959. wait += 1
  960. },
  961. finish: async (load, _item, retry) => {
  962. count += 1
  963. if (!retry) return
  964. return {
  965. retry,
  966. spec: load.spec,
  967. }
  968. },
  969. })
  970. expect(wait).toBe(1)
  971. expect(count).toBe(2)
  972. expect(loaded).toEqual([{ retry: true, spec: tmp.extra.spec }])
  973. })
  974. test("does not wait or retry npm plugin failures", async () => {
  975. const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
  976. let wait = 0
  977. const errors: Array<[string, boolean]> = []
  978. try {
  979. const loaded = await PluginLoader.loadExternal({
  980. items: [
  981. {
  982. spec: "[email protected]",
  983. scope: "local" as const,
  984. source: "test",
  985. },
  986. ],
  987. kind: "tui",
  988. wait: async () => {
  989. wait += 1
  990. },
  991. report: {
  992. error(_candidate, retry, stage) {
  993. errors.push([stage, retry])
  994. },
  995. },
  996. })
  997. expect(loaded).toEqual([])
  998. expect(wait).toBe(0)
  999. expect(errors).toEqual([["install", false]])
  1000. } finally {
  1001. install.mockRestore()
  1002. }
  1003. })
  1004. })