diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1c99a45b17..f1e9b6a3e5 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -322,10 +322,7 @@ function patchJsonc(input: string, patch: unknown, path: string[] = []): string return applyEdits(input, edits) } - return Object.entries(patch).reduce((result, [key, value]) => { - if (value === undefined) return result - return patchJsonc(result, value, [...path, key]) - }, input) + return Object.entries(patch).reduce((result, [key, value]) => patchJsonc(result, value, [...path, key]), input) } function writable(info: Info) { @@ -333,6 +330,13 @@ function writable(info: Info) { return next } +function writableGlobal(info: Info) { + const next = writable(info) + // When a user changes config from a value back to default in the Desktop app, we dont want to leave a blank `"shell": "",` key + if ("shell" in next && next.shell === "") return { ...next, shell: undefined } + return next +} + export const ConfigDirectoryTypoError = NamedError.create( "ConfigDirectoryTypoError", z.object({ @@ -761,15 +765,16 @@ export const layer = Layer.effect( const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) { const file = globalConfigFile() const before = (yield* readConfigFile(file)) ?? "{}" + const patch = writableGlobal(config) let next: Info if (!file.endsWith(".jsonc")) { const existing = ConfigParse.schema(Info.zod, ConfigParse.jsonc(before, file), file) - const merged = mergeDeep(writable(existing), writable(config)) + const merged = mergeDeep(writable(existing), patch) yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie) next = merged } else { - const updated = patchJsonc(before, writable(config)) + const updated = patchJsonc(before, patch) next = ConfigParse.schema(Info.zod, ConfigParse.jsonc(updated, file), file) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 815919ee20..65e95c0bd5 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -55,6 +55,8 @@ const it = testEffect(layer) const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer))) const save = (config: Config.Info) => Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer))) +const saveGlobal = (config: Config.Info) => + Effect.runPromise(Config.Service.use((svc) => svc.updateGlobal(config)).pipe(Effect.scoped, Effect.provide(layer))) const clear = (wait = false) => Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer))) const listDirs = () => @@ -180,6 +182,64 @@ test("updates config and preserves empty shell sentinel", async () => { }) }) +test("updates global config and omits empty shell key in json", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + shell: "bash", + }) + }, + }) + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = tmp.path + await clear(true) + + try { + await saveGlobal({ shell: "" } as any) + + const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "opencode.json")) + expect("shell" in writtenConfig).toBe(false) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear(true) + } +}) + +test("updates global config and omits empty shell key in jsonc", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.jsonc"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + shell: "bash", + model: "test/model", + }), + ) + }, + }) + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = tmp.path + await clear(true) + + try { + await saveGlobal({ shell: "" } as any) + + const file = path.join(tmp.path, "opencode.jsonc") + const writtenConfig = await Filesystem.readText(file) + const parsed = ConfigParse.schema(Config.Info.zod, ConfigParse.jsonc(writtenConfig, file), file) + expect(writtenConfig).not.toContain('"shell"') + expect(parsed.shell).toBeUndefined() + expect(parsed.model).toBe("test/model") + } finally { + ;(Global.Path as { config: string }).config = prev + await clear(true) + } +}) + test("loads formatter boolean config", async () => { await using tmp = await tmpdir({ init: async (dir) => {