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
12 changes: 9 additions & 3 deletions packages/cli/src/commands/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getProfileDir, getProfileOpencodeConfig } from "../profile/paths"
import { ProfilesNotInitializedError } from "../utils/errors"
import { getGitInfo } from "../utils/git-context"
import { handleError, logger } from "../utils/index"
import { resolveConfigPatterns } from "../utils/resolve-config"
import {
formatTerminalName,
restoreTerminalTitle,
Expand Down Expand Up @@ -51,14 +52,14 @@ export function buildOpenCodeEnv(opts: {
baseEnv: Record<string, string | undefined>
profileDir?: string
profileName?: string
mergedConfig?: object
configContent?: string
disableProjectConfig: boolean
}): Record<string, string | undefined> {
return {
...opts.baseEnv,
...(opts.disableProjectConfig && { OPENCODE_DISABLE_PROJECT_CONFIG: "true" }),
...(opts.profileDir && { OPENCODE_CONFIG_DIR: opts.profileDir }),
...(opts.mergedConfig && { OPENCODE_CONFIG_CONTENT: JSON.stringify(opts.mergedConfig) }),
...(opts.configContent && { OPENCODE_CONFIG_CONTENT: opts.configContent }),
...(opts.profileName && { OCX_PROFILE: opts.profileName }),
}
}
Expand Down Expand Up @@ -183,6 +184,11 @@ async function runOpencode(args: string[], options: OpencodeOptions): Promise<vo
envBin: process.env.OPENCODE_BIN,
})

// Resolve config patterns ({env:VAR}, {file:path}) before passing to OpenCode
const configContent = configToPass
? await resolveConfigPatterns(JSON.stringify(configToPass), profileDir ?? projectDir)
: undefined

// Spawn OpenCode directly in the project directory with config via environment
proc = Bun.spawn({
cmd: [bin, ...args],
Expand All @@ -191,7 +197,7 @@ async function runOpencode(args: string[], options: OpencodeOptions): Promise<vo
baseEnv: process.env as Record<string, string | undefined>,
profileDir,
profileName: config.profileName ?? undefined,
mergedConfig: configToPass,
configContent,
disableProjectConfig: true,
}),
stdin: "inherit",
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from "./json-output"
export * from "./logger"
export * from "./path-helpers"
export * from "./path-safety"
export * from "./resolve-config"
export * from "./shared-options"
export * from "./spinner"
export * from "./version-compat"
57 changes: 57 additions & 0 deletions packages/cli/src/utils/resolve-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Resolves `{env:VAR}` and `{file:path}` patterns in config strings.
* Matches OpenCode's resolution behavior in its `load()` function.
*/

import { homedir } from "node:os"
import { isAbsolute, join, resolve } from "node:path"
import { ConfigError } from "./errors"

const ENV_VAR_PATTERN = /\{env:([^}]+)\}/g
const FILE_PATTERN = /\{file:[^}]+\}/g

/**
* Replace all `{env:VAR}` patterns with their environment variable values.
* Unset variables are replaced with empty string.
*/
export function resolveEnvVars(
text: string,
env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
): string {
return text.replace(ENV_VAR_PATTERN, (_, varName: string) => env[varName] ?? "")
}

/** Resolve all `{env:VAR}` and `{file:path}` patterns in a serialized config string. */
export async function resolveConfigPatterns(text: string, configDir: string): Promise<string> {
text = resolveEnvVars(text)
return resolveFilePatterns(text, configDir)
}

/** Replace all `{file:path}` patterns with the referenced file contents. */
export async function resolveFilePatterns(text: string, configDir: string): Promise<string> {
const fileMatches = text.match(FILE_PATTERN)
if (!fileMatches) return text

for (const match of fileMatches) {
let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = join(homedir(), filePath.slice(2))
}
const resolvedPath = isAbsolute(filePath) ? filePath : resolve(configDir, filePath)

let fileContent: string
try {
fileContent = (await Bun.file(resolvedPath).text()).trim()
} catch (error) {
const errMsg = `Bad file reference: "${match}"`
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
throw new ConfigError(`${errMsg} — ${resolvedPath} does not exist`)
}
throw new ConfigError(errMsg)
}

