1
0
Эх сурвалжийг харах

fix(core): discover local plugin packages (#41784)

Kit Langton 6 өдөр өмнө
parent
commit
17536a9237

+ 59 - 11
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<string>()
+    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<string>()
+  })
+}
+
+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),
+            ),
+          ),
+    )
   })
 }
 

+ 87 - 0
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<string, unknown> | undefined,
+  files: Record<string, string>,
+) {
+  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))),
+  ])
+}