loader-shared.test.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836
  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 { Instance } = await import("../../src/project/instance")
  11. const { BunProc } = await import("../../src/bun")
  12. const { Bus } = await import("../../src/bus")
  13. const { Session } = await import("../../src/session")
  14. afterAll(() => {
  15. if (disableDefault === undefined) {
  16. delete process.env.KILO_DISABLE_DEFAULT_PLUGINS
  17. return
  18. }
  19. process.env.KILO_DISABLE_DEFAULT_PLUGINS = disableDefault
  20. })
  21. afterEach(async () => {
  22. await Instance.disposeAll()
  23. })
  24. async function load(dir: string) {
  25. return Instance.provide({
  26. directory: dir,
  27. fn: async () => {
  28. await Plugin.list()
  29. },
  30. })
  31. }
  32. async function errs(dir: string) {
  33. return Instance.provide({
  34. directory: dir,
  35. fn: async () => {
  36. const errors: string[] = []
  37. const off = Bus.subscribe(Session.Event.Error, (evt) => {
  38. const error = evt.properties.error
  39. if (!error || typeof error !== "object") return
  40. if (!("data" in error)) return
  41. if (!error.data || typeof error.data !== "object") return
  42. if (!("message" in error.data)) return
  43. if (typeof error.data.message !== "string") return
  44. errors.push(error.data.message)
  45. })
  46. await Plugin.list()
  47. off()
  48. return errors
  49. },
  50. })
  51. }
  52. describe("plugin.loader.shared", () => {
  53. test("loads a file:// plugin function export", async () => {
  54. await using tmp = await tmpdir({
  55. init: async (dir) => {
  56. const file = path.join(dir, "plugin.ts")
  57. const mark = path.join(dir, "called.txt")
  58. await Bun.write(
  59. file,
  60. [
  61. "export default async () => {",
  62. ` await Bun.write(${JSON.stringify(mark)}, \"called\")`,
  63. " return {}",
  64. "}",
  65. "",
  66. ].join("\n"),
  67. )
  68. await Bun.write(
  69. path.join(dir, "opencode.json"),
  70. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  71. )
  72. return { mark }
  73. },
  74. })
  75. await load(tmp.path)
  76. expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called")
  77. })
  78. test("deduplicates same function exported as default and named", async () => {
  79. await using tmp = await tmpdir({
  80. init: async (dir) => {
  81. const file = path.join(dir, "plugin.ts")
  82. const mark = path.join(dir, "count.txt")
  83. await Bun.write(mark, "")
  84. await Bun.write(
  85. file,
  86. [
  87. "const run = async () => {",
  88. ` const text = await Bun.file(${JSON.stringify(mark)}).text().catch(() => \"\")`,
  89. ` await Bun.write(${JSON.stringify(mark)}, text + \"1\")`,
  90. " return {}",
  91. "}",
  92. "export default run",
  93. "export const named = run",
  94. "",
  95. ].join("\n"),
  96. )
  97. await Bun.write(
  98. path.join(dir, "opencode.json"),
  99. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  100. )
  101. return { mark }
  102. },
  103. })
  104. await load(tmp.path)
  105. expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("1")
  106. })
  107. test("uses only default v1 server plugin when present", async () => {
  108. await using tmp = await tmpdir({
  109. init: async (dir) => {
  110. const file = path.join(dir, "plugin.ts")
  111. const mark = path.join(dir, "count.txt")
  112. await Bun.write(
  113. file,
  114. [
  115. "export default {",
  116. ' id: "demo.v1-default",',
  117. " server: async () => {",
  118. ` await Bun.write(${JSON.stringify(mark)}, "default")`,
  119. " return {}",
  120. " },",
  121. "}",
  122. "export const named = async () => {",
  123. ` await Bun.write(${JSON.stringify(mark)}, "named")`,
  124. " return {}",
  125. "}",
  126. "",
  127. ].join("\n"),
  128. )
  129. await Bun.write(
  130. path.join(dir, "opencode.json"),
  131. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  132. )
  133. return { mark }
  134. },
  135. })
  136. await load(tmp.path)
  137. expect(await Bun.file(tmp.extra.mark).text()).toBe("default")
  138. })
  139. test("rejects v1 file server plugin without id", async () => {
  140. await using tmp = await tmpdir({
  141. init: async (dir) => {
  142. const file = path.join(dir, "plugin.ts")
  143. const mark = path.join(dir, "called.txt")
  144. await Bun.write(
  145. file,
  146. [
  147. "export default {",
  148. " server: async () => {",
  149. ` await Bun.write(${JSON.stringify(mark)}, "called")`,
  150. " return {}",
  151. " },",
  152. "}",
  153. "",
  154. ].join("\n"),
  155. )
  156. await Bun.write(
  157. path.join(dir, "opencode.json"),
  158. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  159. )
  160. return { mark }
  161. },
  162. })
  163. const errors = await errs(tmp.path)
  164. const called = await Bun.file(tmp.extra.mark)
  165. .text()
  166. .then(() => true)
  167. .catch(() => false)
  168. expect(called).toBe(false)
  169. expect(errors.some((x) => x.includes("must export id"))).toBe(true)
  170. })
  171. test("rejects v1 plugin that exports server and tui together", async () => {
  172. await using tmp = await tmpdir({
  173. init: async (dir) => {
  174. const file = path.join(dir, "plugin.ts")
  175. const mark = path.join(dir, "called.txt")
  176. await Bun.write(
  177. file,
  178. [
  179. "export default {",
  180. ' id: "demo.mixed",',
  181. " server: async () => {",
  182. ` await Bun.write(${JSON.stringify(mark)}, "server")`,
  183. " return {}",
  184. " },",
  185. " tui: async () => {},",
  186. "}",
  187. "",
  188. ].join("\n"),
  189. )
  190. await Bun.write(
  191. path.join(dir, "opencode.json"),
  192. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  193. )
  194. return { mark }
  195. },
  196. })
  197. const errors = await errs(tmp.path)
  198. const called = await Bun.file(tmp.extra.mark)
  199. .text()
  200. .then(() => true)
  201. .catch(() => false)
  202. expect(called).toBe(false)
  203. expect(errors.some((x) => x.includes("either server() or tui(), not both"))).toBe(true)
  204. })
  205. test("resolves npm plugin specs with explicit and default versions", async () => {
  206. await using tmp = await tmpdir({
  207. init: async (dir) => {
  208. const acme = path.join(dir, "node_modules", "acme-plugin")
  209. const scope = path.join(dir, "node_modules", "scope-plugin")
  210. await fs.mkdir(acme, { recursive: true })
  211. await fs.mkdir(scope, { recursive: true })
  212. await Bun.write(
  213. path.join(acme, "package.json"),
  214. JSON.stringify({ name: "acme-plugin", type: "module", main: "./index.js" }, null, 2),
  215. )
  216. await Bun.write(path.join(acme, "index.js"), "export default { server: async () => ({}) }\n")
  217. await Bun.write(
  218. path.join(scope, "package.json"),
  219. JSON.stringify({ name: "scope-plugin", type: "module", main: "./index.js" }, null, 2),
  220. )
  221. await Bun.write(path.join(scope, "index.js"), "export default { server: async () => ({}) }\n")
  222. await Bun.write(
  223. path.join(dir, "opencode.json"),
  224. JSON.stringify({ plugin: ["acme-plugin", "[email protected]"] }, null, 2),
  225. )
  226. return { acme, scope }
  227. },
  228. })
  229. const install = spyOn(BunProc, "install").mockImplementation(async (pkg) => {
  230. if (pkg === "acme-plugin") return tmp.extra.acme
  231. return tmp.extra.scope
  232. })
  233. try {
  234. await load(tmp.path)
  235. expect(install.mock.calls).toContainEqual(["acme-plugin", "latest", { ignoreScripts: true }])
  236. expect(install.mock.calls).toContainEqual(["scope-plugin", "2.3.4", { ignoreScripts: true }])
  237. } finally {
  238. install.mockRestore()
  239. }
  240. })
  241. test("loads npm server plugin from package ./server export", async () => {
  242. await using tmp = await tmpdir({
  243. init: async (dir) => {
  244. const mod = path.join(dir, "mods", "acme-plugin")
  245. const mark = path.join(dir, "server-called.txt")
  246. await fs.mkdir(mod, { recursive: true })
  247. await Bun.write(
  248. path.join(mod, "package.json"),
  249. JSON.stringify(
  250. {
  251. name: "acme-plugin",
  252. type: "module",
  253. exports: {
  254. ".": "./index.js",
  255. "./server": "./server.js",
  256. "./tui": "./tui.js",
  257. },
  258. },
  259. null,
  260. 2,
  261. ),
  262. )
  263. await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
  264. await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
  265. await Bun.write(
  266. path.join(mod, "server.js"),
  267. [
  268. "export default {",
  269. " server: async () => {",
  270. ` await Bun.write(${JSON.stringify(mark)}, "called")`,
  271. " return {}",
  272. " },",
  273. "}",
  274. "",
  275. ].join("\n"),
  276. )
  277. await Bun.write(path.join(mod, "tui.js"), "export default {}\n")
  278. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
  279. return {
  280. mod,
  281. mark,
  282. }
  283. },
  284. })
  285. const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
  286. try {
  287. await load(tmp.path)
  288. expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
  289. } finally {
  290. install.mockRestore()
  291. }
  292. })
  293. test("loads npm server plugin from package server export without leading dot", async () => {
  294. await using tmp = await tmpdir({
  295. init: async (dir) => {
  296. const mod = path.join(dir, "mods", "acme-plugin")
  297. const dist = path.join(mod, "dist")
  298. const mark = path.join(dir, "server-called.txt")
  299. await fs.mkdir(dist, { recursive: true })
  300. await Bun.write(
  301. path.join(mod, "package.json"),
  302. JSON.stringify(
  303. {
  304. name: "acme-plugin",
  305. type: "module",
  306. exports: {
  307. ".": "./index.js",
  308. "./server": "dist/server.js",
  309. },
  310. },
  311. null,
  312. 2,
  313. ),
  314. )
  315. await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
  316. await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
  317. await Bun.write(
  318. path.join(dist, "server.js"),
  319. [
  320. "export default {",
  321. " server: async () => {",
  322. ` await Bun.write(${JSON.stringify(mark)}, "called")`,
  323. " return {}",
  324. " },",
  325. "}",
  326. "",
  327. ].join("\n"),
  328. )
  329. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
  330. return {
  331. mod,
  332. mark,
  333. }
  334. },
  335. })
  336. const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
  337. try {
  338. const errors = await errs(tmp.path)
  339. expect(errors).toHaveLength(0)
  340. expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
  341. } finally {
  342. install.mockRestore()
  343. }
  344. })
  345. test("loads npm server plugin from package main without leading dot", async () => {
  346. await using tmp = await tmpdir({
  347. init: async (dir) => {
  348. const mod = path.join(dir, "mods", "acme-plugin")
  349. const dist = path.join(mod, "dist")
  350. const mark = path.join(dir, "main-called.txt")
  351. await fs.mkdir(dist, { recursive: true })
  352. await Bun.write(
  353. path.join(mod, "package.json"),
  354. JSON.stringify(
  355. {
  356. name: "acme-plugin",
  357. type: "module",
  358. main: "dist/index.js",
  359. },
  360. null,
  361. 2,
  362. ),
  363. )
  364. await Bun.write(
  365. path.join(dist, "index.js"),
  366. [
  367. "export default {",
  368. " server: async () => {",
  369. ` await Bun.write(${JSON.stringify(mark)}, "called")`,
  370. " return {}",
  371. " },",
  372. "}",
  373. "",
  374. ].join("\n"),
  375. )
  376. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
  377. return {
  378. mod,
  379. mark,
  380. }
  381. },
  382. })
  383. const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
  384. try {
  385. const errors = await errs(tmp.path)
  386. expect(errors).toHaveLength(0)
  387. expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
  388. } finally {
  389. install.mockRestore()
  390. }
  391. })
  392. test("does not use npm package exports dot for server entry", async () => {
  393. await using tmp = await tmpdir({
  394. init: async (dir) => {
  395. const mod = path.join(dir, "mods", "acme-plugin")
  396. const mark = path.join(dir, "dot-server.txt")
  397. await fs.mkdir(mod, { recursive: true })
  398. await Bun.write(
  399. path.join(mod, "package.json"),
  400. JSON.stringify({
  401. name: "acme-plugin",
  402. type: "module",
  403. exports: { ".": "./index.js" },
  404. }),
  405. )
  406. await Bun.write(
  407. path.join(mod, "index.js"),
  408. [
  409. "export default {",
  410. ' id: "demo.dot.server",',
  411. " server: async () => {",
  412. ` await Bun.write(${JSON.stringify(mark)}, "called")`,
  413. " return {}",
  414. " },",
  415. "}",
  416. "",
  417. ].join("\n"),
  418. )
  419. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
  420. return { mod, mark }
  421. },
  422. })
  423. const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
  424. try {
  425. const errors = await errs(tmp.path)
  426. const called = await Bun.file(tmp.extra.mark)
  427. .text()
  428. .then(() => true)
  429. .catch(() => false)
  430. expect(called).toBe(false)
  431. expect(errors).toHaveLength(0)
  432. } finally {
  433. install.mockRestore()
  434. }
  435. })
  436. test("rejects npm server export that resolves outside plugin directory", async () => {
  437. await using tmp = await tmpdir({
  438. init: async (dir) => {
  439. const mod = path.join(dir, "mods", "acme-plugin")
  440. const outside = path.join(dir, "outside")
  441. const mark = path.join(dir, "outside-server.txt")
  442. await fs.mkdir(mod, { recursive: true })
  443. await fs.mkdir(outside, { recursive: true })
  444. await Bun.write(
  445. path.join(mod, "package.json"),
  446. JSON.stringify(
  447. {
  448. name: "acme-plugin",
  449. type: "module",
  450. exports: {
  451. ".": "./index.js",
  452. "./server": "./escape/server.js",
  453. },
  454. },
  455. null,
  456. 2,
  457. ),
  458. )
  459. await Bun.write(path.join(mod, "index.js"), "export default {}\n")
  460. await Bun.write(
  461. path.join(outside, "server.js"),
  462. [
  463. "export default {",
  464. " server: async () => {",
  465. ` await Bun.write(${JSON.stringify(mark)}, "outside")`,
  466. " return {}",
  467. " },",
  468. "}",
  469. "",
  470. ].join("\n"),
  471. )
  472. await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir")
  473. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin"] }, null, 2))
  474. return {
  475. mod,
  476. mark,
  477. }
  478. },
  479. })
  480. const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
  481. try {
  482. const errors = await errs(tmp.path)
  483. const called = await Bun.file(tmp.extra.mark)
  484. .text()
  485. .then(() => true)
  486. .catch(() => false)
  487. expect(called).toBe(false)
  488. expect(errors.some((x) => x.includes("outside plugin directory"))).toBe(true)
  489. } finally {
  490. install.mockRestore()
  491. }
  492. })
  493. test("skips legacy codex and copilot auth plugin specs", async () => {
  494. await using tmp = await tmpdir({
  495. init: async (dir) => {
  496. await Bun.write(
  497. path.join(dir, "opencode.json"),
  498. JSON.stringify(
  499. {
  500. plugin: ["[email protected]", "[email protected]", "[email protected]"],
  501. },
  502. null,
  503. 2,
  504. ),
  505. )
  506. },
  507. })
  508. const install = spyOn(BunProc, "install").mockResolvedValue("")
  509. try {
  510. await load(tmp.path)
  511. const pkgs = install.mock.calls.map((call) => call[0])
  512. expect(pkgs).toContain("regular-plugin")
  513. expect(pkgs).not.toContain("opencode-openai-codex-auth")
  514. expect(pkgs).not.toContain("opencode-copilot-auth")
  515. } finally {
  516. install.mockRestore()
  517. }
  518. })
  519. test("publishes session.error when install fails", async () => {
  520. await using tmp = await tmpdir({
  521. init: async (dir) => {
  522. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
  523. },
  524. })
  525. const install = spyOn(BunProc, "install").mockRejectedValue(new Error("boom"))
  526. try {
  527. const errors = await errs(tmp.path)
  528. expect(errors.some((x) => x.includes("Failed to install plugin [email protected]") && x.includes("boom"))).toBe(
  529. true,
  530. )
  531. } finally {
  532. install.mockRestore()
  533. }
  534. })
  535. test("publishes session.error when plugin init throws", async () => {
  536. await using tmp = await tmpdir({
  537. init: async (dir) => {
  538. const file = pathToFileURL(path.join(dir, "throws.ts")).href
  539. await Bun.write(
  540. path.join(dir, "throws.ts"),
  541. [
  542. "export default {",
  543. ' id: "demo.throws",',
  544. " server: async () => {",
  545. ' throw new Error("explode")',
  546. " },",
  547. "}",
  548. "",
  549. ].join("\n"),
  550. )
  551. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
  552. return { file }
  553. },
  554. })
  555. const errors = await errs(tmp.path)
  556. expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}: explode`))).toBe(true)
  557. })
  558. test("publishes session.error when plugin module has invalid export", async () => {
  559. await using tmp = await tmpdir({
  560. init: async (dir) => {
  561. const file = pathToFileURL(path.join(dir, "invalid.ts")).href
  562. await Bun.write(
  563. path.join(dir, "invalid.ts"),
  564. ["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"),
  565. )
  566. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
  567. return { file }
  568. },
  569. })
  570. const errors = await errs(tmp.path)
  571. expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}`))).toBe(true)
  572. })
  573. test("publishes session.error when plugin import fails", async () => {
  574. await using tmp = await tmpdir({
  575. init: async (dir) => {
  576. const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
  577. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing] }, null, 2))
  578. return { missing }
  579. },
  580. })
  581. const errors = await errs(tmp.path)
  582. expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.missing}`))).toBe(true)
  583. })
  584. test("loads object plugin via plugin.server", async () => {
  585. await using tmp = await tmpdir({
  586. init: async (dir) => {
  587. const file = path.join(dir, "object-plugin.ts")
  588. const mark = path.join(dir, "object-called.txt")
  589. await Bun.write(
  590. file,
  591. [
  592. "const plugin = {",
  593. ' id: "demo.object",',
  594. " server: async () => {",
  595. ` await Bun.write(${JSON.stringify(mark)}, \"called\")`,
  596. " return {}",
  597. " },",
  598. "}",
  599. "export default plugin",
  600. "",
  601. ].join("\n"),
  602. )
  603. await Bun.write(
  604. path.join(dir, "opencode.json"),
  605. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  606. )
  607. return { mark }
  608. },
  609. })
  610. await load(tmp.path)
  611. expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called")
  612. })
  613. test("passes tuple plugin options into server plugin", async () => {
  614. await using tmp = await tmpdir({
  615. init: async (dir) => {
  616. const file = path.join(dir, "options-plugin.ts")
  617. const mark = path.join(dir, "options.json")
  618. await Bun.write(
  619. file,
  620. [
  621. "const plugin = {",
  622. ' id: "demo.options",',
  623. " server: async (_input, options) => {",
  624. ` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(options ?? null))`,
  625. " return {}",
  626. " },",
  627. "}",
  628. "export default plugin",
  629. "",
  630. ].join("\n"),
  631. )
  632. await Bun.write(
  633. path.join(dir, "opencode.json"),
  634. JSON.stringify({ plugin: [[pathToFileURL(file).href, { source: "tuple", enabled: true }]] }, null, 2),
  635. )
  636. return { mark }
  637. },
  638. })
  639. await load(tmp.path)
  640. expect(await Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark)).toEqual({
  641. source: "tuple",
  642. enabled: true,
  643. })
  644. })
  645. test("initializes server plugins in config order", async () => {
  646. await using tmp = await tmpdir({
  647. init: async (dir) => {
  648. const a = path.join(dir, "a-plugin.ts")
  649. const b = path.join(dir, "b-plugin.ts")
  650. const marker = path.join(dir, "server-order.txt")
  651. const aSpec = pathToFileURL(a).href
  652. const bSpec = pathToFileURL(b).href
  653. await Bun.write(
  654. a,
  655. `import fs from "fs/promises"
  656. export default {
  657. id: "demo.order.a",
  658. server: async () => {
  659. await fs.appendFile(${JSON.stringify(marker)}, "a-start\\n")
  660. await Bun.sleep(25)
  661. await fs.appendFile(${JSON.stringify(marker)}, "a-end\\n")
  662. return {}
  663. },
  664. }
  665. `,
  666. )
  667. await Bun.write(
  668. b,
  669. `import fs from "fs/promises"
  670. export default {
  671. id: "demo.order.b",
  672. server: async () => {
  673. await fs.appendFile(${JSON.stringify(marker)}, "b\\n")
  674. return {}
  675. },
  676. }
  677. `,
  678. )
  679. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [aSpec, bSpec] }, null, 2))
  680. return { marker }
  681. },
  682. })
  683. await load(tmp.path)
  684. const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
  685. expect(lines).toEqual(["a-start", "a-end", "b"])
  686. })
  687. test("skips external plugins in pure mode", async () => {
  688. await using tmp = await tmpdir({
  689. init: async (dir) => {
  690. const file = path.join(dir, "plugin.ts")
  691. const mark = path.join(dir, "called.txt")
  692. await Bun.write(
  693. file,
  694. [
  695. "export default {",
  696. ' id: "demo.pure",',
  697. " server: async () => {",
  698. ` await Bun.write(${JSON.stringify(mark)}, \"called\")`,
  699. " return {}",
  700. " },",
  701. "}",
  702. "",
  703. ].join("\n"),
  704. )
  705. await Bun.write(
  706. path.join(dir, "opencode.json"),
  707. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  708. )
  709. return { mark }
  710. },
  711. })
  712. const pure = process.env.KILO_PURE
  713. process.env.KILO_PURE = "1"
  714. try {
  715. await load(tmp.path)
  716. const called = await fs
  717. .readFile(tmp.extra.mark, "utf8")
  718. .then(() => true)
  719. .catch(() => false)
  720. expect(called).toBe(false)
  721. } finally {
  722. if (pure === undefined) {
  723. delete process.env.KILO_PURE
  724. } else {
  725. process.env.KILO_PURE = pure
  726. }
  727. }
  728. })
  729. })