mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-03 15:36:22 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
595d7ca1f7 | ||
|
|
59b29de409 | ||
|
|
887f319769 | ||
|
|
24f6cb51c8 |
@@ -434,7 +434,7 @@ export function Titlebar(props: {
|
||||
}}
|
||||
>
|
||||
<Show when={!mobile() && (!props.verticalTabs || windows())}>
|
||||
<ChannelIndicator debugTools={props.debugTools} height={windows() ? minHeight() : undefined} />
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
</Show>
|
||||
<Show when={windows() || linux()}>
|
||||
<WindowsAppMenu command={command} platform={platform} />
|
||||
@@ -753,18 +753,23 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void }; height?: string }) {
|
||||
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
|
||||
const platform = usePlatform()
|
||||
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
||||
const classes = () => ({
|
||||
"px-2 rounded-sm": windows(),
|
||||
"inline-flex h-4 shrink-0 items-center leading-4 px-1.5 rounded-full": !windows(),
|
||||
})
|
||||
const style = () => ({
|
||||
height: props.height,
|
||||
"font-size": platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
|
||||
"font-size": windows() ? undefined : platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
|
||||
})
|
||||
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
|
||||
if (channel === "dev" && props.debugTools) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-4 shrink-0 items-center bg-icon-interactive-base text-[#FFF] leading-4 font-medium px-1.5 rounded-full uppercase font-mono cursor-pointer [app-region:no-drag]"
|
||||
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono cursor-pointer [app-region:no-drag]"
|
||||
classList={classes()}
|
||||
style={style()}
|
||||
onClick={props.debugTools.toggle}
|
||||
aria-label="Toggle debug tools"
|
||||
@@ -780,7 +785,8 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
|
||||
<Show when={label}>
|
||||
{(value) => (
|
||||
<div
|
||||
class="inline-flex h-4 shrink-0 items-center bg-icon-interactive-base text-[#FFF] leading-4 font-medium px-1.5 rounded-full uppercase font-mono"
|
||||
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono"
|
||||
classList={classes()}
|
||||
style={style()}
|
||||
>
|
||||
{value()}
|
||||
|
||||
+46
-120
@@ -4,7 +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 { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
|
||||
import { Context, Effect, FiberMap, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
|
||||
import {
|
||||
AgentsDirectory,
|
||||
ClaudeDirectory,
|
||||
@@ -23,6 +23,8 @@ import { Location } from "./location.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { ConfigVariable } from "./config/variable.js"
|
||||
import { ConfigNormalize } from "./config/normalize.js"
|
||||
import { ConfigDiscovery } from "./config/discovery.js"
|
||||
import { ConfigWatch } from "./config/watch.js"
|
||||
import { WellKnown } from "./wellknown.js"
|
||||
|
||||
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
||||
@@ -83,15 +85,12 @@ export const layer = (options?: Options) =>
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const bus = yield* Bus.Service
|
||||
const credentials = yield* Credential.Service
|
||||
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) {
|
||||
@@ -177,90 +176,23 @@ export const layer = (options?: Options) =>
|
||||
|
||||
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
|
||||
return [
|
||||
...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
|
||||
...(yield* Effect.forEach(ConfigDiscovery.names, (file) => loadFile(path.join(directory, file))).pipe(
|
||||
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
|
||||
)),
|
||||
new Directory({ type: "directory", path: directory }),
|
||||
]
|
||||
})
|
||||
|
||||
const discover = Effect.fn("Config.discover")(function* () {
|
||||
const globalDirectory = AbsolutePath.make(global.config)
|
||||
const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
|
||||
const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
|
||||
// Global roots and the walk are compared by canonical path: the same
|
||||
// directory reached under two spellings (a symlinked checkout, macOS
|
||||
// /var vs /private/var, OPENCODE_CONFIG_DIR inside the project) must
|
||||
// classify identically or it enters discovery twice.
|
||||
const globalRoots = yield* Effect.forEach(
|
||||
[globalDirectory, globalClaudeDirectory, globalAgentsDirectory],
|
||||
(item) => fs.resolve(item),
|
||||
)
|
||||
const locationIsGlobal = (yield* fs.resolve(location.directory)) === globalRoots[0]
|
||||
const discovered =
|
||||
locationIsGlobal || options?.project === false
|
||||
? []
|
||||
: yield* fs
|
||||
.up({
|
||||
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
|
||||
start: location.directory,
|
||||
})
|
||||
.pipe(
|
||||
Effect.flatMap((items) =>
|
||||
Effect.forEach(items, (item) =>
|
||||
fs.resolve(item).pipe(Effect.map((resolved) => ({ item, resolved }))),
|
||||
),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
|
||||
const globalEnabled = options?.global !== false
|
||||
// A walked path that resolves into a global root is global config
|
||||
// however the walk reached it (home above the project, or a location
|
||||
// beneath the global config dir), so global: false excludes it
|
||||
// uniformly — classified once here, not per consumer below. With
|
||||
// global enabled, the roots themselves and the global config files are
|
||||
// already loaded below, so the walk must not add them a second time.
|
||||
const globalFiles = yield* Effect.forEach(names, (name) => fs.resolve(path.join(globalDirectory, name)))
|
||||
const visible = discovered
|
||||
.filter(({ resolved }) =>
|
||||
globalEnabled
|
||||
? !globalRoots.includes(resolved) && !globalFiles.includes(resolved)
|
||||
: !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep)),
|
||||
)
|
||||
.map(({ item }) => item)
|
||||
// We load certain files from a few other folders in the ecosystem
|
||||
const claude = [
|
||||
...new Set([
|
||||
...(globalEnabled && (yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
|
||||
...visible.filter((item) => path.basename(item) === ".claude").toReversed(),
|
||||
]),
|
||||
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
|
||||
const agents = [
|
||||
...new Set([
|
||||
...(globalEnabled && (yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
|
||||
...visible.filter((item) => path.basename(item) === ".agents").toReversed(),
|
||||
]),
|
||||
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
|
||||
|
||||
const projectDirectories = visible
|
||||
.filter((item) => path.basename(item) === ".opencode")
|
||||
.toReversed()
|
||||
.map((directory) => AbsolutePath.make(directory))
|
||||
const directPaths = visible
|
||||
.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(
|
||||
const load = Effect.fn("Config.load")(function* (sources: ConfigDiscovery.Sources) {
|
||||
const claude = yield* Effect.filter(sources.claude, (path) => fs.isDir(path))
|
||||
const agents = yield* Effect.filter(sources.agents, (path) => fs.isDir(path))
|
||||
const direct = yield* Effect.forEach(sources.direct, (filepath) => loadFile(filepath)).pipe(
|
||||
Effect.orDie,
|
||||
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(
|
||||
const explicit = sources.explicit
|
||||
? yield* loadFile(sources.explicit).pipe(
|
||||
Effect.map((config) => (config ? [config] : [])),
|
||||
Effect.orDie,
|
||||
)
|
||||
@@ -281,15 +213,18 @@ export const layer = (options?: Options) =>
|
||||
|
||||
// Global entries sit below explicit and direct files; project
|
||||
// directories rank above them.
|
||||
const globalSupplementary = globalEnabled ? yield* loadDirectory(globalDirectory).pipe(Effect.orDie) : []
|
||||
const projectSupplementary = yield* Effect.forEach(projectDirectories, loadDirectory).pipe(
|
||||
const globalSupplementary = sources.global ? yield* loadDirectory(sources.global).pipe(Effect.orDie) : []
|
||||
const projectSupplementary = yield* Effect.forEach(
|
||||
sources.project.filter((root) => root.present),
|
||||
(root) => loadDirectory(root.path),
|
||||
).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((entries) => entries.flat()),
|
||||
)
|
||||
return [
|
||||
...(yield* loadWellknown().pipe(Effect.orDie)),
|
||||
...claude,
|
||||
...agents,
|
||||
...claude.map((path) => new ClaudeDirectory({ type: "claude", path })),
|
||||
...agents.map((path) => new AgentsDirectory({ type: "agents", path })),
|
||||
...globalSupplementary,
|
||||
...explicit,
|
||||
...direct,
|
||||
@@ -298,44 +233,35 @@ export const layer = (options?: Options) =>
|
||||
]
|
||||
})
|
||||
|
||||
const initial = yield* discover()
|
||||
let configs = initial
|
||||
const initial = yield* ConfigDiscovery.discover(options)
|
||||
let configs = yield* load(initial)
|
||||
const updates = yield* PubSub.unbounded<Watcher.Update>()
|
||||
// Vendored trees inside config roots (a plugin's node_modules, a nested
|
||||
// .git) produce event blizzards that can never change discovery output.
|
||||
const ignore = ["node_modules", ".git", "**/{node_modules,.git}/**"]
|
||||
// Watch-once: roots leave discovery only by deletion, so a stale watch is
|
||||
// inert, bounded, and dies with this layer — and keeping a deleted root's
|
||||
// watch alive is exactly what makes its recreation observable.
|
||||
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 === "document" && entry.path ? [entry.path] : [])),
|
||||
...fileTargets,
|
||||
]
|
||||
const targets = [
|
||||
...directories.map((path) => ({ path, type: "directory" as const, ignore })),
|
||||
...files
|
||||
.filter((file) => !directories.some((directory) => FSUtil.contains(directory, file)))
|
||||
.map((path) => ({ path, type: "file" as const })),
|
||||
]
|
||||
for (const target of targets) {
|
||||
const key = JSON.stringify(target)
|
||||
if (watched.has(key)) continue
|
||||
watched.add(key)
|
||||
const stream = yield* watcher.subscribe(target)
|
||||
yield* stream.pipe(
|
||||
Stream.runForEach((update) => PubSub.publish(updates, update)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const reloads = yield* PubSub.sliding<void>(1)
|
||||
// Readiness rescans recover writes made before a watch attached.
|
||||
const requestReload = PubSub.publish(reloads, undefined).pipe(Effect.asVoid)
|
||||
const watched = yield* FiberMap.make<string>()
|
||||
const reconcile = Effect.fn("Config.reconcileWatches")(function* (sources: ConfigDiscovery.Sources) {
|
||||
const plan = ConfigWatch.plan(sources)
|
||||
for (const key of Array.from(watched, ([key]) => key)) {
|
||||
if (!plan.has(key)) yield* FiberMap.remove(watched, key)
|
||||
}
|
||||
for (const [key, target] of plan) {
|
||||
yield* watcher
|
||||
.subscribe(target, requestReload)
|
||||
.pipe(
|
||||
Effect.flatMap(
|
||||
Stream.runForEach((update) => PubSub.publish(updates, update).pipe(Effect.andThen(requestReload))),
|
||||
),
|
||||
FiberMap.run(watched, key, { onlyIfMissing: true, startImmediately: true }),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const reload = Effect.fn("Config.reload")(
|
||||
function* () {
|
||||
const next = yield* discover()
|
||||
yield* reconcile(next)
|
||||
const sources = yield* ConfigDiscovery.discover(options)
|
||||
const next = yield* load(sources)
|
||||
yield* reconcile(sources)
|
||||
if (isDeepStrictEqual(configs, next)) return
|
||||
configs = next
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
@@ -343,12 +269,12 @@ export const layer = (options?: Options) =>
|
||||
(effect) => reloadLock.withPermit(effect),
|
||||
)
|
||||
|
||||
yield* Stream.fromPubSub(updates).pipe(
|
||||
// Subscribe eagerly so synchronous watch readiness isn't dropped.
|
||||
const pendingReloads = yield* PubSub.subscribe(reloads)
|
||||
yield* Stream.fromSubscription(pendingReloads).pipe(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach((update) =>
|
||||
reload().pipe(
|
||||
Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause })),
|
||||
),
|
||||
Stream.runForEach(() =>
|
||||
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { cause }))),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
@@ -389,7 +315,7 @@ export const layer = (options?: Options) =>
|
||||
Effect.forever,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* reconcile(initial)
|
||||
yield* reloadLock.withPermit(reconcile(initial))
|
||||
|
||||
return Service.of({
|
||||
entries: Effect.fnUntraced(function* () {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
export * as ConfigDiscovery from "./discovery.js"
|
||||
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "../location.js"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
import type { Options } from "../config.js"
|
||||
|
||||
export const names = ["opencode.json", "opencode.jsonc"]
|
||||
|
||||
/** Eligible sources in priority order, including paths that may appear later. */
|
||||
export interface Sources {
|
||||
readonly global?: AbsolutePath
|
||||
readonly explicit?: AbsolutePath
|
||||
readonly direct: readonly AbsolutePath[]
|
||||
readonly project: readonly { readonly path: AbsolutePath; readonly present: boolean }[]
|
||||
readonly claude: readonly AbsolutePath[]
|
||||
readonly agents: readonly AbsolutePath[]
|
||||
}
|
||||
|
||||
export const discover = Effect.fn("ConfigDiscovery.discover")(function* (options?: Options) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const globalDirectory = AbsolutePath.make(global.config)
|
||||
const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
|
||||
const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
|
||||
const globalRoots = yield* Effect.forEach([globalDirectory, globalClaudeDirectory, globalAgentsDirectory], (item) =>
|
||||
fs.resolve(item),
|
||||
)
|
||||
const directories =
|
||||
(yield* fs.resolve(location.directory)) === globalRoots[0] || options?.project === false
|
||||
? []
|
||||
: yield* fs.up({ targets: ["."], start: location.directory }).pipe(Effect.orDie)
|
||||
const discovered = yield* Effect.forEach(directories, (directory) =>
|
||||
Effect.gen(function* () {
|
||||
// Resolve the parent too: missing children must honor symlinked global roots.
|
||||
const parent = yield* fs.resolve(directory)
|
||||
return yield* Effect.forEach([".claude", ".agents", ".opencode", ...names.toReversed()], (name) =>
|
||||
fs
|
||||
.resolve(path.join(parent, name))
|
||||
.pipe(Effect.map((resolved) => ({ item: AbsolutePath.make(path.join(directory, name)), resolved }))),
|
||||
)
|
||||
}),
|
||||
).pipe(
|
||||
Effect.map((items) => items.flat()),
|
||||
Effect.orDie,
|
||||
)
|
||||
|
||||
const globalEnabled = options?.global !== false
|
||||
const globalFiles = yield* Effect.forEach(names, (name) => fs.resolve(path.join(globalDirectory, name)))
|
||||
// Global sources must not re-enter through the project walk.
|
||||
const visible = discovered
|
||||
.filter(({ resolved }) =>
|
||||
globalEnabled
|
||||
? !globalRoots.includes(resolved) && !globalFiles.includes(resolved)
|
||||
: !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep)),
|
||||
)
|
||||
.map(({ item }) => item)
|
||||
|
||||
return {
|
||||
global: globalEnabled ? globalDirectory : undefined,
|
||||
explicit: options?.file ? AbsolutePath.make(path.resolve(options.file)) : undefined,
|
||||
direct: visible.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item))).toReversed(),
|
||||
project: yield* Effect.forEach(
|
||||
visible.filter((item) => path.basename(item) === ".opencode").toReversed(),
|
||||
(directory) => fs.isDir(directory).pipe(Effect.map((present) => ({ path: directory, present }))),
|
||||
),
|
||||
claude: [
|
||||
...new Set([
|
||||
...(globalEnabled ? [globalClaudeDirectory] : []),
|
||||
...visible.filter((item) => path.basename(item) === ".claude").toReversed(),
|
||||
]),
|
||||
],
|
||||
agents: [
|
||||
...new Set([
|
||||
...(globalEnabled ? [globalAgentsDirectory] : []),
|
||||
...visible.filter((item) => path.basename(item) === ".agents").toReversed(),
|
||||
]),
|
||||
],
|
||||
} satisfies Sources
|
||||
})
|
||||
@@ -33,7 +33,7 @@ export const Plugin = define({
|
||||
const changes = yield* PubSub.sliding<string>(1)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
|
||||
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: "file" | "directory") {
|
||||
const target = path.resolve(directory)
|
||||
const updates = yield* watcher.subscribe({ path: target, type })
|
||||
yield* FiberMap.run(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export * as ConfigWatch from "./watch.js"
|
||||
|
||||
import path from "path"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import type { Watcher } from "../filesystem/watcher.js"
|
||||
import type { ConfigDiscovery } from "./discovery.js"
|
||||
|
||||
export function plan(sources: ConfigDiscovery.Sources) {
|
||||
const directories = [
|
||||
...(sources.global ? [sources.global] : []),
|
||||
...sources.project.filter((root) => root.present).map((root) => root.path),
|
||||
]
|
||||
const files = [
|
||||
...sources.direct,
|
||||
...sources.project.map((root) => root.path),
|
||||
...sources.claude,
|
||||
...sources.agents,
|
||||
...(sources.explicit ? [sources.explicit] : []),
|
||||
]
|
||||
// Keep a parent watch for each root so deletion/recreation is observable.
|
||||
const parents = Map.groupBy(
|
||||
files.filter((file) => !directories.some((directory) => file !== directory && FSUtil.contains(directory, file))),
|
||||
(file) => path.dirname(file),
|
||||
)
|
||||
return new Map(
|
||||
[
|
||||
...directories.map((path) => ({
|
||||
path,
|
||||
type: "directory" as const,
|
||||
ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"],
|
||||
})),
|
||||
...Array.from(parents, ([parent, files]) => ({
|
||||
path: parent,
|
||||
type: "entries" as const,
|
||||
names: [...new Set(files.map((file) => path.basename(file)))].toSorted(),
|
||||
})),
|
||||
].map((target) => [JSON.stringify(target), target satisfies Watcher.WatchInput]),
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export type Update = ParcelWatcher.Event
|
||||
|
||||
export type WatchInput =
|
||||
| { readonly path: string; readonly type: "file" }
|
||||
| { readonly path: string; readonly type: "entries"; readonly names: readonly string[] }
|
||||
| { readonly path: string; readonly type: "directory"; readonly ignore?: readonly string[] }
|
||||
|
||||
export type Subscription = {
|
||||
@@ -42,25 +43,26 @@ export type Subscription = {
|
||||
readonly backend?: string
|
||||
}
|
||||
|
||||
type Target = {
|
||||
readonly target: string
|
||||
readonly ignore: readonly string[]
|
||||
} & (
|
||||
| { readonly type: "entries"; readonly names: readonly string[] }
|
||||
| { readonly type: "file" | "directory"; readonly names?: readonly string[] }
|
||||
)
|
||||
|
||||
export interface NativeInterface {
|
||||
/** Starts one OS-level watch, reporting events through `publish` until unsubscribed. */
|
||||
readonly subscribe: (input: {
|
||||
readonly type: WatchInput["type"]
|
||||
readonly target: string
|
||||
readonly ignore: readonly string[]
|
||||
readonly publish: (update: Update) => void
|
||||
}) => Effect.Effect<Subscription | undefined>
|
||||
readonly subscribe: (
|
||||
input: Target & { readonly publish: (update: Update) => void },
|
||||
) => Effect.Effect<Subscription | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
* The OS-level watch implementation behind the Watcher service. The default
|
||||
* layer uses `node:fs.watch` for files and `@parcel/watcher` for directories;
|
||||
* tests provide implementations they can control.
|
||||
*/
|
||||
/** Uses fs.watch for immediate entries and Parcel for recursive directories. */
|
||||
export class Native extends Context.Service<Native, NativeInterface>()("@opencode/Watcher/Native") {}
|
||||
|
||||
export interface Interface {
|
||||
readonly subscribe: (input: WatchInput) => Effect.Effect<Stream.Stream<Update>>
|
||||
/** onReady runs after native acquisition and listener registration, when the stream is consumed. */
|
||||
readonly subscribe: (input: WatchInput, onReady?: Effect.Effect<void>) => Effect.Effect<Stream.Stream<Update>>
|
||||
}
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
@@ -89,16 +91,13 @@ export const layer = (options?: Options) =>
|
||||
const native = yield* Native
|
||||
|
||||
// Keys compare structurally (effect Equal), so equivalent watches share one entry.
|
||||
type Key = { readonly type: WatchInput["type"]; readonly target: string; readonly ignore: readonly string[] }
|
||||
const watchers = yield* RcMap.make({
|
||||
lookup: (key: Key) =>
|
||||
lookup: (key: Target) =>
|
||||
Effect.gen(function* () {
|
||||
const pubsub = yield* Effect.acquireRelease(PubSub.unbounded<Update>(), (pubsub) => PubSub.shutdown(pubsub))
|
||||
const subscription = yield* Effect.acquireRelease(
|
||||
native.subscribe({
|
||||
type: key.type,
|
||||
target: key.target,
|
||||
ignore: key.ignore,
|
||||
...key,
|
||||
publish: (update) => PubSub.publishUnsafe(pubsub, update),
|
||||
}),
|
||||
(subscription) =>
|
||||
@@ -127,34 +126,31 @@ export const layer = (options?: Options) =>
|
||||
}),
|
||||
})
|
||||
|
||||
const subscribe = (input: WatchInput) => {
|
||||
const subscribe = Effect.fnUntraced(function* (input: WatchInput, onReady: Effect.Effect<void> = Effect.void) {
|
||||
const target = path.resolve(input.path)
|
||||
const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted()
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.logInfo("watcher subscribe", {
|
||||
path: target,
|
||||
type: input.type,
|
||||
ignores: ignore.length,
|
||||
})
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const pubsub = yield* RcMap.get(watchers, { type: input.type, target, ignore })
|
||||
return Stream.fromPubSub(pubsub)
|
||||
}),
|
||||
)
|
||||
const names = [...new Set(input.type === "entries" ? input.names : [])].toSorted()
|
||||
yield* Effect.logInfo("watcher subscribe", {
|
||||
path: target,
|
||||
type: input.type,
|
||||
ignores: ignore.length,
|
||||
})
|
||||
}
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const pubsub = yield* RcMap.get(watchers, { type: input.type, target, ignore, names })
|
||||
const subscription = yield* PubSub.subscribe(pubsub)
|
||||
if (yield* PubSub.isShutdown(pubsub)) return Stream.empty
|
||||
yield* onReady
|
||||
return Stream.fromSubscription(subscription)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({ subscribe })
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* Watcher for tests: the real lifecycle over an in-memory Native that records
|
||||
* acquired watches and routes emitted updates the way the OS watches would: a
|
||||
* file watch receives updates for its own path, a directory watch receives
|
||||
* updates for paths inside it that no ignore entry covers.
|
||||
*/
|
||||
/** Real subscription lifecycle with in-memory, path-filtered event delivery. */
|
||||
export const testLayer = Layer.effectContext(
|
||||
Effect.gen(function* () {
|
||||
const subscriptions: WatchInput[] = []
|
||||
@@ -165,21 +161,22 @@ export const testLayer = Layer.effectContext(
|
||||
subscriptions.push(
|
||||
input.type === "file"
|
||||
? { path: input.target, type: "file" }
|
||||
: input.ignore.length > 0
|
||||
? { path: input.target, type: "directory", ignore: input.ignore }
|
||||
: { path: input.target, type: "directory" },
|
||||
: input.type === "entries"
|
||||
? { path: input.target, type: "entries", names: input.names }
|
||||
: input.ignore.length > 0
|
||||
? { path: input.target, type: "directory", ignore: input.ignore }
|
||||
: { path: input.target, type: "directory" },
|
||||
)
|
||||
// Ignore entries resolve against the target like the parcel wrapper's
|
||||
// literal paths. Glob entries resolve to paths nothing lives under, so
|
||||
// they are inert here rather than compiled the way parcel compiles them.
|
||||
const ignored = input.ignore.map((entry) => path.resolve(input.target, entry))
|
||||
active.set(
|
||||
input.publish,
|
||||
input.type === "file"
|
||||
? (target) => target === input.target
|
||||
: (target) =>
|
||||
FSUtil.contains(input.target, target) && !ignored.some((entry) => FSUtil.contains(entry, target)),
|
||||
)
|
||||
active.set(input.publish, (target) => {
|
||||
if (input.type === "file") return target === input.target
|
||||
if (input.type === "entries")
|
||||
return path.dirname(target) === input.target && input.names.includes(path.basename(target))
|
||||
return FSUtil.contains(input.target, target) && !ignored.some((entry) => FSUtil.contains(entry, target))
|
||||
})
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
active.delete(input.publish)
|
||||
@@ -208,18 +205,19 @@ export const nativeLayer = Layer.succeed(
|
||||
Native,
|
||||
Native.of({
|
||||
subscribe: (input) => {
|
||||
if (input.type === "file") {
|
||||
if (input.type === "file" || input.type === "entries") {
|
||||
return Effect.sync(() => {
|
||||
const directory = path.dirname(input.target)
|
||||
const directory = input.type === "file" ? path.dirname(input.target) : input.target
|
||||
const names = new Set(input.type === "file" ? [path.basename(input.target)] : input.names)
|
||||
const subscription = watch(directory, { recursive: false }, (_event, file) => {
|
||||
if (file && path.resolve(directory, file.toString()) !== input.target) return
|
||||
input.publish({ path: input.target, type: "update" } satisfies Update)
|
||||
if (file && !names.has(file)) return
|
||||
for (const name of file ? [file] : names) {
|
||||
input.publish({ path: path.join(directory, name), type: "update" })
|
||||
}
|
||||
})
|
||||
if ("on" in subscription && typeof subscription.on === "function") {
|
||||
subscription.on("error", (error: unknown) =>
|
||||
Effect.runFork(Effect.logError("watcher callback failed", { path: input.target, error })),
|
||||
)
|
||||
}
|
||||
subscription.on("error", (error: unknown) =>
|
||||
Effect.runFork(Effect.logError("watcher callback failed", { path: directory, error })),
|
||||
)
|
||||
return { unsubscribe: () => Promise.resolve(subscription.close()), backend: "node" }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@ describe("Config", () => {
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
expect(ambient(yield* config.entries())).toEqual([])
|
||||
const watcher = yield* Watcher.Test
|
||||
expect(
|
||||
(yield* watcher.subscriptions()).filter((watch) => watch.type === "entries" && watch.path === home),
|
||||
).toEqual([])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer(project, global, project, undefined, undefined, undefined, undefined, { global: false }),
|
||||
@@ -165,7 +169,16 @@ describe("Config", () => {
|
||||
const entries = yield* config.entries()
|
||||
expect(entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))).toEqual([global])
|
||||
expect(entries.flatMap((entry) => (entry.type === "document" ? [entry.info.shell] : []))).toEqual(["global"])
|
||||
expect((yield* watcher.subscriptions()).map((subscription) => subscription.path)).toEqual([global])
|
||||
expect(
|
||||
(yield* watcher.subscriptions())
|
||||
.filter((subscription) => subscription.type === "directory")
|
||||
.map((subscription) => subscription.path),
|
||||
).toEqual([global])
|
||||
expect(
|
||||
(yield* watcher.subscriptions()).filter((subscription) =>
|
||||
subscription.path.includes(`${path.sep}.opencode${path.sep}`),
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
return Effect.promise(async () => {
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
@@ -230,6 +243,8 @@ describe("Config", () => {
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
|
||||
const watcher = yield* Watcher.Test
|
||||
expect((yield* watcher.subscriptions()).map((subscription) => subscription.path)).toEqual([global])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
|
||||
@@ -243,17 +258,19 @@ describe("Config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads external config and publishes directory updates", () =>
|
||||
it.live("reloads file substitutions when their source changes", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const file = path.join(global, "opencode.json")
|
||||
const source = path.join(global, "shell.txt")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
await fs.mkdir(project, { recursive: true })
|
||||
await fs.writeFile(file, JSON.stringify({ shell: "first" }))
|
||||
await fs.writeFile(source, "first")
|
||||
await fs.writeFile(file, JSON.stringify({ shell: "{file:shell.txt}" }))
|
||||
})
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
@@ -264,9 +281,8 @@ describe("Config", () => {
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
yield* watcher.emit({ type: "update", path: path.join(global, "commands", "review.md") })
|
||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" })))
|
||||
yield* watcher.emit({ type: "update", path: file })
|
||||
yield* Effect.promise(() => fs.writeFile(source, "second"))
|
||||
yield* watcher.emit({ type: "update", path: source })
|
||||
|
||||
expect(yield* Fiber.join(changed)).toHaveLength(1)
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("second")
|
||||
@@ -276,6 +292,35 @@ describe("Config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("excludes missing files under symlinked global roots when global is disabled", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const link = path.join(tmp.path, "link")
|
||||
const project = path.join(link, "plugins", "demo")
|
||||
return Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(global, "plugins", "demo"), { recursive: true })
|
||||
await fs.symlink(global, link, process.platform === "win32" ? "junction" : undefined)
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
const subscriptions = yield* watcher.subscriptions()
|
||||
expect(subscriptions.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
subscriptions.filter((item) => inFixture(global, item.path) || inFixture(link, item.path)),
|
||||
).toEqual([])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer(project, global, project, undefined, undefined, undefined, undefined, { global: false }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("exposes filesystem updates under config roots through changes", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
@@ -871,7 +916,7 @@ describe("Config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not watch ecosystem config roots", () =>
|
||||
it.live("does not recursively watch ecosystem config roots", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -892,6 +937,11 @@ describe("Config", () => {
|
||||
path: AbsolutePath.make(path.join(tmp.path, "global")),
|
||||
ignore: ["**/{node_modules,.git}/**", ".git", "node_modules"],
|
||||
},
|
||||
{
|
||||
type: "entries",
|
||||
path: tmp.path,
|
||||
names: [".agents", ".claude", ".opencode", "opencode.json", "opencode.jsonc"],
|
||||
},
|
||||
])
|
||||
}).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, Watcher.testLayer)))
|
||||
}),
|
||||
@@ -1447,8 +1497,9 @@ describe("Config", () => {
|
||||
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
|
||||
expect(yield* watcher.subscriptions()).toContainEqual({
|
||||
path: path.join(tmp.path, "opencode.jsonc"),
|
||||
type: "file",
|
||||
path: tmp.path,
|
||||
type: "entries",
|
||||
names: [".agents", ".claude", ".opencode", "opencode.json", "opencode.jsonc"],
|
||||
})
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { writeFileSync } from "node:fs"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
@@ -20,12 +22,19 @@ import { Reference } from "@opencode-ai/core/reference"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { WellKnown } from "@opencode-ai/core/wellknown"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Deferred, Effect, Layer, Schema } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
|
||||
import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node]))),
|
||||
@@ -34,6 +43,192 @@ const decode = Schema.decodeUnknownSync(Info)
|
||||
const document = path.join(import.meta.dir, "opencode.json")
|
||||
|
||||
describe("config plugin reloads", () => {
|
||||
for (const input of [
|
||||
{ root: ".agents", global: false },
|
||||
{ root: "../.claude", global: false },
|
||||
{ root: "home/.agents", global: true },
|
||||
{ root: "home/.claude", global: true },
|
||||
]) {
|
||||
it.live(`loads skills when ${input.root} appears after startup`, () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const root = path.resolve(project, input.root)
|
||||
const file = path.join(project, "opencode.json")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(project, "home"), { recursive: true })
|
||||
await fs.mkdir(path.join(project, "global"))
|
||||
await Bun.write(file, JSON.stringify({ shell: "initial" }))
|
||||
})
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const skills = yield* Skill.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
yield* ConfigSkillPlugin.Plugin.effect(host)
|
||||
expect(yield* skills.list()).toEqual([])
|
||||
|
||||
// Finish startup by observing an ordinary config reload before creating the root.
|
||||
yield* Effect.promise(() => Bun.write(file, JSON.stringify({ shell: "ready" })))
|
||||
yield* waitUntil(
|
||||
config.entries().pipe(Effect.map((entries) => Config.latest(entries, "shell") === "ready")),
|
||||
)
|
||||
const skill = path.join(root, "skills", "probe", "SKILL.md")
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(skill, "---\nname: probe\ndescription: Hot reload\n---\nTest skill"),
|
||||
)
|
||||
yield* waitUntil(skills.list().pipe(Effect.map((items) => items.some((item) => item.id === "probe"))))
|
||||
expect((yield* skills.list())[0]?.location).toBe(AbsolutePath.make(skill))
|
||||
yield* Effect.promise(() => fs.rm(root, { recursive: true }))
|
||||
yield* waitUntil(skills.list().pipe(Effect.map((items) => items.length === 0)))
|
||||
yield* Effect.promise(() => Bun.write(skill, "---\nname: probe\ndescription: Recreated\n---\nTest skill"))
|
||||
yield* waitUntil(skills.list().pipe(Effect.map((items) => items[0]?.description === "Recreated")))
|
||||
}).pipe(Effect.provide(liveConfig(project, undefined, { global: input.global })))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("retains readiness signalled synchronously during initial config startup", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const native = Watcher.Native.of({
|
||||
subscribe: (input) =>
|
||||
Effect.sync(() => {
|
||||
if (input.type === "entries" && input.target === tmp.path) {
|
||||
// No event is emitted: only synchronous readiness can trigger the reload.
|
||||
writeFileSync(path.join(tmp.path, "opencode.json"), JSON.stringify({ references: { docs: "./docs" } }))
|
||||
}
|
||||
return { unsubscribe: () => Promise.resolve() }
|
||||
}),
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const references = yield* Reference.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
yield* ConfigReferencePlugin.Plugin.effect(host)
|
||||
yield* waitUntil(references.list().pipe(Effect.map((items) => items.some((item) => item.name === "docs"))))
|
||||
expect((yield* references.list())[0]?.path).toBe(AbsolutePath.make(path.join(tmp.path, "docs")))
|
||||
}).pipe(Effect.provide(liveConfig(tmp.path, native)))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads the first config written while a new directory watch is starting", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const root = path.join(tmp.path, ".opencode")
|
||||
const parent = yield* Deferred.make<(update: Watcher.Update) => void>()
|
||||
const starting = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const native = Watcher.Native.of({
|
||||
subscribe: (input) =>
|
||||
Effect.gen(function* () {
|
||||
if (input.type === "entries" && input.target === tmp.path) {
|
||||
yield* Deferred.succeed(parent, input.publish)
|
||||
}
|
||||
if (input.type === "directory" && input.target === root) {
|
||||
yield* Deferred.succeed(starting, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
return { unsubscribe: () => Promise.resolve() }
|
||||
}),
|
||||
})
|
||||
return yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const references = yield* Reference.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
yield* ConfigReferencePlugin.Plugin.effect(host)
|
||||
const publish = yield* Deferred.await(parent)
|
||||
yield* Effect.promise(() => fs.mkdir(root))
|
||||
publish({ path: root, type: "create" })
|
||||
yield* Deferred.await(starting).pipe(Effect.timeout("2 seconds"))
|
||||
// No file event: the recursive native watch has not been acquired yet.
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ references: { docs: "./docs" } })),
|
||||
)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* waitUntil(references.list().pipe(Effect.map((items) => items.some((item) => item.name === "docs"))))
|
||||
expect((yield* references.list())[0]?.path).toBe(AbsolutePath.make(path.join(root, "docs")))
|
||||
}).pipe(Effect.provide(liveConfig(tmp.path, native)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
for (const input of [
|
||||
{ file: "opencode.json", empty: false },
|
||||
{ file: "../opencode.jsonc", empty: false },
|
||||
{ file: ".opencode/opencode.json", empty: false },
|
||||
{ file: "../.opencode/opencode.jsonc", empty: true },
|
||||
]) {
|
||||
it.live(`loads references when ${input.file} is first created and keeps watching it`, () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const target = path.resolve(project, input.file)
|
||||
yield* Effect.promise(() => fs.mkdir(project))
|
||||
return yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const references = yield* Reference.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
yield* ConfigReferencePlugin.Plugin.effect(host)
|
||||
expect(yield* references.list()).toEqual([])
|
||||
|
||||
if (input.empty) {
|
||||
yield* Effect.promise(() => fs.mkdir(path.dirname(target)))
|
||||
const config = yield* Config.Service
|
||||
yield* waitUntil(
|
||||
config
|
||||
.entries()
|
||||
.pipe(
|
||||
Effect.map((entries) =>
|
||||
entries.some((entry) => entry.type === "directory" && entry.path === path.dirname(target)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true })
|
||||
await fs.writeFile(target, JSON.stringify({ references: { docs: "./docs" } }))
|
||||
})
|
||||
yield* waitUntil(
|
||||
references.list().pipe(Effect.map((items) => items.some((item) => item.name === "docs"))),
|
||||
)
|
||||
expect((yield* references.list())[0]?.path).toBe(
|
||||
AbsolutePath.make(path.join(path.dirname(target), "docs")),
|
||||
)
|
||||
yield* Effect.promise(() => fs.writeFile(target, JSON.stringify({ references: { next: "./next" } })))
|
||||
yield* waitUntil(
|
||||
references.list().pipe(Effect.map((items) => items.length === 1 && items[0]?.name === "next")),
|
||||
)
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
fs.rm(input.file.includes(".opencode/") ? path.dirname(target) : target, { recursive: true }),
|
||||
)
|
||||
yield* waitUntil(references.list().pipe(Effect.map((items) => items.length === 0)))
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true })
|
||||
await fs.writeFile(target, JSON.stringify({ references: { docs: "./docs" } }))
|
||||
})
|
||||
yield* waitUntil(
|
||||
references.list().pipe(Effect.map((items) => items.length === 1 && items[0]?.name === "docs")),
|
||||
)
|
||||
yield* Effect.promise(() => fs.writeFile(target, JSON.stringify({ references: { next: "./next" } })))
|
||||
yield* waitUntil(
|
||||
references.list().pipe(Effect.map((items) => items.length === 1 && items[0]?.name === "next")),
|
||||
)
|
||||
}).pipe(Effect.provide(liveConfig(project)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("preserves reference precedence and insertion order across documents", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
@@ -123,6 +318,23 @@ describe("config plugin reloads", () => {
|
||||
)
|
||||
})
|
||||
|
||||
function liveConfig(directory: string, native?: Watcher.NativeInterface, options: Config.Options = { global: false }) {
|
||||
return AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node, Reference.node, Global.node, Location.node]), [
|
||||
Config.node.replace(Config.configured(options)),
|
||||
Location.node.replace(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
),
|
||||
Global.node.replace(
|
||||
Global.layerWith({ config: path.join(directory, "global"), home: path.join(directory, "home") }),
|
||||
),
|
||||
Credential.node.replace(emptyCredentialNode),
|
||||
WellKnown.node.replace(emptyWellknownNode),
|
||||
...(native
|
||||
? [Watcher.node.replace(Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native))))]
|
||||
: []),
|
||||
])
|
||||
}
|
||||
|
||||
function config(name: string) {
|
||||
return new Document({
|
||||
type: "document",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ConfigDiscovery } from "@opencode-ai/core/config/discovery"
|
||||
import { ConfigWatch } from "@opencode-ai/core/config/watch"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
||||
const project = path.resolve("watch-plan-project")
|
||||
const root = AbsolutePath.make(path.join(project, ".opencode"))
|
||||
const sources: ConfigDiscovery.Sources = {
|
||||
direct: ["opencode.json", "opencode.jsonc"].map((name) => AbsolutePath.make(path.join(project, name))),
|
||||
project: [{ path: root, present: false }],
|
||||
claude: [AbsolutePath.make(path.join(project, ".claude"))],
|
||||
agents: [AbsolutePath.make(path.join(project, ".agents"))],
|
||||
}
|
||||
|
||||
describe("ConfigWatch.plan", () => {
|
||||
test("groups missing candidates and keeps parent watches when roots appear", () => {
|
||||
const missing = ConfigWatch.plan(sources)
|
||||
expect(Array.from(missing.values())).toEqual([
|
||||
{ path: project, type: "entries", names: [".agents", ".claude", ".opencode", "opencode.json", "opencode.jsonc"] },
|
||||
])
|
||||
const present = ConfigWatch.plan({ ...sources, project: [{ path: root, present: true }] })
|
||||
expect(Array.from(present.values())).toEqual([
|
||||
{ path: root, type: "directory", ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"] },
|
||||
...missing.values(),
|
||||
])
|
||||
})
|
||||
|
||||
test("adds exact watches for explicit files only when not already covered", () => {
|
||||
expect(ConfigWatch.plan({ ...sources, explicit: sources.direct[0] })).toEqual(ConfigWatch.plan(sources))
|
||||
const present = { ...sources, project: [{ path: root, present: true }] }
|
||||
expect(ConfigWatch.plan({ ...present, explicit: AbsolutePath.make(path.join(root, "custom.json")) })).toEqual(
|
||||
ConfigWatch.plan(present),
|
||||
)
|
||||
const directory = path.resolve("watch-plan-external")
|
||||
expect(
|
||||
Array.from(
|
||||
ConfigWatch.plan({ ...sources, explicit: AbsolutePath.make(path.join(directory, "custom.json")) }).values(),
|
||||
),
|
||||
).toContainEqual({ path: directory, type: "entries", names: ["custom.json"] })
|
||||
})
|
||||
})
|
||||
@@ -53,18 +53,121 @@ function countingNative() {
|
||||
}
|
||||
|
||||
describe("Watcher lifecycle", () => {
|
||||
it.effect("signals readiness after acquisition and buffers updates published by the ready callback", () =>
|
||||
Effect.gen(function* () {
|
||||
const publish = yield* Deferred.make<(update: Watcher.Update) => void>()
|
||||
const acquired = yield* Deferred.make<void>()
|
||||
const counts = { ready: 0, closed: 0 }
|
||||
yield* Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Service
|
||||
const consumer = yield* watcher
|
||||
.subscribe(
|
||||
{ path: "/shared", type: "entries", names: ["opencode.json"] },
|
||||
Effect.gen(function* () {
|
||||
counts.ready++
|
||||
const notify = yield* Deferred.await(publish)
|
||||
notify({ path: "/shared/opencode.json", type: "create" })
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.flatMap(Stream.runHead), Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(publish)
|
||||
expect(counts.ready).toBe(0)
|
||||
yield* Deferred.succeed(acquired, undefined)
|
||||
expect(Option.getOrUndefined(yield* Fiber.join(consumer))).toEqual({
|
||||
path: "/shared/opencode.json",
|
||||
type: "create",
|
||||
})
|
||||
expect(counts).toEqual({ ready: 1, closed: 1 })
|
||||
}).pipe(
|
||||
withNative({
|
||||
subscribe: (input) =>
|
||||
Deferred.succeed(publish, input.publish).pipe(
|
||||
Effect.andThen(Deferred.await(acquired)),
|
||||
Effect.as({
|
||||
unsubscribe: async () => {
|
||||
counts.closed++
|
||||
},
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not signal readiness for an unavailable native watch", () => {
|
||||
const counts = { ready: 0 }
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Service
|
||||
const stream = yield* watcher.subscribe(
|
||||
{ path: "/unavailable", type: "directory" },
|
||||
Effect.sync(() => {
|
||||
counts.ready++
|
||||
}),
|
||||
)
|
||||
yield* Stream.runDrain(stream)
|
||||
expect(counts.ready).toBe(0)
|
||||
}).pipe(withNative({ subscribe: () => Effect.undefined }))
|
||||
})
|
||||
|
||||
it.live("watches only named immediate entries, including missing directories", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* Watcher.Native
|
||||
const events: Watcher.Update[] = []
|
||||
yield* Effect.acquireRelease(
|
||||
native.subscribe({
|
||||
type: "entries",
|
||||
target: tmp.path,
|
||||
names: ["opencode.json", ".opencode"],
|
||||
ignore: [],
|
||||
publish: (update) => events.push(update),
|
||||
}),
|
||||
(subscription) => Effect.promise(() => subscription?.unsubscribe() ?? Promise.resolve()),
|
||||
)
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "nested"))
|
||||
await fs.writeFile(path.join(tmp.path, "nested", "opencode.json"), "ignored")
|
||||
await fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "ignored")
|
||||
await fs.writeFile(path.join(tmp.path, "opencode.json"), "first")
|
||||
})
|
||||
yield* Effect.sync(() => events.length).pipe(
|
||||
Effect.filterOrFail((count) => count > 0),
|
||||
Effect.retry(Schedule.spaced("10 millis")),
|
||||
Effect.timeout("1 second"),
|
||||
)
|
||||
expect(events.every((update) => update.path === path.join(tmp.path, "opencode.json"))).toBe(true)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".opencode")))
|
||||
yield* Effect.sync(() => events.some((update) => update.path === path.join(tmp.path, ".opencode"))).pipe(
|
||||
Effect.filterOrFail(Boolean),
|
||||
Effect.retry(Schedule.spaced("10 millis")),
|
||||
Effect.timeout("1 second"),
|
||||
)
|
||||
}).pipe(Effect.provide(Watcher.nativeLayer)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("interrupting a consumer interrupts a pending acquisition", () =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
const counts = { ready: 0 }
|
||||
yield* Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Service
|
||||
const consumer = yield* watcher
|
||||
.subscribe({ path: "/pending", type: "directory" })
|
||||
.subscribe(
|
||||
{ path: "/pending", type: "directory" },
|
||||
Effect.sync(() => {
|
||||
counts.ready++
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(consumer)
|
||||
expect(yield* Deferred.isDone(interrupted)).toBe(true)
|
||||
expect(counts.ready).toBe(0)
|
||||
}).pipe(
|
||||
withNative({
|
||||
subscribe: () =>
|
||||
@@ -77,16 +180,16 @@ describe("Watcher lifecycle", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("shares one subscription and releases exactly once after the final consumer", () => {
|
||||
it.effect("shares equivalent entry sets and releases exactly once after the final consumer", () => {
|
||||
const { native, counts } = countingNative()
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Service
|
||||
const consume = () =>
|
||||
const consume = (names: string[]) =>
|
||||
watcher
|
||||
.subscribe({ path: "/shared", type: "directory" })
|
||||
.subscribe({ path: "/shared", type: "entries", names })
|
||||
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
|
||||
const first = yield* consume()
|
||||
const second = yield* consume()
|
||||
const first = yield* consume(["opencode.json", ".opencode", "opencode.json"])
|
||||
const second = yield* consume([".opencode", "opencode.json"])
|
||||
yield* Effect.yieldNow
|
||||
expect(counts.subscribes).toBe(1)
|
||||
|
||||
@@ -553,17 +656,35 @@ describeNative("LocationWatcher", () => {
|
||||
})
|
||||
|
||||
it.live("publishes .hg/branch events", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<string>()
|
||||
const watcher = Layer.effect(
|
||||
Watcher.Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const branch = path.join(directory, ".hg", "branch")
|
||||
yield* ready(branch)
|
||||
expect(
|
||||
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
|
||||
).toMatchObject({ file: branch })
|
||||
const service = yield* Watcher.Service
|
||||
return Watcher.Service.of({
|
||||
subscribe: (input, onReady) =>
|
||||
service.subscribe(
|
||||
input,
|
||||
Deferred.succeed(started, input.path).pipe(Effect.andThen(onReady ?? Effect.void)),
|
||||
),
|
||||
})
|
||||
}),
|
||||
{ vcs: "hg" },
|
||||
),
|
||||
).pipe(Layer.provide(AppNodeBuilder.build(Watcher.node)))
|
||||
return yield* withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const branch = path.join(directory, ".hg", "branch")
|
||||
// Use the actual acquisition barrier, not a probe write whose event
|
||||
// callback can race the next write in Bun's filesystem watcher.
|
||||
expect(yield* Deferred.await(started)).toBe(branch)
|
||||
expect(
|
||||
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
|
||||
).toMatchObject({ file: branch })
|
||||
}),
|
||||
{ vcs: "hg", watcher },
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -280,6 +280,7 @@ test("keeps consecutive close controls fixed across overflow window changes", as
|
||||
await app.mockMouse.click(11, 0)
|
||||
await app.waitForFrame((frame) => items().length === 4 && Array.from(frame.split("\n")[0] ?? "")[11] === "✕")
|
||||
await app.mockMouse.click(11, 0)
|
||||
await app.waitFor(() => closed.length === 2)
|
||||
|
||||
expect(closed).toEqual(["third", "fourth"])
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user