From 1db76800f91fefcc1bb9c1798ef273ddd0b65f5c Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Sun, 8 Mar 2026 21:43:39 -0700 Subject: [PATCH] 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 --- src/sync/json-config.ts | 2 +- src/targets/opencode.ts | 4 ++++ tests/opencode-writer.test.ts | 36 +++++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/sync/json-config.ts b/src/sync/json-config.ts index 180993b..547ca3b 100644 --- a/src/sync/json-config.ts +++ b/src/sync/json-config.ts @@ -18,8 +18,8 @@ export async function mergeJsonConfigAtKey(options: { const merged = { ...existing, [key]: { - ...existingEntries, ...incoming, + ...existingEntries, // existing user entries win on conflict }, } diff --git a/src/targets/opencode.ts b/src/targets/opencode.ts index e0e89ff..b4bf53e 100644 --- a/src/targets/opencode.ts +++ b/src/targets/opencode.ts @@ -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) { diff --git a/tests/opencode-writer.test.ts b/tests/opencode-writer.test.ts index 5c02cc1..f0aa976 100644 --- a/tests/opencode-writer.test.ts +++ b/tests/opencode-writer.test.ts @@ -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 { @@ -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") + }) +})