export * as Config from "./config" import { Global } from "@opencode-ai/util/global" import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect" import { produce, type Draft } from "immer" import { applyEdits, modify, parse, type ParseError } from "jsonc-parser" import path from "path" import { ConfigMigration } from "./migrate" import { Info } from "./schema" export * from "./schema" export interface Interface { readonly path: string readonly get: () => Effect.Effect readonly update: (update: (draft: Draft) => void) => Effect.Effect } export class Service extends Context.Service()("@opencode/cli/config/Config") {} const decode = Schema.decodeUnknownOption(Info) const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any)) const empty: Info = {} export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const global = yield* Global.Service const file = path.join(global.config, "cli.json") const lock = yield* Semaphore.make(1) const readJson = Effect.fnUntraced(function* () { const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined))) if (text === undefined) return undefined const errors: ParseError[] = [] const value: any = parse(text, errors, { allowTrailingComma: true }) if (errors.length) return undefined 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), ) 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 update = Effect.fn("cli.config.update")((update: (draft: Draft) => void) => lock .withPermits(1)( Effect.gen(function* () { yield* migrate const current = Option.getOrElse(decode(yield* readJson()), () => empty) const next = produce(current, update) const edits = changes(current, next) if (!edits.length) return current const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}"))) const updated = edits.reduce( (text, edit) => applyEdits( text, modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }), ), text, ) 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") return config }), ) .pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))), ) return Service.of({ path: file, get, update }) }), ) type Edit = { readonly path: (string | number)[]; readonly value: any } function changes(before: any, after: any, path: (string | number)[] = []): Edit[] { if (Object.is(before, after)) return [] if ( before !== null && after !== null && typeof before === "object" && typeof after === "object" && !Array.isArray(before) && !Array.isArray(after) ) { return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => { if (!(key in after)) return [{ path: [...path, key], value: undefined }] if (!(key in before)) return [{ path: [...path, key], value: after[key] }] return changes(before[key], after[key], [...path, key]) }) } return [{ path, value: after }] }