Refactor into plugin loader and do not enforce (#20112)
This commit is contained in:
@@ -14,19 +14,8 @@ import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { Installation } from "@/installation"
|
||||
import {
|
||||
checkPluginCompatibility,
|
||||
isDeprecatedPlugin,
|
||||
parsePluginSpecifier,
|
||||
pluginSource,
|
||||
readPluginId,
|
||||
readV1Plugin,
|
||||
resolvePluginEntrypoint,
|
||||
resolvePluginId,
|
||||
resolvePluginTarget,
|
||||
type PluginSource,
|
||||
} from "./shared"
|
||||
import { PluginLoader } from "./loader"
|
||||
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
|
||||
|
||||
export namespace Plugin {
|
||||
const log = Log.create({ service: "plugin" })
|
||||
@@ -36,11 +25,7 @@ export namespace Plugin {
|
||||
}
|
||||
|
||||
type Loaded = {
|
||||
item: Config.PluginSpec
|
||||
spec: string
|
||||
target: string
|
||||
source: PluginSource
|
||||
mod: Record<string, unknown>
|
||||
row: PluginLoader.Loaded
|
||||
}
|
||||
|
||||
// Hook names that follow the (input, output) => Promise<void> trigger pattern
|
||||
@@ -93,91 +78,22 @@ export namespace Plugin {
|
||||
return result
|
||||
}
|
||||
|
||||
async function resolvePlugin(spec: string) {
|
||||
const parsed = parsePluginSpecifier(spec)
|
||||
const target = await resolvePluginTarget(spec, parsed).catch((err) => {
|
||||
const cause = err instanceof Error ? err.cause : err
|
||||
const detail = errorMessage(cause ?? err)
|
||||
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${detail}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return ""
|
||||
})
|
||||
if (!target) return
|
||||
return target
|
||||
}
|
||||
|
||||
async function prepPlugin(item: Config.PluginSpec): Promise<Loaded | undefined> {
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
if (isDeprecatedPlugin(spec)) return
|
||||
log.info("loading plugin", { path: spec })
|
||||
const resolved = await resolvePlugin(spec)
|
||||
if (!resolved) return
|
||||
|
||||
const source = pluginSource(spec)
|
||||
if (source === "npm") {
|
||||
const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
|
||||
.then(() => false)
|
||||
.catch((err) => {
|
||||
const message = errorMessage(err)
|
||||
log.warn("plugin incompatible", { path: spec, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Plugin ${spec} skipped: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return true
|
||||
})
|
||||
if (incompatible) return
|
||||
}
|
||||
|
||||
const target = resolved
|
||||
const entry = await resolvePluginEntrypoint(spec, target, "server").catch((err) => {
|
||||
const message = errorMessage(err)
|
||||
log.error("failed to resolve plugin server entry", { path: spec, target, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${spec}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return
|
||||
})
|
||||
if (!entry) return
|
||||
|
||||
const mod = await import(entry).catch((err) => {
|
||||
const message = errorMessage(err)
|
||||
log.error("failed to load plugin", { path: spec, target: entry, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${spec}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return
|
||||
})
|
||||
if (!mod) return
|
||||
|
||||
return {
|
||||
item,
|
||||
spec,
|
||||
target,
|
||||
source,
|
||||
mod,
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
|
||||
const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
|
||||
const plugin = readV1Plugin(load.row.mod, load.row.spec, "server", "detect")
|
||||
if (plugin) {
|
||||
await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec))
|
||||
hooks.push(await (plugin as PluginModule).server(input, Config.pluginOptions(load.item)))
|
||||
await resolvePluginId(
|
||||
load.row.source,
|
||||
load.row.spec,
|
||||
load.row.target,
|
||||
readPluginId(plugin.id, load.row.spec),
|
||||
load.row.pkg,
|
||||
)
|
||||
hooks.push(await (plugin as PluginModule).server(input, load.row.options))
|
||||
return
|
||||
}
|
||||
|
||||
for (const server of getLegacyPlugins(load.mod)) {
|
||||
hooks.push(await server(input, Config.pluginOptions(load.item)))
|
||||
for (const server of getLegacyPlugins(load.row.mod)) {
|
||||
hooks.push(await server(input, load.row.options))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +148,74 @@ export namespace Plugin {
|
||||
}
|
||||
if (plugins.length) yield* config.waitForDependencies()
|
||||
|
||||
const loaded = yield* Effect.promise(() => Promise.all(plugins.map((item) => prepPlugin(item))))
|
||||
const loaded = yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
plugins.map(async (item) => {
|
||||
const plan = PluginLoader.plan(item)
|
||||
if (plan.deprecated) return
|
||||
log.info("loading plugin", { path: plan.spec })
|
||||
|
||||
const resolved = await PluginLoader.resolve(plan, "server")
|
||||
if (!resolved.ok) {
|
||||
const cause =
|
||||
resolved.error instanceof Error ? (resolved.error.cause ?? resolved.error) : resolved.error
|
||||
const message = errorMessage(cause)
|
||||
|
||||
if (resolved.stage === "install") {
|
||||
const parsed = parsePluginSpecifier(plan.spec)
|
||||
log.error("failed to install plugin", {
|
||||
pkg: parsed.pkg,
|
||||
version: parsed.version,
|
||||
error: message,
|
||||
})
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (resolved.stage === "compatibility") {
|
||||
log.warn("plugin incompatible", { path: plan.spec, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Plugin ${plan.spec} skipped: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.error("failed to resolve plugin server entry", {
|
||||
path: plan.spec,
|
||||
error: message,
|
||||
})
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${plan.spec}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const mod = await PluginLoader.load(resolved.value)
|
||||
if (!mod.ok) {
|
||||
const message = errorMessage(mod.error)
|
||||
log.error("failed to load plugin", { path: plan.spec, target: resolved.value.entry, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${plan.spec}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
row: mod.value,
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
for (const load of loaded) {
|
||||
if (!load) continue
|
||||
|
||||
@@ -242,14 +225,14 @@ export namespace Plugin {
|
||||
try: () => applyPlugin(load, input, hooks),
|
||||
catch: (err) => {
|
||||
const message = errorMessage(err)
|
||||
log.error("failed to load plugin", { path: load.spec, error: message })
|
||||
log.error("failed to load plugin", { path: load.row.spec, error: message })
|
||||
return message
|
||||
},
|
||||
}).pipe(
|
||||
Effect.catch((message) =>
|
||||
bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${load.spec}: ${message}`,
|
||||
message: `Failed to load plugin ${load.row.spec}: ${message}`,
|
||||
}).toObject(),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Config } from "@/config/config"
|
||||
import { Installation } from "@/installation"
|
||||
import {
|
||||
checkPluginCompatibility,
|
||||
createPluginEntry,
|
||||
isDeprecatedPlugin,
|
||||
resolvePluginTarget,
|
||||
type PluginKind,
|
||||
type PluginPackage,
|
||||
type PluginSource,
|
||||
} from "./shared"
|
||||
|
||||
export namespace PluginLoader {
|
||||
export type Plan = {
|
||||
item: Config.PluginSpec
|
||||
spec: string
|
||||
options: Config.PluginOptions | undefined
|
||||
deprecated: boolean
|
||||
}
|
||||
|
||||
export type Resolved = Plan & {
|
||||
source: PluginSource
|
||||
target: string
|
||||
entry: string
|
||||
pkg?: PluginPackage
|
||||
}
|
||||
|
||||
export type Loaded = Resolved & {
|
||||
mod: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function plan(item: Config.PluginSpec): Plan {
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
return {
|
||||
item,
|
||||
spec,
|
||||
options: Config.pluginOptions(item),
|
||||
deprecated: isDeprecatedPlugin(spec),
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolve(
|
||||
plan: Plan,
|
||||
kind: PluginKind,
|
||||
): Promise<
|
||||
{ ok: true; value: Resolved } | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
|
||||
> {
|
||||
let target = ""
|
||||
try {
|
||||
target = await resolvePluginTarget(plan.spec)
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: "install",
|
||||
error,
|
||||
}
|
||||
}
|
||||
if (!target) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: "install",
|
||||
error: new Error(`Plugin ${plan.spec} target is empty`),
|
||||
}
|
||||
}
|
||||
|
||||
let base
|
||||
try {
|
||||
base = await createPluginEntry(plan.spec, target, kind)
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: "entry",
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
if (!base.entry) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: "entry",
|
||||
error: new Error(`Plugin ${plan.spec} entry is empty`),
|
||||
}
|
||||
}
|
||||
|
||||
if (base.source === "npm") {
|
||||
try {
|
||||
await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg)
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: "compatibility",
|
||||
error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
...plan,
|
||||
source: base.source,
|
||||
target: base.target,
|
||||
entry: base.entry,
|
||||
pkg: base.pkg,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
|
||||
let mod
|
||||
try {
|
||||
mod = await import(row.entry)
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
if (!mod) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`Plugin ${row.spec} module is empty`),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
...row,
|
||||
mod,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,13 +23,25 @@ export type PluginSource = "file" | "npm"
|
||||
export type PluginKind = "server" | "tui"
|
||||
type PluginMode = "strict" | "detect"
|
||||
|
||||
export function pluginSource(spec: string): PluginSource {
|
||||
return spec.startsWith("file://") ? "file" : "npm"
|
||||
export type PluginPackage = {
|
||||
dir: string
|
||||
pkg: string
|
||||
json: Record<string, unknown>
|
||||
}
|
||||
|
||||
function hasEntrypoint(json: Record<string, unknown>, kind: PluginKind) {
|
||||
if (!isRecord(json.exports)) return false
|
||||
return `./${kind}` in json.exports
|
||||
export type PluginEntry = {
|
||||
spec: string
|
||||
source: PluginSource
|
||||
target: string
|
||||
pkg?: PluginPackage
|
||||
entry: string
|
||||
}
|
||||
|
||||
const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"]
|
||||
|
||||
export function pluginSource(spec: string): PluginSource {
|
||||
if (isPathPluginSpec(spec)) return "file"
|
||||
return "npm"
|
||||
}
|
||||
|
||||
function resolveExportPath(raw: string, dir: string) {
|
||||
@@ -48,26 +60,97 @@ function extractExportValue(value: unknown): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind) {
|
||||
const pkg = await readPluginPackage(target).catch(() => undefined)
|
||||
if (!pkg) return target
|
||||
if (!hasEntrypoint(pkg.json, kind)) return target
|
||||
|
||||
const exports = pkg.json.exports
|
||||
if (!isRecord(exports)) return target
|
||||
const raw = extractExportValue(exports[`./${kind}`])
|
||||
if (!raw) return target
|
||||
function packageMain(pkg: PluginPackage) {
|
||||
const value = pkg.json.main
|
||||
if (typeof value !== "string") return
|
||||
const next = value.trim()
|
||||
if (!next) return
|
||||
return next
|
||||
}
|
||||
|
||||
function resolvePackagePath(spec: string, raw: string, kind: PluginKind, pkg: PluginPackage) {
|
||||
const resolved = resolveExportPath(raw, pkg.dir)
|
||||
const root = Filesystem.resolve(pkg.dir)
|
||||
const next = Filesystem.resolve(resolved)
|
||||
if (!Filesystem.contains(root, next)) {
|
||||
throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`)
|
||||
}
|
||||
|
||||
return pathToFileURL(next).href
|
||||
}
|
||||
|
||||
function resolvePackageEntrypoint(spec: string, kind: PluginKind, pkg: PluginPackage) {
|
||||
const exports = pkg.json.exports
|
||||
if (isRecord(exports)) {
|
||||
const raw = extractExportValue(exports[`./${kind}`])
|
||||
if (raw) return resolvePackagePath(spec, raw, kind, pkg)
|
||||
}
|
||||
|
||||
if (kind !== "server") return
|
||||
const main = packageMain(pkg)
|
||||
if (!main) return
|
||||
return resolvePackagePath(spec, main, kind, pkg)
|
||||
}
|
||||
|
||||
function targetPath(target: string) {
|
||||
if (target.startsWith("file://")) return fileURLToPath(target)
|
||||
if (path.isAbsolute(target) || /^[A-Za-z]:[\\/]/.test(target)) return target
|
||||
}
|
||||
|
||||
async function resolveDirectoryIndex(dir: string) {
|
||||
for (const name of INDEX_FILES) {
|
||||
const file = path.join(dir, name)
|
||||
if (await Filesystem.exists(file)) return file
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTargetDirectory(target: string) {
|
||||
const file = targetPath(target)
|
||||
if (!file) return
|
||||
const stat = await Filesystem.stat(file)
|
||||
if (!stat?.isDirectory()) return
|
||||
return file
|
||||
}
|
||||
|
||||
async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind, pkg?: PluginPackage) {
|
||||
const source = pluginSource(spec)
|
||||
const hit =
|
||||
pkg ?? (source === "npm" ? await readPluginPackage(target) : await readPluginPackage(target).catch(() => undefined))
|
||||
if (!hit) return target
|
||||
|
||||
const entry = resolvePackageEntrypoint(spec, kind, hit)
|
||||
if (entry) return entry
|
||||
|
||||
const dir = await resolveTargetDirectory(target)
|
||||
|
||||
if (kind === "tui") {
|
||||
if (source === "file" && dir) {
|
||||
const index = await resolveDirectoryIndex(dir)
|
||||
if (index) return pathToFileURL(index).href
|
||||
}
|
||||
|
||||
if (source === "npm") {
|
||||
throw new TypeError(`Plugin ${spec} must define package.json exports["./tui"]`)
|
||||
}
|
||||
|
||||
if (dir) {
|
||||
throw new TypeError(`Plugin ${spec} must define package.json exports["./tui"] or include index file`)
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
if (dir && isRecord(hit.json.exports)) {
|
||||
if (source === "file") {
|
||||
const index = await resolveDirectoryIndex(dir)
|
||||
if (index) return pathToFileURL(index).href
|
||||
}
|
||||
|
||||
throw new TypeError(`Plugin ${spec} must define package.json exports["./server"] or package.json main`)
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
export function isPathPluginSpec(spec: string) {
|
||||
return spec.startsWith("file://") || spec.startsWith(".") || path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)
|
||||
}
|
||||
@@ -81,19 +164,21 @@ export async function resolvePathPluginTarget(spec: string) {
|
||||
return pathToFileURL(file).href
|
||||
}
|
||||
|
||||
const pkg = await Filesystem.readJson<Record<string, unknown>>(path.join(file, "package.json")).catch(() => undefined)
|
||||
if (!pkg) throw new Error(`Plugin directory ${file} is missing package.json`)
|
||||
if (typeof pkg.main !== "string" || !pkg.main.trim()) {
|
||||
throw new Error(`Plugin directory ${file} must define package.json main`)
|
||||
if (await Filesystem.exists(path.join(file, "package.json"))) {
|
||||
return pathToFileURL(file).href
|
||||
}
|
||||
return pathToFileURL(path.resolve(file, pkg.main)).href
|
||||
|
||||
const index = await resolveDirectoryIndex(file)
|
||||
if (index) return pathToFileURL(index).href
|
||||
|
||||
throw new Error(`Plugin directory ${file} is missing package.json or index file`)
|
||||
}
|
||||
|
||||
export async function checkPluginCompatibility(target: string, opencodeVersion: string) {
|
||||
export async function checkPluginCompatibility(target: string, opencodeVersion: string, pkg?: PluginPackage) {
|
||||
if (!semver.valid(opencodeVersion) || semver.major(opencodeVersion) === 0) return
|
||||
const pkg = await readPluginPackage(target).catch(() => undefined)
|
||||
if (!pkg) return
|
||||
const engines = pkg.json.engines
|
||||
const hit = pkg ?? (await readPluginPackage(target).catch(() => undefined))
|
||||
if (!hit) return
|
||||
const engines = hit.json.engines
|
||||
if (!isRecord(engines)) return
|
||||
const range = engines.opencode
|
||||
if (typeof range !== "string") return
|
||||
@@ -107,7 +192,7 @@ export async function resolvePluginTarget(spec: string, parsed = parsePluginSpec
|
||||
return BunProc.install(parsed.pkg, parsed.version)
|
||||
}
|
||||
|
||||
export async function readPluginPackage(target: string) {
|
||||
export async function readPluginPackage(target: string): Promise<PluginPackage> {
|
||||
const file = target.startsWith("file://") ? fileURLToPath(target) : target
|
||||
const stat = await Filesystem.stat(file)
|
||||
const dir = stat?.isDirectory() ? file : path.dirname(file)
|
||||
@@ -116,6 +201,20 @@ export async function readPluginPackage(target: string) {
|
||||
return { dir, pkg, json }
|
||||
}
|
||||
|
||||
export async function createPluginEntry(spec: string, target: string, kind: PluginKind): Promise<PluginEntry> {
|
||||
const source = pluginSource(spec)
|
||||
const pkg =
|
||||
source === "npm" ? await readPluginPackage(target) : await readPluginPackage(target).catch(() => undefined)
|
||||
const entry = await resolvePluginEntrypoint(spec, target, kind, pkg)
|
||||
return {
|
||||
spec,
|
||||
source,
|
||||
target,
|
||||
pkg,
|
||||
entry,
|
||||
}
|
||||
}
|
||||
|
||||
export function readPluginId(id: unknown, spec: string) {
|
||||
if (id === undefined) return
|
||||
if (typeof id !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof id}`)
|
||||
@@ -158,15 +257,21 @@ export function readV1Plugin(
|
||||
return value
|
||||
}
|
||||
|
||||
export async function resolvePluginId(source: PluginSource, spec: string, target: string, id: string | undefined) {
|
||||
export async function resolvePluginId(
|
||||
source: PluginSource,
|
||||
spec: string,
|
||||
target: string,
|
||||
id: string | undefined,
|
||||
pkg?: PluginPackage,
|
||||
) {
|
||||
if (source === "file") {
|
||||
if (id) return id
|
||||
throw new TypeError(`Path plugin ${spec} must export id`)
|
||||
}
|
||||
if (id) return id
|
||||
const pkg = await readPluginPackage(target)
|
||||
if (typeof pkg.json.name !== "string" || !pkg.json.name.trim()) {
|
||||
throw new TypeError(`Plugin package ${pkg.pkg} is missing name`)
|
||||
const hit = pkg ?? (await readPluginPackage(target))
|
||||
if (typeof hit.json.name !== "string" || !hit.json.name.trim()) {
|
||||
throw new TypeError(`Plugin package ${hit.pkg} is missing name`)
|
||||
}
|
||||
return pkg.json.name.trim()
|
||||
return hit.json.name.trim()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user