From 17536a9237aaae8b26efead805daf298d75fef34 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 11 Aug 2026 13:14:46 -0400 Subject: [PATCH] fix(core): discover local plugin packages (#41784) --- packages/core/src/config/plugin/source.ts | 70 +++++++++++++++--- packages/core/test/config/plugin.test.ts | 87 +++++++++++++++++++++++ 2 files changed, 146 insertions(+), 11 deletions(-) diff --git a/packages/core/src/config/plugin/source.ts b/packages/core/src/config/plugin/source.ts index 8b8104576d..c44b8ee2a2 100644 --- a/packages/core/src/config/plugin/source.ts +++ b/packages/core/src/config/plugin/source.ts @@ -4,7 +4,7 @@ import { Directory, Document, type Entry } from "@opencode-ai/schema/config" import { ConfigPlugin } from "@opencode-ai/schema/config/plugin" import { FSUtil } from "@opencode-ai/util/fs-util" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" -import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect" +import { Context, Effect, Layer, Option, Predicate, PubSub, Schema, Scope, Stream } from "effect" import path from "path" import { fileURLToPath } from "url" import { Config } from "../../config" @@ -154,19 +154,67 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* ( }) const sourceDirectories = ["plugin", "plugins"] as const +const Package = Schema.Struct({ + exports: Schema.optional(Schema.Unknown), + module: Schema.optional(Schema.Unknown), + main: Schema.optional(Schema.Unknown), +}) +const decodePackage = Schema.decodeUnknownOption(Package) function discoverDirectory(fs: FSUtil.Interface, directory: string) { return Effect.gen(function* () { - const files = yield* fs - .scan(`{${sourceDirectories.join(",")}}/*.{ts,js}`, { - cwd: directory, - absolute: true, - include: "file", - dot: true, - symlink: true, - }) - .pipe(Effect.orElseSucceed(() => [])) - return files.sort().map((target): Operation => ({ type: "add", target, options: {} })) + const children = (yield* Effect.forEach(sourceDirectories, (source) => + fs.readDirectoryEntries(path.join(directory, source)).pipe( + Effect.orElseSucceed(() => []), + Effect.map((entries) => + entries.map((entry) => ({ ...entry, target: path.join(directory, source, entry.name) })), + ), + ), + )) + .flat() + .sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0)) + const targets = yield* Effect.forEach(children, (entry) => discoverChild(fs, entry)) + return targets.flatMap(Option.toArray).map((target): Operation => ({ type: "add", target, options: {} })) + }) +} + +function discoverChild(fs: FSUtil.Interface, entry: FSUtil.DirEntry & { target: string }) { + return Effect.gen(function* () { + const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js") + if (entry.type === "file" && source) return Option.some(entry.target) + if (entry.type === "directory") return yield* discoverPackage(fs, entry.target) + if (entry.type !== "symlink") return Option.none() + if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target) + if (yield* fs.isDir(entry.target)) return yield* discoverPackage(fs, entry.target) + return Option.none() + }) +} + +function discoverPackage(fs: FSUtil.Interface, directory: string) { + return Effect.gen(function* () { + const root = yield* fs.resolve(directory) + const manifest = yield* fs + .readJson(path.join(directory, "package.json")) + .pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none)) + const configured = Option.isSome(manifest) + ? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(Predicate.isString) + : [] + return yield* Effect.findFirst( + [...configured, "index.ts", "index.js"] + .filter((entry) => !path.isAbsolute(entry)) + .map((entry) => path.resolve(directory, entry)) + .filter((entry) => FSUtil.contains(directory, entry)), + (entry) => + fs + .isFile(entry) + .pipe( + Effect.flatMap((exists) => + exists + ? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved))) + : Effect.succeed(false), + ), + ), + ) }) } diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index eb489c581e..68f39d6487 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -168,6 +168,75 @@ describe("PluginSupervisor config", () => { ), ) + it.live("loads auto-discovered plugin package entrypoints in order", () => + withLocation( + undefined, + Effect.gen(function* () { + yield* ready() + const plugins = yield* Plugin.Service + const ids = (yield* plugins.list()).map((plugin) => String(plugin.id)) + expect(ids).toContain("package-exports") + expect(ids).toContain("package-module") + expect(ids).toContain("package-main") + expect(ids).toContain("package-index") + }), + false, + async (directory) => { + await Promise.all([ + writeDiscoveredPackage(directory, "exports", { exports: "./entry.ts" }, { "entry.ts": "package-exports" }), + writeDiscoveredPackage( + directory, + "module", + { exports: "./missing.js", module: "./entry.js" }, + { "entry.js": "package-module" }, + ), + writeDiscoveredPackage( + directory, + "main", + { exports: { import: "./missing.js" }, module: "./missing.js", main: "./entry.js" }, + { "entry.js": "package-main" }, + ), + writeDiscoveredPackage(directory, "index", undefined, { "index.js": "package-index" }), + ]) + }, + ), + ) + + it.live("keeps auto-discovered package entrypoints inside the package directory", () => + withLocation( + undefined, + Effect.gen(function* () { + yield* ready() + const plugins = yield* Plugin.Service + const ids = (yield* plugins.list()).map((plugin) => String(plugin.id)) + expect(ids).toContain("contained-fallback") + expect(ids).toContain("symlink-fallback") + expect(ids).not.toContain("escaped-entrypoint") + }), + false, + async (directory) => { + await fs.mkdir(path.join(directory, ".opencode"), { recursive: true }) + await fs.writeFile(path.join(directory, ".opencode", "escape.js"), discoveredPlugin("escaped-entrypoint")) + await writeDiscoveredPackage( + directory, + "contained", + { exports: "../../escape.js" }, + { "index.js": "contained-fallback" }, + ) + await writeDiscoveredPackage( + directory, + "symlink", + { exports: "./entry.js" }, + { "index.js": "symlink-fallback" }, + ) + await fs.symlink( + path.join(directory, ".opencode", "escape.js"), + path.join(directory, ".opencode", "plugins", "symlink", "entry.js"), + ) + }, + ), + ) + staticIt.live("uses only internal and SDK plugins when the static source is wired", () => Effect.gen(function* () { const sdk = yield* SdkPlugins.Service @@ -389,3 +458,21 @@ export default Plugin.define({ }) ` } + +function discoveredPlugin(id: string) { + return `export default { id: ${JSON.stringify(id)}, setup() {} }` +} + +async function writeDiscoveredPackage( + directory: string, + name: string, + manifest: Record | undefined, + files: Record, +) { + const plugin = path.join(directory, ".opencode", "plugins", name) + await fs.mkdir(plugin, { recursive: true }) + await Promise.all([ + ...(manifest ? [fs.writeFile(path.join(plugin, "package.json"), JSON.stringify(manifest))] : []), + ...Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))), + ]) +}