Skip to content

Commit 2638e2a

Browse files
authored
refactor: collapse plugin barrel into plugin/index.ts (#22914)
1 parent 49bbea5 commit 2638e2a

3 files changed

Lines changed: 290 additions & 289 deletions

File tree

Lines changed: 289 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,289 @@
1-
export * as Plugin from "./plugin"
1+
import type {
2+
Hooks,
3+
PluginInput,
4+
Plugin as PluginInstance,
5+
PluginModule,
6+
WorkspaceAdaptor as PluginWorkspaceAdaptor,
7+
} from "@opencode-ai/plugin"
8+
import { Config } from "../config"
9+
import { Bus } from "../bus"
10+
import { Log } from "../util"
11+
import { createOpencodeClient } from "@opencode-ai/sdk"
12+
import { Flag } from "../flag/flag"
13+
import { CodexAuthPlugin } from "./codex"
14+
import { Session } from "../session"
15+
import { NamedError } from "@opencode-ai/shared/util/error"
16+
import { CopilotAuthPlugin } from "./github-copilot/copilot"
17+
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
18+
import { PoeAuthPlugin } from "opencode-poe-auth"
19+
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
20+
import { Effect, Layer, Context, Stream } from "effect"
21+
import { EffectBridge } from "@/effect"
22+
import { InstanceState } from "@/effect"
23+
import { errorMessage } from "@/util/error"
24+
import { PluginLoader } from "./loader"
25+
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
26+
import { registerAdaptor } from "@/control-plane/adaptors"
27+
import type { WorkspaceAdaptor } from "@/control-plane/types"
28+
29+
const log = Log.create({ service: "plugin" })
30+
31+
type State = {
32+
hooks: Hooks[]
33+
}
34+
35+
// Hook names that follow the (input, output) => Promise<void> trigger pattern
36+
type TriggerName = {
37+
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
38+
}[keyof Hooks]
39+
40+
export interface Interface {
41+
readonly trigger: <
42+
Name extends TriggerName,
43+
Input = Parameters<Required<Hooks>[Name]>[0],
44+
Output = Parameters<Required<Hooks>[Name]>[1],
45+
>(
46+
name: Name,
47+
input: Input,
48+
output: Output,
49+
) => Effect.Effect<Output>
50+
readonly list: () => Effect.Effect<Hooks[]>
51+
readonly init: () => Effect.Effect<void>
52+
}
53+
54+
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
55+
56+
// Built-in plugins that are directly imported (not installed from npm)
57+
const INTERNAL_PLUGINS: PluginInstance[] = [
58+
CodexAuthPlugin,
59+
CopilotAuthPlugin,
60+
GitlabAuthPlugin,
61+
PoeAuthPlugin,
62+
CloudflareWorkersAuthPlugin,
63+
CloudflareAIGatewayAuthPlugin,
64+
]
65+
66+
function isServerPlugin(value: unknown): value is PluginInstance {
67+
return typeof value === "function"
68+
}
69+
70+
function getServerPlugin(value: unknown) {
71+
if (isServerPlugin(value)) return value
72+
if (!value || typeof value !== "object" || !("server" in value)) return
73+
if (!isServerPlugin(value.server)) return
74+
return value.server
75+
}
76+
77+
function getLegacyPlugins(mod: Record<string, unknown>) {
78+
const seen = new Set<unknown>()
79+
const result: PluginInstance[] = []
80+
81+
for (const entry of Object.values(mod)) {
82+
if (seen.has(entry)) continue
83+
seen.add(entry)
84+
const plugin = getServerPlugin(entry)
85+
if (!plugin) throw new TypeError("Plugin export is not a function")
86+
result.push(plugin)
87+
}
88+
89+
return result
90+
}
91+
92+
async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) {
93+
const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
94+
if (plugin) {
95+
await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg)
96+
hooks.push(await (plugin as PluginModule).server(input, load.options))
97+
return
98+
}
99+
100+
for (const server of getLegacyPlugins(load.mod)) {
101+
hooks.push(await server(input, load.options))
102+
}
103+
}
104+
105+
export const layer = Layer.effect(
106+
Service,
107+
Effect.gen(function* () {
108+
const bus = yield* Bus.Service
109+
const config = yield* Config.Service
110+
111+
const state = yield* InstanceState.make<State>(
112+
Effect.fn("Plugin.state")(function* (ctx) {
113+
const hooks: Hooks[] = []
114+
const bridge = yield* EffectBridge.make()
115+
116+
function publishPluginError(message: string) {
117+
bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }))
118+
}
119+
120+
const { Server } = yield* Effect.promise(() => import("../server/server"))
121+
122+
const client = createOpencodeClient({
123+
baseUrl: "http://localhost:4096",
124+
directory: ctx.directory,
125+
headers: Flag.OPENCODE_SERVER_PASSWORD
126+
? {
127+
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
128+
}
129+
: undefined,
130+
fetch: async (...args) => (await Server.Default()).app.fetch(...args),
131+
})
132+
const cfg = yield* config.get()
133+
const input: PluginInput = {
134+
client,
135+
project: ctx.project,
136+
worktree: ctx.worktree,
137+
directory: ctx.directory,
138+
experimental_workspace: {
139+
register(type: string, adaptor: PluginWorkspaceAdaptor) {
140+
registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor)
141+
},
142+
},
143+
get serverUrl(): URL {
144+
return Server.url ?? new URL("http://localhost:4096")
145+
},
146+
// @ts-expect-error
147+
$: typeof Bun === "undefined" ? undefined : Bun.$,
148+
}
149+
150+
for (const plugin of INTERNAL_PLUGINS) {
151+
log.info("loading internal plugin", { name: plugin.name })
152+
const init = yield* Effect.tryPromise({
153+
try: () => plugin(input),
154+
catch: (err) => {
155+
log.error("failed to load internal plugin", { name: plugin.name, error: err })
156+
},
157+
}).pipe(Effect.option)
158+
if (init._tag === "Some") hooks.push(init.value)
159+
}
160+
161+
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin_origins ?? [])
162+
if (Flag.OPENCODE_PURE && cfg.plugin_origins?.length) {
163+
log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length })
164+
}
165+
if (plugins.length) yield* config.waitForDependencies()
166+
167+
const loaded = yield* Effect.promise(() =>
168+
PluginLoader.loadExternal({
169+
items: plugins,
170+
kind: "server",
171+
report: {
172+
start(candidate) {
173+
log.info("loading plugin", { path: candidate.plan.spec })
174+
},
175+
missing(candidate, _retry, message) {
176+
log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message })
177+
},
178+
error(candidate, _retry, stage, error, resolved) {
179+
const spec = candidate.plan.spec
180+
const cause = error instanceof Error ? (error.cause ?? error) : error
181+
const message = stage === "load" ? errorMessage(error) : errorMessage(cause)
182+
183+
if (stage === "install") {
184+
const parsed = parsePluginSpecifier(spec)
185+
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message })
186+
publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`)
187+
return
188+
}
189+
190+
if (stage === "compatibility") {
191+
log.warn("plugin incompatible", { path: spec, error: message })
192+
publishPluginError(`Plugin ${spec} skipped: ${message}`)
193+
return
194+
}
195+
196+
if (stage === "entry") {
197+
log.error("failed to resolve plugin server entry", { path: spec, error: message })
198+
publishPluginError(`Failed to load plugin ${spec}: ${message}`)
199+
return
200+
}
201+
202+
log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message })
203+
publishPluginError(`Failed to load plugin ${spec}: ${message}`)
204+
},
205+
},
206+
}),
207+
)
208+
for (const load of loaded) {
209+
if (!load) continue
210+
211+
// Keep plugin execution sequential so hook registration and execution
212+
// order remains deterministic across plugin runs.
213+
yield* Effect.tryPromise({
214+
try: () => applyPlugin(load, input, hooks),
215+
catch: (err) => {
216+
const message = errorMessage(err)
217+
log.error("failed to load plugin", { path: load.spec, error: message })
218+
return message
219+
},
220+
}).pipe(
221+
Effect.catch(() => {
222+
// TODO: make proper events for this
223+
// bus.publish(Session.Event.Error, {
224+
// error: new NamedError.Unknown({
225+
// message: `Failed to load plugin ${load.spec}: ${message}`,
226+
// }).toObject(),
227+
// })
228+
return Effect.void
229+
}),
230+
)
231+
}
232+
233+
// Notify plugins of current config
234+
for (const hook of hooks) {
235+
yield* Effect.tryPromise({
236+
try: () => Promise.resolve((hook as any).config?.(cfg)),
237+
catch: (err) => {
238+
log.error("plugin config hook failed", { error: err })
239+
},
240+
}).pipe(Effect.ignore)
241+
}
242+
243+
// Subscribe to bus events, fiber interrupted when scope closes
244+
yield* bus.subscribeAll().pipe(
245+
Stream.runForEach((input) =>
246+
Effect.sync(() => {
247+
for (const hook of hooks) {
248+
void hook["event"]?.({ event: input as any })
249+
}
250+
}),
251+
),
252+
Effect.forkScoped,
253+
)
254+
255+
return { hooks }
256+
}),
257+
)
258+
259+
const trigger = Effect.fn("Plugin.trigger")(function* <
260+
Name extends TriggerName,
261+
Input = Parameters<Required<Hooks>[Name]>[0],
262+
Output = Parameters<Required<Hooks>[Name]>[1],
263+
>(name: Name, input: Input, output: Output) {
264+
if (!name) return output
265+
const s = yield* InstanceState.get(state)
266+
for (const hook of s.hooks) {
267+
const fn = hook[name] as any
268+
if (!fn) continue
269+
yield* Effect.promise(async () => fn(input, output))
270+
}
271+
return output
272+
})
273+
274+
const list = Effect.fn("Plugin.list")(function* () {
275+
const s = yield* InstanceState.get(state)
276+
return s.hooks
277+
})
278+
279+
const init = Effect.fn("Plugin.init")(function* () {
280+
yield* InstanceState.get(state)
281+
})
282+
283+
return Service.of({ trigger, list, init })
284+
}),
285+
)
286+
287+
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
288+
289+
export * as Plugin from "."

0 commit comments

Comments
 (0)