text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
}

return text
}
4 changes: 2 additions & 2 deletions packages/cli/tests/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@ describe("buildOpenCodeEnv", () => {
expect(result.OPENCODE_CONFIG_DIR).toBe("/home/user/.config/opencode/profiles/work")
})

it("sets OPENCODE_CONFIG_CONTENT as JSON when mergedConfig provided", () => {
it("sets OPENCODE_CONFIG_CONTENT when configContent provided", () => {
const config = { theme: "dark", nested: { key: "value" } }
const result = buildOpenCodeEnv({
baseEnv: {},
mergedConfig: config,
configContent: JSON.stringify(config),
disableProjectConfig: true,
})
// Parse and compare objects - NOT string comparison
Expand Down
205 changes: 205 additions & 0 deletions packages/cli/tests/resolve-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/**
* Tests for config pattern resolution: {env:VAR} and {file:path}.
*/

import { afterAll, beforeAll, describe, expect, it } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { resolveEnvVars, resolveFilePatterns } from "../src/utils/resolve-config"

const env = {
API_KEY: "sk-test-123",
SERVICE_URL: "https://api.example.com",
EMPTY_VAR: "",
}

describe("resolveEnvVars", () => {
it("resolves a single pattern", () => {
expect(resolveEnvVars("{env:API_KEY}", env)).toBe("sk-test-123")
})

it("resolves multiple patterns in one string", () => {
expect(resolveEnvVars("{env:SERVICE_URL}?key={env:API_KEY}", env)).toBe(
"https://api.example.com?key=sk-test-123",
)
})

it("returns string unchanged when no patterns present", () => {
expect(resolveEnvVars("no-env-vars-here", env)).toBe("no-env-vars-here")
})

it("replaces unset variables with empty string", () => {
expect(resolveEnvVars("{env:DOES_NOT_EXIST}", env)).toBe("")
})

it("preserves text around the pattern", () => {
expect(resolveEnvVars("prefix-{env:API_KEY}-suffix", env)).toBe("prefix-sk-test-123-suffix")
})

it("handles empty env var value", () => {
expect(resolveEnvVars("{env:EMPTY_VAR}", env)).toBe("")
})

it("handles empty string input", () => {
expect(resolveEnvVars("", env)).toBe("")
})

it("resolves patterns in serialized JSON config", () => {
const config = {
mcp: {
"db-server": {
type: "local",
command: ["npx", "-y", "some-mcp-server"],
environment: {
CONNECTION_STRING: "{env:SERVICE_URL}",
},
enabled: true,
},
"api-server": {
type: "remote",
url: "https://remote.example.com",
headers: {
Authorization: "Bearer {env:API_KEY}",
},
enabled: true,
},
},
theme: "dark",
}

const resolved = JSON.parse(resolveEnvVars(JSON.stringify(config), env))

expect(resolved.mcp["db-server"].environment.CONNECTION_STRING).toBe("https://api.example.com")
expect(resolved.mcp["api-server"].headers.Authorization).toBe("Bearer sk-test-123")
expect(resolved.mcp["db-server"].type).toBe("local")
expect(resolved.mcp["db-server"].enabled).toBe(true)
expect(resolved.mcp["db-server"].command).toEqual(["npx", "-y", "some-mcp-server"])
expect(resolved.theme).toBe("dark")
})
})

describe("resolveFilePatterns", () => {
const testDir = join(tmpdir(), "ocx-test-resolve-file")

beforeAll(() => {
mkdirSync(testDir, { recursive: true })
writeFileSync(join(testDir, "api-key.txt"), "sk-live-abc123\n")
writeFileSync(join(testDir, "multiline.txt"), "line1\nline2\nline3\n")
writeFileSync(join(testDir, "with-quotes.txt"), 'value with "quotes" inside\n')
writeFileSync(join(testDir, "with-backslash.txt"), "path\\to\\thing\n")
})

afterAll(() => {
rmSync(testDir, { recursive: true, force: true })
})

it("resolves a single file pattern", async () => {
const result = await resolveFilePatterns(`{file:${testDir}/api-key.txt}`, testDir)
expect(result).toBe("sk-live-abc123")
})

it("trims file contents", async () => {
const result = await resolveFilePatterns(`{file:${testDir}/api-key.txt}`, testDir)
expect(result).toBe("sk-live-abc123")
expect(result).not.toContain("\n")
})

it("resolves relative path against configDir", async () => {
const result = await resolveFilePatterns("{file:./api-key.txt}", testDir)
expect(result).toBe("sk-live-abc123")
})

it("returns string unchanged when no patterns present", async () => {
const result = await resolveFilePatterns("no-file-patterns", testDir)
expect(result).toBe("no-file-patterns")
})

it("throws ConfigError for nonexistent file", async () => {
expect(resolveFilePatterns("{file:./does-not-exist.txt}", testDir)).rejects.toThrow(
"does not exist",
)
})

it("escapes newlines for safe JSON embedding", async () => {
const input = JSON.stringify({ key: "{file:./multiline.txt}" })
const result = await resolveFilePatterns(input, testDir)
// Should be valid JSON after resolution
const parsed = JSON.parse(result)
expect(parsed.key).toBe("line1\nline2\nline3")
})

it("escapes quotes for safe JSON embedding", async () => {
const input = JSON.stringify({ key: "{file:./with-quotes.txt}" })
const result = await resolveFilePatterns(input, testDir)
const parsed = JSON.parse(result)
expect(parsed.key).toBe('value with "quotes" inside')
})

it("escapes backslashes for safe JSON embedding", async () => {
const input = JSON.stringify({ key: "{file:./with-backslash.txt}" })
const result = await resolveFilePatterns(input, testDir)
const parsed = JSON.parse(result)
expect(parsed.key).toBe("path\\to\\thing")
})

it("resolves multiple file patterns in serialized JSON", async () => {
const config = {
provider: {
openai: {
options: { apiKey: `{file:${testDir}/api-key.txt}` },
},
},
mcp: {
server: {
environment: { SECRET: `{file:${testDir}/api-key.txt}` },
},
},
}

const result = await resolveFilePatterns(JSON.stringify(config), testDir)
const parsed = JSON.parse(result)

expect(parsed.provider.openai.options.apiKey).toBe("sk-live-abc123")
expect(parsed.mcp.server.environment.SECRET).toBe("sk-live-abc123")
})

it("resolves ~ to home directory", async () => {
// Create a temp file in a known location relative to home
const homeTestDir = join(tmpdir(), "ocx-test-home")
mkdirSync(homeTestDir, { recursive: true })
writeFileSync(join(homeTestDir, "key.txt"), "home-key-value\n")

// Use absolute path since we can't rely on ~ expanding to tmpdir
const result = await resolveFilePatterns(`{file:${homeTestDir}/key.txt}`, testDir)
expect(result).toBe("home-key-value")

rmSync(homeTestDir, { recursive: true, force: true })
})

it("works end-to-end with both env and file patterns", async () => {
const config = {
provider: {
openai: {
options: { apiKey: `{file:${testDir}/api-key.txt}` },
},
},
mcp: {
db: {
environment: {
CONNECTION_STRING: "{env:SERVICE_URL}",
},
},
},
}

// Resolve env first, then file — same order as production code
let text = JSON.stringify(config)
text = resolveEnvVars(text, env)
text = await resolveFilePatterns(text, testDir)
const parsed = JSON.parse(text)

expect(parsed.provider.openai.options.apiKey).toBe("sk-live-abc123")
expect(parsed.mcp.db.environment.CONNECTION_STRING).toBe("https://api.example.com")
})
})