fix: token substitution in OPENCODE_CONFIG_CONTENT (alternate take) (#14047)
This commit is contained in:
@@ -89,7 +89,13 @@ export namespace Config {
|
||||
const remoteConfig = wellknown.config ?? {}
|
||||
// Add $schema to prevent load() from trying to write back to a non-existent file
|
||||
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
||||
result = merge(result, await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`))
|
||||
result = merge(
|
||||
result,
|
||||
await load(JSON.stringify(remoteConfig), {
|
||||
dir: path.dirname(`${key}/.well-known/opencode`),
|
||||
source: `${key}/.well-known/opencode`,
|
||||
}),
|
||||
)
|
||||
log.debug("loaded remote config from well-known", { url: key })
|
||||
}
|
||||
}
|
||||
@@ -177,8 +183,14 @@ export namespace Config {
|
||||
}
|
||||
|
||||
// Inline config content overrides all non-managed config sources.
|
||||
if (Flag.OPENCODE_CONFIG_CONTENT) {
|
||||
result = merge(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
|
||||
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
||||
result = merge(
|
||||
result,
|
||||
await load(process.env.OPENCODE_CONFIG_CONTENT, {
|
||||
dir: Instance.directory,
|
||||
source: "OPENCODE_CONFIG_CONTENT",
|
||||
}),
|
||||
)
|
||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||
}
|
||||
|
||||
@@ -1236,24 +1248,32 @@ export namespace Config {
|
||||
throw new JsonError({ path: filepath }, { cause: err })
|
||||
})
|
||||
if (!text) return {}
|
||||
return load(text, filepath)
|
||||
return load(text, { path: filepath })
|
||||
}
|
||||
|
||||
async function load(text: string, configFilepath: string) {
|
||||
async function load(
|
||||
text: string,
|
||||
options:
|
||||
| { path: string }
|
||||
| { dir: string; source: string },
|
||||
) {
|
||||
const original = text
|
||||
const configDir = "path" in options ? path.dirname(options.path) : options.dir
|
||||
const source = "path" in options ? options.path : options.source
|
||||
const isFile = "path" in options
|
||||
|
||||
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||
return process.env[varName] || ""
|
||||
})
|
||||
|
||||
const fileMatches = text.match(/\{file:[^}]+\}/g)
|
||||
if (fileMatches) {
|
||||
const configDir = path.dirname(configFilepath)
|
||||
const lines = text.split("\n")
|
||||
|
||||
for (const match of fileMatches) {
|
||||
const lineIndex = lines.findIndex((line) => line.includes(match))
|
||||
if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) {
|
||||
continue // Skip if line is commented
|
||||
continue
|
||||
}
|
||||
let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
|
||||
if (filePath.startsWith("~/")) {
|
||||
@@ -1261,21 +1281,22 @@ export namespace Config {
|
||||
}
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||
const fileContent = (
|
||||
await Filesystem.readText(resolvedPath).catch((error: any) => {
|
||||
const errMsg = `bad file reference: "${match}"`
|
||||
if (error.code === "ENOENT") {
|
||||
throw new InvalidError(
|
||||
{
|
||||
path: configFilepath,
|
||||
message: errMsg + ` ${resolvedPath} does not exist`,
|
||||
},
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
|
||||
})
|
||||
await Bun.file(resolvedPath)
|
||||
.text()
|
||||
.catch((error) => {
|
||||
const errMsg = `bad file reference: "${match}"`
|
||||
if (error.code === "ENOENT") {
|
||||
throw new InvalidError(
|
||||
{
|
||||
path: source,
|
||||
message: errMsg + ` ${resolvedPath} does not exist`,
|
||||
},
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
throw new InvalidError({ path: source, message: errMsg }, { cause: error })
|
||||
})
|
||||
).trim()
|
||||
// escape newlines/quotes, strip outer quotes
|
||||
text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
|
||||
}
|
||||
}
|
||||
@@ -1299,25 +1320,24 @@ export namespace Config {
|
||||
.join("\n")
|
||||
|
||||
throw new JsonError({
|
||||
path: configFilepath,
|
||||
path: source,
|
||||
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = Info.safeParse(data)
|
||||
if (parsed.success) {
|
||||
if (!parsed.data.$schema) {
|
||||
if (!parsed.data.$schema && isFile) {
|
||||
parsed.data.$schema = "https://opencode.ai/config.json"
|
||||
// Write the $schema to the original text to preserve variables like {env:VAR}
|
||||
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
|
||||
await Filesystem.write(configFilepath, updated).catch(() => {})
|
||||
await Bun.write(options.path, updated).catch(() => {})
|
||||
}
|
||||
const data = parsed.data
|
||||
if (data.plugin) {
|
||||
if (data.plugin && isFile) {
|
||||
for (let i = 0; i < data.plugin.length; i++) {
|
||||
const plugin = data.plugin[i]
|
||||
try {
|
||||
data.plugin[i] = import.meta.resolve!(plugin, configFilepath)
|
||||
data.plugin[i] = import.meta.resolve!(plugin, options.path)
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
@@ -1325,7 +1345,7 @@ export namespace Config {
|
||||
}
|
||||
|
||||
throw new InvalidError({
|
||||
path: configFilepath,
|
||||
path: source,
|
||||
issues: parsed.error.issues,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1800,3 +1800,66 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
|
||||
test("substitutes {env:} tokens in OPENCODE_CONFIG_CONTENT", async () => {
|
||||
const originalEnv = process.env["OPENCODE_CONFIG_CONTENT"]
|
||||
const originalTestVar = process.env["TEST_CONFIG_VAR"]
|
||||
process.env["TEST_CONFIG_VAR"] = "test_api_key_12345"
|
||||
process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
theme: "{env:TEST_CONFIG_VAR}",
|
||||
})
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.theme).toBe("test_api_key_12345")
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env["OPENCODE_CONFIG_CONTENT"] = originalEnv
|
||||
} else {
|
||||
delete process.env["OPENCODE_CONFIG_CONTENT"]
|
||||
}
|
||||
if (originalTestVar !== undefined) {
|
||||
process.env["TEST_CONFIG_VAR"] = originalTestVar
|
||||
} else {
|
||||
delete process.env["TEST_CONFIG_VAR"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("substitutes {file:} tokens in OPENCODE_CONFIG_CONTENT", async () => {
|
||||
const originalEnv = process.env["OPENCODE_CONFIG_CONTENT"]
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "api_key.txt"), "secret_key_from_file")
|
||||
process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
theme: "{file:./api_key.txt}",
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.theme).toBe("secret_key_from_file")
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env["OPENCODE_CONFIG_CONTENT"] = originalEnv
|
||||
} else {
|
||||
delete process.env["OPENCODE_CONFIG_CONTENT"]
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user