config.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789
  1. import { test, expect } from "bun:test"
  2. import { Config } from "../../src/config/config"
  3. import { Instance } from "../../src/project/instance"
  4. import { tmpdir } from "../fixture/fixture"
  5. import path from "path"
  6. import fs from "fs/promises"
  7. import { pathToFileURL } from "url"
  8. test("loads config with defaults when no files exist", async () => {
  9. await using tmp = await tmpdir()
  10. await Instance.provide({
  11. directory: tmp.path,
  12. fn: async () => {
  13. const config = await Config.get()
  14. expect(config.username).toBeDefined()
  15. },
  16. })
  17. })
  18. test("loads JSON config file", async () => {
  19. await using tmp = await tmpdir({
  20. init: async (dir) => {
  21. await Bun.write(
  22. path.join(dir, "opencode.json"),
  23. JSON.stringify({
  24. $schema: "https://opencode.ai/config.json",
  25. model: "test/model",
  26. username: "testuser",
  27. }),
  28. )
  29. },
  30. })
  31. await Instance.provide({
  32. directory: tmp.path,
  33. fn: async () => {
  34. const config = await Config.get()
  35. expect(config.model).toBe("test/model")
  36. expect(config.username).toBe("testuser")
  37. },
  38. })
  39. })
  40. test("loads JSONC config file", async () => {
  41. await using tmp = await tmpdir({
  42. init: async (dir) => {
  43. await Bun.write(
  44. path.join(dir, "opencode.jsonc"),
  45. `{
  46. // This is a comment
  47. "$schema": "https://opencode.ai/config.json",
  48. "model": "test/model",
  49. "username": "testuser"
  50. }`,
  51. )
  52. },
  53. })
  54. await Instance.provide({
  55. directory: tmp.path,
  56. fn: async () => {
  57. const config = await Config.get()
  58. expect(config.model).toBe("test/model")
  59. expect(config.username).toBe("testuser")
  60. },
  61. })
  62. })
  63. test("merges multiple config files with correct precedence", async () => {
  64. await using tmp = await tmpdir({
  65. init: async (dir) => {
  66. await Bun.write(
  67. path.join(dir, "opencode.jsonc"),
  68. JSON.stringify({
  69. $schema: "https://opencode.ai/config.json",
  70. model: "base",
  71. username: "base",
  72. }),
  73. )
  74. await Bun.write(
  75. path.join(dir, "opencode.json"),
  76. JSON.stringify({
  77. $schema: "https://opencode.ai/config.json",
  78. model: "override",
  79. }),
  80. )
  81. },
  82. })
  83. await Instance.provide({
  84. directory: tmp.path,
  85. fn: async () => {
  86. const config = await Config.get()
  87. expect(config.model).toBe("override")
  88. expect(config.username).toBe("base")
  89. },
  90. })
  91. })
  92. test("handles environment variable substitution", async () => {
  93. const originalEnv = process.env["TEST_VAR"]
  94. process.env["TEST_VAR"] = "test_theme"
  95. try {
  96. await using tmp = await tmpdir({
  97. init: async (dir) => {
  98. await Bun.write(
  99. path.join(dir, "opencode.json"),
  100. JSON.stringify({
  101. $schema: "https://opencode.ai/config.json",
  102. theme: "{env:TEST_VAR}",
  103. }),
  104. )
  105. },
  106. })
  107. await Instance.provide({
  108. directory: tmp.path,
  109. fn: async () => {
  110. const config = await Config.get()
  111. expect(config.theme).toBe("test_theme")
  112. },
  113. })
  114. } finally {
  115. if (originalEnv !== undefined) {
  116. process.env["TEST_VAR"] = originalEnv
  117. } else {
  118. delete process.env["TEST_VAR"]
  119. }
  120. }
  121. })
  122. test("handles file inclusion substitution", async () => {
  123. await using tmp = await tmpdir({
  124. init: async (dir) => {
  125. await Bun.write(path.join(dir, "included.txt"), "test_theme")
  126. await Bun.write(
  127. path.join(dir, "opencode.json"),
  128. JSON.stringify({
  129. $schema: "https://opencode.ai/config.json",
  130. theme: "{file:included.txt}",
  131. }),
  132. )
  133. },
  134. })
  135. await Instance.provide({
  136. directory: tmp.path,
  137. fn: async () => {
  138. const config = await Config.get()
  139. expect(config.theme).toBe("test_theme")
  140. },
  141. })
  142. })
  143. test("validates config schema and throws on invalid fields", async () => {
  144. await using tmp = await tmpdir({
  145. init: async (dir) => {
  146. await Bun.write(
  147. path.join(dir, "opencode.json"),
  148. JSON.stringify({
  149. $schema: "https://opencode.ai/config.json",
  150. invalid_field: "should cause error",
  151. }),
  152. )
  153. },
  154. })
  155. await Instance.provide({
  156. directory: tmp.path,
  157. fn: async () => {
  158. // Strict schema should throw an error for invalid fields
  159. await expect(Config.get()).rejects.toThrow()
  160. },
  161. })
  162. })
  163. test("throws error for invalid JSON", async () => {
  164. await using tmp = await tmpdir({
  165. init: async (dir) => {
  166. await Bun.write(path.join(dir, "opencode.json"), "{ invalid json }")
  167. },
  168. })
  169. await Instance.provide({
  170. directory: tmp.path,
  171. fn: async () => {
  172. await expect(Config.get()).rejects.toThrow()
  173. },
  174. })
  175. })
  176. test("handles agent configuration", async () => {
  177. await using tmp = await tmpdir({
  178. init: async (dir) => {
  179. await Bun.write(
  180. path.join(dir, "opencode.json"),
  181. JSON.stringify({
  182. $schema: "https://opencode.ai/config.json",
  183. agent: {
  184. test_agent: {
  185. model: "test/model",
  186. temperature: 0.7,
  187. description: "test agent",
  188. },
  189. },
  190. }),
  191. )
  192. },
  193. })
  194. await Instance.provide({
  195. directory: tmp.path,
  196. fn: async () => {
  197. const config = await Config.get()
  198. expect(config.agent?.["test_agent"]).toEqual(
  199. expect.objectContaining({
  200. model: "test/model",
  201. temperature: 0.7,
  202. description: "test agent",
  203. }),
  204. )
  205. },
  206. })
  207. })
  208. test("handles command configuration", async () => {
  209. await using tmp = await tmpdir({
  210. init: async (dir) => {
  211. await Bun.write(
  212. path.join(dir, "opencode.json"),
  213. JSON.stringify({
  214. $schema: "https://opencode.ai/config.json",
  215. command: {
  216. test_command: {
  217. template: "test template",
  218. description: "test command",
  219. agent: "test_agent",
  220. },
  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.command?.["test_command"]).toEqual({
  231. template: "test template",
  232. description: "test command",
  233. agent: "test_agent",
  234. })
  235. },
  236. })
  237. })
  238. test("migrates autoshare to share field", async () => {
  239. await using tmp = await tmpdir({
  240. init: async (dir) => {
  241. await Bun.write(
  242. path.join(dir, "opencode.json"),
  243. JSON.stringify({
  244. $schema: "https://opencode.ai/config.json",
  245. autoshare: true,
  246. }),
  247. )
  248. },
  249. })
  250. await Instance.provide({
  251. directory: tmp.path,
  252. fn: async () => {
  253. const config = await Config.get()
  254. expect(config.share).toBe("auto")
  255. expect(config.autoshare).toBe(true)
  256. },
  257. })
  258. })
  259. test("migrates mode field to agent field", async () => {
  260. await using tmp = await tmpdir({
  261. init: async (dir) => {
  262. await Bun.write(
  263. path.join(dir, "opencode.json"),
  264. JSON.stringify({
  265. $schema: "https://opencode.ai/config.json",
  266. mode: {
  267. test_mode: {
  268. model: "test/model",
  269. temperature: 0.5,
  270. },
  271. },
  272. }),
  273. )
  274. },
  275. })
  276. await Instance.provide({
  277. directory: tmp.path,
  278. fn: async () => {
  279. const config = await Config.get()
  280. expect(config.agent?.["test_mode"]).toEqual({
  281. model: "test/model",
  282. temperature: 0.5,
  283. mode: "primary",
  284. options: {},
  285. permission: {},
  286. })
  287. },
  288. })
  289. })
  290. test("loads config from .opencode directory", async () => {
  291. await using tmp = await tmpdir({
  292. init: async (dir) => {
  293. const opencodeDir = path.join(dir, ".opencode")
  294. await fs.mkdir(opencodeDir, { recursive: true })
  295. const agentDir = path.join(opencodeDir, "agent")
  296. await fs.mkdir(agentDir, { recursive: true })
  297. await Bun.write(
  298. path.join(agentDir, "test.md"),
  299. `---
  300. model: test/model
  301. ---
  302. Test agent prompt`,
  303. )
  304. },
  305. })
  306. await Instance.provide({
  307. directory: tmp.path,
  308. fn: async () => {
  309. const config = await Config.get()
  310. expect(config.agent?.["test"]).toEqual(
  311. expect.objectContaining({
  312. name: "test",
  313. model: "test/model",
  314. prompt: "Test agent prompt",
  315. }),
  316. )
  317. },
  318. })
  319. })
  320. test("updates config and writes to file", async () => {
  321. await using tmp = await tmpdir()
  322. await Instance.provide({
  323. directory: tmp.path,
  324. fn: async () => {
  325. const newConfig = { model: "updated/model" }
  326. await Config.update(newConfig as any)
  327. const writtenConfig = JSON.parse(await Bun.file(path.join(tmp.path, "config.json")).text())
  328. expect(writtenConfig.model).toBe("updated/model")
  329. },
  330. })
  331. })
  332. test("gets config directories", async () => {
  333. await using tmp = await tmpdir()
  334. await Instance.provide({
  335. directory: tmp.path,
  336. fn: async () => {
  337. const dirs = await Config.directories()
  338. expect(dirs.length).toBeGreaterThanOrEqual(1)
  339. },
  340. })
  341. })
  342. test("resolves scoped npm plugins in config", async () => {
  343. await using tmp = await tmpdir({
  344. init: async (dir) => {
  345. const pluginDir = path.join(dir, "node_modules", "@scope", "plugin")
  346. await fs.mkdir(pluginDir, { recursive: true })
  347. await Bun.write(
  348. path.join(dir, "package.json"),
  349. JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2),
  350. )
  351. await Bun.write(
  352. path.join(pluginDir, "package.json"),
  353. JSON.stringify(
  354. {
  355. name: "@scope/plugin",
  356. version: "1.0.0",
  357. type: "module",
  358. main: "./index.js",
  359. },
  360. null,
  361. 2,
  362. ),
  363. )
  364. await Bun.write(path.join(pluginDir, "index.js"), "export default {}\n")
  365. await Bun.write(
  366. path.join(dir, "opencode.json"),
  367. JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] }, null, 2),
  368. )
  369. },
  370. })
  371. await Instance.provide({
  372. directory: tmp.path,
  373. fn: async () => {
  374. const config = await Config.get()
  375. const pluginEntries = config.plugin ?? []
  376. const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
  377. const expected = import.meta.resolve("@scope/plugin", baseUrl)
  378. expect(pluginEntries.includes(expected)).toBe(true)
  379. const scopedEntry = pluginEntries.find((entry) => entry === expected)
  380. expect(scopedEntry).toBeDefined()
  381. expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true)
  382. },
  383. })
  384. })
  385. test("merges plugin arrays from global and local configs", async () => {
  386. await using tmp = await tmpdir({
  387. init: async (dir) => {
  388. // Create a nested project structure with local .opencode config
  389. const projectDir = path.join(dir, "project")
  390. const opencodeDir = path.join(projectDir, ".opencode")
  391. await fs.mkdir(opencodeDir, { recursive: true })
  392. // Global config with plugins
  393. await Bun.write(
  394. path.join(dir, "opencode.json"),
  395. JSON.stringify({
  396. $schema: "https://opencode.ai/config.json",
  397. plugin: ["global-plugin-1", "global-plugin-2"],
  398. }),
  399. )
  400. // Local .opencode config with different plugins
  401. await Bun.write(
  402. path.join(opencodeDir, "opencode.json"),
  403. JSON.stringify({
  404. $schema: "https://opencode.ai/config.json",
  405. plugin: ["local-plugin-1"],
  406. }),
  407. )
  408. },
  409. })
  410. await Instance.provide({
  411. directory: path.join(tmp.path, "project"),
  412. fn: async () => {
  413. const config = await Config.get()
  414. const plugins = config.plugin ?? []
  415. // Should contain both global and local plugins
  416. expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
  417. expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true)
  418. expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
  419. // Should have all 3 plugins (not replaced, but merged)
  420. const pluginNames = plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin"))
  421. expect(pluginNames.length).toBeGreaterThanOrEqual(3)
  422. },
  423. })
  424. })
  425. test("does not error when only custom agent is a subagent", async () => {
  426. await using tmp = await tmpdir({
  427. init: async (dir) => {
  428. const opencodeDir = path.join(dir, ".opencode")
  429. await fs.mkdir(opencodeDir, { recursive: true })
  430. const agentDir = path.join(opencodeDir, "agent")
  431. await fs.mkdir(agentDir, { recursive: true })
  432. await Bun.write(
  433. path.join(agentDir, "helper.md"),
  434. `---
  435. model: test/model
  436. mode: subagent
  437. ---
  438. Helper subagent prompt`,
  439. )
  440. },
  441. })
  442. await Instance.provide({
  443. directory: tmp.path,
  444. fn: async () => {
  445. const config = await Config.get()
  446. expect(config.agent?.["helper"]).toMatchObject({
  447. name: "helper",
  448. model: "test/model",
  449. mode: "subagent",
  450. prompt: "Helper subagent prompt",
  451. })
  452. },
  453. })
  454. })
  455. test("deduplicates duplicate plugins from global and local configs", async () => {
  456. await using tmp = await tmpdir({
  457. init: async (dir) => {
  458. // Create a nested project structure with local .opencode config
  459. const projectDir = path.join(dir, "project")
  460. const opencodeDir = path.join(projectDir, ".opencode")
  461. await fs.mkdir(opencodeDir, { recursive: true })
  462. // Global config with plugins
  463. await Bun.write(
  464. path.join(dir, "opencode.json"),
  465. JSON.stringify({
  466. $schema: "https://opencode.ai/config.json",
  467. plugin: ["duplicate-plugin", "global-plugin-1"],
  468. }),
  469. )
  470. // Local .opencode config with some overlapping plugins
  471. await Bun.write(
  472. path.join(opencodeDir, "opencode.json"),
  473. JSON.stringify({
  474. $schema: "https://opencode.ai/config.json",
  475. plugin: ["duplicate-plugin", "local-plugin-1"],
  476. }),
  477. )
  478. },
  479. })
  480. await Instance.provide({
  481. directory: path.join(tmp.path, "project"),
  482. fn: async () => {
  483. const config = await Config.get()
  484. const plugins = config.plugin ?? []
  485. // Should contain all unique plugins
  486. expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
  487. expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
  488. expect(plugins.some((p) => p.includes("duplicate-plugin"))).toBe(true)
  489. // Should deduplicate the duplicate plugin
  490. const duplicatePlugins = plugins.filter((p) => p.includes("duplicate-plugin"))
  491. expect(duplicatePlugins.length).toBe(1)
  492. // Should have exactly 3 unique plugins
  493. const pluginNames = plugins.filter(
  494. (p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"),
  495. )
  496. expect(pluginNames.length).toBe(3)
  497. },
  498. })
  499. })
  500. // Legacy tools migration tests
  501. test("migrates legacy tools config to permissions - allow", async () => {
  502. await using tmp = await tmpdir({
  503. init: async (dir) => {
  504. await Bun.write(
  505. path.join(dir, "opencode.json"),
  506. JSON.stringify({
  507. $schema: "https://opencode.ai/config.json",
  508. agent: {
  509. test: {
  510. tools: {
  511. bash: true,
  512. read: true,
  513. },
  514. },
  515. },
  516. }),
  517. )
  518. },
  519. })
  520. await Instance.provide({
  521. directory: tmp.path,
  522. fn: async () => {
  523. const config = await Config.get()
  524. expect(config.agent?.["test"]?.permission).toEqual({
  525. bash: "allow",
  526. read: "allow",
  527. })
  528. },
  529. })
  530. })
  531. test("migrates legacy tools config to permissions - deny", async () => {
  532. await using tmp = await tmpdir({
  533. init: async (dir) => {
  534. await Bun.write(
  535. path.join(dir, "opencode.json"),
  536. JSON.stringify({
  537. $schema: "https://opencode.ai/config.json",
  538. agent: {
  539. test: {
  540. tools: {
  541. bash: false,
  542. webfetch: false,
  543. },
  544. },
  545. },
  546. }),
  547. )
  548. },
  549. })
  550. await Instance.provide({
  551. directory: tmp.path,
  552. fn: async () => {
  553. const config = await Config.get()
  554. expect(config.agent?.["test"]?.permission).toEqual({
  555. bash: "deny",
  556. webfetch: "deny",
  557. })
  558. },
  559. })
  560. })
  561. test("migrates legacy write tool to edit permission", async () => {
  562. await using tmp = await tmpdir({
  563. init: async (dir) => {
  564. await Bun.write(
  565. path.join(dir, "opencode.json"),
  566. JSON.stringify({
  567. $schema: "https://opencode.ai/config.json",
  568. agent: {
  569. test: {
  570. tools: {
  571. write: true,
  572. },
  573. },
  574. },
  575. }),
  576. )
  577. },
  578. })
  579. await Instance.provide({
  580. directory: tmp.path,
  581. fn: async () => {
  582. const config = await Config.get()
  583. expect(config.agent?.["test"]?.permission).toEqual({
  584. edit: "allow",
  585. })
  586. },
  587. })
  588. })
  589. test("migrates legacy edit tool to edit permission", async () => {
  590. await using tmp = await tmpdir({
  591. init: async (dir) => {
  592. await Bun.write(
  593. path.join(dir, "opencode.json"),
  594. JSON.stringify({
  595. $schema: "https://opencode.ai/config.json",
  596. agent: {
  597. test: {
  598. tools: {
  599. edit: false,
  600. },
  601. },
  602. },
  603. }),
  604. )
  605. },
  606. })
  607. await Instance.provide({
  608. directory: tmp.path,
  609. fn: async () => {
  610. const config = await Config.get()
  611. expect(config.agent?.["test"]?.permission).toEqual({
  612. edit: "deny",
  613. })
  614. },
  615. })
  616. })
  617. test("migrates legacy patch tool to edit permission", async () => {
  618. await using tmp = await tmpdir({
  619. init: async (dir) => {
  620. await Bun.write(
  621. path.join(dir, "opencode.json"),
  622. JSON.stringify({
  623. $schema: "https://opencode.ai/config.json",
  624. agent: {
  625. test: {
  626. tools: {
  627. patch: true,
  628. },
  629. },
  630. },
  631. }),
  632. )
  633. },
  634. })
  635. await Instance.provide({
  636. directory: tmp.path,
  637. fn: async () => {
  638. const config = await Config.get()
  639. expect(config.agent?.["test"]?.permission).toEqual({
  640. edit: "allow",
  641. })
  642. },
  643. })
  644. })
  645. test("migrates legacy multiedit tool to edit permission", async () => {
  646. await using tmp = await tmpdir({
  647. init: async (dir) => {
  648. await Bun.write(
  649. path.join(dir, "opencode.json"),
  650. JSON.stringify({
  651. $schema: "https://opencode.ai/config.json",
  652. agent: {
  653. test: {
  654. tools: {
  655. multiedit: false,
  656. },
  657. },
  658. },
  659. }),
  660. )
  661. },
  662. })
  663. await Instance.provide({
  664. directory: tmp.path,
  665. fn: async () => {
  666. const config = await Config.get()
  667. expect(config.agent?.["test"]?.permission).toEqual({
  668. edit: "deny",
  669. })
  670. },
  671. })
  672. })
  673. test("migrates mixed legacy tools config", async () => {
  674. await using tmp = await tmpdir({
  675. init: async (dir) => {
  676. await Bun.write(
  677. path.join(dir, "opencode.json"),
  678. JSON.stringify({
  679. $schema: "https://opencode.ai/config.json",
  680. agent: {
  681. test: {
  682. tools: {
  683. bash: true,
  684. write: true,
  685. read: false,
  686. webfetch: true,
  687. },
  688. },
  689. },
  690. }),
  691. )
  692. },
  693. })
  694. await Instance.provide({
  695. directory: tmp.path,
  696. fn: async () => {
  697. const config = await Config.get()
  698. expect(config.agent?.["test"]?.permission).toEqual({
  699. bash: "allow",
  700. edit: "allow",
  701. read: "deny",
  702. webfetch: "allow",
  703. })
  704. },
  705. })
  706. })
  707. test("merges legacy tools with existing permission config", async () => {
  708. await using tmp = await tmpdir({
  709. init: async (dir) => {
  710. await Bun.write(
  711. path.join(dir, "opencode.json"),
  712. JSON.stringify({
  713. $schema: "https://opencode.ai/config.json",
  714. agent: {
  715. test: {
  716. permission: {
  717. glob: "allow",
  718. },
  719. tools: {
  720. bash: true,
  721. },
  722. },
  723. },
  724. }),
  725. )
  726. },
  727. })
  728. await Instance.provide({
  729. directory: tmp.path,
  730. fn: async () => {
  731. const config = await Config.get()
  732. expect(config.agent?.["test"]?.permission).toEqual({
  733. glob: "allow",
  734. bash: "allow",
  735. })
  736. },
  737. })
  738. })