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 ?? {}
|
const remoteConfig = wellknown.config ?? {}
|
||||||
// Add $schema to prevent load() from trying to write back to a non-existent file
|
// 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"
|
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 })
|
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.
|
// Inline config content overrides all non-managed config sources.
|
||||||
if (Flag.OPENCODE_CONFIG_CONTENT) {
|
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
||||||
result = merge(result, JSON.parse(Flag.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")
|
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1236,24 +1248,32 @@ export namespace Config {
|
|||||||
throw new JsonError({ path: filepath }, { cause: err })
|
throw new JsonError({ path: filepath }, { cause: err })
|
||||||
})
|
})
|
||||||
if (!text) return {}
|
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 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) => {
|
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||||
return process.env[varName] || ""
|
return process.env[varName] || ""
|
||||||
})
|
})
|
||||||
|
|
||||||
const fileMatches = text.match(/\{file:[^}]+\}/g)
|
const fileMatches = text.match(/\{file:[^}]+\}/g)
|
||||||
if (fileMatches) {
|
if (fileMatches) {
|
||||||
const configDir = path.dirname(configFilepath)
|
|
||||||
const lines = text.split("\n")
|
const lines = text.split("\n")
|
||||||
|
|
||||||
for (const match of fileMatches) {
|
for (const match of fileMatches) {
|
||||||
const lineIndex = lines.findIndex((line) => line.includes(match))
|
const lineIndex = lines.findIndex((line) => line.includes(match))
|
||||||
if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) {
|
if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) {
|
||||||
continue // Skip if line is commented
|
continue
|
||||||
}
|
}
|
||||||
let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
|
let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
|
||||||
if (filePath.startsWith("~/")) {
|
if (filePath.startsWith("~/")) {
|
||||||
@@ -1261,21 +1281,22 @@ export namespace Config {
|
|||||||
}
|
}
|
||||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||||
const fileContent = (
|
const fileContent = (
|
||||||
await Filesystem.readText(resolvedPath).catch((error: any) => {
|
await Bun.file(resolvedPath)
|
||||||
const errMsg = `bad file reference: "${match}"`
|
.text()
|
||||||
if (error.code === "ENOENT") {
|
.catch((error) => {
|
||||||
throw new InvalidError(
|
const errMsg = `bad file reference: "${match}"`
|
||||||
{
|
if (error.code === "ENOENT") {
|
||||||
path: configFilepath,
|
throw new InvalidError(
|
||||||
message: errMsg + ` ${resolvedPath} does not exist`,
|
{
|
||||||
},
|
path: source,
|
||||||
{ cause: error },
|
message: errMsg + ` ${resolvedPath} does not exist`,
|
||||||
)
|
},
|
||||||
}
|
{ cause: error },
|
||||||
throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
|
)
|
||||||
})
|
}
|
||||||
|
throw new InvalidError({ path: source, message: errMsg }, { cause: error })
|
||||||
|
})
|
||||||
).trim()
|
).trim()
|
||||||
// escape newlines/quotes, strip outer quotes
|
|
||||||
text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
|
text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1299,25 +1320,24 @@ export namespace Config {
|
|||||||
.join("\n")
|
.join("\n")
|
||||||
|
|
||||||
throw new JsonError({
|
throw new JsonError({
|
||||||
path: configFilepath,
|
path: source,
|
||||||
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
|
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = Info.safeParse(data)
|
const parsed = Info.safeParse(data)
|
||||||
if (parsed.success) {
|
if (parsed.success) {
|
||||||
if (!parsed.data.$schema) {
|
if (!parsed.data.$schema && isFile) {
|
||||||
parsed.data.$schema = "https://opencode.ai/config.json"
|
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",')
|
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
|
const data = parsed.data
|
||||||
if (data.plugin) {
|
if (data.plugin && isFile) {
|
||||||
for (let i = 0; i < data.plugin.length; i++) {
|
for (let i = 0; i < data.plugin.length; i++) {
|
||||||
const plugin = data.plugin[i]
|
const plugin = data.plugin[i]
|
||||||
try {
|
try {
|
||||||
data.plugin[i] = import.meta.resolve!(plugin, configFilepath)
|
data.plugin[i] = import.meta.resolve!(plugin, options.path)
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1325,7 +1345,7 @@ export namespace Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new InvalidError({
|
throw new InvalidError({
|
||||||
path: configFilepath,
|
path: source,
|
||||||
issues: parsed.error.issues,
|
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