Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton cde078e31f fix(tui): discover plugins across config roots 2026-07-31 22:12:22 -04:00
6 changed files with 107 additions and 34 deletions
+4 -1
View File
@@ -378,7 +378,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider packages={input.packages}>
<PluginProvider
packages={input.packages}
configDirectory={global.config}
>
<App
pair={
input.server.endpoint.auth
+5 -4
View File
@@ -13,7 +13,7 @@ import { errorMessage } from "../util/error"
import { builtins } from "./builtins"
import { createPluginContext, usePluginHost, type Dispose } from "./api"
import { createSourceWatcher } from "./watch"
import { discoverTuiPlugins, freshSpecifier, localSource, tuiPluginDirectory } from "./discovery"
import { discoverTuiPlugins, freshSpecifier, localSource, tuiPluginDirectories } from "./discovery"
export interface PackageResolver {
readonly resolve: (spec: string) => Promise<string | undefined>
@@ -57,11 +57,12 @@ type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "
const PluginContext = createContext<Value>()
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
export function PluginProvider(props: ParentProps<{ packages: PackageResolver; configDirectory: string }>) {
const host = usePluginHost()
const config = useConfig()
const lifecycle = useTuiLifecycle()
const directory = config.path ? path.dirname(config.path) : process.cwd()
const pluginDirectories = tuiPluginDirectories(host.paths.cwd, props.configDirectory)
const [store, setStore] = createStore({
ready: false,
states: [] as ReadonlyArray<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<string, string>()
const reconcile = async () => {
const entries = [...(await discoverTuiPlugins(host.paths.cwd)), ...(config.data.plugins ?? [])]
watcher.add(tuiPluginDirectory(host.paths.cwd))
const entries = [...(await discoverTuiPlugins(pluginDirectories)), ...(config.data.plugins ?? [])]
pluginDirectories.forEach((directory) => watcher.add(directory, true))
// Resolve: fold entries into one desired generation. A source that fails
// to import keeps its running previous version and only reports failure.
+25 -12
View File
@@ -4,20 +4,33 @@ import { fileURLToPath, pathToFileURL } from "node:url"
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 function tuiPluginDirectories(cwd: string, configDirectory: string) {
const ancestors: string[] = []
let current = path.resolve(cwd)
while (true) {
ancestors.push(path.join(current, ".opencode", "plugins", "tui"))
const parent = path.dirname(current)
if (parent === current) break
current = parent
}
return [...new Set([path.join(configDirectory, "plugins", "tui"), ...ancestors.reverse()])]
}
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 (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()
}),
)
).flat()
}
export function localSource(spec: string, directory: string) {
+24 -12
View File
@@ -10,21 +10,24 @@ 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 or temporarily
// unwatchable 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<string, ReturnType<typeof watch>>()
const watched = new Map<string, Set<string> | null>()
const missing = new Set<string>()
let disposed = false
const forget = (dir: string) => {
watchers.get(dir)?.close()
watchers.delete(dir)
watched.delete(dir)
}
const arm = (target: string) => {
const arm = (target: string, retry: boolean) => {
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,9 +35,9 @@ export function createSourceWatcher(onChange: () => void) {
if (existing !== undefined) {
if (name === null) watched.set(dir, null)
else existing?.add(name)
if (appeared) onChange()
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
@@ -49,27 +52,36 @@ export function createSourceWatcher(onChange: () => void) {
if (filename && accept && !accept.has(filename.toString())) return
onChange()
})
// 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))
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)
onChange()
})
watchers.set(dir, watcher)
if (appeared) onChange()
})
.catch(() => {
if (retry) missing.add(target)
})
.catch(() => undefined)
}
const add = (target: string) => {
arm(target)
const add = (target: string, retry = false) => {
arm(target, retry)
// A symlinked source receives edits at its resolved target.
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()
}
const poll = setInterval(() => missing.forEach((target) => arm(target, true)), 500)
poll.unref()
return { add, dispose }
}
+23 -3
View File
@@ -1,7 +1,7 @@
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 { tmpdir } from "./fixture/fixture"
test("discovers project TUI plugin files in stable order", async () => {
@@ -15,7 +15,7 @@ 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([
expect(await discoverTuiPlugins(tuiPluginDirectories(tmp.path, path.join(tmp.path, "config")))).toEqual([
path.join(directory, "first.js"),
path.join(directory, "second.tsx"),
])
@@ -23,5 +23,25 @@ test("discovers project TUI plugin files in stable order", async () => {
test("returns no project TUI plugins when the directory is absent", async () => {
await using tmp = await tmpdir()
expect(await discoverTuiPlugins(tmp.path)).toEqual([])
expect(await discoverTuiPlugins(tuiPluginDirectories(tmp.path, path.join(tmp.path, "config")))).toEqual([])
})
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 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"),
]
await Promise.all(directories.map((directory) => mkdir(directory, { recursive: true })))
await Promise.all(
directories.map((directory, index) => writeFile(path.join(directory, `${index}.ts`), "export default {}")),
)
expect(await discoverTuiPlugins(tuiPluginDirectories(cwd, config))).toEqual(
directories.map((directory, index) => path.join(directory, `${index}.ts`)),
)
expect(tuiPluginDirectories(cwd, config)).toContain(path.join(cwd, ".opencode", "plugins", "tui"))
})
+26 -2
View File
@@ -1,7 +1,6 @@
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"
@@ -49,7 +48,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 +64,28 @@ 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 })
const marker = path.join(tmp.path, "marker.txt")
await using app = await bootApp(cwd)
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")