Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(
Expand Down
85 changes: 85 additions & 0 deletions packages/core/src/config/variable.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
}

/** 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)
})
66 changes: 66 additions & 0 deletions packages/core/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
Loading