From c391983ae0f8e443c44f006b598028b00561bab9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 1 Aug 2026 14:06:58 -0400 Subject: [PATCH] fix(tui): discover plugins across config roots (#39988) --- packages/tui/src/app.tsx | 16 +++-- packages/tui/src/context/theme.tsx | 5 +- packages/tui/src/plugin/context.tsx | 16 +++-- packages/tui/src/plugin/discovery.ts | 50 ++++++++++---- packages/tui/src/plugin/watch.ts | 68 +++++++++++++++----- packages/tui/src/theme/discovery.ts | 9 --- packages/tui/src/util/config-directories.ts | 45 +++++++++++++ packages/tui/test/plugin-discovery.test.ts | 57 ++++++++++++++-- packages/tui/test/plugin-hot-reload.test.tsx | 47 ++++++++++++-- packages/tui/test/theme.test.ts | 5 +- 10 files changed, 254 insertions(+), 64 deletions(-) create mode 100644 packages/tui/src/util/config-directories.ts diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 78733756bdf..d692b4e50e8 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -84,6 +84,7 @@ import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { Config, ConfigProvider, useConfig } from "./config" import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context" +import { tuiPluginDirectories } from "./plugin/discovery" import { PluginRoute, PluginSlot } from "./plugin/render" import { CommandPaletteDialog } from "./component/command-palette" import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap" @@ -209,9 +210,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }) const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) } const api = OpenCode.make(options) - const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe( - Effect.map((response) => response.location.directory), - Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))), + const location = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe( + Effect.map((response) => response.location), + Effect.catch(() => Effect.tryPromise(() => api.location.get())), + ) + const directory = location.directory + const pluginDirectories = yield* Effect.promise(() => + tuiPluginDirectories(process.cwd(), global.config), ) const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined const managed = input.server.service @@ -379,7 +384,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - + Promise @@ -57,7 +57,7 @@ type Desired = Pick() -export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) { +export function PluginProvider(props: ParentProps<{ packages: PackageResolver; directories: string[] }>) { const host = usePluginHost() const config = useConfig() const lifecycle = useTuiLifecycle() @@ -171,10 +171,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> void enqueue(reconcile).catch(() => undefined) }, 100) }) - onCleanup(() => { + const stopWatching = () => { clearTimeout(pending) watcher.dispose() - }) + } + onCleanup(stopWatching) // Rebuild the plugin generation as resolve → compare → swap, mirroring the // core plugin registry: fold the ordered entries into a desired end state @@ -186,8 +187,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> // every watch event; remember them until the configuration changes. const npmFailures = new Map() const reconcile = async () => { - const entries = [...(await discoverTuiPlugins(host.paths.cwd)), ...(config.data.plugins ?? [])] - watcher.add(tuiPluginDirectory(host.paths.cwd)) + await Promise.all(props.directories.map(watcher.wait)) + const entries = [...(await discoverTuiPlugins(props.directories)), ...(config.data.plugins ?? [])] // Resolve: fold entries into one desired generation. A source that fails // to import keeps its running previous version and only reports failure. @@ -210,7 +211,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> const options = typeof entry === "string" ? undefined : entry.options // Watch even when the resolve below fails so fixing a broken plugin reloads it. const local = localSource(target, directory) - if (local) watcher.add(fileURLToPath(local)) + if (local) await watcher.add(fileURLToPath(local)) const previous = Object.values(store.registrations).find((registration) => registration.target === target) const memo = local ? undefined : npmFailures.get(target) const resolved = memo @@ -363,6 +364,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> let disposing: Promise | undefined const dispose = () => { if (disposing) return disposing + stopWatching() disposing = loading .catch(() => undefined) .then(() => diff --git a/packages/tui/src/plugin/discovery.ts b/packages/tui/src/plugin/discovery.ts index b1589c7d22c..80b831f07e5 100644 --- a/packages/tui/src/plugin/discovery.ts +++ b/packages/tui/src/plugin/discovery.ts @@ -1,23 +1,47 @@ -import { readdir } from "node:fs/promises" +import { readdir, stat } from "node:fs/promises" import path from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" +import { + isMissingPath, + localProjectDirectory, + projectConfigDirectories, +} from "../util/config-directories" const extensions = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]) -export function tuiPluginDirectory(cwd: string) { - return path.join(cwd, ".opencode", "plugins", "tui") +export async function tuiPluginDirectories(cwd: string, configDirectory: string) { + const projectDirectory = await localProjectDirectory(cwd) + const projectConfig = path.join(projectDirectory, ".opencode") + const directories = [configDirectory, ...projectConfigDirectories(projectDirectory, cwd)] + const exists = await Promise.all( + directories.map((directory) => { + if (directory === configDirectory || directory === projectConfig) return true + return stat(directory).then( + (info) => info.isDirectory(), + (error) => (isMissingPath(error) ? false : Promise.reject(error)), + ) + }), + ) + return directories + .filter((_, index) => exists[index]) + .map((directory) => path.join(directory, "plugins", "tui")) } -export async function discoverTuiPlugins(cwd: string) { - const directory = tuiPluginDirectory(cwd) - const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => { - if (error && typeof error === "object" && Reflect.get(error, "code") === "ENOENT") return [] - return Promise.reject(error) - }) - return entries - .filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name))) - .map((entry) => path.join(directory, entry.name)) - .sort() +export async function discoverTuiPlugins(directories: string[]) { + return ( + await Promise.all( + directories.map(async (directory) => { + const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => { + if (isMissingPath(error)) return [] + return Promise.reject(error) + }) + return entries + .filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name))) + .map((entry) => path.join(directory, entry.name)) + .sort() + }), + ) + ).flat() } export function localSource(spec: string, directory: string) { diff --git a/packages/tui/src/plugin/watch.ts b/packages/tui/src/plugin/watch.ts index f58b866f5b9..d6b479a70d8 100644 --- a/packages/tui/src/plugin/watch.ts +++ b/packages/tui/src/plugin/watch.ts @@ -10,21 +10,30 @@ import { lstat, realpath, stat } from "fs/promises" // Directory targets are watched at their root only: edits to nested helper // files do not change the entrypoint mtime and are not detected. Watches are // never torn down individually (a stale watch costs one fs handle and a -// spurious onChange); all die with dispose(). Failed or vanished watches are -// forgotten so a later add() can re-arm once the path exists. +// spurious onChange); all die with dispose(). Missing retryable targets are +// polled until they can be armed without relying on a racy chain of ancestor +// watches. export function createSourceWatcher(onChange: () => void) { const watchers = new Map>() const watched = new Map | null>() + const missing = new Set() + const arming = new Map>() let disposed = false + const notify = () => { + if (!disposed) onChange() + } const forget = (dir: string) => { watchers.get(dir)?.close() watchers.delete(dir) watched.delete(dir) } - const arm = (target: string) => { - stat(target) + const arm = (target: string, retry: boolean) => { + const active = arming.get(target) + if (active) return active + const result = stat(target) .then((info) => { if (disposed) return + const appeared = missing.delete(target) const dir = info.isDirectory() ? target : path.dirname(target) // Directories accept every filename (null); files accept their basename. const name = info.isDirectory() ? null : path.basename(target) @@ -32,44 +41,69 @@ export function createSourceWatcher(onChange: () => void) { if (existing !== undefined) { if (name === null) watched.set(dir, null) else existing?.add(name) + if (appeared) notify() return } - watched.set(dir, name === null ? null : new Set([name])) const watcher = watch(dir, (_event, filename) => { // A replaced directory keeps this watcher on the dead inode (Linux // emits rename, not error); forget it so a later add() re-arms on // the recreated path, and still schedule so reconcile runs now. if (!existsSync(dir)) { forget(dir) - onChange() + notify() return } // A null filename (platform-dependent) always schedules. const accept = watched.get(dir) if (filename && accept && !accept.has(filename.toString())) return - onChange() + notify() + }) + watched.set(dir, name === null ? null : new Set([name])) + // Reconcile after watcher errors so every source is re-added and any + // temporarily unavailable target moves into the polling set. + watcher.on("error", () => { + forget(dir) + notify() }) - // A watched directory can disappear out from under us; without a - // listener the error event would crash the process. Forget the path - // so a later add can re-arm once it exists again. - watcher.on("error", () => forget(dir)) watchers.set(dir, watcher) + if (appeared) notify() }) - .catch(() => undefined) + .catch((error: unknown) => { + if (!disposed && retry && isMissing(error)) missing.add(target) + }) + .finally(() => arming.delete(target)) + arming.set(target, result) + return result } - const add = (target: string) => { - arm(target) + const add = async (target: string, retry: boolean) => { + await arm(target, retry) // A symlinked source receives edits at its resolved target. - lstat(target) + await lstat(target) .then((info) => { if (!info.isSymbolicLink()) return - return realpath(target).then(arm) + return realpath(target).then((target) => arm(target, retry)) }) .catch(() => undefined) } const dispose = () => { disposed = true + clearInterval(poll) for (const watcher of watchers.values()) watcher.close() + watchers.clear() + watched.clear() + missing.clear() + } + const poll = setInterval(() => missing.forEach((target) => arm(target, true)), 500) + poll.unref() + return { + add: (target: string) => add(target, false), + wait: (target: string) => add(target, true), + dispose, } - return { add, dispose } +} + +function isMissing(error: unknown) { + if (!error || typeof error !== "object") return false + const code = Reflect.get(error, "code") + return code === "ENOENT" || code === "ENOTDIR" } diff --git a/packages/tui/src/theme/discovery.ts b/packages/tui/src/theme/discovery.ts index 70a7ac99138..43e685ae6d4 100644 --- a/packages/tui/src/theme/discovery.ts +++ b/packages/tui/src/theme/discovery.ts @@ -1,15 +1,6 @@ import { readdir, readFile } from "node:fs/promises" import path from "node:path" -export function themeDirectories(config: string, cwd: string) { - const directories: string[] = [] - for (let current = cwd; ; current = path.dirname(current)) { - directories.push(path.join(current, ".opencode")) - if (path.dirname(current) === current) break - } - return [config, ...directories.reverse()] -} - export async function discoverThemes(directories: string[]) { const result: Record = {} for (const directory of directories) { diff --git a/packages/tui/src/util/config-directories.ts b/packages/tui/src/util/config-directories.ts new file mode 100644 index 00000000000..176599a9ac8 --- /dev/null +++ b/packages/tui/src/util/config-directories.ts @@ -0,0 +1,45 @@ +import path from "node:path" +import { stat } from "node:fs/promises" + +export function configDirectories(config: string, cwd: string) { + return [...new Set([config, ...ancestors(cwd).map((directory) => path.join(directory, ".opencode"))])] +} + +export function projectConfigDirectories(project: string, cwd: string) { + const directories = ancestors(cwd) + return directories + .slice(directories.indexOf(path.resolve(project))) + .map((directory) => path.join(directory, ".opencode")) +} + +export async function localProjectDirectory(cwd: string) { + const directories = ancestors(cwd) + const repositories = await Promise.all( + directories.map((directory) => + Promise.all( + [".git", ".hg"].map((name) => + stat(path.join(directory, name)).then( + () => true, + (error) => (isMissingPath(error) ? false : Promise.reject(error)), + ), + ), + ).then((matches) => matches.some(Boolean)), + ), + ) + return directories.findLast((_, index) => repositories[index]) ?? path.resolve(cwd) +} + +export function isMissingPath(error: unknown) { + if (!error || typeof error !== "object") return false + const code = Reflect.get(error, "code") + return code === "ENOENT" || code === "ENOTDIR" +} + +function ancestors(cwd: string) { + const directories: string[] = [] + for (let current = path.resolve(cwd); ; current = path.dirname(current)) { + directories.push(current) + if (path.dirname(current) === current) break + } + return directories.reverse() +} diff --git a/packages/tui/test/plugin-discovery.test.ts b/packages/tui/test/plugin-discovery.test.ts index 3c3c85fcf99..62c5c532ee1 100644 --- a/packages/tui/test/plugin-discovery.test.ts +++ b/packages/tui/test/plugin-discovery.test.ts @@ -1,7 +1,8 @@ import { mkdir, writeFile } from "node:fs/promises" import path from "node:path" import { expect, test } from "bun:test" -import { discoverTuiPlugins } from "../src/plugin/discovery" +import { discoverTuiPlugins, tuiPluginDirectories } from "../src/plugin/discovery" +import { localProjectDirectory } from "../src/util/config-directories" import { tmpdir } from "./fixture/fixture" test("discovers project TUI plugin files in stable order", async () => { @@ -15,13 +16,57 @@ test("discovers project TUI plugin files in stable order", async () => { writeFile(path.join(directory, "nested", "ignored.ts"), "export default {}"), ]) - expect(await discoverTuiPlugins(tmp.path)).toEqual([ - path.join(directory, "first.js"), - path.join(directory, "second.tsx"), - ]) + expect( + await discoverTuiPlugins(await tuiPluginDirectories(tmp.path, path.join(tmp.path, "config"))), + ).toEqual([path.join(directory, "first.js"), path.join(directory, "second.tsx")]) }) test("returns no project TUI plugins when the directory is absent", async () => { await using tmp = await tmpdir() - expect(await discoverTuiPlugins(tmp.path)).toEqual([]) + const roots = await tuiPluginDirectories(tmp.path, path.join(tmp.path, "config")) + expect(await discoverTuiPlugins(roots)).toEqual([]) + expect(roots).toContain(path.join(tmp.path, ".opencode", "plugins", "tui")) +}) + +test("discovers global and ancestor plugin roots in precedence order", async () => { + await using tmp = await tmpdir() + const cwd = path.join(tmp.path, "repo", "packages", "app") + const project = path.join(tmp.path, "repo") + const config = path.join(tmp.path, "config") + const directories = [ + path.join(config, "plugins", "tui"), + path.join(tmp.path, "repo", ".opencode", "plugins", "tui"), + path.join(tmp.path, "repo", "packages", ".opencode", "plugins", "tui"), + ] + const outside = path.join(tmp.path, ".opencode", "plugins", "tui") + await mkdir(path.join(project, ".git"), { recursive: true }) + await Promise.all([...directories, outside].map((directory) => mkdir(directory, { recursive: true }))) + await Promise.all( + directories.map((directory, index) => writeFile(path.join(directory, `${index}.ts`), "export default {}")), + ) + await writeFile(path.join(outside, "outside.ts"), "export default {}") + + const roots = await tuiPluginDirectories(cwd, config) + expect(await discoverTuiPlugins(roots)).toEqual( + directories.map((directory, index) => path.join(directory, `${index}.ts`)), + ) + expect(roots).not.toContain(path.join(cwd, ".opencode", "plugins", "tui")) + expect(roots).not.toContain(outside) +}) + +test("uses an Hg root for a missing project plugin directory", async () => { + await using tmp = await tmpdir() + const project = path.join(tmp.path, "repo") + const cwd = path.join(project, "package") + await mkdir(path.join(project, ".hg"), { recursive: true }) + await mkdir(cwd, { recursive: true }) + + expect(await tuiPluginDirectories(cwd, path.join(tmp.path, "config"))).toContain( + path.join(project, ".opencode", "plugins", "tui"), + ) +}) + +test("propagates non-missing filesystem errors", async () => { + await expect(localProjectDirectory("\0")).rejects.toBeInstanceOf(Error) + await expect(discoverTuiPlugins(["\0"])).rejects.toBeInstanceOf(Error) }) diff --git a/packages/tui/test/plugin-hot-reload.test.tsx b/packages/tui/test/plugin-hot-reload.test.tsx index 4baa3801219..9faeec5c78d 100644 --- a/packages/tui/test/plugin-hot-reload.test.tsx +++ b/packages/tui/test/plugin-hot-reload.test.tsx @@ -1,11 +1,10 @@ import { expect, mock, test } from "bun:test" import { createTestRenderer } from "@opentui/core/testing" import { Effect, FileSystem } from "effect" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/util/global" import { mkdir, readFile, symlink, writeFile } from "node:fs/promises" import path from "node:path" -import { createEventStream, createFetch } from "./fixture/tui-client" +import { createEventStream, createFetch, json } from "./fixture/tui-client" import { tmpdir } from "./fixture/fixture" function lifecycleSource(marker: string, id: string, version: string) { @@ -36,7 +35,16 @@ async function bootApp(directory: string) { const core = await import("@opentui/core") mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) const events = createEventStream() - const calls = createFetch(undefined, events) + const calls = createFetch((url) => { + if (url.pathname !== "/api/fs/list") return + return json({ + location: { + directory, + project: { id: "proj_test", directory, canonical: directory }, + }, + data: [], + }) + }, events) const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) const cwd = process.cwd() process.chdir(directory) @@ -49,7 +57,10 @@ async function bootApp(directory: string) { packages: { resolve: async () => undefined }, args: {}, log: () => {}, - }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + }).pipe( + Effect.provide(Global.layerWith({ config: path.join(directory, ".global") })), + Effect.provide(FileSystem.layerNoop({})), + ), ) return { task, @@ -62,6 +73,34 @@ async function bootApp(directory: string) { } } +test("discovers an ancestor TUI plugin directory created after startup", async () => { + await using tmp = await tmpdir() + const cwd = path.join(tmp.path, "repo", "packages", "app") + await mkdir(cwd, { recursive: true }) + await mkdir(path.join(tmp.path, "repo", ".git")) + const ready = path.join(tmp.path, "ready.txt") + const marker = path.join(tmp.path, "marker.txt") + const initial = path.join(cwd, ".opencode", "plugins", "tui") + await mkdir(initial, { recursive: true }) + await writeFile(path.join(initial, "ready.ts"), lifecycleSource(ready, "test.ready", "ready")) + + await using app = await bootApp(cwd) + expect(await until(() => readFile(ready, "utf8"), (value) => value === "ready:setup\n")).toBe("ready:setup\n") + const directory = path.join(tmp.path, "repo", ".opencode", "plugins", "tui") + await mkdir(directory, { recursive: true }) + await writeFile(path.join(directory, "hot.ts"), lifecycleSource(marker, "test.hot", "v1")) + + expect( + await until( + () => readFile(marker, "utf8"), + (value) => value === "v1:setup\n", + ), + ).toBe("v1:setup\n") + + process.emit("SIGHUP") + await app.task +}) + test("editing a discovered TUI plugin hot-reloads its fresh module", async () => { await using tmp = await tmpdir() const directory = path.join(tmp.path, ".opencode", "plugins", "tui") diff --git a/packages/tui/test/theme.test.ts b/packages/tui/test/theme.test.ts index 05d13d8601e..3b51a378d3f 100644 --- a/packages/tui/test/theme.test.ts +++ b/packages/tui/test/theme.test.ts @@ -12,7 +12,8 @@ import { setCustomThemes, upsertTheme, } from "../src/theme" -import { discoverThemes, themeDirectories } from "../src/theme/discovery" +import { discoverThemes } from "../src/theme/discovery" +import { configDirectories } from "../src/util/config-directories" import { terminalMode } from "../src/theme/system" import { tmpdir } from "./fixture/fixture" @@ -187,7 +188,7 @@ test("theme directories include global config before project directories", async await writeFile(path.join(global, "themes", "global.json"), JSON.stringify({ source: "global" })) await writeFile(path.join(project, ".opencode", "themes", "project.json"), JSON.stringify({ source: "project" })) - await expect(discoverThemes(themeDirectories(global, project))).resolves.toEqual({ + await expect(discoverThemes(configDirectories(global, project))).resolves.toEqual({ global: { source: "global" }, project: { source: "project" }, })