Compare commits

...
4 changed files with 145 additions and 17 deletions
+4 -8
View File
@@ -7,6 +7,7 @@ import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import path from "path"
import { ConfigMigration } from "./migrate"
import { ConfigPersistence } from "./persist"
import { Info, SchemaURL } from "./schema"
export * from "./schema"
@@ -39,13 +40,6 @@ export const layer = Layer.effect(
return Option.getOrUndefined(decodeRecord(value))
})
const write = Effect.fnUntraced(function* (text: string) {
const temp = file + ".tmp"
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
yield* fs.writeFileString(temp, text, { mode: 0o600 })
yield* fs.rename(temp, file)
})
const migrate = ConfigMigration.run({ file, config: global.config, state: global.state }).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
)
@@ -101,7 +95,9 @@ export const layer = Layer.effect(
const errors: ParseError[] = []
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
yield* ConfigPersistence.write(file, updated.endsWith("\n") ? updated : updated + "\n").pipe(
Effect.provideService(FileSystem.FileSystem, fs),
)
return config
}),
).pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
+9 -9
View File
@@ -4,9 +4,9 @@ import { TuiConfigV1 } from "@opencode-ai/tui/config/v1"
import { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind"
import { Definitions } from "@opencode-ai/tui/config/keybind"
import { Effect, FileSystem, Option, Schema } from "effect"
import { randomUUID } from "crypto"
import { applyEdits, createScanner, modify, parse, parseTree, type Node, type ParseError } from "jsonc-parser"
import path from "path"
import { ConfigPersistence } from "./persist"
import { Info, SchemaURL } from "./schema"
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
@@ -21,20 +21,20 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
}) {
const fs = yield* FileSystem.FileSystem
const persist = Effect.fnUntraced(function* (text: string, info: Info) {
const temp = `${input.file}.${process.pid}.${randomUUID()}.tmp`
const cause = yield* Effect.gen(function* () {
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
yield* fs.writeFileString(temp, text, { mode: 0o600 })
yield* fs.rename(temp, input.file)
}).pipe(
const cause = yield* ConfigPersistence.write(input.file, text).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.as(undefined),
Effect.catchCause((cause) => Effect.succeed(cause)),
Effect.ensuring(fs.remove(temp).pipe(Effect.ignore)),
)
return cause === undefined ? { info } : { info, cause }
})
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) {
if (
yield* fs.realPath(input.file).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
) {
const text = yield* fs.readFileString(input.file)
const errors: ParseError[] = []
const value: any = parse(text, errors, { allowTrailingComma: true })
+18
View File
@@ -0,0 +1,18 @@
export * as ConfigPersistence from "./persist"
import { randomUUID } from "crypto"
import { Effect, FileSystem, Option } from "effect"
import path from "path"
export const write = Effect.fn("cli.config.persist")(function* (file: string, text: string) {
const fs = yield* FileSystem.FileSystem
const link = yield* fs.readLink(file).pipe(Effect.option)
const target = Option.isSome(link) ? yield* fs.realPath(file) : file
const temp = `${target}.${process.pid}.${randomUUID()}.tmp`
yield* Effect.gen(function* () {
yield* fs.makeDirectory(path.dirname(target), { recursive: true })
yield* fs.writeFileString(temp, text, { mode: 0o600 })
yield* fs.rename(temp, target)
}).pipe(Effect.ensuring(fs.remove(temp).pipe(Effect.ignore)))
})
+114
View File
@@ -4,6 +4,7 @@ import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem, Option, Schema } from "effect"
import { expect, test } from "bun:test"
import { parse } from "jsonc-parser"
import { lstat, mkdir, symlink } from "node:fs/promises"
import path from "path"
import { Config } from "../src/config"
import { tmpdir } from "./fixture/tmpdir"
@@ -499,6 +500,119 @@ test("updates a config draft while preserving JSONC comments", async () => {
expect(await Bun.file(path.join(directory.path, "cli.json")).text()).toContain("// Keep this comment")
})
test.each(["relative", "absolute"])("updates a %s symlinked cli config without replacing it", async (kind) => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
const target = path.join(directory, "dotfiles", "cli.json")
await Bun.write(target, '{\n // Keep this comment\n "animations": true\n}\n')
await symlink(kind === "relative" ? path.relative(directory, target) : target, file)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.mouse = false
})
}),
)
expect(config).toEqual({ animations: true, mouse: false })
expect((await lstat(file)).isSymbolicLink()).toBe(true)
expect(parse(await Bun.file(target).text())).toEqual({ animations: true, mouse: false })
expect(await Bun.file(target).text()).toContain("// Keep this comment")
expect(await Array.fromAsync(new Bun.Glob("*.tmp").scan(path.dirname(target)))).toEqual([])
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test.each(["relative", "absolute"])("migrates a %s symlinked cli config without replacing it", async (kind) => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
const target = path.join(directory, "dotfiles", "cli.json")
await Bun.write(target, '{\n // Keep this comment\n "keybinds": {"session_delete": "ctrl+d"}\n}\n')
await symlink(kind === "relative" ? path.relative(directory, target) : target, file)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}),
)
expect(config.keybinds).toEqual({ "session.delete": "ctrl+d" })
expect((await lstat(file)).isSymbolicLink()).toBe(true)
expect(parse(await Bun.file(target).text()).keybinds).toEqual({ "session.delete": "ctrl+d" })
expect(await Bun.file(target).text()).toContain("// Keep this comment")
expect(await Array.fromAsync(new Bun.Glob("*.tmp").scan(path.dirname(target)))).toEqual([])
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("fails to update a broken cli config symlink without replacing it", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await mkdir(directory, { recursive: true })
await symlink("missing.json", file, "file")
try {
await expect(
run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.mouse = false
})
}),
),
).rejects.toThrow("Failed to update CLI config")
expect((await lstat(file)).isSymbolicLink()).toBe(true)
expect(await Bun.file(path.join(directory, "missing.json")).exists()).toBe(false)
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("fails to migrate through a broken cli config symlink without replacing it", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "legacy" }))
await symlink("missing.json", file, "file")
const node = await Effect.runPromise(FileSystem.FileSystem.pipe(Effect.provide(NodeFileSystem.layer)))
const fs = new Proxy(node, {
get(target, property, receiver) {
if (property === "exists")
return (target: string) => (target === file ? Effect.succeed(true) : node.exists(target))
return Reflect.get(target, property, receiver)
},
})
try {
const config = await Effect.runPromise(
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}).pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
Effect.provideService(FileSystem.FileSystem, fs),
),
)
expect(config.theme).toEqual({ name: "legacy" })
expect((await lstat(file)).isSymbolicLink()).toBe(true)
expect(await Bun.file(path.join(directory, "missing.json")).exists()).toBe(false)
} finally {
await Bun.$`rm -rf ${directory}`
}
})
async function waitForFile(file: string, exited: Promise<number>) {
const found = await Promise.race([
(async () => {