loader-shared.test.ts 37 KB

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