config.test.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677
  1. import path from "path"
  2. import fs from "fs/promises"
  3. import { describe, expect } from "bun:test"
  4. import { Effect, Layer, Schema } from "effect"
  5. import { FastCheck } from "effect/testing"
  6. import { Config } from "@opencode-ai/core/config"
  7. import { ConfigProvider } from "@opencode-ai/core/config/provider"
  8. import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
  9. import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
  10. import { FSUtil } from "@opencode-ai/core/fs-util"
  11. import { Global } from "@opencode-ai/core/global"
  12. import { Location } from "@opencode-ai/core/location"
  13. import { Policy } from "@opencode-ai/core/policy"
  14. import { Project } from "@opencode-ai/core/project"
  15. import { AbsolutePath } from "@opencode-ai/core/schema"
  16. import { location } from "../fixture/location"
  17. import { tmpdir } from "../fixture/tmpdir"
  18. import { testEffect } from "../lib/effect"
  19. const it = testEffect(Layer.empty)
  20. function testLayer(
  21. directory: string,
  22. globalDirectory = path.join(directory, "global"),
  23. projectDirectory = directory,
  24. vcs?: Project.Vcs,
  25. ) {
  26. return Config.locationLayer.pipe(
  27. Layer.provide(FSUtil.defaultLayer),
  28. Layer.provide(Global.layerWith({ config: globalDirectory })),
  29. Layer.provide(
  30. Layer.succeed(
  31. Location.Service,
  32. Location.Service.of(
  33. location(
  34. { directory: AbsolutePath.make(directory) },
  35. { projectDirectory: AbsolutePath.make(projectDirectory), vcs },
  36. ),
  37. ),
  38. ),
  39. ),
  40. )
  41. }
  42. const provider = {
  43. api: { type: "native", settings: {} },
  44. request: {
  45. headers: {},
  46. body: {},
  47. },
  48. models: {},
  49. }
  50. describe("Config", () => {
  51. it.effect("detects v1 configuration from any v1-only top-level key", () =>
  52. Effect.sync(() => {
  53. expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
  54. expect(ConfigMigrateV1.isV1({ snapshot: false, agents: {} })).toBe(true)
  55. expect(ConfigMigrateV1.isV1({ shell: "/bin/zsh", model: "anthropic/claude" })).toBe(false)
  56. }),
  57. )
  58. it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
  59. Effect.sync(() => {
  60. FastCheck.assert(
  61. FastCheck.property(Schema.toArbitrary(ConfigV1.Info), (info) => {
  62. Schema.decodeUnknownSync(Config.Info)(ConfigMigrateV1.migrate(info), { errors: "all" })
  63. }),
  64. { numRuns: 100 },
  65. )
  66. }),
  67. )
  68. it.effect("migrates v1 provider setup options into AISDK settings", () =>
  69. Effect.sync(() => {
  70. const migrated = ConfigMigrateV1.migrate({
  71. provider: {
  72. bedrock: {
  73. npm: "@ai-sdk/amazon-bedrock",
  74. options: {
  75. headers: { "x-test": "1" },
  76. body: { trace: true },
  77. region: "us-east-1",
  78. profile: "dev",
  79. },
  80. },
  81. },
  82. })
  83. expect(migrated.providers?.bedrock?.api).toEqual({
  84. type: "aisdk",
  85. package: "@ai-sdk/amazon-bedrock",
  86. url: undefined,
  87. settings: { region: "us-east-1", profile: "dev" },
  88. })
  89. expect(migrated.providers?.bedrock?.request).toEqual({
  90. headers: { "x-test": "1" },
  91. body: { trace: true },
  92. })
  93. }),
  94. )
  95. it.effect("migrates v1 command configuration", () =>
  96. Effect.sync(() => {
  97. expect(
  98. ConfigMigrateV1.migrate({
  99. command: {
  100. review: {
  101. template: "Review changes",
  102. description: "Review code",
  103. agent: "reviewer",
  104. model: "anthropic/claude",
  105. variant: "high",
  106. subtask: true,
  107. },
  108. },
  109. }).commands,
  110. ).toEqual({
  111. review: {
  112. template: "Review changes",
  113. description: "Review code",
  114. agent: "reviewer",
  115. model: "anthropic/claude",
  116. variant: "high",
  117. subtask: true,
  118. },
  119. })
  120. }),
  121. )
  122. it.live("returns an empty configuration when directory files do not exist", () =>
  123. Effect.acquireRelease(
  124. Effect.promise(() => tmpdir()),
  125. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  126. ).pipe(
  127. Effect.flatMap((tmp) =>
  128. Effect.gen(function* () {
  129. const config = yield* Config.Service
  130. const entries = yield* config.entries()
  131. expect(entries).toEqual([
  132. new Config.Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
  133. ])
  134. }).pipe(Effect.provide(testLayer(tmp.path))),
  135. ),
  136. ),
  137. )
  138. it.live("loads JSON and JSONC files from lowest to highest priority", () =>
  139. Effect.acquireRelease(
  140. Effect.promise(() => tmpdir()),
  141. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  142. ).pipe(
  143. Effect.flatMap((tmp) =>
  144. Effect.gen(function* () {
  145. yield* Effect.promise(() =>
  146. Promise.all([
  147. fs.writeFile(
  148. path.join(tmp.path, "config.json"),
  149. JSON.stringify({ $schema: "base", providers: { base: provider } }),
  150. ),
  151. fs.writeFile(
  152. path.join(tmp.path, "opencode.json"),
  153. JSON.stringify({ $schema: "middle", providers: { middle: provider } }),
  154. ),
  155. fs.writeFile(
  156. path.join(tmp.path, "opencode.jsonc"),
  157. `{
  158. // Later global files override scalar fields while retaining providers.
  159. "$schema": "last",
  160. "providers": { "last": ${JSON.stringify(provider)} },
  161. }`,
  162. ),
  163. ]),
  164. )
  165. return yield* Effect.gen(function* () {
  166. const config = yield* Config.Service
  167. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  168. expect(documents).toHaveLength(3)
  169. expect(documents.map((document) => document.type)).toEqual(["document", "document", "document"])
  170. expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
  171. expect(documents[0]).toBeInstanceOf(Config.Document)
  172. expect(documents[0]?.path).toBe(path.join(tmp.path, "config.json"))
  173. expect(documents[2]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
  174. yield* Effect.promise(() =>
  175. fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })),
  176. )
  177. expect(
  178. (yield* config.entries())
  179. .filter((entry) => entry.type === "document")
  180. .map((document) => document.info.$schema),
  181. ).toEqual(["base", "middle", "last"])
  182. }).pipe(Effect.provide(testLayer(tmp.path)))
  183. }),
  184. ),
  185. ),
  186. )
  187. it.live("accepts $schema metadata without writing it into config files", () =>
  188. Effect.acquireRelease(
  189. Effect.promise(() => tmpdir()),
  190. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  191. ).pipe(
  192. Effect.flatMap((tmp) =>
  193. Effect.gen(function* () {
  194. const file = path.join(tmp.path, "opencode.json")
  195. const contents = JSON.stringify({
  196. shell: "/bin/zsh",
  197. experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
  198. providers: { local: provider },
  199. })
  200. yield* Effect.promise(() => fs.writeFile(file, contents))
  201. return yield* Effect.gen(function* () {
  202. const config = yield* Config.Service
  203. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  204. expect(documents[0]?.info.$schema).toBeUndefined()
  205. expect(documents[0]?.info.shell).toBe("/bin/zsh")
  206. expect(documents[0]?.info.experimental?.policies?.[0]).toEqual({
  207. effect: "deny",
  208. action: "provider.use",
  209. resource: "openai",
  210. })
  211. expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
  212. }).pipe(Effect.provide(testLayer(tmp.path)))
  213. }),
  214. ),
  215. ),
  216. )
  217. it.live("loads supported scalar and resource configuration", () =>
  218. Effect.acquireRelease(
  219. Effect.promise(() => tmpdir()),
  220. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  221. ).pipe(
  222. Effect.flatMap((tmp) =>
  223. Effect.gen(function* () {
  224. yield* Effect.promise(() =>
  225. fs.writeFile(
  226. path.join(tmp.path, "opencode.json"),
  227. JSON.stringify({
  228. shell: "/bin/bash",
  229. model: "anthropic/claude",
  230. autoupdate: "notify",
  231. share: "disabled",
  232. enterprise: { url: "https://share.example.com" },
  233. username: "test-user",
  234. permissions: [
  235. { action: "bash", resource: "*", effect: "ask" },
  236. { action: "bash", resource: "git status", effect: "allow" },
  237. ],
  238. agents: {
  239. reviewer: {
  240. model: "openrouter/openai/gpt-5",
  241. variant: "high",
  242. request: {
  243. headers: { "x-agent": "reviewer" },
  244. body: { reasoningEffort: "high" },
  245. },
  246. description: "Review changes for correctness",
  247. system: "Find regressions.",
  248. mode: "subagent",
  249. hidden: false,
  250. color: "warning",
  251. steps: 12,
  252. disabled: false,
  253. permissions: [{ action: "edit", resource: "*", effect: "deny" }],
  254. },
  255. },
  256. snapshots: false,
  257. watcher: { ignore: ["node_modules/**", "dist/**", ".git"] },
  258. formatter: {
  259. prettier: { disabled: true },
  260. custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
  261. },
  262. lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } },
  263. attachments: {
  264. image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
  265. },
  266. tool_output: { max_lines: 1000, max_bytes: 32768 },
  267. mcp: {
  268. timeout: 5000,
  269. servers: {
  270. local: {
  271. type: "local",
  272. command: ["node", "./mcp/server.js"],
  273. environment: { API_KEY: "secret" },
  274. disabled: false,
  275. timeout: 10000,
  276. },
  277. remote: {
  278. type: "remote",
  279. url: "https://mcp.example.com/mcp",
  280. headers: { Authorization: "Bearer token" },
  281. oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
  282. disabled: true,
  283. },
  284. },
  285. },
  286. compaction: {
  287. auto: true,
  288. prune: false,
  289. keep: { turns: 3, tokens: 2000 },
  290. buffer: 10000,
  291. },
  292. skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"],
  293. instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"],
  294. references: {
  295. local: { path: "../library" },
  296. sdk: { repository: "github.com/example/sdk", branch: "main" },
  297. shorthand: "github.com/example/docs",
  298. },
  299. plugins: [
  300. "opencode-helicone-session",
  301. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  302. ],
  303. }),
  304. ),
  305. )
  306. return yield* Effect.gen(function* () {
  307. const config = yield* Config.Service
  308. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  309. expect(documents).toHaveLength(1)
  310. expect(documents[0]?.info.shell).toBe("/bin/bash")
  311. expect(documents[0]?.info.model).toBe("anthropic/claude")
  312. expect(documents[0]?.info.autoupdate).toBe("notify")
  313. expect(documents[0]?.info.share).toBe("disabled")
  314. expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
  315. expect(documents[0]?.info.username).toBe("test-user")
  316. expect(documents[0]?.info.permissions).toEqual([
  317. { action: "bash", resource: "*", effect: "ask" },
  318. { action: "bash", resource: "git status", effect: "allow" },
  319. ])
  320. const reviewer = documents[0]?.info.agents?.reviewer
  321. expect(reviewer?.model).toBe("openrouter/openai/gpt-5")
  322. expect(reviewer?.variant).toBe("high")
  323. expect(reviewer?.request).toEqual({
  324. headers: { "x-agent": "reviewer" },
  325. body: { reasoningEffort: "high" },
  326. })
  327. expect(reviewer?.description).toBe("Review changes for correctness")
  328. expect(reviewer?.system).toBe("Find regressions.")
  329. expect(reviewer?.mode).toBe("subagent")
  330. expect(reviewer?.hidden).toBe(false)
  331. expect(reviewer?.color).toBe("warning")
  332. expect(reviewer?.steps).toBe(12)
  333. expect(reviewer?.disabled).toBe(false)
  334. expect(reviewer?.permissions).toEqual([{ action: "edit", resource: "*", effect: "deny" }])
  335. expect(documents[0]?.info.snapshots).toBe(false)
  336. expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })
  337. expect(documents[0]?.info.formatter).toEqual({
  338. prettier: { disabled: true },
  339. custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
  340. })
  341. expect(documents[0]?.info.lsp).toEqual({
  342. typescript: { disabled: true },
  343. custom: { command: ["custom-lsp"], extensions: [".foo"] },
  344. })
  345. expect(documents[0]?.info.attachments).toEqual({
  346. image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
  347. })
  348. expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
  349. expect(documents[0]?.info.mcp).toEqual({
  350. timeout: 5000,
  351. servers: {
  352. local: {
  353. type: "local",
  354. command: ["node", "./mcp/server.js"],
  355. environment: { API_KEY: "secret" },
  356. disabled: false,
  357. timeout: 10000,
  358. },
  359. remote: {
  360. type: "remote",
  361. url: "https://mcp.example.com/mcp",
  362. headers: { Authorization: "Bearer token" },
  363. oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
  364. disabled: true,
  365. },
  366. },
  367. })
  368. expect(documents[0]?.info.compaction).toEqual({
  369. auto: true,
  370. prune: false,
  371. keep: { turns: 3, tokens: 2000 },
  372. buffer: 10000,
  373. })
  374. expect(documents[0]?.info.skills).toEqual([
  375. "./skills",
  376. "~/shared-skills",
  377. "https://example.com/.well-known/skills/",
  378. ])
  379. expect(documents[0]?.info.instructions).toEqual([
  380. "CONTRIBUTING.md",
  381. ".cursor/rules/*.md",
  382. "https://example.com/shared-rules.md",
  383. ])
  384. expect(documents[0]?.info.references).toEqual({
  385. local: { path: "../library" },
  386. sdk: { repository: "github.com/example/sdk", branch: "main" },
  387. shorthand: "github.com/example/docs",
  388. })
  389. expect(documents[0]?.info.plugins).toEqual([
  390. "opencode-helicone-session",
  391. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  392. ])
  393. }).pipe(Effect.provide(testLayer(tmp.path)))
  394. }),
  395. ),
  396. ),
  397. )
  398. it.live("migrates v1 configuration when a v1-only key is present", () =>
  399. Effect.acquireRelease(
  400. Effect.promise(() => tmpdir()),
  401. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  402. ).pipe(
  403. Effect.flatMap((tmp) =>
  404. Effect.gen(function* () {
  405. yield* Effect.promise(() =>
  406. fs.writeFile(
  407. path.join(tmp.path, "opencode.json"),
  408. JSON.stringify({
  409. shell: "/bin/zsh",
  410. snapshot: false,
  411. autoshare: true,
  412. permission: {
  413. bash: "ask",
  414. edit: { "*.md": "allow", "*": "deny" },
  415. },
  416. agent: {
  417. reviewer: {
  418. prompt: "Review changes.",
  419. disable: true,
  420. temperature: 0.2,
  421. permission: { read: "allow" },
  422. },
  423. },
  424. plugin: [
  425. "opencode-helicone-session",
  426. ["@my-org/audit-plugin", { endpoint: "https://audit.example.com" }],
  427. ],
  428. skills: { paths: ["./skills"], urls: ["https://example.com/.well-known/skills/"] },
  429. reference: { docs: { path: "../docs" } },
  430. attachment: { image: { auto_resize: false, max_width: 1200 } },
  431. provider: {
  432. custom: {
  433. options: { apiKey: "secret" },
  434. models: {
  435. model: {
  436. options: { reasoningEffort: "high" },
  437. variants: { fast: { temperature: 0.2 } },
  438. },
  439. },
  440. },
  441. openai: {
  442. npm: "@ai-sdk/openai",
  443. options: { apiKey: "secret", organization: "org" },
  444. models: {
  445. model: { options: { reasoningEffort: "high", serviceTier: "priority" } },
  446. },
  447. },
  448. },
  449. compaction: { auto: true, tail_turns: 3, preserve_recent_tokens: 2000, reserved: 10000 },
  450. experimental: { mcp_timeout: 5000 },
  451. mcp: {
  452. local: { type: "local", command: ["node", "server.js"], enabled: false },
  453. remote: {
  454. type: "remote",
  455. url: "https://mcp.example.com",
  456. oauth: { clientId: "client", callbackPort: 19876 },
  457. },
  458. },
  459. }),
  460. ),
  461. )
  462. return yield* Effect.gen(function* () {
  463. const config = yield* Config.Service
  464. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  465. expect(documents).toHaveLength(1)
  466. expect(documents[0]?.info).toBeInstanceOf(Config.Info)
  467. expect(documents[0]?.info.shell).toBe("/bin/zsh")
  468. expect(documents[0]?.info.snapshots).toBe(false)
  469. expect(documents[0]?.info.share).toBe("auto")
  470. expect(documents[0]?.info.permissions).toEqual([
  471. { action: "bash", resource: "*", effect: "ask" },
  472. { action: "edit", resource: "*.md", effect: "allow" },
  473. { action: "edit", resource: "*", effect: "deny" },
  474. ])
  475. expect(documents[0]?.info.agents?.reviewer).toMatchObject({
  476. system: "Review changes.",
  477. disabled: true,
  478. request: { body: { temperature: 0.2 } },
  479. permissions: [{ action: "read", resource: "*", effect: "allow" }],
  480. })
  481. expect(documents[0]?.info.plugins).toEqual([
  482. "opencode-helicone-session",
  483. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  484. ])
  485. expect(documents[0]?.info.skills).toEqual(["./skills", "https://example.com/.well-known/skills/"])
  486. expect(documents[0]?.info.references).toEqual({ docs: { path: "../docs" } })
  487. expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } })
  488. expect(documents[0]?.info.providers?.custom).toMatchObject({
  489. request: { body: { apiKey: "secret" } },
  490. models: {
  491. model: {
  492. request: { body: { reasoningEffort: "high" } },
  493. variants: [{ id: "fast", body: { temperature: 0.2 } }],
  494. },
  495. },
  496. })
  497. expect(documents[0]?.info.providers?.openai).toMatchObject({
  498. api: { settings: {} },
  499. request: { headers: { Authorization: "Bearer secret", "OpenAI-Organization": "org" } },
  500. models: { model: { request: { body: { reasoning_effort: "high", service_tier: "priority" } } } },
  501. })
  502. expect(documents[0]?.info.compaction).toEqual({
  503. auto: true,
  504. prune: undefined,
  505. keep: { turns: 3, tokens: 2000 },
  506. buffer: 10000,
  507. })
  508. expect(documents[0]?.info.mcp).toMatchObject({
  509. timeout: 5000,
  510. servers: {
  511. local: { type: "local", command: ["node", "server.js"], disabled: true },
  512. remote: {
  513. type: "remote",
  514. url: "https://mcp.example.com",
  515. oauth: { client_id: "client", callback_port: 19876 },
  516. },
  517. },
  518. })
  519. }).pipe(Effect.provide(testLayer(tmp.path)))
  520. }),
  521. ),
  522. ),
  523. )
  524. it.live("ignores invalid files while loading valid config values", () =>
  525. Effect.acquireRelease(
  526. Effect.promise(() => tmpdir()),
  527. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  528. ).pipe(
  529. Effect.flatMap((tmp) =>
  530. Effect.gen(function* () {
  531. yield* Effect.promise(() =>
  532. Promise.all([
  533. fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "base" })),
  534. fs.writeFile(path.join(tmp.path, "opencode.json"), "{ invalid"),
  535. fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ providers: { invalid: true } })),
  536. ]),
  537. )
  538. return yield* Effect.gen(function* () {
  539. const config = yield* Config.Service
  540. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  541. expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
  542. }).pipe(Effect.provide(testLayer(tmp.path)))
  543. }),
  544. ),
  545. ),
  546. )
  547. it.live("loads policy statements in reverse config order", () =>
  548. Effect.acquireRelease(
  549. Effect.promise(() => tmpdir()),
  550. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  551. ).pipe(
  552. Effect.flatMap((tmp) => {
  553. const global = path.join(tmp.path, "global")
  554. return Effect.gen(function* () {
  555. yield* Effect.promise(async () => {
  556. await fs.mkdir(global, { recursive: true })
  557. await fs.writeFile(
  558. path.join(global, "opencode.json"),
  559. JSON.stringify({
  560. experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
  561. }),
  562. )
  563. await fs.writeFile(
  564. path.join(tmp.path, "opencode.json"),
  565. JSON.stringify({
  566. experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] },
  567. }),
  568. )
  569. })
  570. return yield* Effect.gen(function* () {
  571. const policy = yield* Policy.Service
  572. expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
  573. }).pipe(Effect.provide(testLayer(tmp.path, global)))
  574. })
  575. }),
  576. ),
  577. )
  578. it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
  579. Effect.acquireRelease(
  580. Effect.promise(() => tmpdir()),
  581. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  582. ).pipe(
  583. Effect.flatMap((tmp) => {
  584. const global = path.join(tmp.path, "global")
  585. const root = path.join(tmp.path, "repo")
  586. const parent = path.join(root, "packages")
  587. const directory = path.join(parent, "app")
  588. return Effect.gen(function* () {
  589. yield* Effect.promise(async () => {
  590. await fs.mkdir(global, { recursive: true })
  591. await fs.mkdir(directory, { recursive: true })
  592. await fs.mkdir(path.join(root, ".opencode"), { recursive: true })
  593. await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
  594. await Promise.all([
  595. fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })),
  596. fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })),
  597. fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })),
  598. fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })),
  599. fs.writeFile(path.join(directory, "config.json"), JSON.stringify({ $schema: "directory" })),
  600. fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })),
  601. fs.writeFile(
  602. path.join(directory, ".opencode", "opencode.jsonc"),
  603. JSON.stringify({ $schema: "directory-dot" }),
  604. ),
  605. ])
  606. })
  607. return yield* Effect.gen(function* () {
  608. const config = yield* Config.Service
  609. const entries = yield* config.entries()
  610. const documents = entries.filter((entry) => entry.type === "document")
  611. expect(entries.filter((entry) => entry.type === "directory").map((entry) => entry.path)).toEqual([
  612. AbsolutePath.make(global),
  613. AbsolutePath.make(path.join(root, ".opencode")),
  614. AbsolutePath.make(path.join(directory, ".opencode")),
  615. ])
  616. expect(documents.map((document) => document.info.$schema)).toEqual([
  617. "global",
  618. "root",
  619. "parent",
  620. "directory",
  621. "root-dot",
  622. "directory-dot",
  623. ])
  624. expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
  625. "global",
  626. AbsolutePath.make(global),
  627. "root",
  628. "parent",
  629. "directory",
  630. "root-dot",
  631. AbsolutePath.make(path.join(root, ".opencode")),
  632. "directory-dot",
  633. AbsolutePath.make(path.join(directory, ".opencode")),
  634. ])
  635. }).pipe(
  636. Effect.provide(
  637. testLayer(directory, global, root, {
  638. type: "git",
  639. store: AbsolutePath.make(path.join(root, ".git")),
  640. }),
  641. ),
  642. )
  643. })
  644. }),
  645. ),
  646. )
  647. })