config.test.ts 49 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783
  1. import { test, expect, describe, mock, afterEach } from "bun:test"
  2. import { Config } from "../../src/config/config"
  3. import { Instance } from "../../src/project/instance"
  4. import { Auth } from "../../src/auth"
  5. import { tmpdir } from "../fixture/fixture"
  6. import path from "path"
  7. import fs from "fs/promises"
  8. import { pathToFileURL } from "url"
  9. import { Global } from "../../src/global"
  10. // Get managed config directory from environment (set in preload.ts)
  11. const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
  12. afterEach(async () => {
  13. await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
  14. })
  15. async function writeManagedSettings(settings: object, filename = "opencode.json") {
  16. await fs.mkdir(managedConfigDir, { recursive: true })
  17. await Bun.write(path.join(managedConfigDir, filename), JSON.stringify(settings))
  18. }
  19. async function writeConfig(dir: string, config: object, name = "opencode.json") {
  20. await Bun.write(path.join(dir, name), JSON.stringify(config))
  21. }
  22. test("loads config with defaults when no files exist", async () => {
  23. await using tmp = await tmpdir()
  24. await Instance.provide({
  25. directory: tmp.path,
  26. fn: async () => {
  27. const config = await Config.get()
  28. expect(config.username).toBeDefined()
  29. },
  30. })
  31. })
  32. test("loads JSON config file", async () => {
  33. await using tmp = await tmpdir({
  34. init: async (dir) => {
  35. await writeConfig(dir, {
  36. $schema: "https://opencode.ai/config.json",
  37. model: "test/model",
  38. username: "testuser",
  39. })
  40. },
  41. })
  42. await Instance.provide({
  43. directory: tmp.path,
  44. fn: async () => {
  45. const config = await Config.get()
  46. expect(config.model).toBe("test/model")
  47. expect(config.username).toBe("testuser")
  48. },
  49. })
  50. })
  51. test("loads JSONC config file", async () => {
  52. await using tmp = await tmpdir({
  53. init: async (dir) => {
  54. await Bun.write(
  55. path.join(dir, "opencode.jsonc"),
  56. `{
  57. // This is a comment
  58. "$schema": "https://opencode.ai/config.json",
  59. "model": "test/model",
  60. "username": "testuser"
  61. }`,
  62. )
  63. },
  64. })
  65. await Instance.provide({
  66. directory: tmp.path,
  67. fn: async () => {
  68. const config = await Config.get()
  69. expect(config.model).toBe("test/model")
  70. expect(config.username).toBe("testuser")
  71. },
  72. })
  73. })
  74. test("merges multiple config files with correct precedence", async () => {
  75. await using tmp = await tmpdir({
  76. init: async (dir) => {
  77. await writeConfig(
  78. dir,
  79. {
  80. $schema: "https://opencode.ai/config.json",
  81. model: "base",
  82. username: "base",
  83. },
  84. "opencode.jsonc",
  85. )
  86. await writeConfig(dir, {
  87. $schema: "https://opencode.ai/config.json",
  88. model: "override",
  89. })
  90. },
  91. })
  92. await Instance.provide({
  93. directory: tmp.path,
  94. fn: async () => {
  95. const config = await Config.get()
  96. expect(config.model).toBe("override")
  97. expect(config.username).toBe("base")
  98. },
  99. })
  100. })
  101. test("handles environment variable substitution", async () => {
  102. const originalEnv = process.env["TEST_VAR"]
  103. process.env["TEST_VAR"] = "test_theme"
  104. try {
  105. await using tmp = await tmpdir({
  106. init: async (dir) => {
  107. await writeConfig(dir, {
  108. $schema: "https://opencode.ai/config.json",
  109. theme: "{env:TEST_VAR}",
  110. })
  111. },
  112. })
  113. await Instance.provide({
  114. directory: tmp.path,
  115. fn: async () => {
  116. const config = await Config.get()
  117. expect(config.theme).toBe("test_theme")
  118. },
  119. })
  120. } finally {
  121. if (originalEnv !== undefined) {
  122. process.env["TEST_VAR"] = originalEnv
  123. } else {
  124. delete process.env["TEST_VAR"]
  125. }
  126. }
  127. })
  128. test("preserves env variables when adding $schema to config", async () => {
  129. const originalEnv = process.env["PRESERVE_VAR"]
  130. process.env["PRESERVE_VAR"] = "secret_value"
  131. try {
  132. await using tmp = await tmpdir({
  133. init: async (dir) => {
  134. // Config without $schema - should trigger auto-add
  135. await Bun.write(
  136. path.join(dir, "opencode.json"),
  137. JSON.stringify({
  138. theme: "{env:PRESERVE_VAR}",
  139. }),
  140. )
  141. },
  142. })
  143. await Instance.provide({
  144. directory: tmp.path,
  145. fn: async () => {
  146. const config = await Config.get()
  147. expect(config.theme).toBe("secret_value")
  148. // Read the file to verify the env variable was preserved
  149. const content = await Bun.file(path.join(tmp.path, "opencode.json")).text()
  150. expect(content).toContain("{env:PRESERVE_VAR}")
  151. expect(content).not.toContain("secret_value")
  152. expect(content).toContain("$schema")
  153. },
  154. })
  155. } finally {
  156. if (originalEnv !== undefined) {
  157. process.env["PRESERVE_VAR"] = originalEnv
  158. } else {
  159. delete process.env["PRESERVE_VAR"]
  160. }
  161. }
  162. })
  163. test("handles file inclusion substitution", async () => {
  164. await using tmp = await tmpdir({
  165. init: async (dir) => {
  166. await Bun.write(path.join(dir, "included.txt"), "test_theme")
  167. await writeConfig(dir, {
  168. $schema: "https://opencode.ai/config.json",
  169. theme: "{file:included.txt}",
  170. })
  171. },
  172. })
  173. await Instance.provide({
  174. directory: tmp.path,
  175. fn: async () => {
  176. const config = await Config.get()
  177. expect(config.theme).toBe("test_theme")
  178. },
  179. })
  180. })
  181. test("validates config schema and throws on invalid fields", async () => {
  182. await using tmp = await tmpdir({
  183. init: async (dir) => {
  184. await writeConfig(dir, {
  185. $schema: "https://opencode.ai/config.json",
  186. invalid_field: "should cause error",
  187. })
  188. },
  189. })
  190. await Instance.provide({
  191. directory: tmp.path,
  192. fn: async () => {
  193. // Strict schema should throw an error for invalid fields
  194. await expect(Config.get()).rejects.toThrow()
  195. },
  196. })
  197. })
  198. test("throws error for invalid JSON", async () => {
  199. await using tmp = await tmpdir({
  200. init: async (dir) => {
  201. await Bun.write(path.join(dir, "opencode.json"), "{ invalid json }")
  202. },
  203. })
  204. await Instance.provide({
  205. directory: tmp.path,
  206. fn: async () => {
  207. await expect(Config.get()).rejects.toThrow()
  208. },
  209. })
  210. })
  211. test("handles agent configuration", async () => {
  212. await using tmp = await tmpdir({
  213. init: async (dir) => {
  214. await writeConfig(dir, {
  215. $schema: "https://opencode.ai/config.json",
  216. agent: {
  217. test_agent: {
  218. model: "test/model",
  219. temperature: 0.7,
  220. description: "test agent",
  221. },
  222. },
  223. })
  224. },
  225. })
  226. await Instance.provide({
  227. directory: tmp.path,
  228. fn: async () => {
  229. const config = await Config.get()
  230. expect(config.agent?.["test_agent"]).toEqual(
  231. expect.objectContaining({
  232. model: "test/model",
  233. temperature: 0.7,
  234. description: "test agent",
  235. }),
  236. )
  237. },
  238. })
  239. })
  240. test("treats agent variant as model-scoped setting (not provider option)", async () => {
  241. await using tmp = await tmpdir({
  242. init: async (dir) => {
  243. await writeConfig(dir, {
  244. $schema: "https://opencode.ai/config.json",
  245. agent: {
  246. test_agent: {
  247. model: "openai/gpt-5.2",
  248. variant: "xhigh",
  249. max_tokens: 123,
  250. },
  251. },
  252. })
  253. },
  254. })
  255. await Instance.provide({
  256. directory: tmp.path,
  257. fn: async () => {
  258. const config = await Config.get()
  259. const agent = config.agent?.["test_agent"]
  260. expect(agent?.variant).toBe("xhigh")
  261. expect(agent?.options).toMatchObject({
  262. max_tokens: 123,
  263. })
  264. expect(agent?.options).not.toHaveProperty("variant")
  265. },
  266. })
  267. })
  268. test("handles command configuration", async () => {
  269. await using tmp = await tmpdir({
  270. init: async (dir) => {
  271. await writeConfig(dir, {
  272. $schema: "https://opencode.ai/config.json",
  273. command: {
  274. test_command: {
  275. template: "test template",
  276. description: "test command",
  277. agent: "test_agent",
  278. },
  279. },
  280. })
  281. },
  282. })
  283. await Instance.provide({
  284. directory: tmp.path,
  285. fn: async () => {
  286. const config = await Config.get()
  287. expect(config.command?.["test_command"]).toEqual({
  288. template: "test template",
  289. description: "test command",
  290. agent: "test_agent",
  291. })
  292. },
  293. })
  294. })
  295. test("migrates autoshare to share field", async () => {
  296. await using tmp = await tmpdir({
  297. init: async (dir) => {
  298. await Bun.write(
  299. path.join(dir, "opencode.json"),
  300. JSON.stringify({
  301. $schema: "https://opencode.ai/config.json",
  302. autoshare: true,
  303. }),
  304. )
  305. },
  306. })
  307. await Instance.provide({
  308. directory: tmp.path,
  309. fn: async () => {
  310. const config = await Config.get()
  311. expect(config.share).toBe("auto")
  312. expect(config.autoshare).toBe(true)
  313. },
  314. })
  315. })
  316. test("migrates mode field to agent field", async () => {
  317. await using tmp = await tmpdir({
  318. init: async (dir) => {
  319. await Bun.write(
  320. path.join(dir, "opencode.json"),
  321. JSON.stringify({
  322. $schema: "https://opencode.ai/config.json",
  323. mode: {
  324. test_mode: {
  325. model: "test/model",
  326. temperature: 0.5,
  327. },
  328. },
  329. }),
  330. )
  331. },
  332. })
  333. await Instance.provide({
  334. directory: tmp.path,
  335. fn: async () => {
  336. const config = await Config.get()
  337. expect(config.agent?.["test_mode"]).toEqual({
  338. model: "test/model",
  339. temperature: 0.5,
  340. mode: "primary",
  341. options: {},
  342. permission: {},
  343. })
  344. },
  345. })
  346. })
  347. test("loads config from .opencode directory", async () => {
  348. await using tmp = await tmpdir({
  349. init: async (dir) => {
  350. const opencodeDir = path.join(dir, ".opencode")
  351. await fs.mkdir(opencodeDir, { recursive: true })
  352. const agentDir = path.join(opencodeDir, "agent")
  353. await fs.mkdir(agentDir, { recursive: true })
  354. await Bun.write(
  355. path.join(agentDir, "test.md"),
  356. `---
  357. model: test/model
  358. ---
  359. Test agent prompt`,
  360. )
  361. },
  362. })
  363. await Instance.provide({
  364. directory: tmp.path,
  365. fn: async () => {
  366. const config = await Config.get()
  367. expect(config.agent?.["test"]).toEqual(
  368. expect.objectContaining({
  369. name: "test",
  370. model: "test/model",
  371. prompt: "Test agent prompt",
  372. }),
  373. )
  374. },
  375. })
  376. })
  377. test("loads agents from .opencode/agents (plural)", async () => {
  378. await using tmp = await tmpdir({
  379. init: async (dir) => {
  380. const opencodeDir = path.join(dir, ".opencode")
  381. await fs.mkdir(opencodeDir, { recursive: true })
  382. const agentsDir = path.join(opencodeDir, "agents")
  383. await fs.mkdir(path.join(agentsDir, "nested"), { recursive: true })
  384. await Bun.write(
  385. path.join(agentsDir, "helper.md"),
  386. `---
  387. model: test/model
  388. mode: subagent
  389. ---
  390. Helper agent prompt`,
  391. )
  392. await Bun.write(
  393. path.join(agentsDir, "nested", "child.md"),
  394. `---
  395. model: test/model
  396. mode: subagent
  397. ---
  398. Nested agent prompt`,
  399. )
  400. },
  401. })
  402. await Instance.provide({
  403. directory: tmp.path,
  404. fn: async () => {
  405. const config = await Config.get()
  406. expect(config.agent?.["helper"]).toMatchObject({
  407. name: "helper",
  408. model: "test/model",
  409. mode: "subagent",
  410. prompt: "Helper agent prompt",
  411. })
  412. expect(config.agent?.["nested/child"]).toMatchObject({
  413. name: "nested/child",
  414. model: "test/model",
  415. mode: "subagent",
  416. prompt: "Nested agent prompt",
  417. })
  418. },
  419. })
  420. })
  421. test("loads commands from .opencode/command (singular)", async () => {
  422. await using tmp = await tmpdir({
  423. init: async (dir) => {
  424. const opencodeDir = path.join(dir, ".opencode")
  425. await fs.mkdir(opencodeDir, { recursive: true })
  426. const commandDir = path.join(opencodeDir, "command")
  427. await fs.mkdir(path.join(commandDir, "nested"), { recursive: true })
  428. await Bun.write(
  429. path.join(commandDir, "hello.md"),
  430. `---
  431. description: Test command
  432. ---
  433. Hello from singular command`,
  434. )
  435. await Bun.write(
  436. path.join(commandDir, "nested", "child.md"),
  437. `---
  438. description: Nested command
  439. ---
  440. Nested command template`,
  441. )
  442. },
  443. })
  444. await Instance.provide({
  445. directory: tmp.path,
  446. fn: async () => {
  447. const config = await Config.get()
  448. expect(config.command?.["hello"]).toEqual({
  449. description: "Test command",
  450. template: "Hello from singular command",
  451. })
  452. expect(config.command?.["nested/child"]).toEqual({
  453. description: "Nested command",
  454. template: "Nested command template",
  455. })
  456. },
  457. })
  458. })
  459. test("loads commands from .opencode/commands (plural)", async () => {
  460. await using tmp = await tmpdir({
  461. init: async (dir) => {
  462. const opencodeDir = path.join(dir, ".opencode")
  463. await fs.mkdir(opencodeDir, { recursive: true })
  464. const commandsDir = path.join(opencodeDir, "commands")
  465. await fs.mkdir(path.join(commandsDir, "nested"), { recursive: true })
  466. await Bun.write(
  467. path.join(commandsDir, "hello.md"),
  468. `---
  469. description: Test command
  470. ---
  471. Hello from plural commands`,
  472. )
  473. await Bun.write(
  474. path.join(commandsDir, "nested", "child.md"),
  475. `---
  476. description: Nested command
  477. ---
  478. Nested command template`,
  479. )
  480. },
  481. })
  482. await Instance.provide({
  483. directory: tmp.path,
  484. fn: async () => {
  485. const config = await Config.get()
  486. expect(config.command?.["hello"]).toEqual({
  487. description: "Test command",
  488. template: "Hello from plural commands",
  489. })
  490. expect(config.command?.["nested/child"]).toEqual({
  491. description: "Nested command",
  492. template: "Nested command template",
  493. })
  494. },
  495. })
  496. })
  497. test("updates config and writes to file", async () => {
  498. await using tmp = await tmpdir()
  499. await Instance.provide({
  500. directory: tmp.path,
  501. fn: async () => {
  502. const newConfig = { model: "updated/model" }
  503. await Config.update(newConfig as any)
  504. const writtenConfig = JSON.parse(await Bun.file(path.join(tmp.path, "config.json")).text())
  505. expect(writtenConfig.model).toBe("updated/model")
  506. },
  507. })
  508. })
  509. test("gets config directories", async () => {
  510. await using tmp = await tmpdir()
  511. await Instance.provide({
  512. directory: tmp.path,
  513. fn: async () => {
  514. const dirs = await Config.directories()
  515. expect(dirs.length).toBeGreaterThanOrEqual(1)
  516. },
  517. })
  518. })
  519. test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", async () => {
  520. if (process.platform === "win32") return
  521. await using tmp = await tmpdir<string>({
  522. init: async (dir) => {
  523. const ro = path.join(dir, "readonly")
  524. await fs.mkdir(ro, { recursive: true })
  525. await fs.chmod(ro, 0o555)
  526. return ro
  527. },
  528. dispose: async (dir) => {
  529. const ro = path.join(dir, "readonly")
  530. await fs.chmod(ro, 0o755).catch(() => {})
  531. return ro
  532. },
  533. })
  534. const prev = process.env.OPENCODE_CONFIG_DIR
  535. process.env.OPENCODE_CONFIG_DIR = tmp.extra
  536. try {
  537. await Instance.provide({
  538. directory: tmp.path,
  539. fn: async () => {
  540. await Config.get()
  541. },
  542. })
  543. } finally {
  544. if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR
  545. else process.env.OPENCODE_CONFIG_DIR = prev
  546. }
  547. })
  548. test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
  549. await using tmp = await tmpdir<string>({
  550. init: async (dir) => {
  551. const cfg = path.join(dir, "configdir")
  552. await fs.mkdir(cfg, { recursive: true })
  553. return cfg
  554. },
  555. })
  556. const prev = process.env.OPENCODE_CONFIG_DIR
  557. process.env.OPENCODE_CONFIG_DIR = tmp.extra
  558. try {
  559. await Instance.provide({
  560. directory: tmp.path,
  561. fn: async () => {
  562. await Config.get()
  563. await Config.waitForDependencies()
  564. },
  565. })
  566. expect(await Bun.file(path.join(tmp.extra, "package.json")).exists()).toBe(true)
  567. expect(await Bun.file(path.join(tmp.extra, ".gitignore")).exists()).toBe(true)
  568. } finally {
  569. if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR
  570. else process.env.OPENCODE_CONFIG_DIR = prev
  571. }
  572. })
  573. test("resolves scoped npm plugins in config", async () => {
  574. await using tmp = await tmpdir({
  575. init: async (dir) => {
  576. const pluginDir = path.join(dir, "node_modules", "@scope", "plugin")
  577. await fs.mkdir(pluginDir, { recursive: true })
  578. await Bun.write(
  579. path.join(dir, "package.json"),
  580. JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2),
  581. )
  582. await Bun.write(
  583. path.join(pluginDir, "package.json"),
  584. JSON.stringify(
  585. {
  586. name: "@scope/plugin",
  587. version: "1.0.0",
  588. type: "module",
  589. main: "./index.js",
  590. },
  591. null,
  592. 2,
  593. ),
  594. )
  595. await Bun.write(path.join(pluginDir, "index.js"), "export default {}\n")
  596. await Bun.write(
  597. path.join(dir, "opencode.json"),
  598. JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] }, null, 2),
  599. )
  600. },
  601. })
  602. await Instance.provide({
  603. directory: tmp.path,
  604. fn: async () => {
  605. const config = await Config.get()
  606. const pluginEntries = config.plugin ?? []
  607. const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
  608. const expected = import.meta.resolve("@scope/plugin", baseUrl)
  609. expect(pluginEntries.includes(expected)).toBe(true)
  610. const scopedEntry = pluginEntries.find((entry) => entry === expected)
  611. expect(scopedEntry).toBeDefined()
  612. expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true)
  613. },
  614. })
  615. })
  616. test("merges plugin arrays from global and local configs", async () => {
  617. await using tmp = await tmpdir({
  618. init: async (dir) => {
  619. // Create a nested project structure with local .opencode config
  620. const projectDir = path.join(dir, "project")
  621. const opencodeDir = path.join(projectDir, ".opencode")
  622. await fs.mkdir(opencodeDir, { recursive: true })
  623. // Global config with plugins
  624. await Bun.write(
  625. path.join(dir, "opencode.json"),
  626. JSON.stringify({
  627. $schema: "https://opencode.ai/config.json",
  628. plugin: ["global-plugin-1", "global-plugin-2"],
  629. }),
  630. )
  631. // Local .opencode config with different plugins
  632. await Bun.write(
  633. path.join(opencodeDir, "opencode.json"),
  634. JSON.stringify({
  635. $schema: "https://opencode.ai/config.json",
  636. plugin: ["local-plugin-1"],
  637. }),
  638. )
  639. },
  640. })
  641. await Instance.provide({
  642. directory: path.join(tmp.path, "project"),
  643. fn: async () => {
  644. const config = await Config.get()
  645. const plugins = config.plugin ?? []
  646. // Should contain both global and local plugins
  647. expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
  648. expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true)
  649. expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
  650. // Should have all 3 plugins (not replaced, but merged)
  651. const pluginNames = plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin"))
  652. expect(pluginNames.length).toBeGreaterThanOrEqual(3)
  653. },
  654. })
  655. })
  656. test("does not error when only custom agent is a subagent", async () => {
  657. await using tmp = await tmpdir({
  658. init: async (dir) => {
  659. const opencodeDir = path.join(dir, ".opencode")
  660. await fs.mkdir(opencodeDir, { recursive: true })
  661. const agentDir = path.join(opencodeDir, "agent")
  662. await fs.mkdir(agentDir, { recursive: true })
  663. await Bun.write(
  664. path.join(agentDir, "helper.md"),
  665. `---
  666. model: test/model
  667. mode: subagent
  668. ---
  669. Helper subagent prompt`,
  670. )
  671. },
  672. })
  673. await Instance.provide({
  674. directory: tmp.path,
  675. fn: async () => {
  676. const config = await Config.get()
  677. expect(config.agent?.["helper"]).toMatchObject({
  678. name: "helper",
  679. model: "test/model",
  680. mode: "subagent",
  681. prompt: "Helper subagent prompt",
  682. })
  683. },
  684. })
  685. })
  686. test("merges instructions arrays from global and local configs", async () => {
  687. await using tmp = await tmpdir({
  688. init: async (dir) => {
  689. const projectDir = path.join(dir, "project")
  690. const opencodeDir = path.join(projectDir, ".opencode")
  691. await fs.mkdir(opencodeDir, { recursive: true })
  692. await Bun.write(
  693. path.join(dir, "opencode.json"),
  694. JSON.stringify({
  695. $schema: "https://opencode.ai/config.json",
  696. instructions: ["global-instructions.md", "shared-rules.md"],
  697. }),
  698. )
  699. await Bun.write(
  700. path.join(opencodeDir, "opencode.json"),
  701. JSON.stringify({
  702. $schema: "https://opencode.ai/config.json",
  703. instructions: ["local-instructions.md"],
  704. }),
  705. )
  706. },
  707. })
  708. await Instance.provide({
  709. directory: path.join(tmp.path, "project"),
  710. fn: async () => {
  711. const config = await Config.get()
  712. const instructions = config.instructions ?? []
  713. expect(instructions).toContain("global-instructions.md")
  714. expect(instructions).toContain("shared-rules.md")
  715. expect(instructions).toContain("local-instructions.md")
  716. expect(instructions.length).toBe(3)
  717. },
  718. })
  719. })
  720. test("deduplicates duplicate instructions from global and local configs", async () => {
  721. await using tmp = await tmpdir({
  722. init: async (dir) => {
  723. const projectDir = path.join(dir, "project")
  724. const opencodeDir = path.join(projectDir, ".opencode")
  725. await fs.mkdir(opencodeDir, { recursive: true })
  726. await Bun.write(
  727. path.join(dir, "opencode.json"),
  728. JSON.stringify({
  729. $schema: "https://opencode.ai/config.json",
  730. instructions: ["duplicate.md", "global-only.md"],
  731. }),
  732. )
  733. await Bun.write(
  734. path.join(opencodeDir, "opencode.json"),
  735. JSON.stringify({
  736. $schema: "https://opencode.ai/config.json",
  737. instructions: ["duplicate.md", "local-only.md"],
  738. }),
  739. )
  740. },
  741. })
  742. await Instance.provide({
  743. directory: path.join(tmp.path, "project"),
  744. fn: async () => {
  745. const config = await Config.get()
  746. const instructions = config.instructions ?? []
  747. expect(instructions).toContain("global-only.md")
  748. expect(instructions).toContain("local-only.md")
  749. expect(instructions).toContain("duplicate.md")
  750. const duplicates = instructions.filter((i) => i === "duplicate.md")
  751. expect(duplicates.length).toBe(1)
  752. expect(instructions.length).toBe(3)
  753. },
  754. })
  755. })
  756. test("deduplicates duplicate plugins from global and local configs", async () => {
  757. await using tmp = await tmpdir({
  758. init: async (dir) => {
  759. // Create a nested project structure with local .opencode config
  760. const projectDir = path.join(dir, "project")
  761. const opencodeDir = path.join(projectDir, ".opencode")
  762. await fs.mkdir(opencodeDir, { recursive: true })
  763. // Global config with plugins
  764. await Bun.write(
  765. path.join(dir, "opencode.json"),
  766. JSON.stringify({
  767. $schema: "https://opencode.ai/config.json",
  768. plugin: ["duplicate-plugin", "global-plugin-1"],
  769. }),
  770. )
  771. // Local .opencode config with some overlapping plugins
  772. await Bun.write(
  773. path.join(opencodeDir, "opencode.json"),
  774. JSON.stringify({
  775. $schema: "https://opencode.ai/config.json",
  776. plugin: ["duplicate-plugin", "local-plugin-1"],
  777. }),
  778. )
  779. },
  780. })
  781. await Instance.provide({
  782. directory: path.join(tmp.path, "project"),
  783. fn: async () => {
  784. const config = await Config.get()
  785. const plugins = config.plugin ?? []
  786. // Should contain all unique plugins
  787. expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
  788. expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
  789. expect(plugins.some((p) => p.includes("duplicate-plugin"))).toBe(true)
  790. // Should deduplicate the duplicate plugin
  791. const duplicatePlugins = plugins.filter((p) => p.includes("duplicate-plugin"))
  792. expect(duplicatePlugins.length).toBe(1)
  793. // Should have exactly 3 unique plugins
  794. const pluginNames = plugins.filter(
  795. (p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"),
  796. )
  797. expect(pluginNames.length).toBe(3)
  798. },
  799. })
  800. })
  801. // Legacy tools migration tests
  802. test("migrates legacy tools config to permissions - allow", async () => {
  803. await using tmp = await tmpdir({
  804. init: async (dir) => {
  805. await Bun.write(
  806. path.join(dir, "opencode.json"),
  807. JSON.stringify({
  808. $schema: "https://opencode.ai/config.json",
  809. agent: {
  810. test: {
  811. tools: {
  812. bash: true,
  813. read: true,
  814. },
  815. },
  816. },
  817. }),
  818. )
  819. },
  820. })
  821. await Instance.provide({
  822. directory: tmp.path,
  823. fn: async () => {
  824. const config = await Config.get()
  825. expect(config.agent?.["test"]?.permission).toEqual({
  826. bash: "allow",
  827. read: "allow",
  828. })
  829. },
  830. })
  831. })
  832. test("migrates legacy tools config to permissions - deny", async () => {
  833. await using tmp = await tmpdir({
  834. init: async (dir) => {
  835. await Bun.write(
  836. path.join(dir, "opencode.json"),
  837. JSON.stringify({
  838. $schema: "https://opencode.ai/config.json",
  839. agent: {
  840. test: {
  841. tools: {
  842. bash: false,
  843. webfetch: false,
  844. },
  845. },
  846. },
  847. }),
  848. )
  849. },
  850. })
  851. await Instance.provide({
  852. directory: tmp.path,
  853. fn: async () => {
  854. const config = await Config.get()
  855. expect(config.agent?.["test"]?.permission).toEqual({
  856. bash: "deny",
  857. webfetch: "deny",
  858. })
  859. },
  860. })
  861. })
  862. test("migrates legacy write tool to edit permission", async () => {
  863. await using tmp = await tmpdir({
  864. init: async (dir) => {
  865. await Bun.write(
  866. path.join(dir, "opencode.json"),
  867. JSON.stringify({
  868. $schema: "https://opencode.ai/config.json",
  869. agent: {
  870. test: {
  871. tools: {
  872. write: true,
  873. },
  874. },
  875. },
  876. }),
  877. )
  878. },
  879. })
  880. await Instance.provide({
  881. directory: tmp.path,
  882. fn: async () => {
  883. const config = await Config.get()
  884. expect(config.agent?.["test"]?.permission).toEqual({
  885. edit: "allow",
  886. })
  887. },
  888. })
  889. })
  890. // Managed settings tests
  891. // Note: preload.ts sets OPENCODE_TEST_MANAGED_CONFIG which Global.Path.managedConfig uses
  892. test("managed settings override user settings", async () => {
  893. await using tmp = await tmpdir({
  894. init: async (dir) => {
  895. await writeConfig(dir, {
  896. $schema: "https://opencode.ai/config.json",
  897. model: "user/model",
  898. share: "auto",
  899. username: "testuser",
  900. })
  901. },
  902. })
  903. await writeManagedSettings({
  904. $schema: "https://opencode.ai/config.json",
  905. model: "managed/model",
  906. share: "disabled",
  907. })
  908. await Instance.provide({
  909. directory: tmp.path,
  910. fn: async () => {
  911. const config = await Config.get()
  912. expect(config.model).toBe("managed/model")
  913. expect(config.share).toBe("disabled")
  914. expect(config.username).toBe("testuser")
  915. },
  916. })
  917. })
  918. test("managed settings override project settings", async () => {
  919. await using tmp = await tmpdir({
  920. init: async (dir) => {
  921. await writeConfig(dir, {
  922. $schema: "https://opencode.ai/config.json",
  923. autoupdate: true,
  924. disabled_providers: [],
  925. theme: "dark",
  926. })
  927. },
  928. })
  929. await writeManagedSettings({
  930. $schema: "https://opencode.ai/config.json",
  931. autoupdate: false,
  932. disabled_providers: ["openai"],
  933. })
  934. await Instance.provide({
  935. directory: tmp.path,
  936. fn: async () => {
  937. const config = await Config.get()
  938. expect(config.autoupdate).toBe(false)
  939. expect(config.disabled_providers).toEqual(["openai"])
  940. expect(config.theme).toBe("dark")
  941. },
  942. })
  943. })
  944. test("missing managed settings file is not an error", async () => {
  945. await using tmp = await tmpdir({
  946. init: async (dir) => {
  947. await writeConfig(dir, {
  948. $schema: "https://opencode.ai/config.json",
  949. model: "user/model",
  950. })
  951. },
  952. })
  953. await Instance.provide({
  954. directory: tmp.path,
  955. fn: async () => {
  956. const config = await Config.get()
  957. expect(config.model).toBe("user/model")
  958. },
  959. })
  960. })
  961. test("migrates legacy edit tool to edit permission", async () => {
  962. await using tmp = await tmpdir({
  963. init: async (dir) => {
  964. await Bun.write(
  965. path.join(dir, "opencode.json"),
  966. JSON.stringify({
  967. $schema: "https://opencode.ai/config.json",
  968. agent: {
  969. test: {
  970. tools: {
  971. edit: false,
  972. },
  973. },
  974. },
  975. }),
  976. )
  977. },
  978. })
  979. await Instance.provide({
  980. directory: tmp.path,
  981. fn: async () => {
  982. const config = await Config.get()
  983. expect(config.agent?.["test"]?.permission).toEqual({
  984. edit: "deny",
  985. })
  986. },
  987. })
  988. })
  989. test("migrates legacy patch tool to edit permission", async () => {
  990. await using tmp = await tmpdir({
  991. init: async (dir) => {
  992. await Bun.write(
  993. path.join(dir, "opencode.json"),
  994. JSON.stringify({
  995. $schema: "https://opencode.ai/config.json",
  996. agent: {
  997. test: {
  998. tools: {
  999. patch: true,
  1000. },
  1001. },
  1002. },
  1003. }),
  1004. )
  1005. },
  1006. })
  1007. await Instance.provide({
  1008. directory: tmp.path,
  1009. fn: async () => {
  1010. const config = await Config.get()
  1011. expect(config.agent?.["test"]?.permission).toEqual({
  1012. edit: "allow",
  1013. })
  1014. },
  1015. })
  1016. })
  1017. test("migrates legacy multiedit tool to edit permission", async () => {
  1018. await using tmp = await tmpdir({
  1019. init: async (dir) => {
  1020. await Bun.write(
  1021. path.join(dir, "opencode.json"),
  1022. JSON.stringify({
  1023. $schema: "https://opencode.ai/config.json",
  1024. agent: {
  1025. test: {
  1026. tools: {
  1027. multiedit: false,
  1028. },
  1029. },
  1030. },
  1031. }),
  1032. )
  1033. },
  1034. })
  1035. await Instance.provide({
  1036. directory: tmp.path,
  1037. fn: async () => {
  1038. const config = await Config.get()
  1039. expect(config.agent?.["test"]?.permission).toEqual({
  1040. edit: "deny",
  1041. })
  1042. },
  1043. })
  1044. })
  1045. test("migrates mixed legacy tools config", async () => {
  1046. await using tmp = await tmpdir({
  1047. init: async (dir) => {
  1048. await Bun.write(
  1049. path.join(dir, "opencode.json"),
  1050. JSON.stringify({
  1051. $schema: "https://opencode.ai/config.json",
  1052. agent: {
  1053. test: {
  1054. tools: {
  1055. bash: true,
  1056. write: true,
  1057. read: false,
  1058. webfetch: true,
  1059. },
  1060. },
  1061. },
  1062. }),
  1063. )
  1064. },
  1065. })
  1066. await Instance.provide({
  1067. directory: tmp.path,
  1068. fn: async () => {
  1069. const config = await Config.get()
  1070. expect(config.agent?.["test"]?.permission).toEqual({
  1071. bash: "allow",
  1072. edit: "allow",
  1073. read: "deny",
  1074. webfetch: "allow",
  1075. })
  1076. },
  1077. })
  1078. })
  1079. test("merges legacy tools with existing permission config", async () => {
  1080. await using tmp = await tmpdir({
  1081. init: async (dir) => {
  1082. await Bun.write(
  1083. path.join(dir, "opencode.json"),
  1084. JSON.stringify({
  1085. $schema: "https://opencode.ai/config.json",
  1086. agent: {
  1087. test: {
  1088. permission: {
  1089. glob: "allow",
  1090. },
  1091. tools: {
  1092. bash: true,
  1093. },
  1094. },
  1095. },
  1096. }),
  1097. )
  1098. },
  1099. })
  1100. await Instance.provide({
  1101. directory: tmp.path,
  1102. fn: async () => {
  1103. const config = await Config.get()
  1104. expect(config.agent?.["test"]?.permission).toEqual({
  1105. glob: "allow",
  1106. bash: "allow",
  1107. })
  1108. },
  1109. })
  1110. })
  1111. test("permission config preserves key order", async () => {
  1112. await using tmp = await tmpdir({
  1113. init: async (dir) => {
  1114. await Bun.write(
  1115. path.join(dir, "opencode.json"),
  1116. JSON.stringify({
  1117. $schema: "https://opencode.ai/config.json",
  1118. permission: {
  1119. "*": "deny",
  1120. edit: "ask",
  1121. write: "ask",
  1122. external_directory: "ask",
  1123. read: "allow",
  1124. todowrite: "allow",
  1125. todoread: "allow",
  1126. "thoughts_*": "allow",
  1127. "reasoning_model_*": "allow",
  1128. "tools_*": "allow",
  1129. "pr_comments_*": "allow",
  1130. },
  1131. }),
  1132. )
  1133. },
  1134. })
  1135. await Instance.provide({
  1136. directory: tmp.path,
  1137. fn: async () => {
  1138. const config = await Config.get()
  1139. expect(Object.keys(config.permission!)).toEqual([
  1140. "*",
  1141. "edit",
  1142. "write",
  1143. "external_directory",
  1144. "read",
  1145. "todowrite",
  1146. "todoread",
  1147. "thoughts_*",
  1148. "reasoning_model_*",
  1149. "tools_*",
  1150. "pr_comments_*",
  1151. ])
  1152. },
  1153. })
  1154. })
  1155. // MCP config merging tests
  1156. test("project config can override MCP server enabled status", async () => {
  1157. await using tmp = await tmpdir({
  1158. init: async (dir) => {
  1159. // Simulates a base config (like from remote .well-known) with disabled MCP
  1160. await Bun.write(
  1161. path.join(dir, "opencode.jsonc"),
  1162. JSON.stringify({
  1163. $schema: "https://opencode.ai/config.json",
  1164. mcp: {
  1165. jira: {
  1166. type: "remote",
  1167. url: "https://jira.example.com/mcp",
  1168. enabled: false,
  1169. },
  1170. wiki: {
  1171. type: "remote",
  1172. url: "https://wiki.example.com/mcp",
  1173. enabled: false,
  1174. },
  1175. },
  1176. }),
  1177. )
  1178. // Project config enables just jira
  1179. await Bun.write(
  1180. path.join(dir, "opencode.json"),
  1181. JSON.stringify({
  1182. $schema: "https://opencode.ai/config.json",
  1183. mcp: {
  1184. jira: {
  1185. type: "remote",
  1186. url: "https://jira.example.com/mcp",
  1187. enabled: true,
  1188. },
  1189. },
  1190. }),
  1191. )
  1192. },
  1193. })
  1194. await Instance.provide({
  1195. directory: tmp.path,
  1196. fn: async () => {
  1197. const config = await Config.get()
  1198. // jira should be enabled (overridden by project config)
  1199. expect(config.mcp?.jira).toEqual({
  1200. type: "remote",
  1201. url: "https://jira.example.com/mcp",
  1202. enabled: true,
  1203. })
  1204. // wiki should still be disabled (not overridden)
  1205. expect(config.mcp?.wiki).toEqual({
  1206. type: "remote",
  1207. url: "https://wiki.example.com/mcp",
  1208. enabled: false,
  1209. })
  1210. },
  1211. })
  1212. })
  1213. test("MCP config deep merges preserving base config properties", async () => {
  1214. await using tmp = await tmpdir({
  1215. init: async (dir) => {
  1216. // Base config with full MCP definition
  1217. await Bun.write(
  1218. path.join(dir, "opencode.jsonc"),
  1219. JSON.stringify({
  1220. $schema: "https://opencode.ai/config.json",
  1221. mcp: {
  1222. myserver: {
  1223. type: "remote",
  1224. url: "https://myserver.example.com/mcp",
  1225. enabled: false,
  1226. headers: {
  1227. "X-Custom-Header": "value",
  1228. },
  1229. },
  1230. },
  1231. }),
  1232. )
  1233. // Override just enables it, should preserve other properties
  1234. await Bun.write(
  1235. path.join(dir, "opencode.json"),
  1236. JSON.stringify({
  1237. $schema: "https://opencode.ai/config.json",
  1238. mcp: {
  1239. myserver: {
  1240. type: "remote",
  1241. url: "https://myserver.example.com/mcp",
  1242. enabled: true,
  1243. },
  1244. },
  1245. }),
  1246. )
  1247. },
  1248. })
  1249. await Instance.provide({
  1250. directory: tmp.path,
  1251. fn: async () => {
  1252. const config = await Config.get()
  1253. expect(config.mcp?.myserver).toEqual({
  1254. type: "remote",
  1255. url: "https://myserver.example.com/mcp",
  1256. enabled: true,
  1257. headers: {
  1258. "X-Custom-Header": "value",
  1259. },
  1260. })
  1261. },
  1262. })
  1263. })
  1264. test("local .opencode config can override MCP from project config", async () => {
  1265. await using tmp = await tmpdir({
  1266. init: async (dir) => {
  1267. // Project config with disabled MCP
  1268. await Bun.write(
  1269. path.join(dir, "opencode.json"),
  1270. JSON.stringify({
  1271. $schema: "https://opencode.ai/config.json",
  1272. mcp: {
  1273. docs: {
  1274. type: "remote",
  1275. url: "https://docs.example.com/mcp",
  1276. enabled: false,
  1277. },
  1278. },
  1279. }),
  1280. )
  1281. // Local .opencode directory config enables it
  1282. const opencodeDir = path.join(dir, ".opencode")
  1283. await fs.mkdir(opencodeDir, { recursive: true })
  1284. await Bun.write(
  1285. path.join(opencodeDir, "opencode.json"),
  1286. JSON.stringify({
  1287. $schema: "https://opencode.ai/config.json",
  1288. mcp: {
  1289. docs: {
  1290. type: "remote",
  1291. url: "https://docs.example.com/mcp",
  1292. enabled: true,
  1293. },
  1294. },
  1295. }),
  1296. )
  1297. },
  1298. })
  1299. await Instance.provide({
  1300. directory: tmp.path,
  1301. fn: async () => {
  1302. const config = await Config.get()
  1303. expect(config.mcp?.docs?.enabled).toBe(true)
  1304. },
  1305. })
  1306. })
  1307. test("project config overrides remote well-known config", async () => {
  1308. const originalFetch = globalThis.fetch
  1309. let fetchedUrl: string | undefined
  1310. const mockFetch = mock((url: string | URL | Request) => {
  1311. const urlStr = url.toString()
  1312. if (urlStr.includes(".well-known/opencode")) {
  1313. fetchedUrl = urlStr
  1314. return Promise.resolve(
  1315. new Response(
  1316. JSON.stringify({
  1317. config: {
  1318. mcp: {
  1319. jira: {
  1320. type: "remote",
  1321. url: "https://jira.example.com/mcp",
  1322. enabled: false,
  1323. },
  1324. },
  1325. },
  1326. }),
  1327. { status: 200 },
  1328. ),
  1329. )
  1330. }
  1331. return originalFetch(url)
  1332. })
  1333. globalThis.fetch = mockFetch as unknown as typeof fetch
  1334. const originalAuthAll = Auth.all
  1335. Auth.all = mock(() =>
  1336. Promise.resolve({
  1337. "https://example.com": {
  1338. type: "wellknown" as const,
  1339. key: "TEST_TOKEN",
  1340. token: "test-token",
  1341. },
  1342. }),
  1343. )
  1344. try {
  1345. await using tmp = await tmpdir({
  1346. git: true,
  1347. init: async (dir) => {
  1348. // Project config enables jira (overriding remote default)
  1349. await Bun.write(
  1350. path.join(dir, "opencode.json"),
  1351. JSON.stringify({
  1352. $schema: "https://opencode.ai/config.json",
  1353. mcp: {
  1354. jira: {
  1355. type: "remote",
  1356. url: "https://jira.example.com/mcp",
  1357. enabled: true,
  1358. },
  1359. },
  1360. }),
  1361. )
  1362. },
  1363. })
  1364. await Instance.provide({
  1365. directory: tmp.path,
  1366. fn: async () => {
  1367. const config = await Config.get()
  1368. // Verify fetch was called for wellknown config
  1369. expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
  1370. // Project config (enabled: true) should override remote (enabled: false)
  1371. expect(config.mcp?.jira?.enabled).toBe(true)
  1372. },
  1373. })
  1374. } finally {
  1375. globalThis.fetch = originalFetch
  1376. Auth.all = originalAuthAll
  1377. }
  1378. })
  1379. describe("getPluginName", () => {
  1380. test("extracts name from file:// URL", () => {
  1381. expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo")
  1382. expect(Config.getPluginName("file:///path/to/plugin/bar.ts")).toBe("bar")
  1383. expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin")
  1384. })
  1385. test("extracts name from npm package with version", () => {
  1386. expect(Config.getPluginName("[email protected]")).toBe("oh-my-opencode")
  1387. expect(Config.getPluginName("[email protected]")).toBe("some-plugin")
  1388. expect(Config.getPluginName("plugin@latest")).toBe("plugin")
  1389. })
  1390. test("extracts name from scoped npm package", () => {
  1391. expect(Config.getPluginName("@scope/[email protected]")).toBe("@scope/pkg")
  1392. expect(Config.getPluginName("@opencode/[email protected]")).toBe("@opencode/plugin")
  1393. })
  1394. test("returns full string for package without version", () => {
  1395. expect(Config.getPluginName("some-plugin")).toBe("some-plugin")
  1396. expect(Config.getPluginName("@scope/pkg")).toBe("@scope/pkg")
  1397. })
  1398. })
  1399. describe("deduplicatePlugins", () => {
  1400. test("removes duplicates keeping higher priority (later entries)", () => {
  1401. const plugins = ["[email protected]", "[email protected]", "[email protected]", "[email protected]"]
  1402. const result = Config.deduplicatePlugins(plugins)
  1403. expect(result).toContain("[email protected]")
  1404. expect(result).toContain("[email protected]")
  1405. expect(result).toContain("[email protected]")
  1406. expect(result).not.toContain("[email protected]")
  1407. expect(result.length).toBe(3)
  1408. })
  1409. test("prefers local file over npm package with same name", () => {
  1410. const plugins = ["[email protected]", "file:///project/.opencode/plugin/oh-my-opencode.js"]
  1411. const result = Config.deduplicatePlugins(plugins)
  1412. expect(result.length).toBe(1)
  1413. expect(result[0]).toBe("file:///project/.opencode/plugin/oh-my-opencode.js")
  1414. })
  1415. test("preserves order of remaining plugins", () => {
  1416. const plugins = ["[email protected]", "[email protected]", "[email protected]"]
  1417. const result = Config.deduplicatePlugins(plugins)
  1418. expect(result).toEqual(["[email protected]", "[email protected]", "[email protected]"])
  1419. })
  1420. test("local plugin directory overrides global opencode.json plugin", async () => {
  1421. await using tmp = await tmpdir({
  1422. init: async (dir) => {
  1423. const projectDir = path.join(dir, "project")
  1424. const opencodeDir = path.join(projectDir, ".opencode")
  1425. const pluginDir = path.join(opencodeDir, "plugin")
  1426. await fs.mkdir(pluginDir, { recursive: true })
  1427. await Bun.write(
  1428. path.join(dir, "opencode.json"),
  1429. JSON.stringify({
  1430. $schema: "https://opencode.ai/config.json",
  1431. plugin: ["[email protected]"],
  1432. }),
  1433. )
  1434. await Bun.write(path.join(pluginDir, "my-plugin.js"), "export default {}")
  1435. },
  1436. })
  1437. await Instance.provide({
  1438. directory: path.join(tmp.path, "project"),
  1439. fn: async () => {
  1440. const config = await Config.get()
  1441. const plugins = config.plugin ?? []
  1442. const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin")
  1443. expect(myPlugins.length).toBe(1)
  1444. expect(myPlugins[0].startsWith("file://")).toBe(true)
  1445. },
  1446. })
  1447. })
  1448. })
  1449. describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
  1450. test("skips project config files when flag is set", async () => {
  1451. const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1452. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
  1453. try {
  1454. await using tmp = await tmpdir({
  1455. init: async (dir) => {
  1456. // Create a project config that would normally be loaded
  1457. await Bun.write(
  1458. path.join(dir, "opencode.json"),
  1459. JSON.stringify({
  1460. $schema: "https://opencode.ai/config.json",
  1461. model: "project/model",
  1462. username: "project-user",
  1463. }),
  1464. )
  1465. },
  1466. })
  1467. await Instance.provide({
  1468. directory: tmp.path,
  1469. fn: async () => {
  1470. const config = await Config.get()
  1471. // Project config should NOT be loaded - model should be default, not "project/model"
  1472. expect(config.model).not.toBe("project/model")
  1473. expect(config.username).not.toBe("project-user")
  1474. },
  1475. })
  1476. } finally {
  1477. if (originalEnv === undefined) {
  1478. delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1479. } else {
  1480. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalEnv
  1481. }
  1482. }
  1483. })
  1484. test("skips project .opencode/ directories when flag is set", async () => {
  1485. const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1486. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
  1487. try {
  1488. await using tmp = await tmpdir({
  1489. init: async (dir) => {
  1490. // Create a .opencode directory with a command
  1491. const opencodeDir = path.join(dir, ".opencode", "command")
  1492. await fs.mkdir(opencodeDir, { recursive: true })
  1493. await Bun.write(path.join(opencodeDir, "test-cmd.md"), "# Test Command\nThis is a test command.")
  1494. },
  1495. })
  1496. await Instance.provide({
  1497. directory: tmp.path,
  1498. fn: async () => {
  1499. const directories = await Config.directories()
  1500. // Project .opencode should NOT be in directories list
  1501. const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path))
  1502. expect(hasProjectOpencode).toBe(false)
  1503. },
  1504. })
  1505. } finally {
  1506. if (originalEnv === undefined) {
  1507. delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1508. } else {
  1509. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalEnv
  1510. }
  1511. }
  1512. })
  1513. test("still loads global config when flag is set", async () => {
  1514. const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1515. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
  1516. try {
  1517. await using tmp = await tmpdir()
  1518. await Instance.provide({
  1519. directory: tmp.path,
  1520. fn: async () => {
  1521. // Should still get default config (from global or defaults)
  1522. const config = await Config.get()
  1523. expect(config).toBeDefined()
  1524. expect(config.username).toBeDefined()
  1525. },
  1526. })
  1527. } finally {
  1528. if (originalEnv === undefined) {
  1529. delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1530. } else {
  1531. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalEnv
  1532. }
  1533. }
  1534. })
  1535. test("skips relative instructions with warning when flag is set but no config dir", async () => {
  1536. const originalDisable = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1537. const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
  1538. try {
  1539. // Ensure no config dir is set
  1540. delete process.env["OPENCODE_CONFIG_DIR"]
  1541. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
  1542. await using tmp = await tmpdir({
  1543. init: async (dir) => {
  1544. // Create a config with relative instruction path
  1545. await Bun.write(
  1546. path.join(dir, "opencode.json"),
  1547. JSON.stringify({
  1548. $schema: "https://opencode.ai/config.json",
  1549. instructions: ["./CUSTOM.md"],
  1550. }),
  1551. )
  1552. // Create the instruction file (should be skipped)
  1553. await Bun.write(path.join(dir, "CUSTOM.md"), "# Custom Instructions")
  1554. },
  1555. })
  1556. await Instance.provide({
  1557. directory: tmp.path,
  1558. fn: async () => {
  1559. // The relative instruction should be skipped without error
  1560. // We're mainly verifying this doesn't throw and the config loads
  1561. const config = await Config.get()
  1562. expect(config).toBeDefined()
  1563. // The instruction should have been skipped (warning logged)
  1564. // We can't easily test the warning was logged, but we verify
  1565. // the relative path didn't cause an error
  1566. },
  1567. })
  1568. } finally {
  1569. if (originalDisable === undefined) {
  1570. delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1571. } else {
  1572. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalDisable
  1573. }
  1574. if (originalConfigDir === undefined) {
  1575. delete process.env["OPENCODE_CONFIG_DIR"]
  1576. } else {
  1577. process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
  1578. }
  1579. }
  1580. })
  1581. test("OPENCODE_CONFIG_DIR still works when flag is set", async () => {
  1582. const originalDisable = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1583. const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
  1584. try {
  1585. await using configDirTmp = await tmpdir({
  1586. init: async (dir) => {
  1587. // Create config in the custom config dir
  1588. await Bun.write(
  1589. path.join(dir, "opencode.json"),
  1590. JSON.stringify({
  1591. $schema: "https://opencode.ai/config.json",
  1592. model: "configdir/model",
  1593. }),
  1594. )
  1595. },
  1596. })
  1597. await using projectTmp = await tmpdir({
  1598. init: async (dir) => {
  1599. // Create config in project (should be ignored)
  1600. await Bun.write(
  1601. path.join(dir, "opencode.json"),
  1602. JSON.stringify({
  1603. $schema: "https://opencode.ai/config.json",
  1604. model: "project/model",
  1605. }),
  1606. )
  1607. },
  1608. })
  1609. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
  1610. process.env["OPENCODE_CONFIG_DIR"] = configDirTmp.path
  1611. await Instance.provide({
  1612. directory: projectTmp.path,
  1613. fn: async () => {
  1614. const config = await Config.get()
  1615. // Should load from OPENCODE_CONFIG_DIR, not project
  1616. expect(config.model).toBe("configdir/model")
  1617. },
  1618. })
  1619. } finally {
  1620. if (originalDisable === undefined) {
  1621. delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1622. } else {
  1623. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalDisable
  1624. }
  1625. if (originalConfigDir === undefined) {
  1626. delete process.env["OPENCODE_CONFIG_DIR"]
  1627. } else {
  1628. process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
  1629. }
  1630. }
  1631. })
  1632. })