From 30d54150d28ed31855496dca32bb273772c825a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E5=86=A0=E8=88=9F?= Date: Wed, 25 Feb 2026 18:23:21 +0800 Subject: [PATCH] fix: resolve config file read/write mismatch in update endpoint Fixes a critical bug where PATCH /config would write to config.json, but GET /config only read from opencode.json/opencode.jsonc, causing updated configuration to not take effect immediately. Changes: - Add projectConfigFile() to find existing project config files - Update Config.update() to use projectConfigFile() (similar to updateGlobal) - Support JSON and JSONC formats with comment preservation - Default to opencode.json for new project configs - Add config.json support to all project config loading paths: * Project directory config loading * .opencode directory config loading * Managed config directory loading - Make PATCH endpoint return merged config (not just input) - Add comprehensive tests for config update scenarios This ensures consistent config file precedence across all operations: opencode.jsonc > opencode.json > config.json --- packages/opencode/src/config/config.ts | 41 ++++++++++++++++--- packages/opencode/src/server/routes/config.ts | 4 +- packages/opencode/test/config/config.test.ts | 41 ++++++++++++++++++- 3 files changed, 76 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 761ce23f3d6c..3abbd0f80a6c 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -117,7 +117,7 @@ export namespace Config { // Project config overrides global and remote config. if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { - for (const file of ["opencode.jsonc", "opencode.json"]) { + for (const file of ["opencode.jsonc", "opencode.json", "config.json"]) { const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree) for (const resolved of found.toReversed()) { result = merge(result, await loadFile(resolved)) @@ -161,7 +161,7 @@ export namespace Config { for (const dir of unique(directories)) { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { - for (const file of ["opencode.jsonc", "opencode.json"]) { + for (const file of ["opencode.jsonc", "opencode.json", "config.json"]) { log.debug(`loading config from ${path.join(dir, file)}`) result = merge(result, await loadFile(path.join(dir, file))) // to satisfy the type checker @@ -201,7 +201,7 @@ export namespace Config { // which would fail on system directories requiring elevated permissions // This way it only loads config file and not skills/plugins/commands if (existsSync(managedConfigDir)) { - for (const file of ["opencode.jsonc", "opencode.json"]) { + for (const file of ["opencode.jsonc", "opencode.json", "config.json"]) { result = merge(result, await loadFile(path.join(managedConfigDir, file))) } } @@ -1388,10 +1388,39 @@ export namespace Config { } export async function update(config: Info) { - const filepath = path.join(Instance.directory, "config.json") - const existing = await loadFile(filepath) - await Filesystem.writeJson(filepath, mergeDeep(existing, config)) + const filepath = projectConfigFile() + const before = await Filesystem.readText(filepath).catch((err: any) => { + if (err.code === "ENOENT") return "{}" + throw new JsonError({ path: filepath }, { cause: err }) + }) + + const next = await (async () => { + if (!filepath.endsWith(".jsonc")) { + const existing = parseConfig(before, filepath) + const merged = mergeDeep(existing, config) + await Filesystem.writeJson(filepath, merged) + return merged + } + + const updated = patchJsonc(before, config) + const merged = parseConfig(updated, filepath) + await Filesystem.write(filepath, updated) + return merged + })() + await Instance.dispose() + return next + } + + function projectConfigFile() { + const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) => + path.join(Instance.directory, file), + ) + for (const file of candidates) { + if (existsSync(file)) return file + } + // Default to opencode.json (standard project config file name) + return path.join(Instance.directory, "opencode.json") } function globalConfigFile() { diff --git a/packages/opencode/src/server/routes/config.ts b/packages/opencode/src/server/routes/config.ts index 85d28f6aa6b8..7737a70c9e3c 100644 --- a/packages/opencode/src/server/routes/config.ts +++ b/packages/opencode/src/server/routes/config.ts @@ -54,8 +54,8 @@ export const ConfigRoutes = lazy(() => validator("json", Config.Info), async (c) => { const config = c.req.valid("json") - await Config.update(config) - return c.json(config) + const next = await Config.update(config) + return c.json(next) }, ) .get( diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 2b1ba816ea3b..d284ef8e2274 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -567,14 +567,51 @@ test("updates config and writes to file", async () => { directory: tmp.path, fn: async () => { const newConfig = { model: "updated/model" } - await Config.update(newConfig as any) + const result = await Config.update(newConfig as any) - const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "config.json")) + // Should return merged config + expect(result.model).toBe("updated/model") + + // Should write to opencode.json by default (not config.json) + const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "opencode.json")) expect(writtenConfig.model).toBe("updated/model") }, }) }) +test("updates existing opencode.json instead of creating config.json", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Create an existing opencode.json file + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + model: "original/model", + username: "testuser", + }) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const newConfig = { model: "updated/model" } + const result = await Config.update(newConfig as any) + + // Should return merged config with both old and new fields + expect(result.model).toBe("updated/model") + expect(result.username).toBe("testuser") + + // Should update existing opencode.json (not create config.json) + const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "opencode.json")) + expect(writtenConfig.model).toBe("updated/model") + expect(writtenConfig.username).toBe("testuser") // Should preserve existing fields + + // config.json should not be created + const configJsonExists = await Filesystem.exists(path.join(tmp.path, "config.json")) + expect(configJsonExists).toBe(false) + }, + }) +}) + test("gets config directories", async () => { await using tmp = await tmpdir() await Instance.provide({