|
|
@@ -4,13 +4,14 @@ 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,
|
|
|
Directory,
|
|
|
Document,
|
|
|
- File,
|
|
|
Info,
|
|
|
type Entry,
|
|
|
Event,
|
|
|
@@ -36,6 +37,8 @@ export function latest<K extends keyof Info>(entries: readonly Entry[], key: K):
|
|
|
export interface Interface {
|
|
|
/** Returns location config documents and discovery sources from lowest to highest priority. */
|
|
|
readonly entries: () => Effect.Effect<Entry[]>
|
|
|
+ /** Updates the first file-backed configuration document. */
|
|
|
+ readonly update: (update: (draft: Draft<Info>) => void) => Effect.Effect<Info, UpdateError>
|
|
|
/**
|
|
|
* Streams raw filesystem updates under config roots. Config owns root
|
|
|
* topology and watch reconciliation; domain owners filter this feed for the
|
|
|
@@ -44,6 +47,11 @@ export interface Interface {
|
|
|
readonly changes: () => Stream.Stream<Watcher.Update>
|
|
|
}
|
|
|
|
|
|
+export class UpdateError extends Schema.TaggedErrorClass<UpdateError>()("Config.UpdateError", {
|
|
|
+ message: Schema.String,
|
|
|
+ cause: Schema.optional(Schema.Defect()),
|
|
|
+}) {}
|
|
|
+
|
|
|
export const Options = Schema.Struct({
|
|
|
project: Schema.optional(Schema.Boolean),
|
|
|
file: Schema.optional(Schema.String),
|
|
|
@@ -70,6 +78,18 @@ export const testLayer = (initial: Entry[] = []) =>
|
|
|
const updates = yield* PubSub.unbounded<Watcher.Update>()
|
|
|
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)
|
|
|
+ if (index === -1) return yield* Effect.fail(new UpdateError({ message: "No editable config document found" }))
|
|
|
+ const entry = current[index]
|
|
|
+ if (!entry || entry.type !== "document")
|
|
|
+ return yield* Effect.fail(new UpdateError({ message: "No editable config document found" }))
|
|
|
+ const info = produce(entry.info, update)
|
|
|
+ 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),
|
|
|
@@ -91,6 +111,7 @@ export const layer = (options?: Options) =>
|
|
|
const wellknown = yield* WellKnown.Service
|
|
|
const names = ["opencode.json", "opencode.jsonc"]
|
|
|
const reloadLock = Semaphore.makeUnsafe(1)
|
|
|
+ const fileTargets = new Set<AbsolutePath>()
|
|
|
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
|
|
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
|
|
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
|
|
|
@@ -131,7 +152,7 @@ export const layer = (options?: Options) =>
|
|
|
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
|
|
|
const info = yield* parseInfo(substituted, filepath)
|
|
|
if (!info) return
|
|
|
- return new Document({ type: "document", path: filepath, info })
|
|
|
+ return new Document({ type: "document", path: AbsolutePath.make(filepath), info })
|
|
|
})
|
|
|
|
|
|
const loadWellknown = Effect.fn("Config.loadWellknown")(function* () {
|
|
|
@@ -224,25 +245,20 @@ export const layer = (options?: Options) =>
|
|
|
const directPaths = discovered
|
|
|
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
|
|
|
.toReversed()
|
|
|
+ fileTargets.clear()
|
|
|
+ directPaths.forEach((filepath) => fileTargets.add(AbsolutePath.make(filepath)))
|
|
|
const direct = yield* Effect.forEach(directPaths, (filepath) =>
|
|
|
- loadFile(filepath).pipe(
|
|
|
- Effect.map((config) => [
|
|
|
- ...(config ? [config] : []),
|
|
|
- new File({ type: "file", path: AbsolutePath.make(filepath) }),
|
|
|
- ]),
|
|
|
- ),
|
|
|
+ loadFile(filepath),
|
|
|
).pipe(
|
|
|
Effect.orDie,
|
|
|
- Effect.map((entries) => entries.flat()),
|
|
|
+ Effect.map((entries) => entries.filter((entry): entry is Document => entry !== undefined)),
|
|
|
)
|
|
|
|
|
|
const file = options?.file
|
|
|
+ if (file) fileTargets.add(AbsolutePath.make(path.resolve(file)))
|
|
|
const explicit = file
|
|
|
? yield* loadFile(path.resolve(file)).pipe(
|
|
|
- Effect.map((config) => [
|
|
|
- ...(config ? [config] : []),
|
|
|
- new File({ type: "file", path: AbsolutePath.make(path.resolve(file)) }),
|
|
|
- ]),
|
|
|
+ Effect.map((config) => (config ? [config] : [])),
|
|
|
Effect.orDie,
|
|
|
)
|
|
|
: []
|
|
|
@@ -285,7 +301,10 @@ export const layer = (options?: Options) =>
|
|
|
const watched = new Set<string>()
|
|
|
const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
|
|
|
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
|
|
- const files = entries.flatMap((entry) => (entry.type === "file" ? [entry.path] : []))
|
|
|
+ const files = [
|
|
|
+ ...entries.flatMap((entry) => (entry.type === "document" && entry.path ? [entry.path] : [])),
|
|
|
+ ...fileTargets,
|
|
|
+ ]
|
|
|
const targets = [
|
|
|
...directories.map((path) => ({ path, type: "directory" as const, ignore })),
|
|
|
...files
|
|
|
@@ -308,9 +327,9 @@ export const layer = (options?: Options) =>
|
|
|
reloadLock.withPermit(
|
|
|
Effect.gen(function* () {
|
|
|
const next = yield* discover()
|
|
|
+ yield* reconcile(next)
|
|
|
if (isDeepStrictEqual(configs, next)) return
|
|
|
configs = next
|
|
|
- yield* reconcile(next)
|
|
|
yield* bus.publish(Event.Updated, {})
|
|
|
}),
|
|
|
),
|
|
|
@@ -364,10 +383,47 @@ export const layer = (options?: Options) =>
|
|
|
)
|
|
|
yield* reconcile(initial)
|
|
|
|
|
|
+ const update = Effect.fn("Config.update")((mutate: (draft: Draft<Info>) => 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.fn("Config.entries")(function* () {
|
|
|
return configs
|
|
|
}),
|
|
|
+ update,
|
|
|
changes: () => Stream.fromPubSub(updates),
|
|
|
})
|
|
|
}),
|
|
|
@@ -382,3 +438,26 @@ 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 (
|
|
|
+ before !== null &&
|
|
|
+ after !== null &&
|
|
|
+ typeof before === "object" &&
|
|
|
+ typeof after === "object" &&
|
|
|
+ !Array.isArray(before) &&
|
|
|
+ !Array.isArray(after)
|
|
|
+ ) {
|
|
|
+ const previous = before as Record<string, unknown>
|
|
|
+ const next = after as Record<string, unknown>
|
|
|
+ return [...new Set([...Object.keys(previous), ...Object.keys(next)])].flatMap((key) => {
|
|
|
+ if (!(key in next)) return [{ path: [...path, key], value: undefined }]
|
|
|
+ if (!(key in previous)) return [{ path: [...path, key], value: next[key] }]
|
|
|
+ return changes(previous[key], next[key], [...path, key])
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return [{ path, value: after }]
|
|
|
+}
|