diff --git a/packages/opencode/src/config/migrate-tui-config.ts b/packages/opencode/src/config/migrate-tui-config.ts index b426e4fbd1..a59574b8d3 100644 --- a/packages/opencode/src/config/migrate-tui-config.ts +++ b/packages/opencode/src/config/migrate-tui-config.ts @@ -1,6 +1,6 @@ import path from "path" import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser" -import { unique } from "remeda" +import { mergeDeep, unique } from "remeda" import z from "zod" import { ConfigPaths } from "./paths" import { TuiInfo, TuiOptions } from "./tui-schema" @@ -31,72 +31,104 @@ interface MigrateInput { managed: string } +interface SourceGroup { + target: string + files: string[] +} + +interface LegacyFile { + file: string + source: string + legacy: Record +} + /** - * Migrates tui-specific keys (theme, keybinds, tui) from opencode.json files - * into dedicated tui.json files. Migration is performed per-directory and - * skips only locations where a tui.json already exists. + * Migrates tui-specific keys (theme, keybinds, tui) from server config files + * into dedicated tui.json files. Source files are merged in server precedence + * order before writing each target tui.json. */ export async function migrateTuiConfig(input: MigrateInput) { - const opencode = await opencodeFiles(input) - for (const file of opencode) { - const source = await Filesystem.readText(file).catch((error) => { - log.warn("failed to read config for tui migration", { path: file, error }) - return undefined - }) - if (!source) continue - const errors: JsoncParseError[] = [] - const data = parseJsonc(source, errors, { allowTrailingComma: true }) - if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) continue - - const theme = LegacyTheme.safeParse("theme" in data ? data.theme : undefined) - const keybinds = LegacyRecord.safeParse("keybinds" in data ? data.keybinds : undefined) - const legacyTui = LegacyRecord.safeParse("tui" in data ? data.tui : undefined) - const extracted = { - theme: theme.success ? theme.data : undefined, - keybinds: keybinds.success ? keybinds.data : undefined, - tui: legacyTui.success ? legacyTui.data : undefined, - } - const tui = extracted.tui ? normalizeTui(extracted.tui) : undefined - if (extracted.theme === undefined && extracted.keybinds === undefined && !tui) continue - - const target = path.join(path.dirname(file), "tui.json") - const targetExists = await Filesystem.exists(target) + const groups = await sourceGroups(input) + for (const group of groups) { + const targetExists = await Filesystem.exists(group.target) if (targetExists) continue - const payload: Record = { - $schema: TUI_SCHEMA_URL, - } - if (extracted.theme !== undefined) payload.theme = extracted.theme - if (extracted.keybinds !== undefined) payload.keybinds = extracted.keybinds - if (tui) Object.assign(payload, tui) + const parsed = (await Promise.all(group.files.map(parseLegacyFile))).filter((item): item is LegacyFile => !!item) + if (!parsed.length) continue - const wrote = await Bun.write(target, JSON.stringify(payload, null, 2)) + const payload = parsed.reduce((acc, item) => mergeDeep(acc, item.legacy), { $schema: TUI_SCHEMA_URL } as Record< + string, + unknown + >) + + const wrote = await Bun.write(group.target, JSON.stringify(payload, null, 2)) .then(() => true) .catch((error) => { - log.warn("failed to write tui migration target", { from: file, to: target, error }) + log.warn("failed to write tui migration target", { + from: parsed.map((item) => item.file), + to: group.target, + error, + }) return false }) if (!wrote) continue - const stripped = await backupAndStripLegacy(file, source) - if (!stripped) { - log.warn("tui config migrated but source file was not stripped", { from: file, to: target }) + const stripped = await Promise.all(parsed.map((item) => backupAndStripLegacy(item.file, item.source))) + if (stripped.some((ok) => !ok)) { + log.warn("tui config migrated but some source files were not stripped", { + from: parsed.map((item) => item.file), + to: group.target, + }) continue } - log.info("migrated tui config", { from: file, to: target }) + + log.info("migrated tui config", { + from: parsed.map((item) => item.file), + to: group.target, + }) + } +} + +async function parseLegacyFile(file: string) { + const source = await Filesystem.readText(file).catch((error) => { + log.warn("failed to read config for tui migration", { path: file, error }) + return undefined + }) + if (!source) return + + const errors: JsoncParseError[] = [] + const data = parseJsonc(source, errors, { allowTrailingComma: true }) + if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) return + + const theme = LegacyTheme.safeParse("theme" in data ? data.theme : undefined) + const keybinds = LegacyRecord.safeParse("keybinds" in data ? data.keybinds : undefined) + const legacyTui = LegacyRecord.safeParse("tui" in data ? data.tui : undefined) + const tui = legacyTui.success && legacyTui.data ? normalizeTui(legacyTui.data) : undefined + + const legacy: Record = {} + if (theme.success && theme.data !== undefined) legacy.theme = theme.data + if (keybinds.success && keybinds.data !== undefined) legacy.keybinds = keybinds.data + if (tui) Object.assign(legacy, tui) + if (!Object.keys(legacy).length) return + + return { + file, + source, + legacy, } } function normalizeTui(data: Record) { - const parsed = TuiLegacy.parse(data) + const parsed = TuiLegacy.safeParse(data) + if (!parsed.success) return if ( - parsed.scroll_speed === undefined && - parsed.diff_style === undefined && - parsed.scroll_acceleration === undefined + parsed.data.scroll_speed === undefined && + parsed.data.diff_style === undefined && + parsed.data.scroll_acceleration === undefined ) { return } - return parsed + return parsed.data } async function backupAndStripLegacy(file: string, source: string) { @@ -134,15 +166,24 @@ async function backupAndStripLegacy(file: string, source: string) { }) } -async function opencodeFiles(input: { directories: string[]; managed: string }) { - const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG - ? [] - : await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree) - const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")] +async function sourceGroups(input: MigrateInput): Promise { + const files = [ + path.join(Global.Path.config, "config.json"), + path.join(Global.Path.config, "opencode.json"), + path.join(Global.Path.config, "opencode.jsonc"), + ] + + if (input.custom) files.push(input.custom) + + if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { + files.push(...(await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree))) + } + for (const dir of unique(input.directories)) { + if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue files.push(...ConfigPaths.fileInDirectory(dir, "opencode")) } - if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG) + files.push(...ConfigPaths.fileInDirectory(input.managed, "opencode")) const existing = await Promise.all( @@ -151,5 +192,16 @@ async function opencodeFiles(input: { directories: string[]; managed: string }) return ok ? file : undefined }), ) - return existing.filter((file): file is string => !!file) + + const result = new Map() + for (const file of existing) { + if (!file) continue + const target = path.join(path.dirname(file), "tui.json") + result.set(target, [...(result.get(target) ?? []), file]) + } + + return Array.from(result.entries()).map(([target, groupFiles]) => ({ + target, + files: groupFiles, + })) } diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index f9de5b041b..0218cd939d 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -80,6 +80,62 @@ test("migrates tui-specific keys from opencode.json when tui.json does not exist }) }) +test("merges legacy tui keys from opencode.jsonc and opencode.json before migrating", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.jsonc"), + `{ + "theme": "from-jsonc", + "tui": { "scroll_speed": 2 }, + "keybinds": { "app_exit": "ctrl+q" } +}`, + ) + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + theme: "from-json", + keybinds: { theme_list: "ctrl+k" }, + }, + null, + 2, + ), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.theme).toBe("from-json") + expect(config.scroll_speed).toBe(2) + expect(config.keybinds?.app_exit).toBe("ctrl+q") + expect(config.keybinds?.theme_list).toBe("ctrl+k") + + const text = await Filesystem.readText(path.join(tmp.path, "tui.json")) + expect(JSON.parse(text)).toMatchObject({ + theme: "from-json", + scroll_speed: 2, + keybinds: { app_exit: "ctrl+q", theme_list: "ctrl+k" }, + }) + + const json = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json"))) + expect(json.theme).toBeUndefined() + expect(json.keybinds).toBeUndefined() + + const jsonc = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc")) + expect(jsonc).not.toContain('"theme"') + expect(jsonc).not.toContain('"keybinds"') + expect(jsonc).not.toContain('"tui"') + + expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true) + expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(true) + }, + }) +}) + test("migrates project legacy tui keys even when global tui.json already exists", async () => { await using tmp = await tmpdir({ init: async (dir) => {