diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 7aaef7884bd..904262c6f81 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -4,9 +4,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import path from "path" import { isDeepStrictEqual } from "node:util" import { type ParseError, parse } from "jsonc-parser" -import { applyEdits, modify } from "jsonc-parser" import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect" -import { produce, type Draft } from "immer" import { AgentsDirectory, ClaudeDirectory, @@ -16,7 +14,6 @@ import { type Entry, Event, } from "@opencode-ai/schema/config" -import { isRecord } from "@opencode-ai/ai/utils/record" import { Credential } from "./credential.js" import { Bus } from "./bus.js" import { Watcher } from "./filesystem/watcher.js" @@ -36,8 +33,6 @@ export function latest(entries: readonly Entry[], key: K): export interface Interface { /** Returns location config documents and discovery sources from lowest to highest priority. */ readonly entries: () => Effect.Effect - /** Updates the first file-backed configuration document. */ - readonly update: (update: (draft: Draft) => void) => Effect.Effect /** * Streams raw filesystem updates under config roots. Config owns root * topology and watch reconciliation; domain owners filter this feed for the @@ -46,11 +41,6 @@ export interface Interface { readonly changes: () => Stream.Stream } -export class UpdateError extends Schema.TaggedError()("Config.UpdateError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) {} - export const Options = Schema.Struct({ project: Schema.optional(Schema.Boolean), // false skips the global config dir, ~/.claude, and ~/.agents; wellknown, @@ -80,20 +70,6 @@ export const testLayer = (initial: Entry[] = []) => const updates = yield* PubSub.unbounded() const service = Test.of({ entries: () => Ref.get(entries), - update: (update) => - Effect.gen(function* () { - const current = yield* Ref.get(entries) - const index = current.findIndex((entry) => entry.type === "document" && entry.path !== undefined) - const entry = current[index] - if (!entry || entry.type !== "document") - return yield* Effect.fail(new UpdateError({ message: "No editable config document found" })) - const info = yield* Effect.try({ - try: () => produce(entry.info, update), - catch: (cause) => new UpdateError({ message: "Config update failed", cause }), - }) - yield* Ref.set(entries, current.with(index, new Document({ type: "document", path: entry.path, info }))) - return info - }), changes: () => Stream.fromPubSub(updates), setEntries: (next) => Ref.set(entries, next), emitChange: (update) => PubSub.publish(updates, update).pipe(Effect.asVoid), @@ -400,54 +376,10 @@ export const layer = (options?: Options) => ) yield* reconcile(initial) - const update = Effect.fn("Config.update")((mutate: (draft: Draft) => void) => - reloadLock.withPermit( - Effect.gen(function* () { - // TODO: Replace entry-order selection with an explicit config scope/target model. - const document = configs.find((entry) => entry.type === "document" && entry.path !== undefined) - if (!document || document.type !== "document" || !document.path) - return yield* Effect.fail(new UpdateError({ message: "No editable config document found" })) - const next = yield* Effect.try({ - try: () => produce(document.info, mutate), - catch: (cause) => new UpdateError({ message: "Config update failed", cause }), - }) - const edits = changes(document.info, next) - if (!edits.length) return document.info - const text = yield* fs - .readFileString(document.path) - .pipe( - Effect.mapError( - (cause) => new UpdateError({ message: `Failed to read config: ${document.path}`, cause }), - ), - ) - const updated = edits.reduce( - (text, edit) => - applyEdits( - text, - modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }), - ), - text, - ) - const info = yield* parseInfo(updated, document.path) - if (!info) - return yield* Effect.fail(new UpdateError({ message: `Invalid config update: ${document.path}` })) - const temporary = document.path + ".tmp" - yield* fs.writeFileString(temporary, updated.endsWith("\n") ? updated : updated + "\n").pipe( - Effect.andThen(fs.rename(temporary, document.path)), - Effect.mapError( - (cause) => new UpdateError({ message: `Failed to write config: ${document.path}`, cause }), - ), - ) - return info - }), - ), - ) - return Service.of({ entries: Effect.fnUntraced(function* () { return configs }), - update, changes: () => Stream.fromPubSub(updates), }) }), @@ -462,17 +394,3 @@ export function configured(options?: Options) { } export const node = configured() - -type Edit = { readonly path: (string | number)[]; readonly value: unknown } - -function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] { - if (Object.is(before, after)) return [] - if (isRecord(before) && isRecord(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 }] -} diff --git a/packages/core/src/config/file.ts b/packages/core/src/config/file.ts new file mode 100644 index 00000000000..b5692f1b2fc --- /dev/null +++ b/packages/core/src/config/file.ts @@ -0,0 +1,150 @@ +export * as ConfigFile from "./file.js" + +import { isDeepStrictEqual } from "node:util" +import { isRecord } from "@opencode-ai/ai/utils/record" +import { FSUtil } from "@opencode-ai/util/fs-util" +import { Effect, Schema, Semaphore } from "effect" +import { + applyEdits, + createScanner, + findNodeAtLocation, + modify, + parseTree, + type Node, + type ParseError, +} from "jsonc-parser" + +export class UpdateError extends Schema.TaggedError()("ConfigFile.UpdateError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) {} + +const isJson = Schema.is(Schema.MutableJson) +const isDocument = (value: unknown): value is Schema.MutableJsonObject => isRecord(value) && isJson(value) +const lock = Semaphore.makeUnsafe(1) + +/** + * Edits an existing JSON(C) file using raw source values, not resolved Config.Info. + * The synchronous callback mutates a source clone; its return value is ignored. + * Validates JSON only; normalization and substitution remain the reader's job. + * Does not discover files, start watchers, or refresh Config state. + * Read-modify-write calls are serialized within this process. + */ +export const update = Effect.fn("ConfigFile.update")( + function* ( + filepath: string, + mutate: (draft: Schema.MutableJsonObject) => void, + ): Effect.fn.Return { + const fs = yield* FSUtil.Service + const text = yield* fs + .readFileString(filepath) + .pipe(Effect.mapError((cause) => new UpdateError({ message: `Failed to read config: ${filepath}`, cause }))) + const errors: ParseError[] = [] + const current = parseSource(text, errors) + if (errors.length || !isDocument(current)) + return yield* Effect.fail(new UpdateError({ message: `Invalid config file: ${filepath}` })) + + const next = yield* Effect.try({ + try: () => { + const draft = structuredClone(current) + mutate(draft) + return draft + }, + catch: (cause) => new UpdateError({ message: "Config update failed", cause }), + }) + if (!isDocument(next)) + return yield* Effect.fail(new UpdateError({ message: `Config update must produce a JSON object: ${filepath}` })) + + const edits = changes(current, next) + if (!edits.length) return next + const updated = yield* Effect.try({ + try: () => edits.reduce(patch, text), + catch: (cause) => new UpdateError({ message: `Failed to patch config: ${filepath}`, cause }), + }) + // Duplicate keys can make parse choose the last value while modify edits the first. + const written = parseSource(updated, errors) + if (errors.length || !isDeepStrictEqual(written, next)) + return yield* Effect.fail( + new UpdateError({ message: `Config patch does not match the requested update: ${filepath}` }), + ) + const temporary = filepath + ".tmp" + yield* fs.writeFileString(temporary, updated.endsWith("\n") ? updated : updated + "\n").pipe( + Effect.andThen(fs.rename(temporary, filepath)), + Effect.mapError((cause) => new UpdateError({ message: `Failed to write config: ${filepath}`, cause })), + ) + return next + }, + (effect) => lock.withPermit(effect), +) + +type Edit = { readonly path: (string | number)[]; readonly value: unknown } + +function parseSource(text: string, errors: ParseError[]) { + const root = parseTree(text, errors, { allowTrailingComma: true }) + if (!root || errors.length) return undefined + // parse() assigns onto {}, invoking the __proto__ setter instead of retaining + // an own JSON key. Construct object entries from the AST without those setters. + const value = (node: Node): unknown => { + if (node.type === "array") return (node.children ?? []).map(value) + if (node.type === "object") + return Object.fromEntries( + (node.children ?? []).map((property) => { + const child = property.children?.[1] + return [property.children?.[0]?.value, child && value(child)] + }), + ) + return node.value + } + return value(root) +} + +function patch(text: string, edit: Edit) { + if (edit.value !== undefined) + return applyEdits( + text, + modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }), + ) + + const tree = parseTree(text) + const node = tree && findNodeAtLocation(tree, edit.path) + if (!node) return text + // jsonc-parser removes adjacent comments along with the separator. Remove only + // the property/element itself and one comma, leaving surrounding comments intact. + const target = node.parent?.type === "property" ? node.parent : node + const siblings = target.parent?.children ?? [] + const previous = siblings[siblings.indexOf(target) - 1] + const scanner = createScanner(text, true) + scanner.setPosition(target.offset + target.length) + scanner.scan() + const following = text[scanner.getTokenOffset()] === "," + if (!following && previous) { + scanner.setPosition(previous.offset + previous.length) + scanner.scan() + } + return applyEdits(text, [ + { offset: target.offset, length: target.length, content: "" }, + ...(following || previous ? [{ offset: scanner.getTokenOffset(), length: 1, content: "" }] : []), + ]) +} + +function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] { + if (isDeepStrictEqual(before, after)) return [] + if (Array.isArray(before) && Array.isArray(after)) { + return [ + ...after.flatMap((value, index) => changes(before[index], value, [...path, index])), + // Remove from the end so earlier deletions cannot shift later paths. + ...before + .slice(after.length) + .map((_, index) => ({ path: [...path, after.length + index], value: undefined })) + .toReversed(), + ] + } + if (isRecord(before) && isRecord(after)) { + return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => { + if (!Object.hasOwn(after, key)) return [{ path: [...path, key], value: undefined }] + if (!Object.hasOwn(before, key)) return [{ path: [...path, key], value: after[key] }] + return changes(before[key], after[key], [...path, key]) + }) + } + return [{ path, value: after }] +} diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 50ce0aaafd4..3a296727e00 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -76,57 +76,6 @@ const provider = { } describe("Config", () => { - it.live("updates the first file-backed document", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => { - const global = path.join(tmp.path, "global") - const project = path.join(tmp.path, "project") - const globalFile = path.join(global, "opencode.jsonc") - const projectFile = path.join(project, "opencode.json") - return Effect.promise(async () => { - await Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]) - await Promise.all([ - fs.writeFile(globalFile, '{\n // Keep this comment.\n "shell": "global"\n}\n'), - fs.writeFile(projectFile, JSON.stringify({ shell: "project" })), - ]) - }).pipe( - Effect.andThen( - Effect.gen(function* () { - const config = yield* Config.Service - const content = yield* Effect.promise(() => fs.readFile(globalFile, "utf8")) - const cause = new Error("Rejected config update") - const error = yield* config - .update((draft) => { - draft.shell = "discarded" - throw cause - }) - .pipe(Effect.flip) - - expect(error).toBeInstanceOf(Config.UpdateError) - expect(error.message).toBe("Config update failed") - expect(error.cause).toBe(cause) - expect(yield* Effect.promise(() => fs.readFile(globalFile, "utf8"))).toBe(content) - - const updated = yield* config.update((draft) => { - draft.shell = "updated" - }) - - expect(updated.shell).toBe("updated") - expect(yield* Effect.promise(() => fs.readFile(globalFile, "utf8"))).toContain("// Keep this comment.") - expect(yield* Effect.promise(() => fs.readFile(globalFile, "utf8"))).toContain('"shell": "updated"') - expect(JSON.parse(yield* Effect.promise(() => fs.readFile(projectFile, "utf8")))).toEqual({ - shell: "project", - }) - }).pipe(Effect.provide(testLayer(project, global))), - ), - ) - }), - ), - ) - it.live("excludes home-level claude and agents directories when global is disabled", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -206,16 +155,6 @@ describe("Config", () => { ), ) - it.effect("fails updates when no file-backed document exists", () => - Effect.gen(function* () { - const config = yield* Config.Service - const error = yield* config.update((draft) => void draft).pipe(Effect.flip) - expect(error.message).toBe("No editable config document found") - }).pipe( - Effect.provide(Config.testLayer([new Document({ type: "document", info: new Info({ shell: "virtual" }) })])), - ), - ) - it.live("loads explicit file and content overrides in priority order", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -430,38 +369,6 @@ describe("Config", () => { }).pipe(Effect.provide(Config.testLayer())), ) - it.effect("keeps test config unchanged after an update callback fails", () => - Effect.gen(function* () { - const config = yield* Config.Service - const test = yield* Config.Test - const entry = new Document({ - type: "document", - path: AbsolutePath.make(path.join(import.meta.dir, "opencode.json")), - info: new Info({ shell: "initial" }), - }) - yield* test.setEntries([entry]) - const cause = new Error("Rejected config update") - const error = yield* config - .update((draft) => { - draft.shell = "discarded" - throw cause - }) - .pipe(Effect.flip) - - expect(error).toBeInstanceOf(Config.UpdateError) - expect(error.message).toBe("Config update failed") - expect(error.cause).toBe(cause) - expect(yield* config.entries()).toEqual([entry]) - expect(entry.info.shell).toBe("initial") - - const updated = yield* config.update((draft) => { - draft.shell = "recovered" - }) - expect(updated.shell).toBe("recovered") - expect(Config.latest(yield* test.entries(), "shell")).toBe("recovered") - }).pipe(Effect.provide(Config.testLayer())), - ) - it.effect("returns the latest defined scalar from priority-ordered documents", () => Effect.sync(() => { const entries = [ diff --git a/packages/core/test/config/entry-observer.test.ts b/packages/core/test/config/entry-observer.test.ts index d6743a192b0..42ff6354c4f 100644 --- a/packages/core/test/config/entry-observer.test.ts +++ b/packages/core/test/config/entry-observer.test.ts @@ -14,7 +14,6 @@ describe("ConfigEntryObserver", () => { const reloaded = yield* Deferred.make() const config = Config.Service.of({ entries: () => Ref.get(current), - update: () => Effect.die("unused config.update"), changes: () => Stream.empty, }) const event = { diff --git a/packages/core/test/config/file.test.ts b/packages/core/test/config/file.test.ts new file mode 100644 index 00000000000..139fc0529d9 --- /dev/null +++ b/packages/core/test/config/file.test.ts @@ -0,0 +1,411 @@ +import path from "path" +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { parse } from "jsonc-parser" +import { isRecord } from "@opencode-ai/ai/utils/record" +import { ConfigFile } from "@opencode-ai/core/config/file" +import { FSUtil } from "@opencode-ai/util/fs-util" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { withTempDir } from "../fixture/tmpdir" +import { testEffect } from "../lib/effect" + +// No Config, Location, Watcher, Credential, or WellKnown services are provided. +const it = testEffect(LayerNode.compile(FSUtil.node)) + +describe("ConfigFile", () => { + it.live("edits the explicit target and preserves comments and unrelated fields", () => + withTempDir((tmp) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const global = path.join(tmp.path, "global", "opencode.jsonc") + const target = path.join(tmp.path, "project", "custom.jsonc") + const text = '{\n // Keep this comment.\n "shell": "project",\n "custom": { "value": 1 },\n}\n' + yield* fs.writeWithDirs(global, '{ "shell": "global" }') + yield* fs.writeWithDirs(target, text) + + const updated = yield* ConfigFile.update(target, (draft) => { + draft.shell = "updated" + }) + + expect(updated).toEqual({ shell: "updated", custom: { value: 1 } }) + expect(yield* fs.readFileString(target)).toBe(text.replace('"project"', '"updated"')) + expect(yield* fs.readFileString(global)).toBe('{ "shell": "global" }') + }), + ), + ) + + it.live("leaves raw substitutions, model shorthand, and legacy shapes unresolved", () => + withTempDir((tmp) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const target = path.join(tmp.path, "opencode.jsonc") + const text = `{ + "model": "{env:OPENCODE_TEST_CONFIG_MODEL}", + "shell": "{file:missing-shell.txt}", + "skills": { "paths": ["./skills"] }, + "agent": { "review": { "model": "acme/reasoner" } }, + "username": "before" +} +` + yield* fs.writeFileString(target, text) + yield* ConfigFile.update(target, (draft) => { + expect(draft.model).toBe("{env:OPENCODE_TEST_CONFIG_MODEL}") + expect(draft.shell).toBe("{file:missing-shell.txt}") + expect(draft.skills).toEqual({ paths: ["./skills"] }) + draft.username = "after" + }) + + expect(yield* fs.readFileString(target)).toBe(text.replace('"before"', '"after"')) + }), + ), + ) + + it.live("patches nested source fields and deletes legacy keys without migrating them", () => + withTempDir((tmp) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const target = path.join(tmp.path, "opencode.jsonc") + yield* fs.writeFileString( + target, + `{ + "agent": { + "review": { "description": "before", "hidden": true }, + // Keep the other definition. + "build": { "description": "unchanged" } + }, + "snapshot": true +} +`, + ) + const updated = yield* ConfigFile.update(target, (draft) => { + const agent: unknown = draft.agent + if (!isRecord(agent) || !isRecord(agent.review)) throw new Error("Missing fixture agent") + agent.review.description = "after" + agent.review.color = "blue" + delete agent.review.hidden + delete draft.snapshot + }) + + expect(updated).toEqual({ + agent: { review: { description: "after", color: "blue" }, build: { description: "unchanged" } }, + }) + expect(parse(yield* fs.readFileString(target))).toEqual(updated) + expect(yield* fs.readFileString(target)).toContain("// Keep the other definition.") + }), + ), + ) + + it.live("patches array elements without rewriting untouched comments", () => + withTempDir((tmp) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const target = path.join(tmp.path, "opencode.jsonc") + const text = `{ + "plugins": [ + // Keep the first plugin. + "first", + "second", + // Keep the third plugin. + "third", + "fourth" + ] +} +` + yield* fs.writeFileString(target, text) + yield* ConfigFile.update(target, (draft) => { + if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins") + draft.plugins[1] = "updated" + }) + expect(yield* fs.readFileString(target)).toBe(text.replace('"second"', '"updated"')) + + const shortened = yield* ConfigFile.update(target, (draft) => { + if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins") + draft.plugins.splice(1, 3) + }) + expect(shortened.plugins).toEqual(["first"]) + expect(parse(yield* fs.readFileString(target))).toEqual(shortened) + + const extended = yield* ConfigFile.update(target, (draft) => { + if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins") + draft.plugins.push("added", "last") + }) + expect(extended.plugins).toEqual(["first", "added", "last"]) + expect(parse(yield* fs.readFileString(target))).toEqual(extended) + expect(yield* fs.readFileString(target)).toContain("// Keep the first plugin.") + }), + ), + ) + + it.live("preserves adjacent comments when deleting properties and array elements", () => + withTempDir((tmp) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const target = path.join(tmp.path, "opencode.jsonc") + yield* fs.writeFileString( + target, + `{ + "shell": "remove", + // Keep the model explanation. + "model": "acme/reasoner", + "plugins": ["first", "second", /* Keep the plugin explanation. */ "third"], + "skills": [/* Keep the source explanation. */ "remove",], +} +`, + ) + const updated = yield* ConfigFile.update(target, (draft) => { + delete draft.shell + if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins") + draft.plugins.splice(1, 1) + draft.skills = [] + }) + + expect(parse(yield* fs.readFileString(target))).toEqual(updated) + expect(updated).toEqual({ model: "acme/reasoner", plugins: ["first", "third"], skills: [] }) + expect(yield* fs.readFileString(target)).toContain("// Keep the model explanation.") + expect(yield* fs.readFileString(target)).toContain("/* Keep the plugin explanation. */") + expect(yield* fs.readFileString(target)).toContain("/* Keep the source explanation. */") + }), + ), + ) + + it.live("deletes own JSON keys that also exist on Object.prototype", () => + withTempDir((tmp) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const target = path.join(tmp.path, "opencode.json") + yield* fs.writeFileString( + target, + '{ "\\u005f_proto__": "remove", "constructor": "remove", "toString": "remove", "shell": "keep" }', + ) + const updated = yield* ConfigFile.update(target, (draft) => { + ;["__proto__", "constructor", "toString"].forEach((key) => { + delete draft[key] + }) + }) + + expect(updated).toEqual({ shell: "keep" }) + expect(yield* fs.readJson(target)).toEqual(updated) + }), + ), + ) + + it.live("preserves and edits object-valued __proto__ source keys", () => + withTempDir((tmp) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const target = path.join(tmp.path, "opencode.json") + yield* fs.writeFileString(target, '{ "__proto__": { "value": "before" }, "shell": "keep" }') + const updated = yield* ConfigFile.update(target, (draft) => { + expect(Object.hasOwn(draft, "__proto__")).toBe(true) + const entry: unknown = draft["__proto__"] + if (!isRecord(entry)) throw new Error("Missing fixture entry") + entry.value = "after" + }) + + expect(updated).toEqual({ ["__proto__"]: { value: "after" }, shell: "keep" }) + expect(yield* fs.readJson(target)).toEqual(updated) + expect(Object.getPrototypeOf(updated)).toBe(Object.prototype) + }), + ), + ) + + it.live("rejects a duplicate-key patch that would not change the effective value", () => + withTempDir((tmp) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const target = path.join(tmp.path, "opencode.json") + const text = '{ "shell": "first", "shell": "second" }' + yield* fs.writeFileString(target, text) + const error = yield* ConfigFile.update(target, (draft) => { + draft.shell = "after" + }).pipe(Effect.flip) + + expect(error).toBeInstanceOf(ConfigFile.UpdateError) + expect(error.message).toBe(`Config patch does not match the requested update: ${target}`) + expect(yield* fs.readFileString(target)).toBe(text) + expect(yield* fs.exists(target + ".tmp")).toBe(false) + }), + ), + ) + + it.live("rereads the selected file for consecutive edits without a watcher", () => + withTempDir((tmp) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const target = path.join(tmp.path, "opencode.json") + yield* fs.writeFileString(target, '{ "shell": "first" }') + yield* ConfigFile.update(target, (draft) => { + draft.shell = "second" + }) + yield* ConfigFile.update(target, (draft) => { + expect(draft.shell).toBe("second") + draft.username = "added" + }) + expect(yield* fs.readJson(target)).toEqual({ shell: "second", username: "added" }) + + yield* fs.writeFileString(target, '{ "shell": "external", "username": "added" }') + const updated = yield* ConfigFile.update(target, (draft) => { + expect(draft.shell).toBe("external") + draft.snapshots = false + }) + expect(yield* fs.readJson(target)).toEqual(updated) + expect(updated).toEqual({ shell: "external", username: "added", snapshots: false }) + }), + ), + ) + + it.live("serializes concurrent read-modify-write calls", () => + withTempDir((tmp) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const target = path.join(tmp.path, "opencode.json") + yield* fs.writeFileString(target, '{ "count": 0 }') + const increment = ConfigFile.update(target, (draft) => { + if (typeof draft.count !== "number") throw new Error("Missing fixture count") + draft.count++ + }) + yield* Effect.all([increment, increment, increment], { concurrency: "unbounded" }) + + expect(yield* fs.readJson(target)).toEqual({ count: 3 }) + }), + ), + ) + + it.live("does not rewrite no-op or structurally equal edits", () => + withTempDir((tmp) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const target = path.join(tmp.path, "opencode.json") + const text = '{\r\n "plugins": ["first"]\r\n}' + yield* fs.writeFileString(target, text) + const before = yield* fs.stat(target) + yield* ConfigFile.update(target, () => {}) + yield* ConfigFile.update(target, (draft) => { + draft.plugins = ["first"] + }) + + expect(yield* fs.readFileString(target)).toBe(text) + expect((yield* fs.stat(target)).ino).toEqual(before.ino) + expect((yield* fs.stat(target)).mtime).toEqual(before.mtime) + expect(yield* fs.exists(target + ".tmp")).toBe(false) + }), + ), + ) + + it.live("leaves the file unchanged when a callback throws and permits a later edit", () => + withTempDir((tmp) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const target = path.join(tmp.path, "opencode.json") + const text = '{ "shell": "before" }' + yield* fs.writeFileString(target, text) + const cause = new Error("Rejected config update") + const error = yield* ConfigFile.update(target, (draft) => { + draft.shell = "discarded" + throw cause + }).pipe(Effect.flip) + + expect(error).toBeInstanceOf(ConfigFile.UpdateError) + expect(error.message).toBe("Config update failed") + expect(error.cause).toBe(cause) + expect(yield* fs.readFileString(target)).toBe(text) + expect(yield* fs.exists(target + ".tmp")).toBe(false) + + yield* ConfigFile.update(target, (draft) => { + draft.shell = "recovered" + }) + expect(yield* fs.readJson(target)).toEqual({ shell: "recovered" }) + }), + ), + ) + + it.live("ignores callback return values instead of replacing the document", () => + withTempDir((tmp) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const target = path.join(tmp.path, "opencode.json") + yield* fs.writeFileString(target, "{}") + + expect(yield* ConfigFile.update(target, () => new Date(0))).toEqual({}) + expect(yield* fs.readFileString(target)).toBe("{}") + + const updated = yield* ConfigFile.update(target, (draft) => (draft.shell = "updated")) + expect(updated).toEqual({ shell: "updated" }) + expect(yield* fs.readJson(target)).toEqual(updated) + }), + ), + ) + + it.live("rejects non-JSON mutations before writing", () => + withTempDir((tmp) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const target = path.join(tmp.path, "opencode.json") + const text = '{ "shell": "before" }' + yield* fs.writeFileString(target, text) + const error = yield* ConfigFile.update(target, (draft) => { + draft.invalid = Number.NaN + }).pipe(Effect.flip) + + expect(error).toBeInstanceOf(ConfigFile.UpdateError) + expect(error.message).toBe(`Config update must produce a JSON object: ${target}`) + expect(yield* fs.readFileString(target)).toBe(text) + expect(yield* fs.exists(target + ".tmp")).toBe(false) + }), + ), + ) + ;["", "{", "[]", "null"].forEach((text) => { + it.live(`rejects invalid or non-object source ${JSON.stringify(text)}`, () => + withTempDir((tmp) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const target = path.join(tmp.path, "opencode.json") + yield* fs.writeFileString(target, text) + const error = yield* ConfigFile.update(target, () => { + throw new Error("Callback must not run") + }).pipe(Effect.flip) + + expect(error).toBeInstanceOf(ConfigFile.UpdateError) + expect(error.message).toBe(`Invalid config file: ${target}`) + expect(yield* fs.readFileString(target)).toBe(text) + expect(yield* fs.exists(target + ".tmp")).toBe(false) + }), + ), + ) + }) + + it.live("reports a missing target without creating it", () => + withTempDir((tmp) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const target = path.join(tmp.path, "missing.json") + const error = yield* ConfigFile.update(target, () => {}).pipe(Effect.flip) + + expect(error).toBeInstanceOf(ConfigFile.UpdateError) + expect(error.message).toBe(`Failed to read config: ${target}`) + expect(error.cause).toBeDefined() + expect(yield* fs.exists(target)).toBe(false) + }), + ), + ) + + it.live("reports write failures without replacing the target", () => + withTempDir((tmp) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const target = path.join(tmp.path, "opencode.json") + const text = '{ "shell": "before" }' + yield* fs.writeFileString(target, text) + yield* fs.makeDirectory(target + ".tmp") + const error = yield* ConfigFile.update(target, (draft) => { + draft.shell = "discarded" + }).pipe(Effect.flip) + + expect(error).toBeInstanceOf(ConfigFile.UpdateError) + expect(error.message).toBe(`Failed to write config: ${target}`) + expect(error.cause).toBeDefined() + expect(yield* fs.readFileString(target)).toBe(text) + }), + ), + ) +}) diff --git a/packages/core/test/config/image.test.ts b/packages/core/test/config/image.test.ts index dde9e962dc4..38c75092e3d 100644 --- a/packages/core/test/config/image.test.ts +++ b/packages/core/test/config/image.test.ts @@ -55,7 +55,6 @@ describe("ConfigImagePlugin.Plugin", () => { let reads = 0 const config = Config.Service.of({ entries: () => Effect.sync(() => [document({ max_width: reads++ === 0 ? 1_200 : 700, max_base64_bytes: 1 })]), - update: () => Effect.die(new Error("Config update is unavailable")), changes: () => Stream.empty, }) yield* ConfigImagePlugin.Plugin.effect(yield* PluginHost.make(plugins)).pipe( diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index 3137ef50c88..570acf91b2d 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -255,7 +255,6 @@ describe("LocationWatcher subscriptions", () => { Config.Service, Config.Service.of({ entries: () => Effect.sync(() => entries.current), - update: () => Effect.die("unused config.update"), changes: () => Stream.never, }), ) diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index f65844c0009..31b72b2c476 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -205,7 +205,6 @@ function resourceMcpLayer( Config.Service, Config.Service.of({ entries: overrides.entries, - update: () => Effect.die("unused config update"), changes: () => Stream.never, }), )