loader-shared.test.ts 33 KB

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