config.test.ts 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782
  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. },
  564. })
  565. expect(await Bun.file(path.join(tmp.extra, "package.json")).exists()).toBe(true)
  566. expect(await Bun.file(path.join(tmp.extra, ".gitignore")).exists()).toBe(true)
  567. } finally {
  568. if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR
  569. else process.env.OPENCODE_CONFIG_DIR = prev
  570. }
  571. })
  572. test("resolves scoped npm plugins in config", async () => {
  573. await using tmp = await tmpdir({
  574. init: async (dir) => {
  575. const pluginDir = path.join(dir, "node_modules", "@scope", "plugin")
  576. await fs.mkdir(pluginDir, { recursive: true })
  577. await Bun.write(
  578. path.join(dir, "package.json"),
  579. JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2),
  580. )
  581. await Bun.write(
  582. path.join(pluginDir, "package.json"),
  583. JSON.stringify(
  584. {
  585. name: "@scope/plugin",
  586. version: "1.0.0",
  587. type: "module",
  588. main: "./index.js",
  589. },
  590. null,
  591. 2,
  592. ),
  593. )
  594. await Bun.write(path.join(pluginDir, "index.js"), "export default {}\n")
  595. await Bun.write(
  596. path.join(dir, "opencode.json"),
  597. JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] }, null, 2),
  598. )
  599. },
  600. })
  601. await Instance.provide({
  602. directory: tmp.path,
  603. fn: async () => {
  604. const config = await Config.get()
  605. const pluginEntries = config.plugin ?? []
  606. const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
  607. const expected = import.meta.resolve("@scope/plugin", baseUrl)
  608. expect(pluginEntries.includes(expected)).toBe(true)
  609. const scopedEntry = pluginEntries.find((entry) => entry === expected)
  610. expect(scopedEntry).toBeDefined()
  611. expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true)
  612. },
  613. })
  614. })
  615. test("merges plugin arrays from global and local configs", async () => {
  616. await using tmp = await tmpdir({
  617. init: async (dir) => {
  618. // Create a nested project structure with local .opencode config
  619. const projectDir = path.join(dir, "project")
  620. const opencodeDir = path.join(projectDir, ".opencode")
  621. await fs.mkdir(opencodeDir, { recursive: true })
  622. // Global config with plugins
  623. await Bun.write(
  624. path.join(dir, "opencode.json"),
  625. JSON.stringify({
  626. $schema: "https://opencode.ai/config.json",
  627. plugin: ["global-plugin-1", "global-plugin-2"],
  628. }),
  629. )
  630. // Local .opencode config with different plugins
  631. await Bun.write(
  632. path.join(opencodeDir, "opencode.json"),
  633. JSON.stringify({
  634. $schema: "https://opencode.ai/config.json",
  635. plugin: ["local-plugin-1"],
  636. }),
  637. )
  638. },
  639. })
  640. await Instance.provide({
  641. directory: path.join(tmp.path, "project"),
  642. fn: async () => {
  643. const config = await Config.get()
  644. const plugins = config.plugin ?? []
  645. // Should contain both global and local plugins
  646. expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
  647. expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true)
  648. expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
  649. // Should have all 3 plugins (not replaced, but merged)
  650. const pluginNames = plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin"))
  651. expect(pluginNames.length).toBeGreaterThanOrEqual(3)
  652. },
  653. })
  654. })
  655. test("does not error when only custom agent is a subagent", async () => {
  656. await using tmp = await tmpdir({
  657. init: async (dir) => {
  658. const opencodeDir = path.join(dir, ".opencode")
  659. await fs.mkdir(opencodeDir, { recursive: true })
  660. const agentDir = path.join(opencodeDir, "agent")
  661. await fs.mkdir(agentDir, { recursive: true })
  662. await Bun.write(
  663. path.join(agentDir, "helper.md"),
  664. `---
  665. model: test/model
  666. mode: subagent
  667. ---
  668. Helper subagent prompt`,
  669. )
  670. },
  671. })
  672. await Instance.provide({
  673. directory: tmp.path,
  674. fn: async () => {
  675. const config = await Config.get()
  676. expect(config.agent?.["helper"]).toMatchObject({
  677. name: "helper",
  678. model: "test/model",
  679. mode: "subagent",
  680. prompt: "Helper subagent prompt",
  681. })
  682. },
  683. })
  684. })
  685. test("merges instructions arrays from global and local configs", async () => {
  686. await using tmp = await tmpdir({
  687. init: async (dir) => {
  688. const projectDir = path.join(dir, "project")
  689. const opencodeDir = path.join(projectDir, ".opencode")
  690. await fs.mkdir(opencodeDir, { recursive: true })
  691. await Bun.write(
  692. path.join(dir, "opencode.json"),
  693. JSON.stringify({
  694. $schema: "https://opencode.ai/config.json",
  695. instructions: ["global-instructions.md", "shared-rules.md"],
  696. }),
  697. )
  698. await Bun.write(
  699. path.join(opencodeDir, "opencode.json"),
  700. JSON.stringify({
  701. $schema: "https://opencode.ai/config.json",
  702. instructions: ["local-instructions.md"],
  703. }),
  704. )
  705. },
  706. })
  707. await Instance.provide({
  708. directory: path.join(tmp.path, "project"),
  709. fn: async () => {
  710. const config = await Config.get()
  711. const instructions = config.instructions ?? []
  712. expect(instructions).toContain("global-instructions.md")
  713. expect(instructions).toContain("shared-rules.md")
  714. expect(instructions).toContain("local-instructions.md")
  715. expect(instructions.length).toBe(3)
  716. },
  717. })
  718. })
  719. test("deduplicates duplicate instructions from global and local configs", async () => {
  720. await using tmp = await tmpdir({
  721. init: async (dir) => {
  722. const projectDir = path.join(dir, "project")
  723. const opencodeDir = path.join(projectDir, ".opencode")
  724. await fs.mkdir(opencodeDir, { recursive: true })
  725. await Bun.write(
  726. path.join(dir, "opencode.json"),
  727. JSON.stringify({
  728. $schema: "https://opencode.ai/config.json",
  729. instructions: ["duplicate.md", "global-only.md"],
  730. }),
  731. )
  732. await Bun.write(
  733. path.join(opencodeDir, "opencode.json"),
  734. JSON.stringify({
  735. $schema: "https://opencode.ai/config.json",
  736. instructions: ["duplicate.md", "local-only.md"],
  737. }),
  738. )
  739. },
  740. })
  741. await Instance.provide({
  742. directory: path.join(tmp.path, "project"),
  743. fn: async () => {
  744. const config = await Config.get()
  745. const instructions = config.instructions ?? []
  746. expect(instructions).toContain("global-only.md")
  747. expect(instructions).toContain("local-only.md")
  748. expect(instructions).toContain("duplicate.md")
  749. const duplicates = instructions.filter((i) => i === "duplicate.md")
  750. expect(duplicates.length).toBe(1)
  751. expect(instructions.length).toBe(3)
  752. },
  753. })
  754. })
  755. test("deduplicates duplicate plugins from global and local configs", async () => {
  756. await using tmp = await tmpdir({
  757. init: async (dir) => {
  758. // Create a nested project structure with local .opencode config
  759. const projectDir = path.join(dir, "project")
  760. const opencodeDir = path.join(projectDir, ".opencode")
  761. await fs.mkdir(opencodeDir, { recursive: true })
  762. // Global config with plugins
  763. await Bun.write(
  764. path.join(dir, "opencode.json"),
  765. JSON.stringify({
  766. $schema: "https://opencode.ai/config.json",
  767. plugin: ["duplicate-plugin", "global-plugin-1"],
  768. }),
  769. )
  770. // Local .opencode config with some overlapping plugins
  771. await Bun.write(
  772. path.join(opencodeDir, "opencode.json"),
  773. JSON.stringify({
  774. $schema: "https://opencode.ai/config.json",
  775. plugin: ["duplicate-plugin", "local-plugin-1"],
  776. }),
  777. )
  778. },
  779. })
  780. await Instance.provide({
  781. directory: path.join(tmp.path, "project"),
  782. fn: async () => {
  783. const config = await Config.get()
  784. const plugins = config.plugin ?? []
  785. // Should contain all unique plugins
  786. expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
  787. expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
  788. expect(plugins.some((p) => p.includes("duplicate-plugin"))).toBe(true)
  789. // Should deduplicate the duplicate plugin
  790. const duplicatePlugins = plugins.filter((p) => p.includes("duplicate-plugin"))
  791. expect(duplicatePlugins.length).toBe(1)
  792. // Should have exactly 3 unique plugins
  793. const pluginNames = plugins.filter(
  794. (p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"),
  795. )
  796. expect(pluginNames.length).toBe(3)
  797. },
  798. })
  799. })
  800. // Legacy tools migration tests
  801. test("migrates legacy tools config to permissions - allow", async () => {
  802. await using tmp = await tmpdir({
  803. init: async (dir) => {
  804. await Bun.write(
  805. path.join(dir, "opencode.json"),
  806. JSON.stringify({
  807. $schema: "https://opencode.ai/config.json",
  808. agent: {
  809. test: {
  810. tools: {
  811. bash: true,
  812. read: true,
  813. },
  814. },
  815. },
  816. }),
  817. )
  818. },
  819. })
  820. await Instance.provide({
  821. directory: tmp.path,
  822. fn: async () => {
  823. const config = await Config.get()
  824. expect(config.agent?.["test"]?.permission).toEqual({
  825. bash: "allow",
  826. read: "allow",
  827. })
  828. },
  829. })
  830. })
  831. test("migrates legacy tools config to permissions - deny", async () => {
  832. await using tmp = await tmpdir({
  833. init: async (dir) => {
  834. await Bun.write(
  835. path.join(dir, "opencode.json"),
  836. JSON.stringify({
  837. $schema: "https://opencode.ai/config.json",
  838. agent: {
  839. test: {
  840. tools: {
  841. bash: false,
  842. webfetch: false,
  843. },
  844. },
  845. },
  846. }),
  847. )
  848. },
  849. })
  850. await Instance.provide({
  851. directory: tmp.path,
  852. fn: async () => {
  853. const config = await Config.get()
  854. expect(config.agent?.["test"]?.permission).toEqual({
  855. bash: "deny",
  856. webfetch: "deny",
  857. })
  858. },
  859. })
  860. })
  861. test("migrates legacy write tool to edit permission", async () => {
  862. await using tmp = await tmpdir({
  863. init: async (dir) => {
  864. await Bun.write(
  865. path.join(dir, "opencode.json"),
  866. JSON.stringify({
  867. $schema: "https://opencode.ai/config.json",
  868. agent: {
  869. test: {
  870. tools: {
  871. write: true,
  872. },
  873. },
  874. },
  875. }),
  876. )
  877. },
  878. })
  879. await Instance.provide({
  880. directory: tmp.path,
  881. fn: async () => {
  882. const config = await Config.get()
  883. expect(config.agent?.["test"]?.permission).toEqual({
  884. edit: "allow",
  885. })
  886. },
  887. })
  888. })
  889. // Managed settings tests
  890. // Note: preload.ts sets OPENCODE_TEST_MANAGED_CONFIG which Global.Path.managedConfig uses
  891. test("managed settings override user settings", async () => {
  892. await using tmp = await tmpdir({
  893. init: async (dir) => {
  894. await writeConfig(dir, {
  895. $schema: "https://opencode.ai/config.json",
  896. model: "user/model",
  897. share: "auto",
  898. username: "testuser",
  899. })
  900. },
  901. })
  902. await writeManagedSettings({
  903. $schema: "https://opencode.ai/config.json",
  904. model: "managed/model",
  905. share: "disabled",
  906. })
  907. await Instance.provide({
  908. directory: tmp.path,
  909. fn: async () => {
  910. const config = await Config.get()
  911. expect(config.model).toBe("managed/model")
  912. expect(config.share).toBe("disabled")
  913. expect(config.username).toBe("testuser")
  914. },
  915. })
  916. })
  917. test("managed settings override project settings", async () => {
  918. await using tmp = await tmpdir({
  919. init: async (dir) => {
  920. await writeConfig(dir, {
  921. $schema: "https://opencode.ai/config.json",
  922. autoupdate: true,
  923. disabled_providers: [],
  924. theme: "dark",
  925. })
  926. },
  927. })
  928. await writeManagedSettings({
  929. $schema: "https://opencode.ai/config.json",
  930. autoupdate: false,
  931. disabled_providers: ["openai"],
  932. })
  933. await Instance.provide({
  934. directory: tmp.path,
  935. fn: async () => {
  936. const config = await Config.get()
  937. expect(config.autoupdate).toBe(false)
  938. expect(config.disabled_providers).toEqual(["openai"])
  939. expect(config.theme).toBe("dark")
  940. },
  941. })
  942. })
  943. test("missing managed settings file is not an error", async () => {
  944. await using tmp = await tmpdir({
  945. init: async (dir) => {
  946. await writeConfig(dir, {
  947. $schema: "https://opencode.ai/config.json",
  948. model: "user/model",
  949. })
  950. },
  951. })
  952. await Instance.provide({
  953. directory: tmp.path,
  954. fn: async () => {
  955. const config = await Config.get()
  956. expect(config.model).toBe("user/model")
  957. },
  958. })
  959. })
  960. test("migrates legacy edit tool to edit permission", async () => {
  961. await using tmp = await tmpdir({
  962. init: async (dir) => {
  963. await Bun.write(
  964. path.join(dir, "opencode.json"),
  965. JSON.stringify({
  966. $schema: "https://opencode.ai/config.json",
  967. agent: {
  968. test: {
  969. tools: {
  970. edit: false,
  971. },
  972. },
  973. },
  974. }),
  975. )
  976. },
  977. })
  978. await Instance.provide({
  979. directory: tmp.path,
  980. fn: async () => {
  981. const config = await Config.get()
  982. expect(config.agent?.["test"]?.permission).toEqual({
  983. edit: "deny",
  984. })
  985. },
  986. })
  987. })
  988. test("migrates legacy patch tool to edit permission", async () => {
  989. await using tmp = await tmpdir({
  990. init: async (dir) => {
  991. await Bun.write(
  992. path.join(dir, "opencode.json"),
  993. JSON.stringify({
  994. $schema: "https://opencode.ai/config.json",
  995. agent: {
  996. test: {
  997. tools: {
  998. patch: true,
  999. },
  1000. },
  1001. },
  1002. }),
  1003. )
  1004. },
  1005. })
  1006. await Instance.provide({
  1007. directory: tmp.path,
  1008. fn: async () => {
  1009. const config = await Config.get()
  1010. expect(config.agent?.["test"]?.permission).toEqual({
  1011. edit: "allow",
  1012. })
  1013. },
  1014. })
  1015. })
  1016. test("migrates legacy multiedit tool to edit permission", async () => {
  1017. await using tmp = await tmpdir({
  1018. init: async (dir) => {
  1019. await Bun.write(
  1020. path.join(dir, "opencode.json"),
  1021. JSON.stringify({
  1022. $schema: "https://opencode.ai/config.json",
  1023. agent: {
  1024. test: {
  1025. tools: {
  1026. multiedit: false,
  1027. },
  1028. },
  1029. },
  1030. }),
  1031. )
  1032. },
  1033. })
  1034. await Instance.provide({
  1035. directory: tmp.path,
  1036. fn: async () => {
  1037. const config = await Config.get()
  1038. expect(config.agent?.["test"]?.permission).toEqual({
  1039. edit: "deny",
  1040. })
  1041. },
  1042. })
  1043. })
  1044. test("migrates mixed legacy tools config", async () => {
  1045. await using tmp = await tmpdir({
  1046. init: async (dir) => {
  1047. await Bun.write(
  1048. path.join(dir, "opencode.json"),
  1049. JSON.stringify({
  1050. $schema: "https://opencode.ai/config.json",
  1051. agent: {
  1052. test: {
  1053. tools: {
  1054. bash: true,
  1055. write: true,
  1056. read: false,
  1057. webfetch: true,
  1058. },
  1059. },
  1060. },
  1061. }),
  1062. )
  1063. },
  1064. })
  1065. await Instance.provide({
  1066. directory: tmp.path,
  1067. fn: async () => {
  1068. const config = await Config.get()
  1069. expect(config.agent?.["test"]?.permission).toEqual({
  1070. bash: "allow",
  1071. edit: "allow",
  1072. read: "deny",
  1073. webfetch: "allow",
  1074. })
  1075. },
  1076. })
  1077. })
  1078. test("merges legacy tools with existing permission config", async () => {
  1079. await using tmp = await tmpdir({
  1080. init: async (dir) => {
  1081. await Bun.write(
  1082. path.join(dir, "opencode.json"),
  1083. JSON.stringify({
  1084. $schema: "https://opencode.ai/config.json",
  1085. agent: {
  1086. test: {
  1087. permission: {
  1088. glob: "allow",
  1089. },
  1090. tools: {
  1091. bash: true,
  1092. },
  1093. },
  1094. },
  1095. }),
  1096. )
  1097. },
  1098. })
  1099. await Instance.provide({
  1100. directory: tmp.path,
  1101. fn: async () => {
  1102. const config = await Config.get()
  1103. expect(config.agent?.["test"]?.permission).toEqual({
  1104. glob: "allow",
  1105. bash: "allow",
  1106. })
  1107. },
  1108. })
  1109. })
  1110. test("permission config preserves key order", async () => {
  1111. await using tmp = await tmpdir({
  1112. init: async (dir) => {
  1113. await Bun.write(
  1114. path.join(dir, "opencode.json"),
  1115. JSON.stringify({
  1116. $schema: "https://opencode.ai/config.json",
  1117. permission: {
  1118. "*": "deny",
  1119. edit: "ask",
  1120. write: "ask",
  1121. external_directory: "ask",
  1122. read: "allow",
  1123. todowrite: "allow",
  1124. todoread: "allow",
  1125. "thoughts_*": "allow",
  1126. "reasoning_model_*": "allow",
  1127. "tools_*": "allow",
  1128. "pr_comments_*": "allow",
  1129. },
  1130. }),
  1131. )
  1132. },
  1133. })
  1134. await Instance.provide({
  1135. directory: tmp.path,
  1136. fn: async () => {
  1137. const config = await Config.get()
  1138. expect(Object.keys(config.permission!)).toEqual([
  1139. "*",
  1140. "edit",
  1141. "write",
  1142. "external_directory",
  1143. "read",
  1144. "todowrite",
  1145. "todoread",
  1146. "thoughts_*",
  1147. "reasoning_model_*",
  1148. "tools_*",
  1149. "pr_comments_*",
  1150. ])
  1151. },
  1152. })
  1153. })
  1154. // MCP config merging tests
  1155. test("project config can override MCP server enabled status", async () => {
  1156. await using tmp = await tmpdir({
  1157. init: async (dir) => {
  1158. // Simulates a base config (like from remote .well-known) with disabled MCP
  1159. await Bun.write(
  1160. path.join(dir, "opencode.jsonc"),
  1161. JSON.stringify({
  1162. $schema: "https://opencode.ai/config.json",
  1163. mcp: {
  1164. jira: {
  1165. type: "remote",
  1166. url: "https://jira.example.com/mcp",
  1167. enabled: false,
  1168. },
  1169. wiki: {
  1170. type: "remote",
  1171. url: "https://wiki.example.com/mcp",
  1172. enabled: false,
  1173. },
  1174. },
  1175. }),
  1176. )
  1177. // Project config enables just jira
  1178. await Bun.write(
  1179. path.join(dir, "opencode.json"),
  1180. JSON.stringify({
  1181. $schema: "https://opencode.ai/config.json",
  1182. mcp: {
  1183. jira: {
  1184. type: "remote",
  1185. url: "https://jira.example.com/mcp",
  1186. enabled: true,
  1187. },
  1188. },
  1189. }),
  1190. )
  1191. },
  1192. })
  1193. await Instance.provide({
  1194. directory: tmp.path,
  1195. fn: async () => {
  1196. const config = await Config.get()
  1197. // jira should be enabled (overridden by project config)
  1198. expect(config.mcp?.jira).toEqual({
  1199. type: "remote",
  1200. url: "https://jira.example.com/mcp",
  1201. enabled: true,
  1202. })
  1203. // wiki should still be disabled (not overridden)
  1204. expect(config.mcp?.wiki).toEqual({
  1205. type: "remote",
  1206. url: "https://wiki.example.com/mcp",
  1207. enabled: false,
  1208. })
  1209. },
  1210. })
  1211. })
  1212. test("MCP config deep merges preserving base config properties", async () => {
  1213. await using tmp = await tmpdir({
  1214. init: async (dir) => {
  1215. // Base config with full MCP definition
  1216. await Bun.write(
  1217. path.join(dir, "opencode.jsonc"),
  1218. JSON.stringify({
  1219. $schema: "https://opencode.ai/config.json",
  1220. mcp: {
  1221. myserver: {
  1222. type: "remote",
  1223. url: "https://myserver.example.com/mcp",
  1224. enabled: false,
  1225. headers: {
  1226. "X-Custom-Header": "value",
  1227. },
  1228. },
  1229. },
  1230. }),
  1231. )
  1232. // Override just enables it, should preserve other properties
  1233. await Bun.write(
  1234. path.join(dir, "opencode.json"),
  1235. JSON.stringify({
  1236. $schema: "https://opencode.ai/config.json",
  1237. mcp: {
  1238. myserver: {
  1239. type: "remote",
  1240. url: "https://myserver.example.com/mcp",
  1241. enabled: true,
  1242. },
  1243. },
  1244. }),
  1245. )
  1246. },
  1247. })
  1248. await Instance.provide({
  1249. directory: tmp.path,
  1250. fn: async () => {
  1251. const config = await Config.get()
  1252. expect(config.mcp?.myserver).toEqual({
  1253. type: "remote",
  1254. url: "https://myserver.example.com/mcp",
  1255. enabled: true,
  1256. headers: {
  1257. "X-Custom-Header": "value",
  1258. },
  1259. })
  1260. },
  1261. })
  1262. })
  1263. test("local .opencode config can override MCP from project config", async () => {
  1264. await using tmp = await tmpdir({
  1265. init: async (dir) => {
  1266. // Project config with disabled MCP
  1267. await Bun.write(
  1268. path.join(dir, "opencode.json"),
  1269. JSON.stringify({
  1270. $schema: "https://opencode.ai/config.json",
  1271. mcp: {
  1272. docs: {
  1273. type: "remote",
  1274. url: "https://docs.example.com/mcp",
  1275. enabled: false,
  1276. },
  1277. },
  1278. }),
  1279. )
  1280. // Local .opencode directory config enables it
  1281. const opencodeDir = path.join(dir, ".opencode")
  1282. await fs.mkdir(opencodeDir, { recursive: true })
  1283. await Bun.write(
  1284. path.join(opencodeDir, "opencode.json"),
  1285. JSON.stringify({
  1286. $schema: "https://opencode.ai/config.json",
  1287. mcp: {
  1288. docs: {
  1289. type: "remote",
  1290. url: "https://docs.example.com/mcp",
  1291. enabled: true,
  1292. },
  1293. },
  1294. }),
  1295. )
  1296. },
  1297. })
  1298. await Instance.provide({
  1299. directory: tmp.path,
  1300. fn: async () => {
  1301. const config = await Config.get()
  1302. expect(config.mcp?.docs?.enabled).toBe(true)
  1303. },
  1304. })
  1305. })
  1306. test("project config overrides remote well-known config", async () => {
  1307. const originalFetch = globalThis.fetch
  1308. let fetchedUrl: string | undefined
  1309. const mockFetch = mock((url: string | URL | Request) => {
  1310. const urlStr = url.toString()
  1311. if (urlStr.includes(".well-known/opencode")) {
  1312. fetchedUrl = urlStr
  1313. return Promise.resolve(
  1314. new Response(
  1315. JSON.stringify({
  1316. config: {
  1317. mcp: {
  1318. jira: {
  1319. type: "remote",
  1320. url: "https://jira.example.com/mcp",
  1321. enabled: false,
  1322. },
  1323. },
  1324. },
  1325. }),
  1326. { status: 200 },
  1327. ),
  1328. )
  1329. }
  1330. return originalFetch(url)
  1331. })
  1332. globalThis.fetch = mockFetch as unknown as typeof fetch
  1333. const originalAuthAll = Auth.all
  1334. Auth.all = mock(() =>
  1335. Promise.resolve({
  1336. "https://example.com": {
  1337. type: "wellknown" as const,
  1338. key: "TEST_TOKEN",
  1339. token: "test-token",
  1340. },
  1341. }),
  1342. )
  1343. try {
  1344. await using tmp = await tmpdir({
  1345. git: true,
  1346. init: async (dir) => {
  1347. // Project config enables jira (overriding remote default)
  1348. await Bun.write(
  1349. path.join(dir, "opencode.json"),
  1350. JSON.stringify({
  1351. $schema: "https://opencode.ai/config.json",
  1352. mcp: {
  1353. jira: {
  1354. type: "remote",
  1355. url: "https://jira.example.com/mcp",
  1356. enabled: true,
  1357. },
  1358. },
  1359. }),
  1360. )
  1361. },
  1362. })
  1363. await Instance.provide({
  1364. directory: tmp.path,
  1365. fn: async () => {
  1366. const config = await Config.get()
  1367. // Verify fetch was called for wellknown config
  1368. expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
  1369. // Project config (enabled: true) should override remote (enabled: false)
  1370. expect(config.mcp?.jira?.enabled).toBe(true)
  1371. },
  1372. })
  1373. } finally {
  1374. globalThis.fetch = originalFetch
  1375. Auth.all = originalAuthAll
  1376. }
  1377. })
  1378. describe("getPluginName", () => {
  1379. test("extracts name from file:// URL", () => {
  1380. expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo")
  1381. expect(Config.getPluginName("file:///path/to/plugin/bar.ts")).toBe("bar")
  1382. expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin")
  1383. })
  1384. test("extracts name from npm package with version", () => {
  1385. expect(Config.getPluginName("[email protected]")).toBe("oh-my-opencode")
  1386. expect(Config.getPluginName("[email protected]")).toBe("some-plugin")
  1387. expect(Config.getPluginName("plugin@latest")).toBe("plugin")
  1388. })
  1389. test("extracts name from scoped npm package", () => {
  1390. expect(Config.getPluginName("@scope/[email protected]")).toBe("@scope/pkg")
  1391. expect(Config.getPluginName("@opencode/[email protected]")).toBe("@opencode/plugin")
  1392. })
  1393. test("returns full string for package without version", () => {
  1394. expect(Config.getPluginName("some-plugin")).toBe("some-plugin")
  1395. expect(Config.getPluginName("@scope/pkg")).toBe("@scope/pkg")
  1396. })
  1397. })
  1398. describe("deduplicatePlugins", () => {
  1399. test("removes duplicates keeping higher priority (later entries)", () => {
  1400. const plugins = ["[email protected]", "[email protected]", "[email protected]", "[email protected]"]
  1401. const result = Config.deduplicatePlugins(plugins)
  1402. expect(result).toContain("[email protected]")
  1403. expect(result).toContain("[email protected]")
  1404. expect(result).toContain("[email protected]")
  1405. expect(result).not.toContain("[email protected]")
  1406. expect(result.length).toBe(3)
  1407. })
  1408. test("prefers local file over npm package with same name", () => {
  1409. const plugins = ["[email protected]", "file:///project/.opencode/plugin/oh-my-opencode.js"]
  1410. const result = Config.deduplicatePlugins(plugins)
  1411. expect(result.length).toBe(1)
  1412. expect(result[0]).toBe("file:///project/.opencode/plugin/oh-my-opencode.js")
  1413. })
  1414. test("preserves order of remaining plugins", () => {
  1415. const plugins = ["[email protected]", "[email protected]", "[email protected]"]
  1416. const result = Config.deduplicatePlugins(plugins)
  1417. expect(result).toEqual(["[email protected]", "[email protected]", "[email protected]"])
  1418. })
  1419. test("local plugin directory overrides global opencode.json plugin", async () => {
  1420. await using tmp = await tmpdir({
  1421. init: async (dir) => {
  1422. const projectDir = path.join(dir, "project")
  1423. const opencodeDir = path.join(projectDir, ".opencode")
  1424. const pluginDir = path.join(opencodeDir, "plugin")
  1425. await fs.mkdir(pluginDir, { recursive: true })
  1426. await Bun.write(
  1427. path.join(dir, "opencode.json"),
  1428. JSON.stringify({
  1429. $schema: "https://opencode.ai/config.json",
  1430. plugin: ["[email protected]"],
  1431. }),
  1432. )
  1433. await Bun.write(path.join(pluginDir, "my-plugin.js"), "export default {}")
  1434. },
  1435. })
  1436. await Instance.provide({
  1437. directory: path.join(tmp.path, "project"),
  1438. fn: async () => {
  1439. const config = await Config.get()
  1440. const plugins = config.plugin ?? []
  1441. const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin")
  1442. expect(myPlugins.length).toBe(1)
  1443. expect(myPlugins[0].startsWith("file://")).toBe(true)
  1444. },
  1445. })
  1446. })
  1447. })
  1448. describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
  1449. test("skips project config files when flag is set", async () => {
  1450. const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1451. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
  1452. try {
  1453. await using tmp = await tmpdir({
  1454. init: async (dir) => {
  1455. // Create a project config that would normally be loaded
  1456. await Bun.write(
  1457. path.join(dir, "opencode.json"),
  1458. JSON.stringify({
  1459. $schema: "https://opencode.ai/config.json",
  1460. model: "project/model",
  1461. username: "project-user",
  1462. }),
  1463. )
  1464. },
  1465. })
  1466. await Instance.provide({
  1467. directory: tmp.path,
  1468. fn: async () => {
  1469. const config = await Config.get()
  1470. // Project config should NOT be loaded - model should be default, not "project/model"
  1471. expect(config.model).not.toBe("project/model")
  1472. expect(config.username).not.toBe("project-user")
  1473. },
  1474. })
  1475. } finally {
  1476. if (originalEnv === undefined) {
  1477. delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1478. } else {
  1479. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalEnv
  1480. }
  1481. }
  1482. })
  1483. test("skips project .opencode/ directories when flag is set", async () => {
  1484. const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1485. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
  1486. try {
  1487. await using tmp = await tmpdir({
  1488. init: async (dir) => {
  1489. // Create a .opencode directory with a command
  1490. const opencodeDir = path.join(dir, ".opencode", "command")
  1491. await fs.mkdir(opencodeDir, { recursive: true })
  1492. await Bun.write(path.join(opencodeDir, "test-cmd.md"), "# Test Command\nThis is a test command.")
  1493. },
  1494. })
  1495. await Instance.provide({
  1496. directory: tmp.path,
  1497. fn: async () => {
  1498. const directories = await Config.directories()
  1499. // Project .opencode should NOT be in directories list
  1500. const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path))
  1501. expect(hasProjectOpencode).toBe(false)
  1502. },
  1503. })
  1504. } finally {
  1505. if (originalEnv === undefined) {
  1506. delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1507. } else {
  1508. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalEnv
  1509. }
  1510. }
  1511. })
  1512. test("still loads global config when flag is set", async () => {
  1513. const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1514. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
  1515. try {
  1516. await using tmp = await tmpdir()
  1517. await Instance.provide({
  1518. directory: tmp.path,
  1519. fn: async () => {
  1520. // Should still get default config (from global or defaults)
  1521. const config = await Config.get()
  1522. expect(config).toBeDefined()
  1523. expect(config.username).toBeDefined()
  1524. },
  1525. })
  1526. } finally {
  1527. if (originalEnv === undefined) {
  1528. delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1529. } else {
  1530. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalEnv
  1531. }
  1532. }
  1533. })
  1534. test("skips relative instructions with warning when flag is set but no config dir", async () => {
  1535. const originalDisable = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1536. const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
  1537. try {
  1538. // Ensure no config dir is set
  1539. delete process.env["OPENCODE_CONFIG_DIR"]
  1540. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
  1541. await using tmp = await tmpdir({
  1542. init: async (dir) => {
  1543. // Create a config with relative instruction path
  1544. await Bun.write(
  1545. path.join(dir, "opencode.json"),
  1546. JSON.stringify({
  1547. $schema: "https://opencode.ai/config.json",
  1548. instructions: ["./CUSTOM.md"],
  1549. }),
  1550. )
  1551. // Create the instruction file (should be skipped)
  1552. await Bun.write(path.join(dir, "CUSTOM.md"), "# Custom Instructions")
  1553. },
  1554. })
  1555. await Instance.provide({
  1556. directory: tmp.path,
  1557. fn: async () => {
  1558. // The relative instruction should be skipped without error
  1559. // We're mainly verifying this doesn't throw and the config loads
  1560. const config = await Config.get()
  1561. expect(config).toBeDefined()
  1562. // The instruction should have been skipped (warning logged)
  1563. // We can't easily test the warning was logged, but we verify
  1564. // the relative path didn't cause an error
  1565. },
  1566. })
  1567. } finally {
  1568. if (originalDisable === undefined) {
  1569. delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1570. } else {
  1571. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalDisable
  1572. }
  1573. if (originalConfigDir === undefined) {
  1574. delete process.env["OPENCODE_CONFIG_DIR"]
  1575. } else {
  1576. process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
  1577. }
  1578. }
  1579. })
  1580. test("OPENCODE_CONFIG_DIR still works when flag is set", async () => {
  1581. const originalDisable = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1582. const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
  1583. try {
  1584. await using configDirTmp = await tmpdir({
  1585. init: async (dir) => {
  1586. // Create config in the custom config dir
  1587. await Bun.write(
  1588. path.join(dir, "opencode.json"),
  1589. JSON.stringify({
  1590. $schema: "https://opencode.ai/config.json",
  1591. model: "configdir/model",
  1592. }),
  1593. )
  1594. },
  1595. })
  1596. await using projectTmp = await tmpdir({
  1597. init: async (dir) => {
  1598. // Create config in project (should be ignored)
  1599. await Bun.write(
  1600. path.join(dir, "opencode.json"),
  1601. JSON.stringify({
  1602. $schema: "https://opencode.ai/config.json",
  1603. model: "project/model",
  1604. }),
  1605. )
  1606. },
  1607. })
  1608. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
  1609. process.env["OPENCODE_CONFIG_DIR"] = configDirTmp.path
  1610. await Instance.provide({
  1611. directory: projectTmp.path,
  1612. fn: async () => {
  1613. const config = await Config.get()
  1614. // Should load from OPENCODE_CONFIG_DIR, not project
  1615. expect(config.model).toBe("configdir/model")
  1616. },
  1617. })
  1618. } finally {
  1619. if (originalDisable === undefined) {
  1620. delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
  1621. } else {
  1622. process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalDisable
  1623. }
  1624. if (originalConfigDir === undefined) {
  1625. delete process.env["OPENCODE_CONFIG_DIR"]
  1626. } else {
  1627. process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
  1628. }
  1629. }
  1630. })
  1631. })