diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index f0197be59d1e..7f22183e5dcf 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -7,6 +7,7 @@ import { Script } from "@opencode/script" import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin" import type { BunPlugin } from "bun" import pkg from "../package.json" +import { discoverPluginRuntimeSpecifiers, pluginRuntimeLoaderCode } from "@opencode/plugin/runtime-modules" import { buildAppArchive } from "./app-assets" import { verifyArtifact, verifySimulationGraph } from "./verify-artifact" import { resolveOpencodePty } from "./opencode-pty" @@ -82,6 +83,30 @@ export default () => readFileSync(archive)`, })) }, } +const pluginRuntimeEntries = discoverPluginRuntimeSpecifiers() +const pluginRuntimeModulesSource = [ + "export const resolveHostPackageRoots = () => []", + "const modules = {", + ...[...pluginRuntimeEntries.keys()].map( + (specifier) => ` ${JSON.stringify(specifier)}: ${pluginRuntimeLoaderCode(specifier, pluginRuntimeEntries)},`, + ), + "}", + "export const loadRuntimeModules = () => modules", +].join("\n") +const pluginRuntimePlugin: BunPlugin = { + name: "opencode-plugin-runtime", + setup(build) { + build.onLoad({ filter: /plugin[/\\]src[/\\]runtime-modules\.ts$/ }, () => ({ + contents: pluginRuntimeModulesSource, + loader: "ts", + })) + build.onLoad({ filter: /[/\\]internal[/\\]httpApi(?:Scalar|Swagger)\.js$/ }, () => ({ + contents: + 'export const css = ""; export const javascript = \'document.body.textContent = "Scalar/Swagger UI assets are not bundled in OpenCode"\'', + loader: "js", + })) + }, +} for (const item of targets) { const opencodePty = await resolveOpencodePty({ @@ -128,7 +153,14 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh const result = await Bun.build({ entrypoints: ["./src/index.ts"], tsconfig: "./tsconfig.json", - plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin], + plugins: [ + appAssetsPlugin, + solidPlugin, + parcelWatcherPlugin, + opencodePtyPlugin, + pluginRuntimePlugin, + simulationGraphPlugin, + ], external: ["node-gyp"], format: "esm", minify: true, diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f396062c2fb2..0ae8c1f12fc7 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun +import { ensurePluginRuntime } from "@opencode/plugin/runtime" import { NodeRuntime, NodeServices } from "@effect/platform-node" import { Cause, Effect } from "effect" import { getErrorReported } from "effect/Runtime" @@ -17,6 +18,8 @@ import { EffectFlock } from "@opencode/util/effect-flock" import { Heap } from "./heap" import { CpuProfile } from "./cpu-profile" +ensurePluginRuntime() + if (process.env.OPENCODE_SSH_ASKPASS_PORT) { const { askpass } = await import("./ssh-askpass") process.exit(await Effect.runPromise(askpass.pipe(Effect.provide(NodeServices.layer)))) diff --git a/packages/core/test/plugin/module.test.ts b/packages/core/test/plugin/module.test.ts index 1b4c2087c686..f19d8e398123 100644 --- a/packages/core/test/plugin/module.test.ts +++ b/packages/core/test/plugin/module.test.ts @@ -1,21 +1,32 @@ import { expect } from "bun:test" +import { cp } from "node:fs/promises" import path from "node:path" -import { Deferred, Effect, Exit, Fiber, Layer, Schedule, Scope, Stream } from "effect" +import { Brand, Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schedule, Schema, Scope, Stream } from "effect" +import { Agent } from "@opencode/schema/agent" +import { Session } from "@opencode/schema/session" +import { SessionMessage } from "@opencode/schema/session-message" import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" import { Watcher } from "@opencode/core/filesystem/watcher" -import { Config } from "@opencode/core/config" +import { Plugin } from "@opencode/core/plugin" import { ManagedPolicy } from "@opencode/core/managed-policy" import { PluginModule } from "@opencode/core/plugin/module" +import { Rpc } from "@opencode/core/rpc" +import { Tool } from "@opencode/core/tool" +import { execute } from "@opencode/core/tool/runtime" import { Global } from "@opencode/util/global" import { Npm } from "@opencode/util/npm" +import { createForeignPackageFilter, ensurePluginRuntime } from "@opencode/plugin/runtime" +import { createLoader, discoverPluginRuntimeSpecifiers, pluginRuntimeLoaderCode } from "@opencode/plugin/runtime-modules" import { tempGlobalLayer } from "../fixture/global" import { tmpdirScoped } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +ensurePluginRuntime() const it = testEffect( Layer.mergeAll( - Config.testLayer(), - ManagedPolicy.layer, + PluginTestLayer, AppNodeBuilder.build(Npm.node, [Global.node.replace(tempGlobalLayer)]), Watcher.layer().pipe(Layer.provide(Watcher.nativeLayer)), ), @@ -95,6 +106,297 @@ it.live("interrupts pending watcher setup when the loader scope closes during mo }), ) +it.live("loads plugins and their transitive dependencies against the host's Effect and @opencode/plugin instances", () => + Effect.gen(function* () { + const directory = yield* tmpdirScoped() + const pluginDir = path.join(directory.path, "plugin") + const pluginEffectDir = path.join(pluginDir, "node_modules/effect") + const hostEffectDir = path.dirname(Bun.resolveSync("effect/package.json", import.meta.dir)) + + yield* Effect.promise(async () => { + await cp(path.join(hostEffectDir, "dist"), path.join(pluginEffectDir, "dist"), { + recursive: true, + filter: (src) => !src.endsWith(".d.ts") && !src.endsWith(".map") && !/httpApi(?:Scalar|Swagger)\.js$/.test(src), + }) + const pkg = { ...(await Bun.file(path.join(hostEffectDir, "package.json")).json()), version: "4.0.0-rc.111" } + + // Sabotage the plugin's own Effect copy with the version-skew failure modes so loading it would crash: + // 1. Effect.log reading an incompatible fiber log-level property (crashing host logger with logLevel.toUpperCase) + // 2. Effect.runPromise calling fiber.succeedWith on a host fiber + // 3. Schema.withDecodingDefault / Schema.Int / Schema.isPattern / Schema.Trim using foreign parser sentinels + const internalEffectPath = path.join(pluginEffectDir, "dist/internal/effect.js") + const originalInternalEffect = await Bun.file(internalEffectPath).text() + const logPattern = + /const logLevel = level \?\? fiber\.(?:currentLogLevel|cache\.logLevel);\r?\n\s*if \(isLogLevelGreaterThan\(fiber\.(?:minimumLogLevel|cache\.minimumLogLevel), logLevel\)\) \{/ + const runPromisePattern = /const runPromiseExit = runPromiseExitWith\(context\);/ + expect(logPattern.test(originalInternalEffect)).toBe(true) + expect(runPromisePattern.test(originalInternalEffect)).toBe(true) + + await writeFiles(pluginDir, { + "node_modules/effect/package.json": JSON.stringify(pkg), + "node_modules/effect/dist/internal/effect.js": originalInternalEffect + .replace( + logPattern, + "const logLevel = level ?? fiber.foreignSkew?.logLevel;\n if (isLogLevelGreaterThan(fiber.foreignSkew?.minimumLogLevel, logLevel)) {", + ) + .replace( + runPromisePattern, + "if (true) return (effect) => Promise.resolve().then(() => { const fiber = {}; return fiber.succeedWith(effect); });\n const runPromiseExit = runPromiseExitWith(context);", + ), + "node_modules/transitive-dep/package.json": + '{"name":"transitive-dep","type":"module","exports":{".":"./index.js"}}', + "node_modules/transitive-dep/index.js": `import { Effect, Schema } from "effect" +import { some } from "effect/Option" +export const depToolInput = Schema.Struct({ + mode: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed("from-dep"))), + count: Schema.Int, + code: Schema.Trim.check(Schema.isPattern(/^v[0-9]+$/)), +}) +export const depCaptured = { Effect, Schema, some }`, + "node_modules/@opencode/plugin/package.json": + '{"name":"@opencode/plugin","type":"module","exports":{"./effect":"./effect.js","./rpc":"./rpc.js"}}', + "node_modules/@opencode/plugin/effect.js": "export const Plugin = { define: (p) => p }", + "node_modules/@opencode/plugin/rpc.js": "export const Rpc = { define: (d) => d }", + "index.ts": `import { Plugin } from "@opencode/plugin/effect" +import { Rpc } from "@opencode/plugin/rpc" +import { Effect, Schema } from "effect" +import { some } from "effect/Option" +import { nominal } from "effect/Brand" +import { depCaptured, depToolInput } from "transitive-dep" + +export const captured = { + plugin: { Effect, Schema, some, nominal }, + dep: depCaptured, + pluginCount: -1, +} + +const Contract = Rpc.define({ + id: "host-effect-rpc", + methods: { + check: { + input: Schema.Struct({ + count: Schema.Int.pipe(Schema.withDecodingDefault(Effect.succeed(5))), + tag: Schema.Trim.check(Schema.isPattern(/^v[0-9]+$/)), + }), + output: Schema.Struct({ value: Schema.String }), + }, + }, + events: {}, +}) + +export default Plugin.define({ + id: "host-effect-fixture", + effect: (ctx) => + Effect.gen(function* () { + yield* Effect.log("setup log from plugin") + const listed = yield* Effect.promise(() => + Effect.runPromise(ctx.plugin.list().pipe(Effect.orDie)), + ) + captured.pluginCount = listed.data.length + + yield* ctx.tool.transform((editor) => { + editor.add({ + name: "check_tool", + description: "Tool with decoding default from transitive dependency and Int/Trim/isPattern checks", + input: depToolInput, + output: Schema.Struct({ formatted: Schema.String }), + execute: ({ mode, count, code }) => + Effect.log("executing check_tool").pipe( + Effect.as({ + output: { formatted: \`\${mode}:\${code}:\${count}\` }, + content: \`\${mode}:\${code}:\${count}\`, + }), + ), + }) + }) + + yield* ctx.rpc.register(Contract, { + check: ({ count, tag }) => + Effect.log("executing rpc check").pipe( + Effect.as({ value: \`\${tag}#\${count}\` }), + ), + }).pipe(Effect.orDie) + }), +})`, + }) + }) + + const modules = yield* PluginModule.make() + const plugins = yield* Plugin.Service + const tools = yield* Tool.Service + const rpc = yield* Rpc.Service + + const definition = yield* modules.load({ type: "add", target: pluginDir, options: {} }) + if ("pending" in definition || "blocked" in definition) return yield* Effect.die(new Error("Local plugin was not loaded")) + yield* plugins.activate([definition]) + yield* plugins.awaitActivation + + expect(yield* plugins.list()).toMatchObject([{ id: "host-effect-fixture", state: { status: "active" } }]) + + const imported = yield* Effect.promise(() => import(path.join(pluginDir, "index.ts"))) + expect(imported.captured.plugin.Effect).toBe(Effect) + expect(imported.captured.plugin.Schema).toBe(Schema) + expect(imported.captured.plugin.some).toBe(Option.some) + expect(imported.captured.plugin.nominal).toBe(Brand.nominal) + expect(imported.captured.dep.Effect).toBe(Effect) + expect(imported.captured.dep.Schema).toBe(Schema) + expect(imported.captured.dep.some).toBe(Option.some) + expect(imported.captured.pluginCount).toBe(0) + + const checkTool = (yield* tools.list()).find((tool) => tool.id === "check_tool") + expect(checkTool).toBeDefined() + if (!checkTool) return + + const context = { + sessionID: Session.ID.make("ses_host_effect"), + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.make("msg_host_effect"), + id: Tool.CallID.make("call_host_effect"), + progress: () => Effect.void, + } + + expect(yield* execute(checkTool, { count: 3, code: " v42 " }, context)).toEqual({ + output: { formatted: "from-dep:v42:3" }, + content: [{ type: "text", text: "from-dep:v42:3" }], + }) + expect(yield* rpc.call("host-effect-rpc", "check", { tag: " v9 " })).toEqual({ value: "v9#5" }) + }), +) + +it.live("redirects plugin dependencies with a nested Effect 3 installation to the host, allows effect/package.json, and fails loudly on unprovided subpaths", () => + Effect.gen(function* () { + const directory = yield* tmpdirScoped() + const pluginDir = path.join(directory.path, "v3-dep-plugin") + const badDir = path.join(directory.path, "removed-subpath-plugin") + const v3DepDir = path.join(pluginDir, "node_modules/v3-dep") + + yield* Effect.promise(() => + writeFiles(directory.path, { + "v3-dep-plugin/node_modules/effect/package.json": + '{"name":"effect","version":"4.0.0-rc.111","type":"module","exports":{"./package.json":"./package.json"}}', + "v3-dep-plugin/node_modules/v3-dep/node_modules/effect/package.json": + '{"name":"effect","version":"3.19.19","type":"module","exports":{".":"./index.js","./Option":"./Option.js","./ReadonlyArray":"./ReadonlyArray.js"}}', + "v3-dep-plugin/node_modules/v3-dep/node_modules/effect/index.js": + "export const Effect = { major: 3 }; export const Schema = { major: 3 }", + "v3-dep-plugin/node_modules/v3-dep/node_modules/effect/Option.js": "export const some = () => ({ major: 3 })", + "v3-dep-plugin/node_modules/v3-dep/node_modules/effect/ReadonlyArray.js": "export const fromIterable = () => []", + "v3-dep-plugin/node_modules/v3-dep/package.json": + '{"name":"v3-dep","type":"module","exports":{".":"./index.js","./v3-only":"./v3-only.js"}}', + "v3-dep-plugin/node_modules/v3-dep/index.js": + 'import { Effect, Schema } from "effect"; import { some } from "effect/Option"; export const v3DepCaptured = { Effect, Schema, some }', + "v3-dep-plugin/node_modules/v3-dep/v3-only.js": + 'import { fromIterable } from "effect/ReadonlyArray"; export { fromIterable }', + "v3-dep-plugin/index.ts": + 'import pkg from "effect/package.json" with { type: "json" }; import { v3DepCaptured } from "v3-dep"; export const effectPkgName = pkg.name; export { v3DepCaptured }; export default { id: "v3-dep-plugin", async setup() {} }', + "removed-subpath-plugin/node_modules/effect/package.json": + '{"name":"effect","type":"module","exports":{"./RemovedLegacySubpath":"./RemovedLegacySubpath.js"}}', + "removed-subpath-plugin/node_modules/effect/RemovedLegacySubpath.js": "export const legacy = true", + "removed-subpath-plugin/index.ts": + 'import { legacy } from "effect/RemovedLegacySubpath"; export default { id: "removed-subpath", async setup() { void legacy } }', + }), + ) + + const modules = yield* PluginModule.make() + expect(yield* modules.load({ type: "add", target: pluginDir, options: {} })).toMatchObject({ id: "v3-dep-plugin" }) + + const imported = yield* Effect.promise(() => import(path.join(pluginDir, "index.ts"))) + expect(imported.effectPkgName).toBe("effect") + expect(imported.v3DepCaptured.Effect).toBe(Effect) + expect(imported.v3DepCaptured.Schema).toBe(Schema) + expect(imported.v3DepCaptured.some).toBe(Option.some) + yield* Effect.promise(async () => { + await expect(import(path.join(v3DepDir, "v3-only.js"))).rejects.toThrow("effect/ReadonlyArray.js") + }) + + const exit = yield* modules.load({ type: "add", target: badDir, options: {} }).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(String(Cause.squash(exit.cause))).toContain("effect/RemovedLegacySubpath.js") + } + }), +) + +it.live("discovers exported specifiers from the resolved tree even when dist/ exists, and validates barrel loader routing, async loader dedup, and Windows foreign filters", () => + Effect.gen(function* () { + const directory = yield* tmpdirScoped() + const consumerDir = path.join(directory.path, "consumer") + const jitFixtureDir = path.join(directory.path, "jit-fixture") + const asyncModPath = path.join(directory.path, "async-mod.ts") + + yield* Effect.promise(() => + writeFiles(directory.path, { + "consumer/node_modules/@opencode/plugin/package.json": JSON.stringify({ + name: "@opencode/plugin", + type: "module", + exports: { + ".": "./src/promise/index.ts", + "./effect": "./src/effect/index.ts", + "./effect/*": "./src/effect/*.ts", + }, + }), + "consumer/node_modules/@opencode/plugin/src/promise/index.ts": "export const root = 'src'", + "consumer/node_modules/@opencode/plugin/src/effect/index.ts": "export * as plugin from './plugin.ts'", + "consumer/node_modules/@opencode/plugin/src/effect/plugin.ts": "export const leaf = 'src'", + "consumer/node_modules/@opencode/plugin/src/unexported.ts": "export const secret = true", + "consumer/node_modules/@opencode/plugin/dist/promise/index.js": "export const root = 'dist'", + "consumer/node_modules/@opencode/plugin/dist/effect/index.js": "export const index = 'dist'", + "consumer/node_modules/@opencode/plugin/dist/effect/plugin.js": "export const leaf = 'dist'", + "jit-fixture/SchemaJITCompiler.js": "export const enable = () => 'fn'", + "jit-fixture/enable.js": "export {}", + "async-mod.ts": "await new Promise((r) => setTimeout(r, 20)); export const nonce = Math.random()", + }), + ) + + const discovered = discoverPluginRuntimeSpecifiers(consumerDir, ["@opencode/plugin"]) + expect(discovered.get("@opencode/plugin")?.replaceAll("\\", "/")).toEndWith("src/promise/index.ts") + expect(discovered.get("@opencode/plugin/effect")?.replaceAll("\\", "/")).toEndWith("src/effect/index.ts") + expect(discovered.get("@opencode/plugin/effect/plugin")?.replaceAll("\\", "/")).toEndWith("src/effect/plugin.ts") + expect(discovered.has("@opencode/plugin/unexported")).toBe(false) + + const hostDiscovered = new Map(discoverPluginRuntimeSpecifiers()) + hostDiscovered.set("effect/schema/SchemaJITCompiler", path.join(jitFixtureDir, "SchemaJITCompiler.js")) + hostDiscovered.set("effect/schema/SchemaJITCompiler/enable", path.join(jitFixtureDir, "enable.js")) + expect(pluginRuntimeLoaderCode("effect/Option", hostDiscovered)).toBe('() => require("effect")["Option"]') + expect(pluginRuntimeLoaderCode("effect/testing", hostDiscovered)).toBe('() => require("effect/testing")') + expect(pluginRuntimeLoaderCode("effect/unstable/http/MultipartParser/HeadersParser", hostDiscovered)).toBe( + '() => require("effect/unstable/http/MultipartParser/HeadersParser")', + ) + expect(pluginRuntimeLoaderCode("effect/schema/SchemaJITCompiler/enable", hostDiscovered)).toBe( + '() => require("effect/schema/SchemaJITCompiler/enable")', + ) + + const loadAsync = createLoader(asyncModPath) + const first = loadAsync() + const second = loadAsync() + expect(first).toBeInstanceOf(Promise) + expect(first).toBe(second) + const [res1, res2] = yield* Effect.promise(() => Promise.all([first, second])) + expect(res1).toBe(res2) + + const winFilter = createForeignPackageFilter([ + "C:\\runner\\_work\\opencode\\node_modules\\.bun\\effect@4.0.0-rc.112\\node_modules\\effect", + ]) + expect( + winFilter.test( + "C:\\runner\\_work\\opencode\\node_modules\\.bun\\effect@4.0.0-rc.112\\node_modules\\effect\\dist\\index.js", + ), + ).toBe(false) + expect( + winFilter.test( + "C:/runner/_work/opencode/node_modules/.bun/effect@4.0.0-rc.112/node_modules/effect/dist/index.js", + ), + ).toBe(false) + expect(winFilter.test("C:\\Users\\plugin\\node_modules\\effect\\dist\\index.js")).toBe(true) + expect(winFilter.test("C:/Users/plugin/node_modules/effect/dist/index.js")).toBe(true) + expect(winFilter.test("C:\\Users\\user\\.bun\\install\\cache\\effect@4.0.1@@@1\\dist\\index.js")).toBe(true) + expect(winFilter.test("C:/Users/user/.bun/install/cache/effect@4.0.1@@@1/dist/index.js")).toBe(true) + }), +) + +async function writeFiles(root: string, files: Record) { + await Promise.all(Object.entries(files).map(([file, text]) => Bun.write(path.join(root, file), text))) +} + it.live("blocks a local plugin before module initialization and honors replacement policies", () => Effect.gen(function* () { const directory = yield* tmpdirScoped() diff --git a/packages/plugin/README.md b/packages/plugin/README.md new file mode 100644 index 000000000000..72b7b56ee922 --- /dev/null +++ b/packages/plugin/README.md @@ -0,0 +1,18 @@ +# `@opencode/plugin` + +Authoring interfaces and runtime loader support for OpenCode V2 plugins: + +- `@opencode/plugin` — [Promise plugin API](./src/README.md) +- `@opencode/plugin/effect` — [Effect plugin API](./src/effect/README.md) +- `@opencode/plugin/rpc` — portable RPC contract definitions +- `@opencode/plugin/tui` — terminal UI plugin API + +## Packaging And Runtime `effect` + +When the OpenCode CLI loads server or TUI plugins, it resolves imports of `effect`, exported `effect/*` subpaths, and `@opencode/plugin` entrypoints (including imports from dependencies inside a plugin's `node_modules`) to the host's runtime module instances so fibers, loggers, and `Schema` parsers share one copy. + +- Declare `effect` as a `peerDependency` (and `devDependency` for local type-checking and testing) rather than a bundled runtime dependency. +- Do not bundle `effect` into published plugin files; if you build with a bundler, keep `effect` and `effect/*` external. Two copies of `effect` do not share fiber, logger, or `Schema` internals. +- Plugins and their `node_modules` dependencies always receive OpenCode's host `effect` instance. Use `effect` APIs and module paths compatible with the OpenCode release you target; dependencies built on another `effect` major (such as Effect 3) are not supported. +- Only public `effect` subpaths are provided. A plugin that imports one of Effect's private `internal` modules fails to load with an error naming the path. +- The compiled OpenCode binary does not include the Scalar and Swagger UI assets used by Effect's HTTP API docs pages; serving those pages from a plugin shows a notice instead. diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 94c248f66ce7..442f0a3aa37b 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -13,6 +13,7 @@ ".": "./src/promise/index.ts", "./effect": "./src/effect/index.ts", "./host": "./src/host.ts", + "./runtime": "./src/runtime.ts", "./tui": "./src/tui/index.ts", "./*": "./src/*.ts" }, diff --git a/packages/plugin/src/runtime-modules.ts b/packages/plugin/src/runtime-modules.ts new file mode 100644 index 000000000000..bbbc1034f241 --- /dev/null +++ b/packages/plugin/src/runtime-modules.ts @@ -0,0 +1,113 @@ +import { existsSync, realpathSync } from "node:fs" +import path from "node:path" +import { pathToFileURL } from "node:url" + +export type RuntimeModuleLoader = () => Record | Promise> + +const runtimePackages = ["effect", "@opencode/plugin"] as const + +export function resolveHostPackageRoots(from = import.meta.dir, packages: readonly string[] = runtimePackages) { + return packages.flatMap((pkgName) => { + const dir = path.dirname(Bun.resolveSync(`${pkgName}/package.json`, from)) + return [dir, findNodeModulesDir(pkgName, from, dir)] + }) +} + +export function loadRuntimeModules(): Readonly> { + const entries = discoverPluginRuntimeSpecifiers() + const effectEntry = entries.get("effect") + if (effectEntry) require(effectEntry) + return Object.fromEntries( + [...entries.entries()].map(([specifier, resolved]) => [specifier, createLoader(resolved)]), + ) +} + +export function discoverPluginRuntimeSpecifiers( + from = import.meta.dir, + packages: readonly string[] = runtimePackages, +): ReadonlyMap { + const entries = new Map() + for (const pkgName of packages) { + const realDir = path.dirname(Bun.resolveSync(`${pkgName}/package.json`, from)) + // Resolve workspace @opencode/plugin paths via node_modules so OpenTUI does not wrap host files in async rewrite loaders. + const loadDir = findNodeModulesDir(pkgName, from, realDir) + const toLoadPath = (resolved: string) => + loadDir === realDir ? resolved : path.join(loadDir, path.relative(realDir, resolved)) + const rootEntry = Bun.resolveSync(pkgName, from) + const relParts = path.relative(realDir, rootEntry).replaceAll("\\", "/").split("/") + const scanDir = relParts.length > 1 ? path.join(realDir, relParts[0]) : realDir + const ext = path.extname(rootEntry) || ".js" + entries.set(pkgName, toLoadPath(rootEntry)) + for (const file of new Bun.Glob(`**/*${ext}`).scanSync({ cwd: scanDir })) { + const normalized = file.replaceAll("\\", "/") + if ( + normalized.startsWith("internal/") || + normalized.includes("/internal/") || + normalized.startsWith("source.") || + normalized.startsWith("runtime") + ) { + continue + } + const base = normalized.slice(0, -ext.length) + if (base === "index") continue + const candidates = base.endsWith("/index") + ? [`${pkgName}/${base.slice(0, -"/index".length)}`, `${pkgName}/${base}`] + : [`${pkgName}/${base}`] + for (const specifier of candidates) { + if (entries.has(specifier)) continue + try { + entries.set(specifier, toLoadPath(Bun.resolveSync(specifier, from))) + } catch {} + } + } + } + return entries +} + +export function pluginRuntimeLoaderCode(specifier: string, entries: ReadonlyMap) { + if (specifier.startsWith("effect/")) { + const slash = specifier.lastIndexOf("/") + const parent = specifier.slice(0, slash) + const member = specifier.slice(slash + 1) + const parentResolved = entries.get(parent) + const resolved = entries.get(specifier) + if ( + member !== "index" && + parentResolved && + resolved && + (require(parentResolved) as Record)[member] === require(resolved) + ) { + return `() => require(${JSON.stringify(parent)})[${JSON.stringify(member)}]` + } + } + return `() => require(${JSON.stringify(specifier)})` +} + +export function createLoader(resolved: string): RuntimeModuleLoader { + let cached: Record | undefined + let pending: Promise> | undefined + return () => { + if (cached) return cached + if (pending) return pending + try { + return (cached = require(resolved) as Record) + } catch { + return (pending = import(pathToFileURL(resolved).href).then( + (mod: Record) => (cached = mod), + (error) => { + pending = undefined + throw error + }, + )) + } + } +} + +function findNodeModulesDir(pkgName: string, from: string, realDir: string) { + if (/[/\\]node_modules[/\\]/.test(realDir)) return realDir + for (let dir = path.resolve(from); ; dir = path.dirname(dir)) { + const candidate = path.join(dir, "node_modules", pkgName) + if (existsSync(candidate) && realpathSync(candidate) === realDir) return candidate + if (path.dirname(dir) === dir) return realDir + } +} diff --git a/packages/plugin/src/runtime.ts b/packages/plugin/src/runtime.ts new file mode 100644 index 000000000000..d7df51df2ae1 --- /dev/null +++ b/packages/plugin/src/runtime.ts @@ -0,0 +1,52 @@ +import { loadRuntimeModules, resolveHostPackageRoots, type RuntimeModuleLoader } from "./runtime-modules.js" + +const foreignPkgSuffix = String.raw`(?:node_modules[/\\](@opencode[/\\]plugin|effect)|(@opencode[/\\]plugin|effect)@[^/\\]+@@@\d+)` +const foreignPkgPattern = new RegExp(String.raw`^(.*[/\\]${foreignPkgSuffix})[/\\](.+)$`) + +let installed: Readonly> | undefined + +export function provides(specifier: string) { + return installed !== undefined && Object.hasOwn(installed, specifier) +} + +export function ensurePluginRuntime() { + if (typeof Bun === "undefined") return {} + if (installed) return installed + const modules = loadRuntimeModules() + installed = modules + Bun.plugin({ + name: "opencode-plugin-runtime", + setup(build) { + for (const [specifier, load] of Object.entries(modules)) { + build.module(specifier, () => { + const exports = load() + return exports instanceof Promise + ? exports.then((value) => ({ exports: value, loader: "object" as const })) + : { exports, loader: "object" as const } + }) + } + build.onLoad({ filter: createForeignPackageFilter() }, (args) => { + const match = args.path.match(foreignPkgPattern) + const target = match ? `${match[2] ?? match[3]}/${match[4]}`.replaceAll("\\", "/") : args.path + throw new Error( + `Cannot load "${target}" from plugin node_modules: "${target}" is not provided by OpenCode; plugins must use the host's "effect" and "@opencode/plugin" modules.`, + ) + }) + }, + }) + return modules +} + +export function createForeignPackageFilter(rootsInput: Iterable = resolveHostPackageRoots()) { + const suffix = String.raw`[/\\]${foreignPkgSuffix}[/\\].*\.[cm]?[jt]sx?(?:[?#].*)?$` + const roots = [...new Set(rootsInput)] + if (roots.length === 0) return new RegExp(suffix) + const escaped = roots + .map((value) => + value + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + .replace(/(?:\\\/|\\\\|\/)+/g, "[/\\\\]"), + ) + .join("|") + return new RegExp(`^(?!(?:${escaped})[/\\\\]).*${suffix}`) +} diff --git a/packages/plugin/src/source.bun.ts b/packages/plugin/src/source.bun.ts index 618f51556b92..7a2d6fd54e4d 100644 --- a/packages/plugin/src/source.bun.ts +++ b/packages/plugin/src/source.bun.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs" import path from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" import { Host } from "./host.js" +import { provides } from "./runtime.js" import { localSource } from "./source.js" import { missingPackageTarget } from "./source.package.js" @@ -39,6 +40,7 @@ export async function prepareSource(entrypoint: string, track: (file: string, di ? new URL(item.path, pathToFileURL(file)) : localSource(item.path, path.dirname(file)) if (!local) { + if (provides(item.path)) continue try { Bun.resolveSync(item.path, path.dirname(file)) } catch { diff --git a/packages/plugin/test/host.test.ts b/packages/plugin/test/host.test.ts index 7d90cf702594..707081d180b6 100644 --- a/packages/plugin/test/host.test.ts +++ b/packages/plugin/test/host.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict" -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" import { describe, it } from "node:test" @@ -11,7 +11,7 @@ const source = 'throw new Error("Plugin code must not run during resolution")' const name = "@fixture/plugin" async function fixture(files: Record, installed = false) { - const root = await mkdtemp(path.join(tmpdir(), "opencode-host-")) + const root = await realpath(await mkdtemp(path.join(tmpdir(), "opencode-host-"))) const directory = installed ? path.join(root, "node_modules", name) : root await Promise.all( Object.entries(files).map(async ([file, content]) => { diff --git a/packages/tui/src/plugin/runtime-plugin-support.bun.ts b/packages/tui/src/plugin/runtime-plugin-support.bun.ts index e1d7ae2cfa8d..b1ebab022675 100644 --- a/packages/tui/src/plugin/runtime-plugin-support.bun.ts +++ b/packages/tui/src/plugin/runtime-plugin-support.bun.ts @@ -1,8 +1,11 @@ +import { ensurePluginRuntime, provides } from "@opencode/plugin/runtime" import { Plugin, PluginContextProvider, usePlugin } from "@opencode/plugin/tui" import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure" +ensurePluginRuntime() ensureRuntimePluginSupport({ additional: { "@opencode/plugin/tui": { Plugin, PluginContextProvider, usePlugin }, }, + preserve: provides, }) diff --git a/packages/tui/test/plugin-source.test.ts b/packages/tui/test/plugin-source.test.ts index bffbd2807d39..2f4d396a6155 100644 --- a/packages/tui/test/plugin-source.test.ts +++ b/packages/tui/test/plugin-source.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test" +import { createRequire } from "node:module" import path from "node:path" import { mkdir, rename, symlink } from "node:fs/promises" import { fileURLToPath, pathToFileURL } from "node:url" @@ -7,6 +8,7 @@ import "../src/plugin/runtime-plugin-support.bun" import { createPluginSources } from "../src/plugin/source" import { createSourceWatcher } from "../src/plugin/watch" import { createSignal } from "solid-js" +import { Effect, Option, Schema } from "effect" import { Plugin } from "@opencode/plugin/tui" import { tmpdir } from "./fixture/fixture" @@ -138,6 +140,104 @@ test("shared runtime and ordinary package identities survive plugin generations" } }) +test("TUI plugins importing @opencode/plugin/tui and solid-js alongside effect resolve effect and effect/* to the host copy without rewriting host Effect or hijacking plugin dependencies", async () => { + const effectPlugin = await import("@opencode/plugin/effect") + const req = createRequire(import.meta.url) + expect(req("effect").Effect).toBe(Effect) + expect(req("effect/Option").some).toBe(Option.some) + expect(req("@opencode/plugin/effect").Plugin).toBe(effectPlugin.Plugin) + expect(JSON.stringify(Schema.Option(Schema.String).ast)).not.toContain("opentui:runtime-module:") + + const standalone = Bun.spawnSync( + [ + process.execPath, + "-e", + [ + `import ${JSON.stringify(fileURLToPath(new URL("../src/plugin/runtime-plugin-support.bun.ts", import.meta.url)))}`, + 'import assert from "node:assert/strict"', + 'import { createRequire } from "node:module"', + "const req = createRequire(import.meta.url)", + 'const eff = req("effect")', + 'const opt = req("effect/Option")', + 'const plug = req("@opencode/plugin/effect")', + 'const { Effect } = await import("effect")', + 'const { some } = await import("effect/Option")', + 'const { Plugin } = await import("@opencode/plugin/effect")', + "assert.equal(eff.Effect, Effect)", + "assert.equal(opt.some, some)", + "assert.equal(plug.Plugin, Plugin)", + ].join("\n"), + ], + { cwd: fileURLToPath(new URL("..", import.meta.url)), stdout: "pipe", stderr: "pipe" }, + ) + expect({ stderr: standalone.stderr.toString(), exit: standalone.exitCode }).toEqual({ stderr: "", exit: 0 }) + + const watched: string[] = [] + await using sources = await fixture(async (file) => { + watched.push(file) + }) + const entry = new URL("tui.ts", sources.url) + const badEntry = new URL("bad-tui.ts", sources.url) + await Promise.all( + Object.entries({ + "node_modules/effect/package.json": + '{"name":"effect","version":"3.19.19","type":"module","exports":{".":{"import":"./dist/esm/index.js"},"./Option":{"import":"./dist/esm/Option.js"},"./RemovedSubpath":{"import":"./dist/esm/RemovedSubpath.js"},"./package.json":"./package.json"}}', + "node_modules/effect/dist/esm/index.js": + "export const Effect = { foreign: true }; export const Schema = { foreign: true }", + "node_modules/effect/dist/esm/Option.js": "export const some = () => null", + "node_modules/effect/dist/esm/RemovedSubpath.js": "export const removed = true", + "node_modules/effect-helper/package.json": + '{"name":"effect-helper","type":"module","exports":{".":"./index.js"}}', + "node_modules/effect-helper/index.js": + 'import { Effect, Schema } from "effect"; import { some } from "effect/Option"; export const helper = { Effect, Schema, some }', + "node_modules/zod/package.json": '{"name":"zod","type":"module","exports":{".":"./index.js"}}', + "node_modules/zod/index.js": "export const fromPluginZod = true", + "helper.ts": ['import { fromPluginZod }', 'from "zod"; export { fromPluginZod }'].join(" "), + "tui.ts": [ + "import { createSignal }", + 'from "solid-js"', + "import { Plugin }", + 'from "@opencode/plugin/tui"', + 'import { Plugin as HostEffectPlugin } from "@opencode/plugin/effect"', + 'import { Effect, Schema } from "effect"', + 'import { some } from "effect/Option"', + "import pkg", + 'from "effect/package.json" with { type: "json" }', + 'import { helper } from "effect-helper"', + 'const { fromPluginZod } = await import("./helper.ts")', + 'const dynOption = await import("effect/Option")', + 'const dynEffectPlugin = await import("@opencode/plugin/effect")', + "export const plugin = { createSignal, Plugin, HostEffectPlugin, Effect, Schema, some, dynSome: dynOption.some, dynEffectPlugin: dynEffectPlugin.Plugin, pkgName: pkg.name, fromPluginZod }", + "export { helper }", + ].join("\n"), + "bad-tui.ts": [ + "import { Plugin }", + 'from "@opencode/plugin/tui"; import { removed } from "effect/RemovedSubpath"; export default { Plugin, removed }', + ].join(" "), + }).map(([file, text]) => Bun.write(new URL(file, sources.url), text)), + ) + const loaded = (await sources.read(entry.href)).module as { + plugin: Record + helper: Record + } + expect(loaded.plugin.createSignal).toBe(createSignal) + expect(loaded.plugin.Plugin).toBe(Plugin) + expect(loaded.plugin.HostEffectPlugin).toBe(effectPlugin.Plugin) + expect(loaded.plugin.Effect).toBe(Effect) + expect(loaded.plugin.Schema).toBe(Schema) + expect(loaded.plugin.some).toBe(Option.some) + expect(loaded.plugin.dynSome).toBe(Option.some) + expect(loaded.plugin.dynEffectPlugin).toBe(effectPlugin.Plugin) + expect(loaded.plugin.pkgName).toBe("effect") + expect(loaded.plugin.fromPluginZod).toBe(true) + expect(loaded.helper.Effect).toBe(Effect) + expect(loaded.helper.Schema).toBe(Schema) + expect(loaded.helper.some).toBe(Option.some) + expect(watched.some((item) => item.replaceAll("\\", "/").endsWith("/node_modules/effect"))).toBe(false) + expect(watched.some((item) => item.replaceAll("\\", "/").endsWith("/node_modules/@opencode/plugin"))).toBe(false) + await expect(sources.read(badEntry.href)).rejects.toThrow("effect/dist/esm/RemovedSubpath.js") +}) + test("helper import.meta stays anchored to its source, including assets and resolution", async () => { await using sources = await fixture() const entry = new URL("tui.ts", sources.url) diff --git a/services/www/src/docs/content/build/plugins/effect.mdx b/services/www/src/docs/content/build/plugins/effect.mdx index 2e91100bf37a..5f187ad0cd72 100644 --- a/services/www/src/docs/content/build/plugins/effect.mdx +++ b/services/www/src/docs/content/build/plugins/effect.mdx @@ -3,11 +3,14 @@ title: "Effect" --- `@opencode/plugin/effect` is the Effect-native version of the OpenCode plugin API. Its context operations return -Effects or Streams, callbacks return Effects, and plugin lifetime is represented by `Scope`. Install `effect` with the -plugin package. +Effects or Streams, callbacks return Effects, and plugin lifetime is represented by `Scope`. + +OpenCode provides `effect` (including exported `effect/*` subpaths) and `@opencode/plugin` at runtime when loading +plugins and their `node_modules` dependencies. Install them as peer/dev dependencies for local type-checking and testing +rather than bundling them into your plugin. ```sh -bun add @opencode/plugin effect +bun add --peer @opencode/plugin effect ``` Export an Effect plugin from `.opencode/plugins/` to load it automatically. @@ -1500,7 +1503,7 @@ type Transform = (callback: (input: Input) => void) => Effect.Effect=4.0.0-rc.112" } } ``` +OpenCode redirects imports of `effect`, exported `effect/*` subpaths, and `@opencode/plugin` (including imports from +dependencies in the plugin's `node_modules`) to the host's runtime instances so fibers, loggers, and `Schema` parsers +share one runtime copy. + +- Do not bundle `effect` into the published plugin artifact. If you build with a bundler, mark `effect` and `effect/*` + external. +- Plugins and their `node_modules` dependencies always execute against the host OpenCode release's `effect` instance. + Use `effect` APIs and module paths compatible with the OpenCode release you target; dependencies built on another + `effect` major (such as Effect 3) also receive the host's `effect` and are not supported. +- Only public `effect` subpaths are provided. A plugin that imports one of Effect's private `internal` modules fails to + load with an error naming the path. +- The compiled OpenCode binary does not include the Scalar and Swagger UI assets used by Effect's HTTP API docs pages; + serving those pages from a plugin shows a notice instead. + The package entrypoint exports `Plugin.define` with an `effect` function. ```ts title="src/index.ts"