fix(install): merge config instead of overwriting on opencode target

The sync path's mergeJsonConfigAtKey had incoming entries overwriting
existing user entries on conflict. Reverse the spread order so user
config wins, matching the install path's existing behavior.

Also add merge feedback logging and a test for the sync merge path.

Fixes #125

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Van Horn
2026-03-08 21:43:39 -07:00
parent 69f2a96e66
commit 1db76800f9
3 changed files with 41 additions and 1 deletions

View File

@@ -18,8 +18,8 @@ export async function mergeJsonConfigAtKey(options: {
const merged = {
...existing,
[key]: {
...existingEntries,
...incoming,
...existingEntries, // existing user entries win on conflict
},
}

View File

@@ -58,12 +58,16 @@ export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBu
const openCodePaths = resolveOpenCodePaths(outputRoot)
await ensureDir(openCodePaths.root)
const hadExistingConfig = await pathExists(openCodePaths.configPath)
const backupPath = await backupFile(openCodePaths.configPath)
if (backupPath) {
console.log(`Backed up existing config to ${backupPath}`)
}
const merged = await mergeOpenCodeConfig(openCodePaths.configPath, bundle.config)
await writeJson(openCodePaths.configPath, merged)
if (hadExistingConfig) {
console.log("Merged plugin config into existing opencode.json (user settings preserved)")
}
const agentsDir = openCodePaths.agentsDir
for (const agent of bundle.agents) {

View File

@@ -3,6 +3,7 @@ import { promises as fs } from "fs"
import path from "path"
import os from "os"
import { writeOpenCodeBundle } from "../src/targets/opencode"
import { mergeJsonConfigAtKey } from "../src/sync/json-config"
import type { OpenCodeBundle } from "../src/types/opencode"
async function exists(filePath: string): Promise<boolean> {
@@ -254,3 +255,38 @@ describe("writeOpenCodeBundle", () => {
expect(backupContent).toBe("old content\n")
})
})
describe("mergeJsonConfigAtKey", () => {
test("preserves existing user entries on conflict", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "json-merge-"))
const configPath = path.join(tempDir, "opencode.json")
// User has an existing MCP server config
const existingConfig = {
model: "my-model",
mcp: {
"user-server": { type: "local", command: ["uvx", "user-srv"] },
},
}
await fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2))
// Plugin tries to add its own server and override user-server
await mergeJsonConfigAtKey({
configPath,
key: "mcp",
incoming: {
"plugin-server": { type: "local", command: ["uvx", "plugin-srv"] },
"user-server": { type: "local", command: ["uvx", "plugin-override"] },
},
})
const merged = JSON.parse(await fs.readFile(configPath, "utf8"))
// User's top-level keys preserved
expect(merged.model).toBe("my-model")
// Plugin server added
expect(merged.mcp["plugin-server"]).toBeDefined()
// User's server NOT overwritten by plugin
expect(merged.mcp["user-server"].command[1]).toBe("user-srv")
})
})