diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts index 0245d311e0..f17673f36e 100644 --- a/packages/opencode/src/plugin/loader.ts +++ b/packages/opencode/src/plugin/loader.ts @@ -12,31 +12,41 @@ import { ConfigPlugin } from "@/config/plugin" import { InstallationVersion } from "@/installation/version" export namespace PluginLoader { + // A normalized plugin declaration derived from config before any filesystem or npm work happens. export type Plan = { spec: string options: ConfigPlugin.Options | undefined deprecated: boolean } + + // A plugin that has been resolved to a concrete target and entrypoint on disk. export type Resolved = Plan & { source: PluginSource target: string entry: string pkg?: PluginPackage } + + // A plugin target we could inspect, but which does not expose the requested kind of entrypoint. export type Missing = Plan & { source: PluginSource target: string pkg?: PluginPackage message: string } + + // A resolved plugin whose module has been imported successfully. export type Loaded = Resolved & { mod: Record } type Candidate = { origin: ConfigPlugin.Origin; plan: Plan } type Report = { + // Called before each attempt so callers can log initial load attempts and retries uniformly. start?: (candidate: Candidate, retry: boolean) => void + // Called when the package exists but does not provide the requested entrypoint. missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void + // Called for operational failures such as install, compatibility, or dynamic import errors. error?: ( candidate: Candidate, retry: boolean, @@ -46,19 +56,25 @@ export namespace PluginLoader { ) => void } + // Normalize a config item into the loader's internal representation. function plan(item: ConfigPlugin.Spec): Plan { const spec = ConfigPlugin.pluginSpecifier(item) return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) } } + // Resolve a configured plugin into a concrete entrypoint that can later be imported. + // + // The stages here intentionally separate install/target resolution, entrypoint detection, + // and compatibility checks so callers can report the exact reason a plugin was skipped. export async function resolve( plan: Plan, kind: PluginKind, ): Promise< | { ok: true; value: Resolved } - | { ok: false; stage: "missing"; value: Missing } - | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown } + | { ok: false; stage: "missing"; value: Missing } + | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown } > { + // First make sure the plugin exists locally, installing npm plugins on demand. let target = "" try { target = await resolvePluginTarget(plan.spec) @@ -67,6 +83,7 @@ export namespace PluginLoader { } if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) } + // Then inspect the target for the requested server/tui entrypoint. let base try { base = await createPluginEntry(plan.spec, target, kind) @@ -86,6 +103,8 @@ export namespace PluginLoader { }, } + // npm plugins can declare which opencode versions they support; file plugins are treated + // as local development code and skip this compatibility gate. if (base.source === "npm") { try { await checkPluginCompatibility(base.target, InstallationVersion, base.pkg) @@ -96,6 +115,7 @@ export namespace PluginLoader { return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } } } + // Import the resolved module only after all earlier validation has succeeded. export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> { let mod try { @@ -107,6 +127,8 @@ export namespace PluginLoader { return { ok: true, value: { ...row, mod } } } + // Run one candidate through the full pipeline: resolve, optionally surface a missing entry, + // import the module, and finally let the caller transform the loaded plugin into any result type. async function attempt( candidate: Candidate, kind: PluginKind, @@ -116,11 +138,17 @@ export namespace PluginLoader { report: Report | undefined, ): Promise { const plan = candidate.plan + + // Deprecated plugin packages are silently ignored because they are now built in. if (plan.deprecated) return + report?.start?.(candidate, retry) + const resolved = await resolve(plan, kind) if (!resolved.ok) { if (resolved.stage === "missing") { + // Missing entrypoints are handled separately so callers can still inspect package metadata, + // for example to load theme files from a tui plugin package that has no code entrypoint. if (missing) { const value = await missing(resolved.value, candidate.origin, retry) if (value !== undefined) return value @@ -131,11 +159,15 @@ export namespace PluginLoader { report?.error?.(candidate, retry, resolved.stage, resolved.error) return } + const loaded = await load(resolved.value) if (!loaded.ok) { report?.error?.(candidate, retry, "load", loaded.error, resolved.value) return } + + // The default behavior is to return the successfully loaded plugin as-is, but callers can + // provide a finisher to adapt the result into a more specific runtime shape. if (!finish) return loaded.value as R return finish(loaded.value, candidate.origin, retry) } @@ -149,6 +181,11 @@ export namespace PluginLoader { report?: Report } + // Resolve and load all configured plugins in parallel. + // + // If `wait` is provided, file-based plugins that initially failed are retried once after the + // caller finishes preparing dependencies. This supports local plugins that depend on an install + // step happening elsewhere before their entrypoint becomes loadable. export async function loadExternal(input: Input): Promise { const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) })) const list: Array> = [] @@ -160,6 +197,9 @@ export namespace PluginLoader { let deps: Promise | undefined for (let i = 0; i < candidates.length; i++) { if (out[i] !== undefined) continue + + // Only local file plugins are retried. npm plugins already attempted installation during + // the first pass, while file plugins may need the caller's dependency preparation to finish. const candidate = candidates[i] if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue deps ??= input.wait() @@ -167,6 +207,8 @@ export namespace PluginLoader { out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report) } } + + // Drop skipped/failed entries while preserving the successful result order. const ready: R[] = [] for (const item of out) if (item !== undefined) ready.push(item) return ready