mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-05 06:56:16 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f06708d0e |
No files matched your search
@@ -3,6 +3,7 @@
|
||||
import { $ } from "bun"
|
||||
import { mkdir, rm } from "fs/promises"
|
||||
import path from "path"
|
||||
import { discoverEffectSpecifiers } from "@opencode/plugin/runtime-modules.bun"
|
||||
import { Script } from "@opencode/script"
|
||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import type { BunPlugin } from "bun"
|
||||
@@ -78,6 +79,28 @@ const appAssetsPlugin: BunPlugin = {
|
||||
}))
|
||||
},
|
||||
}
|
||||
const pluginRuntimeSpecifiers = [
|
||||
"@opencode/plugin",
|
||||
"@opencode/plugin/effect",
|
||||
"@opencode/plugin/effect/plugin",
|
||||
"@opencode/plugin/effect/tool",
|
||||
"@opencode/plugin/promise/plugin",
|
||||
"@opencode/plugin/promise/tool",
|
||||
"@opencode/plugin/rpc",
|
||||
...discoverEffectSpecifiers().map(([specifier]) => specifier),
|
||||
]
|
||||
const pluginRuntimeModulesSource = `const modules = {\n${pluginRuntimeSpecifiers
|
||||
.map((specifier) => ` ${JSON.stringify(specifier)}: () => require(${JSON.stringify(specifier)}),`)
|
||||
.join("\n")}\n}\nexport function pluginRuntimeModules() {\n return modules\n}\n`
|
||||
const pluginRuntimeModulesPlugin: BunPlugin = {
|
||||
name: "opencode-plugin-runtime-modules",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /plugin[/\\]src[/\\]runtime-modules\.bun\.ts$/ }, () => ({
|
||||
contents: pluginRuntimeModulesSource,
|
||||
loader: "js",
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
for (const item of targets) {
|
||||
const opencodePty = await resolveOpencodePty({
|
||||
@@ -124,7 +147,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,
|
||||
pluginRuntimeModulesPlugin,
|
||||
simulationGraphPlugin,
|
||||
],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
|
||||
@@ -61,9 +61,17 @@ const layer = Layer.effect(
|
||||
release: holdUnsafe(),
|
||||
})
|
||||
})
|
||||
const exit = yield* Effect.suspend(() =>
|
||||
plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) }),
|
||||
).pipe(
|
||||
const exit = yield* Effect.suspend(() => {
|
||||
const effect = plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) })
|
||||
if (!Effect.isEffect(effect) || effect.pipe !== Effect.void.pipe) {
|
||||
return Effect.die(
|
||||
new Error(
|
||||
`Plugin "${plugin.id}" returned an Effect from a bundled copy of effect. Declare "effect" as a peerDependency and do not bundle it.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
return effect
|
||||
}).pipe(
|
||||
grouped,
|
||||
inherit,
|
||||
Effect.updateContext((context: Context.Context<never>) =>
|
||||
|
||||
@@ -128,6 +128,17 @@ const load = Effect.fn("PluginModule.load")(function* (
|
||||
target: operation.target,
|
||||
...(installed?.version ? { version: installed.version } : {}),
|
||||
},
|
||||
effect: (host) => plugin.effect({ ...host, options: operation.options }),
|
||||
effect: (host) =>
|
||||
Effect.suspend(() => {
|
||||
const effect = plugin.effect({ ...host, options: operation.options })
|
||||
if (!Effect.isEffect(effect) || effect.pipe !== Effect.void.pipe) {
|
||||
return Effect.die(
|
||||
new Error(
|
||||
`Plugin "${plugin.id}" returned an Effect from a bundled copy of effect. Declare "effect" as a peerDependency and do not bundle it.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
return effect
|
||||
}),
|
||||
} satisfies Generation
|
||||
})
|
||||
@@ -1,17 +1,27 @@
|
||||
import { expect } from "bun:test"
|
||||
import { cp, mkdir, readFile, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Schedule, Scope, Stream } from "effect"
|
||||
import { Brand, 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 { Plugin } from "@opencode/core/plugin"
|
||||
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 { tempGlobalLayer } from "../fixture/global"
|
||||
import { tmpdirScoped } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.merge(
|
||||
Layer.mergeAll(
|
||||
PluginTestLayer,
|
||||
AppNodeBuilder.build(Npm.node, [Global.node.replace(tempGlobalLayer)]),
|
||||
Watcher.layer().pipe(Layer.provide(Watcher.nativeLayer)),
|
||||
),
|
||||
@@ -90,3 +100,252 @@ it.live("interrupts pending watcher setup when the loader scope closes during mo
|
||||
yield* Effect.promise(() => Bun.sleep(50))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loads plugins and their transitive dependencies against the host's Effect instance", () =>
|
||||
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 mkdir(path.join(pluginDir, "node_modules"), { recursive: true })
|
||||
await cp(hostEffectDir, pluginEffectDir, { recursive: true })
|
||||
|
||||
const pkgPath = path.join(pluginEffectDir, "package.json")
|
||||
const pkg = { ...(await Bun.file(pkgPath).json()), version: "4.0.0-rc.111" }
|
||||
await writeFile(pkgPath, JSON.stringify(pkg, null, 2))
|
||||
|
||||
// Reproduce the version-skew failure modes on the plugin's own Effect copy 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 internalEffect = (await readFile(internalEffectPath, "utf8"))
|
||||
.replace(
|
||||
"const logLevel = level ?? fiber.currentLogLevel;\n if (isLogLevelGreaterThan(fiber.minimumLogLevel, logLevel)) {",
|
||||
"const logLevel = level ?? fiber.cache?.logLevel;\n if (isLogLevelGreaterThan(fiber.cache?.minimumLogLevel, logLevel)) {",
|
||||
)
|
||||
.replace(
|
||||
"export const runPromiseWith = context => {",
|
||||
"export const runPromiseWith = context => {\n return (effect) => Promise.resolve().then(() => { const fiber = {}; return fiber.succeedWith(effect); });",
|
||||
)
|
||||
await writeFile(internalEffectPath, internalEffect)
|
||||
|
||||
const depDir = path.join(pluginDir, "node_modules/transitive-dep")
|
||||
await mkdir(depDir, { recursive: true })
|
||||
await writeFile(
|
||||
path.join(depDir, "package.json"),
|
||||
JSON.stringify({ name: "transitive-dep", type: "module", exports: { ".": "./index.js" } }),
|
||||
)
|
||||
await writeFile(
|
||||
path.join(depDir, "index.js"),
|
||||
`import { Effect as DepEffect, Schema as DepSchema } from "effect"
|
||||
import { some as depSome } from "effect/Option"
|
||||
export const depToolInput = DepSchema.Struct({
|
||||
mode: DepSchema.String.pipe(DepSchema.withDecodingDefault(DepEffect.succeed("from-dep"))),
|
||||
})
|
||||
export { DepEffect, DepSchema, depSome }`,
|
||||
)
|
||||
|
||||
const foreignPluginPkgDir = path.join(pluginDir, "node_modules/@opencode/plugin")
|
||||
await mkdir(foreignPluginPkgDir, { recursive: true })
|
||||
await writeFile(
|
||||
path.join(foreignPluginPkgDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@opencode/plugin",
|
||||
type: "module",
|
||||
exports: { "./effect": "./effect.js", "./rpc": "./rpc.js" },
|
||||
}),
|
||||
)
|
||||
await writeFile(
|
||||
path.join(foreignPluginPkgDir, "effect.js"),
|
||||
"export const Plugin = { define: (p) => p }",
|
||||
)
|
||||
await writeFile(path.join(foreignPluginPkgDir, "rpc.js"), "export const Rpc = { define: (d) => d }")
|
||||
|
||||
await writeFile(
|
||||
path.join(pluginDir, "index.ts"),
|
||||
`import { Plugin } from "@opencode/plugin/effect"
|
||||
import { Rpc } from "@opencode/plugin/rpc"
|
||||
import { Effect as PluginEffect, Schema as PluginSchema } from "effect"
|
||||
import { some as pluginSome } from "effect/Option"
|
||||
import { nominal as pluginNominal } from "effect/Brand"
|
||||
import { DepEffect, DepSchema, depSome, depToolInput } from "transitive-dep"
|
||||
|
||||
export const captured = {
|
||||
PluginEffect,
|
||||
PluginSchema,
|
||||
pluginSome,
|
||||
pluginNominal,
|
||||
DepEffect,
|
||||
DepSchema,
|
||||
depSome,
|
||||
pluginCount: -1,
|
||||
}
|
||||
|
||||
const Contract = Rpc.define({
|
||||
id: "host-effect-rpc",
|
||||
methods: {
|
||||
check: {
|
||||
input: PluginSchema.Struct({
|
||||
count: PluginSchema.Int.pipe(PluginSchema.withDecodingDefault(PluginEffect.succeed(5))),
|
||||
tag: PluginSchema.Trim.check(PluginSchema.isPattern(/^v[0-9]+$/)),
|
||||
}),
|
||||
output: PluginSchema.Struct({
|
||||
value: PluginSchema.String,
|
||||
}),
|
||||
},
|
||||
},
|
||||
events: {},
|
||||
})
|
||||
|
||||
export default Plugin.define({
|
||||
id: "host-effect-fixture",
|
||||
effect: (ctx) =>
|
||||
PluginEffect.gen(function* () {
|
||||
yield* PluginEffect.log("setup log from plugin")
|
||||
const listed = yield* PluginEffect.promise(() =>
|
||||
PluginEffect.runPromise(ctx.plugin.list().pipe(PluginEffect.orDie)),
|
||||
)
|
||||
captured.pluginCount = listed.data.length
|
||||
|
||||
yield* ctx.tool.transform((editor) => {
|
||||
editor.add({
|
||||
name: "default_tool",
|
||||
description: "Tool with decoding default from transitive dependency",
|
||||
input: depToolInput,
|
||||
output: PluginSchema.Struct({ mode: PluginSchema.String }),
|
||||
execute: ({ mode }) =>
|
||||
PluginEffect.log("executing default_tool").pipe(
|
||||
PluginEffect.as({ output: { mode }, content: mode }),
|
||||
),
|
||||
})
|
||||
editor.add({
|
||||
name: "check_tool",
|
||||
description: "Tool with Int, Trim, and isPattern checks",
|
||||
input: PluginSchema.Struct({
|
||||
count: PluginSchema.Int,
|
||||
code: PluginSchema.Trim.check(PluginSchema.isPattern(/^v[0-9]+$/)),
|
||||
}),
|
||||
output: PluginSchema.Struct({ formatted: PluginSchema.String }),
|
||||
execute: ({ count, code }) =>
|
||||
PluginEffect.succeed({
|
||||
output: { formatted: \`\${code}:\${count}\` },
|
||||
content: \`\${code}:\${count}\`,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
yield* ctx.rpc.register(Contract, {
|
||||
check: ({ count, tag }) =>
|
||||
PluginEffect.log("executing rpc check").pipe(
|
||||
PluginEffect.as({ value: \`\${tag}#\${count}\` }),
|
||||
),
|
||||
}).pipe(PluginEffect.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) 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")))) as {
|
||||
captured: {
|
||||
PluginEffect: unknown
|
||||
PluginSchema: unknown
|
||||
pluginSome: unknown
|
||||
pluginNominal: unknown
|
||||
DepEffect: unknown
|
||||
DepSchema: unknown
|
||||
depSome: unknown
|
||||
pluginCount: number
|
||||
}
|
||||
}
|
||||
expect(imported.captured.PluginEffect).toBe(Effect)
|
||||
expect(imported.captured.PluginSchema).toBe(Schema)
|
||||
expect(imported.captured.pluginSome).toBe(Option.some)
|
||||
expect(imported.captured.pluginNominal).toBe(Brand.nominal)
|
||||
expect(imported.captured.DepEffect).toBe(Effect)
|
||||
expect(imported.captured.DepSchema).toBe(Schema)
|
||||
expect(imported.captured.depSome).toBe(Option.some)
|
||||
expect(imported.captured.pluginCount).toBe(0)
|
||||
|
||||
const registeredTools = yield* tools.list()
|
||||
const defaultTool = registeredTools.find((tool) => tool.id === "default_tool")
|
||||
const checkTool = registeredTools.find((tool) => tool.id === "check_tool")
|
||||
expect(defaultTool).toBeDefined()
|
||||
expect(checkTool).toBeDefined()
|
||||
if (!defaultTool || !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(defaultTool, {}, context)).toEqual({
|
||||
output: { mode: "from-dep" },
|
||||
content: [{ type: "text", text: "from-dep" }],
|
||||
})
|
||||
expect(yield* execute(checkTool, { count: 3, code: " v42 " }, context)).toEqual({
|
||||
output: { formatted: "v42:3" },
|
||||
content: [{ type: "text", text: "v42:3" }],
|
||||
})
|
||||
expect(yield* rpc.call("host-effect-rpc", "check", { tag: " v9 " })).toEqual({ value: "v9#5" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("fails activation when a plugin returns an Effect from a bundled copy of effect", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const pluginDir = path.join(directory.path, "bundled-plugin")
|
||||
const bundledEffectDir = path.join(pluginDir, "bundled-effect")
|
||||
const hostEffectDir = path.dirname(Bun.resolveSync("effect/package.json", import.meta.dir))
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await mkdir(pluginDir, { recursive: true })
|
||||
await cp(hostEffectDir, bundledEffectDir, { recursive: true })
|
||||
await writeFile(
|
||||
path.join(pluginDir, "index.ts"),
|
||||
`import { Effect as BundledEffect } from "./bundled-effect/dist/index.js"
|
||||
export default {
|
||||
id: "bundled-effect-plugin",
|
||||
effect: () => BundledEffect.void,
|
||||
}`,
|
||||
)
|
||||
})
|
||||
|
||||
const modules = yield* PluginModule.make()
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
const definition = yield* modules.load({ type: "add", target: pluginDir, options: {} })
|
||||
if ("pending" 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: "bundled-effect-plugin",
|
||||
state: {
|
||||
status: "failed",
|
||||
error: expect.stringContaining(
|
||||
'Plugin "bundled-effect-plugin" returned an Effect from a bundled copy of effect. Declare "effect" as a peerDependency and do not bundle it.',
|
||||
),
|
||||
},
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
# `@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`
|
||||
|
||||
OpenCode resolves imports of `effect`, every exported `effect/*` subpath, and `@opencode/plugin` entrypoints (including transitive imports from dependencies inside a plugin's `node_modules`) to the host's runtime module instances.
|
||||
|
||||
- Declare `effect` as a `peerDependency` (and `devDependency` for local type-checking/testing) rather than bundling it.
|
||||
- Do not bundle `effect` into published plugin files; if you build with a bundler, keep `effect` and `effect/*` external. OpenCode detects and rejects Effect plugins whose returned `Effect` comes from a bundled copy.
|
||||
- At runtime, plugins execute against the host OpenCode release's `effect` version, so plugins must use `effect` APIs and module paths compatible with the OpenCode release they target.
|
||||
@@ -17,6 +17,12 @@
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#plugin-runtime": {
|
||||
"workerd": "./src/runtime.workerd.ts",
|
||||
"bun": "./src/runtime.bun.ts",
|
||||
"node": "./src/runtime.node.ts",
|
||||
"default": "./src/runtime.node.ts"
|
||||
},
|
||||
"#plugin-source": {
|
||||
"bun": "./src/source.bun.ts",
|
||||
"node": "./src/source.node.ts",
|
||||
|
||||
@@ -140,6 +140,8 @@ await ctx.tool.transform((tools) => {
|
||||
})
|
||||
```
|
||||
|
||||
When using `effect` schemas in a plugin, declare `effect` as a `peerDependency` and do not bundle it; OpenCode resolves `effect` and `effect/*` imports to the host's runtime instance.
|
||||
|
||||
## Reloading A Domain
|
||||
|
||||
When data captured by a transform changes, reload the affected domain:
|
||||
|
||||
@@ -29,6 +29,14 @@ Configuration supplied for the plugin is available as `ctx.options`.
|
||||
|
||||
Registrations are owned by the plugin scope. Closing the scope removes them automatically; a registration may also be removed early through `dispose`.
|
||||
|
||||
## Packaging And Runtime `effect`
|
||||
|
||||
OpenCode redirects imports of `effect`, `effect/*` subpaths, and `@opencode/plugin` (including transitive imports from packages in the plugin's `node_modules`) to the host's runtime instances so fibers, loggers, and `Schema` parsers share one runtime copy.
|
||||
|
||||
- Declare `effect` in `peerDependencies` (and `devDependencies` for local development) rather than bundling it.
|
||||
- Do not bundle `effect` into the published plugin artifact; if using a bundler, mark `effect` and `effect/*` external. OpenCode rejects Effect plugins whose returned `Effect` comes from a bundled copy.
|
||||
- Plugins always execute against the host OpenCode release's `effect` instance, so use `effect` APIs and module paths compatible with the OpenCode release you target.
|
||||
|
||||
## Transform Hooks
|
||||
|
||||
Transform hooks contribute to stateful domains. Their editor callbacks are
|
||||
|
||||
@@ -14,6 +14,7 @@ export { Provider } from "@opencode/schema/provider"
|
||||
export { Reference } from "@opencode/schema/reference"
|
||||
export { Rpc } from "@opencode/schema/rpc"
|
||||
export { Skill } from "@opencode/schema/skill"
|
||||
export { Tool } from "@opencode/schema/tool"
|
||||
export { Vcs } from "@opencode/schema/vcs"
|
||||
export { WebSearch } from "@opencode/schema/websearch"
|
||||
export { Worktree } from "@opencode/schema/worktree"
|
||||
@@ -1,3 +1,6 @@
|
||||
export { CallID, Error, Tool } from "@opencode/schema/tool"
|
||||
export type { Metadata, Options, Result } from "@opencode/schema/tool"
|
||||
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import type { Agent } from "@opencode/schema/agent"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as Host from "./host.js"
|
||||
|
||||
import path from "node:path"
|
||||
import { importModule, resolveModule } from "@opencode/util/runtime-import"
|
||||
import { ensurePluginRuntime } from "#plugin-runtime"
|
||||
|
||||
export interface Target {
|
||||
readonly directory: string
|
||||
@@ -43,6 +44,7 @@ export function resolve(target: Target): Entrypoints {
|
||||
return { server: entry(["server", ""]), tui: entry(["tui"]), rpc: entry(["rpc"]) }
|
||||
}
|
||||
|
||||
export function load(entrypoint: string): Promise<unknown> {
|
||||
export async function load(entrypoint: string): Promise<unknown> {
|
||||
await ensurePluginRuntime(entrypoint)
|
||||
return importModule(entrypoint)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export { Provider } from "@opencode/schema/provider"
|
||||
export { Reference } from "@opencode/schema/reference"
|
||||
export { Rpc } from "@opencode/schema/rpc"
|
||||
export { Skill } from "@opencode/schema/skill"
|
||||
export { Tool } from "@opencode/schema/tool"
|
||||
export { Vcs } from "@opencode/schema/vcs"
|
||||
export { WebSearch } from "@opencode/schema/websearch"
|
||||
export { Worktree } from "@opencode/schema/worktree"
|
||||
@@ -0,0 +1,46 @@
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
|
||||
export type RuntimeModuleLoader = () => Record<string, unknown> | Promise<Record<string, unknown>>
|
||||
export type RuntimeModuleEntry = Record<string, unknown> | RuntimeModuleLoader
|
||||
|
||||
export function discoverEffectSpecifiers(from = import.meta.dir): ReadonlyArray<readonly [string, string]> {
|
||||
const pkg = Bun.resolveSync("effect/package.json", from)
|
||||
const dir = path.dirname(pkg)
|
||||
const dist = path.join(dir, "dist")
|
||||
const entries: Array<readonly [string, string]> = [["effect", Bun.resolveSync("effect", dir)]]
|
||||
for (const file of new Bun.Glob("**/*.js").scanSync({ cwd: dist })) {
|
||||
const normalized = file.replaceAll("\\", "/")
|
||||
if (normalized === "index.js" || normalized.startsWith("internal/") || normalized.includes("/internal/")) continue
|
||||
const subpath = normalized.endsWith("/index.js")
|
||||
? normalized.slice(0, -"/index.js".length)
|
||||
: normalized.slice(0, -".js".length)
|
||||
const specifier = `effect/${subpath}`
|
||||
try {
|
||||
entries.push([specifier, Bun.resolveSync(specifier, dir)])
|
||||
} catch {}
|
||||
}
|
||||
return entries.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
let cached: Readonly<Record<string, RuntimeModuleLoader>> | undefined
|
||||
|
||||
export function pluginRuntimeModules(): Readonly<Record<string, RuntimeModuleLoader>> {
|
||||
if (cached) return cached
|
||||
cached = {
|
||||
"@opencode/plugin": () => import("./promise/index.js"),
|
||||
"@opencode/plugin/effect": () => import("./effect/index.js"),
|
||||
"@opencode/plugin/effect/plugin": () => import("./effect/plugin.js"),
|
||||
"@opencode/plugin/effect/tool": () => import("./effect/tool.js"),
|
||||
"@opencode/plugin/promise/plugin": () => import("./promise/plugin.js"),
|
||||
"@opencode/plugin/promise/tool": () => import("./promise/tool.js"),
|
||||
"@opencode/plugin/rpc": () => import("./rpc.js"),
|
||||
...Object.fromEntries(
|
||||
discoverEffectSpecifiers().map(([specifier, resolved]) => [
|
||||
specifier,
|
||||
() => import(pathToFileURL(resolved).href) as Promise<Record<string, unknown>>,
|
||||
]),
|
||||
),
|
||||
}
|
||||
return cached
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { existsSync, readFileSync, realpathSync } from "node:fs"
|
||||
import { dirname, isAbsolute, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { plugin, type PluginBuilder } from "bun"
|
||||
import { pluginRuntimeModules, type RuntimeModuleEntry } from "./runtime-modules.bun.js"
|
||||
|
||||
export { pluginRuntimeModules } from "./runtime-modules.bun.js"
|
||||
|
||||
const RUNTIME_MODULE_PREFIX = "opentui:runtime-module:"
|
||||
const opentuiInstalledKey = Symbol.for("opentui.solid.runtime-plugin-support")
|
||||
const solidTransformKey = Symbol.for("opentui.solid.transform")
|
||||
const pluginRuntimeStateKey = Symbol.for("opencode.plugin.runtime-plugin-support")
|
||||
|
||||
type RuntimeState = {
|
||||
readonly prepare: (entrypoint?: string) => void
|
||||
}
|
||||
|
||||
type GlobalState = typeof globalThis & {
|
||||
[opentuiInstalledKey]?: { readonly specifiers?: ReadonlySet<string> }
|
||||
[solidTransformKey]?: { readonly installed?: boolean }
|
||||
[pluginRuntimeStateKey]?: RuntimeState
|
||||
}
|
||||
|
||||
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
|
||||
const runtimeModuleIdForSpecifier = (specifier: string) => `${RUNTIME_MODULE_PREFIX}${encodeURIComponent(specifier)}`
|
||||
|
||||
const sourcePath = (value: string) => {
|
||||
const search = value.indexOf("?")
|
||||
const hash = value.indexOf("#")
|
||||
const end = [search, hash].filter((index) => index >= 0).sort((a, b) => a - b)[0]
|
||||
return end === undefined ? value : value.slice(0, end)
|
||||
}
|
||||
|
||||
const normalizedPaths = new Map<string, string>()
|
||||
|
||||
const normalizeSourcePath = (value: string) => {
|
||||
const clean = sourcePath(value)
|
||||
const cached = normalizedPaths.get(clean)
|
||||
if (cached !== undefined) return cached
|
||||
const normalized = (() => {
|
||||
try {
|
||||
return realpathSync(clean)
|
||||
} catch {
|
||||
return clean
|
||||
}
|
||||
})()
|
||||
normalizedPaths.set(clean, normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
const exactPathFilter = (paths: readonly string[]) => {
|
||||
const candidates = [...new Set(paths.map(sourcePath))]
|
||||
return new RegExp(`^(?:${candidates.map(escapeRegExp).join("|")})(?:[?#].*)?$`)
|
||||
}
|
||||
|
||||
const isNodeModulesPath = (value: string) => /(?:^|[/\\])node_modules(?:[/\\])/.test(value)
|
||||
|
||||
const runtimeLoaderForPath = (value: string) => {
|
||||
const clean = sourcePath(value)
|
||||
if (clean.endsWith(".tsx")) return "tsx" as const
|
||||
if (clean.endsWith(".jsx")) return "jsx" as const
|
||||
if (clean.endsWith(".ts") || clean.endsWith(".mts") || clean.endsWith(".cts")) return "ts" as const
|
||||
if (clean.endsWith(".js") || clean.endsWith(".mjs") || clean.endsWith(".cjs")) return "js" as const
|
||||
return null
|
||||
}
|
||||
|
||||
const resolveImportSpecifierPatterns = [
|
||||
/(\bfrom\s*["'])([^"']+)(["'])/g,
|
||||
/(\bimport\s*["'])([^"']+)(["'])/g,
|
||||
/(\bimport\s*\(\s*["'])([^"']+)(["']\s*\))/g,
|
||||
/(\brequire\s*\(\s*["'])([^"']+)(["']\s*\))/g,
|
||||
]
|
||||
|
||||
const isImportLikeMatchInsideQuotes = (code: string, offset: number) => {
|
||||
const previous = code[offset - 1]
|
||||
return previous === '"' || previous === "'" || previous === "`"
|
||||
}
|
||||
|
||||
const collectImportSpecifiers = (code: string) => {
|
||||
const specifiers = new Set<string>()
|
||||
for (const pattern of resolveImportSpecifierPatterns) {
|
||||
code.replace(pattern, (full, _prefix, specifier: string, _suffix, offset: number) => {
|
||||
if (!isImportLikeMatchInsideQuotes(code, offset)) specifiers.add(specifier)
|
||||
return full
|
||||
})
|
||||
}
|
||||
return [...specifiers]
|
||||
}
|
||||
|
||||
const resolveSourcePathFromSpecifier = (specifier: string, importer?: string) => {
|
||||
if (
|
||||
specifier.startsWith("node:") ||
|
||||
specifier.startsWith("bun:") ||
|
||||
specifier.startsWith("http:") ||
|
||||
specifier.startsWith("https:") ||
|
||||
specifier.startsWith("data:") ||
|
||||
specifier.startsWith(RUNTIME_MODULE_PREFIX)
|
||||
)
|
||||
return null
|
||||
const candidate = specifier.startsWith("file:") ? sourcePath(fileURLToPath(specifier)) : sourcePath(specifier)
|
||||
if (isAbsolute(candidate) && existsSync(candidate) && runtimeLoaderForPath(candidate)) return candidate
|
||||
const baseDir = importer
|
||||
? dirname(importer.startsWith("file:") ? fileURLToPath(importer) : sourcePath(importer))
|
||||
: process.cwd()
|
||||
try {
|
||||
const resolved = Bun.resolveSync(candidate, baseDir)
|
||||
if (resolved.startsWith("node:") || resolved.startsWith("bun:")) return null
|
||||
return sourcePath(resolved)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function ensurePluginRuntime(entrypoint?: string) {
|
||||
const state = globalThis as GlobalState
|
||||
if (state[opentuiInstalledKey]) return
|
||||
if (!state[pluginRuntimeStateKey]) {
|
||||
state[pluginRuntimeStateKey] = install(state)
|
||||
}
|
||||
state[pluginRuntimeStateKey].prepare(entrypoint)
|
||||
}
|
||||
|
||||
function install(state: GlobalState): RuntimeState {
|
||||
const modules: Readonly<Record<string, RuntimeModuleEntry>> = pluginRuntimeModules()
|
||||
const runtimeModuleIds = new Map<string, string>()
|
||||
for (const specifier of Object.keys(modules)) {
|
||||
runtimeModuleIds.set(specifier, runtimeModuleIdForSpecifier(specifier))
|
||||
}
|
||||
|
||||
const resolveRuntimeId = (specifier: string) => {
|
||||
const known = runtimeModuleIds.get(specifier)
|
||||
if (known) return known
|
||||
if (
|
||||
specifier === "@opentui/core" ||
|
||||
specifier === "@opentui/core/testing" ||
|
||||
state[opentuiInstalledKey]?.specifiers?.has(specifier)
|
||||
)
|
||||
return runtimeModuleIdForSpecifier(specifier)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isSolidTransformActiveFor = (file: string) =>
|
||||
Boolean(state[solidTransformKey]?.installed) && /\.[cm]?[jt]sx$/.test(sourcePath(file))
|
||||
|
||||
const installedLoaders = new Set<string>()
|
||||
const packageTypes = new Map<string, "module" | "commonjs">()
|
||||
const nodeModulesAnalysis = new Map<
|
||||
string,
|
||||
{ readonly importSpecifiers: readonly string[]; readonly needsRewrite: boolean; readonly isEsm: boolean }
|
||||
>()
|
||||
const nodeModulesRewritePaths = new Map<string, readonly string[]>()
|
||||
let activeBuild: PluginBuilder | undefined
|
||||
|
||||
const packageTypeForPath = (file: string) => {
|
||||
let current = dirname(file)
|
||||
while (true) {
|
||||
const pkgJson = join(current, "package.json")
|
||||
if (existsSync(pkgJson)) {
|
||||
const cached = packageTypes.get(pkgJson)
|
||||
if (cached) return cached
|
||||
const type = (() => {
|
||||
try {
|
||||
return JSON.parse(readFileSync(pkgJson, "utf8")).type === "module"
|
||||
? ("module" as const)
|
||||
: ("commonjs" as const)
|
||||
} catch {
|
||||
return "commonjs" as const
|
||||
}
|
||||
})()
|
||||
packageTypes.set(pkgJson, type)
|
||||
return type
|
||||
}
|
||||
const parent = dirname(current)
|
||||
if (parent === current) return "commonjs"
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
const analyzeFile = (file: string, cacheable: boolean) => {
|
||||
const normalized = normalizeSourcePath(file)
|
||||
if (cacheable) {
|
||||
const cached = nodeModulesAnalysis.get(normalized)
|
||||
if (cached) return cached
|
||||
}
|
||||
const contents = (() => {
|
||||
try {
|
||||
return readFileSync(normalized, "utf8")
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
if (contents === undefined) return { importSpecifiers: [], needsRewrite: false, isEsm: false }
|
||||
const importSpecifiers = collectImportSpecifiers(contents)
|
||||
const analysis = {
|
||||
importSpecifiers,
|
||||
needsRewrite: importSpecifiers.some((specifier) => resolveRuntimeId(specifier) !== undefined),
|
||||
isEsm: /\b(?:import|export)\b/.test(contents),
|
||||
}
|
||||
if (cacheable) nodeModulesAnalysis.set(normalized, analysis)
|
||||
return analysis
|
||||
}
|
||||
|
||||
const isNodeModulesEsm = (file: string) => {
|
||||
const normalized = normalizeSourcePath(file)
|
||||
if (!isNodeModulesPath(normalized)) return false
|
||||
if (/\.(?:mjs|mts|ts|tsx|jsx)$/.test(normalized)) return true
|
||||
if (/\.(?:cjs|cts)$/.test(normalized) || !normalized.endsWith(".js")) return false
|
||||
return packageTypeForPath(normalized) === "module" || analyzeFile(normalized, true).isEsm
|
||||
}
|
||||
|
||||
const collectNodeModulesRewrites = (file: string, visiting = new Set<string>()): readonly string[] => {
|
||||
const normalized = normalizeSourcePath(file)
|
||||
if (!isNodeModulesEsm(normalized)) return []
|
||||
const cached = nodeModulesRewritePaths.get(normalized)
|
||||
if (cached) return cached
|
||||
if (visiting.has(normalized)) return []
|
||||
visiting.add(normalized)
|
||||
const rewrites = new Set<string>()
|
||||
const analysis = analyzeFile(normalized, true)
|
||||
if (analysis.needsRewrite) rewrites.add(normalized)
|
||||
for (const specifier of analysis.importSpecifiers) {
|
||||
if (resolveRuntimeId(specifier) !== undefined) continue
|
||||
const resolved = resolveSourcePathFromSpecifier(specifier, normalized)
|
||||
if (!resolved || !isNodeModulesEsm(resolved)) continue
|
||||
for (const nested of collectNodeModulesRewrites(resolved, visiting)) rewrites.add(nested)
|
||||
}
|
||||
visiting.delete(normalized)
|
||||
const result = [...rewrites]
|
||||
nodeModulesRewritePaths.set(normalized, result)
|
||||
return result
|
||||
}
|
||||
|
||||
const installRewriteLoader = (file: string) => {
|
||||
if (!activeBuild) return
|
||||
const resolvedTarget = sourcePath(file)
|
||||
if (!isNodeModulesPath(resolvedTarget) && isSolidTransformActiveFor(resolvedTarget)) return
|
||||
const canonicalTarget = normalizeSourcePath(resolvedTarget)
|
||||
if (installedLoaders.has(canonicalTarget)) return
|
||||
installedLoaders.add(canonicalTarget)
|
||||
const build = activeBuild
|
||||
build.onLoad({ filter: exactPathFilter([resolvedTarget, canonicalTarget]) }, (args) => {
|
||||
const loaded = normalizeSourcePath(args.path)
|
||||
if (loaded !== canonicalTarget) return undefined as never
|
||||
const loader = runtimeLoaderForPath(args.path)
|
||||
if (!loader) throw new Error(`Unable to determine runtime loader for path: ${args.path}`)
|
||||
let code = readFileSync(loaded, "utf8")
|
||||
for (const pattern of resolveImportSpecifierPatterns) {
|
||||
code = code.replace(pattern, (full, prefix: string, specifier: string, suffix: string, offset: number) => {
|
||||
if (isImportLikeMatchInsideQuotes(code, offset)) return full
|
||||
const replacement = resolveRuntimeId(specifier)
|
||||
return replacement ? `${prefix}${replacement}${suffix}` : full
|
||||
})
|
||||
}
|
||||
return { contents: code, loader }
|
||||
})
|
||||
}
|
||||
|
||||
const prescanGraph = (entryFile: string, visiting = new Set<string>()) => {
|
||||
const normalized = normalizeSourcePath(entryFile)
|
||||
if (visiting.has(normalized)) return
|
||||
visiting.add(normalized)
|
||||
const analysis = analyzeFile(normalized, false)
|
||||
if (analysis.needsRewrite) installRewriteLoader(entryFile)
|
||||
for (const specifier of analysis.importSpecifiers) {
|
||||
if (resolveRuntimeId(specifier) !== undefined) continue
|
||||
const resolved = resolveSourcePathFromSpecifier(specifier, normalized)
|
||||
if (!resolved || !runtimeLoaderForPath(resolved)) continue
|
||||
if (isNodeModulesPath(resolved)) {
|
||||
for (const rewrite of collectNodeModulesRewrites(resolved)) installRewriteLoader(rewrite)
|
||||
continue
|
||||
}
|
||||
prescanGraph(resolved, visiting)
|
||||
}
|
||||
}
|
||||
|
||||
plugin({
|
||||
name: "opencode-plugin-runtime-modules",
|
||||
setup(build) {
|
||||
activeBuild = build
|
||||
if (!state[opentuiInstalledKey]) {
|
||||
for (const [specifier, entry] of Object.entries(modules)) {
|
||||
const moduleId = runtimeModuleIds.get(specifier)
|
||||
if (!moduleId) continue
|
||||
build.module(moduleId, async () => ({
|
||||
exports: typeof entry === "function" ? await entry() : entry,
|
||||
loader: "object",
|
||||
}))
|
||||
}
|
||||
}
|
||||
build.onResolve({ filter: /^(?:effect(?:\/|$)|@opencode\/plugin(?:\/|$))/ }, (args) => {
|
||||
const direct = resolveRuntimeId(args.path)
|
||||
return direct ? { path: direct } : undefined
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
prepare(entrypoint?: string) {
|
||||
if (!entrypoint) return
|
||||
const resolved = resolveSourcePathFromSpecifier(entrypoint)
|
||||
if (!resolved || !runtimeLoaderForPath(resolved)) return
|
||||
prescanGraph(resolved)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createRequire, registerHooks } from "node:module"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
|
||||
const PREFIX = "opencode:runtime-module:"
|
||||
const modulesKey = Symbol.for("opencode.plugin.runtime.modules")
|
||||
|
||||
type GlobalState = typeof globalThis & {
|
||||
[modulesKey]?: Map<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
let pending: Promise<void> | undefined
|
||||
|
||||
export function ensurePluginRuntime(_entrypoint?: string): Promise<void> {
|
||||
if (pending) return pending
|
||||
pending = setup()
|
||||
return pending
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const state = globalThis as GlobalState
|
||||
if (state[modulesKey]) return
|
||||
|
||||
const [effect, promiseIndex, promisePlugin, promiseTool, effectIndex, effectPlugin, effectTool, rpc] =
|
||||
await Promise.all([
|
||||
import("effect") as Promise<Record<string, unknown>>,
|
||||
import("./promise/index.js") as Promise<Record<string, unknown>>,
|
||||
import("./promise/plugin.js") as Promise<Record<string, unknown>>,
|
||||
import("./promise/tool.js") as Promise<Record<string, unknown>>,
|
||||
import("./effect/index.js") as Promise<Record<string, unknown>>,
|
||||
import("./effect/plugin.js") as Promise<Record<string, unknown>>,
|
||||
import("./effect/tool.js") as Promise<Record<string, unknown>>,
|
||||
import("./rpc.js") as Promise<Record<string, unknown>>,
|
||||
])
|
||||
|
||||
const modules = new Map<string, Record<string, unknown>>([
|
||||
["effect", effect],
|
||||
["@opencode/plugin", promiseIndex],
|
||||
["@opencode/plugin/promise/plugin", promisePlugin],
|
||||
["@opencode/plugin/promise/tool", promiseTool],
|
||||
["@opencode/plugin/effect", effectIndex],
|
||||
["@opencode/plugin/effect/plugin", effectPlugin],
|
||||
["@opencode/plugin/effect/tool", effectTool],
|
||||
["@opencode/plugin/rpc", rpc],
|
||||
])
|
||||
|
||||
for (const [key, value] of Object.entries(effect)) {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
modules.set(`effect/${key}`, value as Record<string, unknown>)
|
||||
}
|
||||
}
|
||||
|
||||
state[modulesKey] = modules
|
||||
|
||||
const loadEffectSubpath = (specifier: string) => {
|
||||
const existing = modules.get(specifier)
|
||||
if (existing) return existing
|
||||
for (const base of [import.meta.url, pathToFileURL(path.join(process.cwd(), "package.json")).href]) {
|
||||
try {
|
||||
const loaded = createRequire(base)(specifier) as Record<string, unknown>
|
||||
if (typeof loaded === "object" && loaded !== null) {
|
||||
modules.set(specifier, loaded)
|
||||
return loaded
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (modules.has(specifier) || (specifier.startsWith("effect/") && loadEffectSubpath(specifier) !== undefined)) {
|
||||
return { url: `${PREFIX}${encodeURIComponent(specifier)}`, shortCircuit: true }
|
||||
}
|
||||
return nextResolve(specifier, context)
|
||||
},
|
||||
load(url, context, nextLoad) {
|
||||
if (url.startsWith(PREFIX)) {
|
||||
const specifier = decodeURIComponent(url.slice(PREFIX.length))
|
||||
const exports = modules.get(specifier)
|
||||
if (exports) {
|
||||
return { format: "module", source: createModuleSource(specifier, exports), shortCircuit: true }
|
||||
}
|
||||
}
|
||||
return nextLoad(url, context)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createModuleSource(specifier: string, exports: Record<string, unknown>) {
|
||||
const keys = Object.keys(exports).filter((key) => key !== "default" && /^[A-Za-z_$][\w$]*$/.test(key))
|
||||
const lines = [
|
||||
`const mod = globalThis[Symbol.for("opencode.plugin.runtime.modules")].get(${JSON.stringify(specifier)})`,
|
||||
...keys.map((key, index) => `const e${index} = mod[${JSON.stringify(key)}]`),
|
||||
...(keys.length > 0 ? [`export { ${keys.map((key, index) => `e${index} as ${key}`).join(", ")} }`] : []),
|
||||
]
|
||||
if ("default" in exports) lines.push("export default mod.default")
|
||||
return lines.join("\n")
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export function ensurePluginRuntime(_entrypoint?: string) {}
|
||||
@@ -3,12 +3,14 @@ import { readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { Host } from "./host.js"
|
||||
import { ensurePluginRuntime } from "./runtime.bun.js"
|
||||
import { localSource } from "./source.js"
|
||||
import { missingPackageTarget } from "./source.package.js"
|
||||
|
||||
let generation = Date.now()
|
||||
|
||||
export async function prepareSource(entrypoint: string, track: (file: string, directory?: boolean) => void) {
|
||||
ensurePluginRuntime(entrypoint)
|
||||
const root = fileURLToPath(entrypoint)
|
||||
const files = new Set<string>()
|
||||
const visit = (file: string, search = "") => {
|
||||
|
||||
@@ -4,10 +4,12 @@ import { fileURLToPath } from "node:url"
|
||||
import { localSource } from "./source.js"
|
||||
import { missingPackageTarget } from "./source.package.js"
|
||||
import { Host } from "./host.js"
|
||||
import { ensurePluginRuntime } from "./runtime.node.js"
|
||||
|
||||
let generation = Date.now()
|
||||
|
||||
export async function prepareSource(entrypoint: string, track: (file: string, directory?: boolean) => void) {
|
||||
await ensurePluginRuntime()
|
||||
const version = String(++generation)
|
||||
const fresh = (specifier: string) => {
|
||||
const url = new URL(specifier)
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { cp, 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"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Brand } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { Plugin, Tool } from "../src/effect/index.js"
|
||||
import { Rpc } from "../src/rpc.js"
|
||||
import { Host } from "../src/host.js"
|
||||
|
||||
// Every entrypoint throws if evaluated: resolution must never execute plugins.
|
||||
@@ -11,7 +16,7 @@ const source = 'throw new Error("Plugin code must not run during resolution")'
|
||||
const name = "@fixture/plugin"
|
||||
|
||||
async function fixture(files: Record<string, string>, 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]) => {
|
||||
@@ -154,3 +159,168 @@ describe("Host.resolve", () => {
|
||||
assert.deepEqual(Host.resolve(plugin.target), { server: undefined, tui: undefined, rpc: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
describe("Host.load", () => {
|
||||
it("resolves effect, effect/* subpaths, and @opencode/plugin/* to the host across plugin node_modules", async () => {
|
||||
const root = await realpath(await mkdtemp(path.join(tmpdir(), "opencode-host-load-")))
|
||||
try {
|
||||
const hostEffectDir = path.dirname(Bun.resolveSync("effect/package.json", import.meta.dir))
|
||||
const pluginEffectDir = path.join(root, "node_modules/effect")
|
||||
await mkdir(path.join(root, "node_modules"), { recursive: true })
|
||||
await cp(hostEffectDir, pluginEffectDir, { recursive: true })
|
||||
const manifest = { ...(await Bun.file(path.join(pluginEffectDir, "package.json")).json()), version: "4.0.0-rc.111" }
|
||||
await writeFile(path.join(pluginEffectDir, "package.json"), JSON.stringify(manifest, null, 2))
|
||||
|
||||
const depDir = path.join(root, "node_modules/transitive-dep")
|
||||
await mkdir(depDir, { recursive: true })
|
||||
await writeFile(
|
||||
path.join(depDir, "package.json"),
|
||||
JSON.stringify({ name: "transitive-dep", type: "module", exports: { ".": "./index.js" } }),
|
||||
)
|
||||
await writeFile(
|
||||
path.join(depDir, "index.js"),
|
||||
`import { Effect as DepEffect, Schema as DepSchema } from "effect"
|
||||
import { some as depSome } from "effect/Option"
|
||||
export { DepEffect, DepSchema, depSome }`,
|
||||
)
|
||||
|
||||
const foreignPluginPkgDir = path.join(root, "node_modules/@opencode/plugin")
|
||||
await mkdir(foreignPluginPkgDir, { recursive: true })
|
||||
await writeFile(
|
||||
path.join(foreignPluginPkgDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@opencode/plugin",
|
||||
type: "module",
|
||||
exports: { "./effect": "./effect.js", "./rpc": "./rpc.js" },
|
||||
}),
|
||||
)
|
||||
await writeFile(
|
||||
path.join(foreignPluginPkgDir, "effect.js"),
|
||||
"export const Plugin = { define: (p) => p }; export const Tool = {}",
|
||||
)
|
||||
await writeFile(path.join(foreignPluginPkgDir, "rpc.js"), "export const Rpc = { define: (d) => d }")
|
||||
|
||||
await writeFile(
|
||||
path.join(root, "helper.ts"),
|
||||
`import { nominal } from "effect/Brand"
|
||||
import { HttpClient as HelperHttpClient } from "effect/unstable/http"
|
||||
import { DepEffect, DepSchema, depSome } from "transitive-dep"
|
||||
export { nominal, HelperHttpClient, DepEffect, DepSchema, depSome }`,
|
||||
)
|
||||
|
||||
const entry = path.join(root, "index.ts")
|
||||
await writeFile(
|
||||
entry,
|
||||
`import { Effect as PluginEffect, Schema as PluginSchema } from "effect"
|
||||
import { some as pluginSome } from "effect/Option"
|
||||
import { Plugin as PluginDef, Tool as PluginTool } from "@opencode/plugin/effect"
|
||||
import { Rpc as PluginRpc } from "@opencode/plugin/rpc"
|
||||
import { nominal, HelperHttpClient, DepEffect, DepSchema, depSome } from "./helper"
|
||||
export { PluginEffect, PluginSchema, pluginSome, PluginDef, PluginTool, PluginRpc, nominal, HelperHttpClient, DepEffect, DepSchema, depSome }`,
|
||||
)
|
||||
|
||||
const loaded = (await Host.load(pathToFileURL(entry).href)) as Record<string, unknown>
|
||||
assert.equal(loaded.PluginEffect, Effect)
|
||||
assert.equal(loaded.PluginSchema, Schema)
|
||||
assert.equal(loaded.pluginSome, Option.some)
|
||||
assert.equal(loaded.nominal, Brand.nominal)
|
||||
assert.equal(loaded.HelperHttpClient, HttpClient)
|
||||
assert.equal(loaded.PluginDef, Plugin)
|
||||
assert.equal(loaded.PluginTool, Tool)
|
||||
assert.equal(loaded.PluginRpc, Rpc)
|
||||
assert.equal(loaded.DepEffect, Effect)
|
||||
assert.equal(loaded.DepSchema, Schema)
|
||||
assert.equal(loaded.depSome, Option.some)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("resolves effect, effect/* subpaths, and @opencode/plugin/effect to the host under Node", async () => {
|
||||
const root = await realpath(await mkdtemp(path.join(tmpdir(), "opencode-host-node-")))
|
||||
try {
|
||||
const pluginDir = path.join(root, "plugin")
|
||||
await mkdir(path.join(pluginDir, "node_modules/effect"), { recursive: true })
|
||||
await writeFile(
|
||||
path.join(pluginDir, "node_modules/effect/package.json"),
|
||||
JSON.stringify({
|
||||
name: "effect",
|
||||
version: "4.0.0-rc.111",
|
||||
type: "module",
|
||||
exports: { ".": "./index.js", "./Option": "./Option.js" },
|
||||
}),
|
||||
)
|
||||
await writeFile(path.join(pluginDir, "node_modules/effect/index.js"), "export const Effect = { foreign: true }")
|
||||
await writeFile(path.join(pluginDir, "node_modules/effect/Option.js"), "export const some = () => null")
|
||||
|
||||
await mkdir(path.join(pluginDir, "node_modules/transitive-dep"), { recursive: true })
|
||||
await writeFile(
|
||||
path.join(pluginDir, "node_modules/transitive-dep/package.json"),
|
||||
JSON.stringify({ name: "transitive-dep", type: "module", exports: { ".": "./index.js" } }),
|
||||
)
|
||||
await writeFile(
|
||||
path.join(pluginDir, "node_modules/transitive-dep/index.js"),
|
||||
'import { Effect as DepEffect } from "effect"; import { some as depSome } from "effect/Option"; export { DepEffect, depSome }',
|
||||
)
|
||||
|
||||
await mkdir(path.join(pluginDir, "node_modules/@opencode/plugin"), { recursive: true })
|
||||
await writeFile(
|
||||
path.join(pluginDir, "node_modules/@opencode/plugin/package.json"),
|
||||
JSON.stringify({ name: "@opencode/plugin", type: "module", exports: { "./effect": "./effect.js" } }),
|
||||
)
|
||||
await writeFile(
|
||||
path.join(pluginDir, "node_modules/@opencode/plugin/effect.js"),
|
||||
"export const Plugin = { define: (p) => p }",
|
||||
)
|
||||
|
||||
const entry = path.join(pluginDir, "index.mjs")
|
||||
await writeFile(
|
||||
entry,
|
||||
`import { Effect as PluginEffect, Schema as PluginSchema } from "effect"
|
||||
import { some as pluginSome } from "effect/Option"
|
||||
import { Plugin as PluginDef } from "@opencode/plugin/effect"
|
||||
import { DepEffect, depSome } from "transitive-dep"
|
||||
export { PluginEffect, PluginSchema, pluginSome, PluginDef, DepEffect, depSome }`,
|
||||
)
|
||||
|
||||
const script = path.join(root, "probe.ts")
|
||||
await writeFile(
|
||||
script,
|
||||
`import assert from "node:assert/strict"
|
||||
import { Effect, Option, Schema } from ${JSON.stringify(Bun.resolveSync("effect", import.meta.dir))}
|
||||
import { Plugin } from ${JSON.stringify(path.join(import.meta.dir, "../src/effect/index.ts"))}
|
||||
import { Host } from ${JSON.stringify(path.join(import.meta.dir, "../src/host.ts"))}
|
||||
const loaded = await Host.load(${JSON.stringify(pathToFileURL(entry).href)}) as Record<string, unknown>
|
||||
assert.equal(loaded.PluginEffect, Effect)
|
||||
assert.equal(loaded.PluginSchema, Schema)
|
||||
assert.equal(loaded.pluginSome, Option.some)
|
||||
assert.equal(loaded.PluginDef, Plugin)
|
||||
assert.equal(loaded.DepEffect, Effect)
|
||||
assert.equal(loaded.depSome, Option.some)
|
||||
console.log("node host load passed")`,
|
||||
)
|
||||
|
||||
const build = await Bun.build({
|
||||
entrypoints: [script],
|
||||
target: "node",
|
||||
format: "esm",
|
||||
outdir: root,
|
||||
naming: "probe.mjs",
|
||||
})
|
||||
assert.equal(build.success, true)
|
||||
const child = Bun.spawn(["node", "--no-warnings", path.join(root, "probe.mjs")], {
|
||||
cwd: path.join(import.meta.dir, ".."),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, stderr, exit] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
child.exited,
|
||||
])
|
||||
assert.deepEqual({ stdout, stderr, exit }, { stdout: "node host load passed\n", stderr: "", exit: 0 })
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ensurePluginRuntime, pluginRuntimeModules } from "@opencode/plugin/runtime.bun"
|
||||
import { Plugin, PluginContextProvider, usePlugin } from "@opencode/plugin/tui"
|
||||
import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
|
||||
|
||||
ensureRuntimePluginSupport({
|
||||
additional: {
|
||||
...pluginRuntimeModules(),
|
||||
"@opencode/plugin/tui": { Plugin, PluginContextProvider, usePlugin },
|
||||
},
|
||||
})
|
||||
ensurePluginRuntime()
|
||||
@@ -7,6 +7,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 +139,42 @@ test("shared runtime and ordinary package identities survive plugin generations"
|
||||
}
|
||||
})
|
||||
|
||||
test("TUI plugins and their dependencies resolve effect and effect/* to the host copy", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(
|
||||
new URL("node_modules/effect/package.json", sources.url),
|
||||
'{"name":"effect","type":"module","exports":{".":"./index.js","./Option":"./Option.js"}}',
|
||||
)
|
||||
await Bun.write(
|
||||
new URL("node_modules/effect/index.js", sources.url),
|
||||
"export const Effect = { foreign: true }; export const Schema = { foreign: true }",
|
||||
)
|
||||
await Bun.write(new URL("node_modules/effect/Option.js", sources.url), "export const some = () => null")
|
||||
await Bun.write(
|
||||
new URL("node_modules/effect-helper/package.json", sources.url),
|
||||
'{"name":"effect-helper","type":"module","exports":{".":"./index.js"}}',
|
||||
)
|
||||
await Bun.write(
|
||||
new URL("node_modules/effect-helper/index.js", sources.url),
|
||||
'import { Effect as HelperEffect, Schema as HelperSchema } from "effect"; import { some as helperSome } from "effect/Option"; export { HelperEffect, HelperSchema, helperSome }',
|
||||
)
|
||||
await Bun.write(
|
||||
entry,
|
||||
`import { Effect as PluginEffect, Schema as PluginSchema } from "effect"
|
||||
import { some as pluginSome } from "effect/Option"
|
||||
import { HelperEffect, HelperSchema, helperSome } from "effect-helper"
|
||||
export { PluginEffect, PluginSchema, pluginSome, HelperEffect, HelperSchema, helperSome }`,
|
||||
)
|
||||
const loaded = (await sources.read(entry.href)).module as Record<string, unknown>
|
||||
expect(loaded.PluginEffect).toBe(Effect)
|
||||
expect(loaded.PluginSchema).toBe(Schema)
|
||||
expect(loaded.pluginSome).toBe(Option.some)
|
||||
expect(loaded.HelperEffect).toBe(Effect)
|
||||
expect(loaded.HelperSchema).toBe(Schema)
|
||||
expect(loaded.helperSome).toBe(Option.some)
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
@@ -3,11 +3,13 @@ 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 `effect/*` subpaths) and `@opencode/plugin` at runtime when loading plugins.
|
||||
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 +1502,7 @@ type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Regi
|
||||
## Publish
|
||||
|
||||
A package plugin uses the same default export as a local Effect plugin. Export the Effect implementation from the main
|
||||
entrypoint and declare both runtime dependencies.
|
||||
entrypoint and declare `effect` as a `peerDependency`.
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
@@ -1511,12 +1513,23 @@ entrypoint and declare both runtime dependencies.
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "latest",
|
||||
"effect": "4.0.0-rc.111"
|
||||
"@opencode/plugin": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": ">=4.0.0-rc.112"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OpenCode redirects imports of `effect`, `effect/*` subpaths, and `@opencode/plugin` (including 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; OpenCode rejects Effect plugins whose returned `Effect` comes from a bundled copy.
|
||||
- Because the plugin always runs against the host OpenCode release's `effect` instance, use `effect` APIs and module
|
||||
paths compatible with the OpenCode release you target.
|
||||
|
||||
The package entrypoint exports `Plugin.define` with an `effect` function.
|
||||
|
||||
```ts title="src/index.ts"
|
||||
|
||||
Reference in new issue
Block a user