diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 9d03d09c38dd..6a0c18f43634 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -22,6 +22,7 @@ import { ConfigPlugin } from "./config/plugin" import { ConfigProvider } from "./config/provider" import { ConfigReference } from "./config/reference" import { ConfigToolOutput } from "./config/tool-output" +import { ConfigVariable } from "./config/variable" import { ConfigWatcher } from "./config/watcher" import { ConfigV1 } from "./v1/config/config" import { ConfigMigrateV1 } from "./v1/config/migrate" @@ -147,9 +148,10 @@ const layer = Layer.effect( const loadFile = Effect.fnUntraced(function* (filepath: string) { const text = yield* fs.readFileStringSafe(filepath) if (!text) return + const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text }) const errors: ParseError[] = [] - const input: unknown = parse(text, errors, { allowTrailingComma: true }) + const input: unknown = parse(substituted, errors, { allowTrailingComma: true }) if (errors.length) return const info = Option.getOrUndefined( diff --git a/packages/core/src/config/variable.ts b/packages/core/src/config/variable.ts new file mode 100644 index 000000000000..3bb1d22dd3e1 --- /dev/null +++ b/packages/core/src/config/variable.ts @@ -0,0 +1,85 @@ +export * as ConfigVariable from "./variable" + +import os from "os" +import path from "path" +import { Effect } from "effect" +import { FSUtil } from "../fs-util" +import { InvalidError } from "../v1/config/error" + +type ParseSource = + | { + type: "path" + path: string + } + | { + type: "virtual" + source: string + dir: string + } + +type SubstituteInput = ParseSource & { + text: string + missing?: "error" | "empty" + env?: Record +} + +/** Apply {env:VAR} and {file:path} substitutions to config text. */ +export const substitute = Effect.fn("ConfigVariable.substitute")(function* (input: SubstituteInput) { + const text = input.text.replace( + /\{env:([^}]+)\}/g, + (_, varName: string) => (input.env?.[varName] ?? process.env[varName]) || "", + ) + if (!text.includes("{file:")) return text + return yield* substituteFiles(input, text) +}) + +const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, text: string) { + const fs = yield* FSUtil.Service + const configDir = input.type === "path" ? path.dirname(input.path) : input.dir + const configSource = input.type === "path" ? input.path : input.source + const matches = Array.from(text.matchAll(/\{file:[^}]+\}/g)) + let out = "" + let cursor = 0 + + for (const match of matches) { + const token = match[0] + const index = match.index + out += text.slice(cursor, index) + + const lineStart = text.lastIndexOf("\n", index - 1) + 1 + const prefix = text.slice(lineStart, index).trimStart() + if (prefix.startsWith("//")) { + out += token + cursor = index + token.length + continue + } + + const filePath = token.replace(/^\{file:/, "").replace(/\}$/, "") + const expandedPath = filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath + const resolvedPath = path.isAbsolute(expandedPath) ? expandedPath : path.resolve(configDir, expandedPath) + const fileContent = yield* fs.readFileString(resolvedPath).pipe( + Effect.catch((error) => { + if (input.missing === "empty") return Effect.succeed("") + + const message = `bad file reference: "${token}"` + return Effect.fail( + new InvalidError( + { + path: configSource, + message: + error._tag === "PlatformError" && error.reason._tag === "NotFound" + ? `${message} ${resolvedPath} does not exist` + : message, + }, + { cause: error }, + ), + ) + }), + ) + + out += JSON.stringify(fileContent.trim()).slice(1, -1) + cursor = index + token.length + } + + return out + text.slice(cursor) +}) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index e46644abaee0..d7dd398787ab 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -242,6 +242,72 @@ describe("Config", () => { ), ) + it.live("substitutes environment variables and relative file contents", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = { + token: process.env.OPENCODE_TEST_MCP_TOKEN, + missing: process.env.OPENCODE_TEST_MISSING, + } + process.env.OPENCODE_TEST_MCP_TOKEN = "secret" + delete process.env.OPENCODE_TEST_MISSING + return previous + }), + () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all([ + fs.writeFile(path.join(tmp.path, "token.txt"), 'file\n"token"\n'), + fs.writeFile( + path.join(tmp.path, "opencode.jsonc"), + `{ + // Ignored reference: {file:missing.txt} + "username": "user-{env:OPENCODE_TEST_MISSING}", + "mcp": { + "servers": { + "remote": { + "type": "remote", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}", + "X-Token": "{file:token.txt}" + } + } + } + } + }`, + ), + ]), + ) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const document = (yield* config.entries()).find((entry) => entry.type === "document") + expect(document?.info.username).toBe("user-") + const remote = document?.info.mcp?.servers?.remote + expect(remote?.type).toBe("remote") + if (remote?.type !== "remote") return + expect(remote.headers).toEqual({ + Authorization: "Bearer secret", + "X-Token": 'file\n"token"', + }) + }).pipe(Effect.provide(testLayer(tmp.path))) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + (previous) => + Effect.sync(() => { + if (previous.token === undefined) delete process.env.OPENCODE_TEST_MCP_TOKEN + else process.env.OPENCODE_TEST_MCP_TOKEN = previous.token + if (previous.missing === undefined) delete process.env.OPENCODE_TEST_MISSING + else process.env.OPENCODE_TEST_MISSING = previous.missing + }), + ), + ) + it.live("does not load legacy config.json files", () => Effect.acquireRelease( Effect.promise(() => tmpdir()),