fix(cli): harden keybind migration

This commit is contained in:
Sebastian Herrlinger
2026-07-20 19:09:57 +02:00
committed by opencode-agent[bot]
parent ef3037afeb
commit 198c824f68
3 changed files with 91 additions and 11 deletions
+8 -4
View File
@@ -50,10 +50,14 @@ export const layer = Layer.effect(
Effect.provideService(FileSystem.FileSystem, fs),
)
const get = Effect.fn("cli.config.get")(function* () {
yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
return Option.getOrElse(decode(yield* readJson()), () => empty)
})
const get = Effect.fn("cli.config.get")(() =>
lock.withPermits(1)(
Effect.gen(function* () {
yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
return Option.getOrElse(decode(yield* readJson()), () => empty)
}),
),
)
const update = Effect.fn("cli.config.update")((update: (draft: Draft<Info>) => void) =>
lock
+2 -6
View File
@@ -6,7 +6,6 @@ import { Definitions } from "@opencode-ai/tui/config/keybind"
import { Effect, FileSystem, Option, Schema } from "effect"
import {
createScanner,
findNodeAtLocation,
parse,
parseTree,
type Node,
@@ -82,11 +81,8 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
function findKeybindProperties(text: string, name: string) {
const tree = parseTree(text)
if (tree === undefined) return []
return (
findNodeAtLocation(tree, ["keybinds"])?.children?.filter(
(property) => property.children?.[0]?.value === name,
) ?? []
)
const keybinds = tree.children?.findLast((property) => property.children?.[0]?.value === "keybinds")?.children?.[1]
return keybinds?.children?.filter((property) => property.children?.[0]?.value === name) ?? []
}
function removeProperty(text: string, property: Node) {
+81 -1
View File
@@ -1,6 +1,6 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { Effect } from "effect"
import { Effect, FileSystem } from "effect"
import { expect, test } from "bun:test"
import { parse } from "jsonc-parser"
import path from "path"
@@ -232,6 +232,86 @@ test("preserves the effective value when migrating duplicate legacy keybinds", a
}
})
test("migrates the effective duplicate top-level keybinds", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, `{"keybinds":{"session_delete":"first"},"keybinds":{"session_delete":"last"}}`)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}),
)
expect(config.keybinds).toEqual({ "session.delete": "last" })
expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "last" })
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("does not overwrite a concurrent config update during migration", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+d"}}`)
const node = await Effect.runPromise(
Effect.gen(function* () {
return yield* FileSystem.FileSystem
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
const started = Promise.withResolvers<void>()
const resume = Promise.withResolvers<void>()
const state = { writes: 0 }
const writeFileString: FileSystem.FileSystem["writeFileString"] = (path, data, options) => {
state.writes++
if (state.writes !== 1) return node.writeFileString(path, data, options)
started.resolve()
return Effect.gen(function* () {
yield* Effect.promise(() => resume.promise)
yield* node.writeFileString(path, data, options)
})
}
const fs = new Proxy(node, {
get(target, property, receiver) {
if (property === "writeFileString") return writeFileString
return Reflect.get(target, property, receiver)
},
})
try {
const config = await Effect.runPromise(
Effect.gen(function* () {
const service = yield* Config.Service
return yield* Effect.promise(async () => {
const reading = Effect.runPromise(service.get())
await started.promise
const updating = Effect.runPromise(
service.update((draft) => {
draft.mouse = false
}),
)
await Promise.race([updating, Bun.sleep(100)]).finally(() => resume.resolve())
await Promise.all([reading, updating])
return Effect.runPromise(service.get())
})
}).pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
Effect.provideService(FileSystem.FileSystem, fs),
),
)
expect(config).toMatchObject({ keybinds: { "session.delete": "ctrl+d" }, mouse: false })
expect(await Bun.file(file).json()).toMatchObject({ keybinds: { "session.delete": "ctrl+d" }, mouse: false })
} finally {
resume.resolve()
await Bun.$`rm -rf ${directory}`
}
})
test("removes a sole orphaned keybind with a trailing comma", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")