From bce4ee95ed5581ce3403179ddd5514baa5fa8584 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Apr 2026 15:20:25 -0400 Subject: [PATCH] refactor: unwrap ConfigPlugin namespace to flat exports + self-reexport --- packages/opencode/src/config/plugin.ts | 134 ++++++++++++------------- 1 file changed, 67 insertions(+), 67 deletions(-) diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index 3a10c0a715a3..7d335bcc5355 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -4,81 +4,81 @@ import { pathToFileURL } from "url" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" import path from "path" -export namespace ConfigPlugin { - const Options = z.record(z.string(), z.unknown()) - export type Options = z.infer - - // Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options. - // It answers "what should we load?" but says nothing about where that value came from. - export const Spec = z.union([z.string(), z.tuple([z.string(), Options])]) - export type Spec = z.infer - - export type Scope = "global" | "local" - - // Origin keeps the original config provenance attached to a spec. - // After multiple config files are merged, callers still need to know which file declared the plugin - // and whether it should behave like a global or project-local plugin. - export type Origin = { - spec: Spec - source: string - scope: Scope - } +const Options = z.record(z.string(), z.unknown()) +export type Options = z.infer - export async function load(dir: string) { - const plugins: ConfigPlugin.Spec[] = [] - - for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", { - cwd: dir, - absolute: true, - dot: true, - symlink: true, - })) { - plugins.push(pathToFileURL(item).href) - } - return plugins - } +// Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options. +// It answers "what should we load?" but says nothing about where that value came from. +export const Spec = z.union([z.string(), z.tuple([z.string(), Options])]) +export type Spec = z.infer - export function pluginSpecifier(plugin: Spec): string { - return Array.isArray(plugin) ? plugin[0] : plugin - } +export type Scope = "global" | "local" + +// Origin keeps the original config provenance attached to a spec. +// After multiple config files are merged, callers still need to know which file declared the plugin +// and whether it should behave like a global or project-local plugin. +export type Origin = { + spec: Spec + source: string + scope: Scope +} + +export async function load(dir: string) { + const plugins: Spec[] = [] - export function pluginOptions(plugin: Spec): Options | undefined { - return Array.isArray(plugin) ? plugin[1] : undefined + for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", { + cwd: dir, + absolute: true, + dot: true, + symlink: true, + })) { + plugins.push(pathToFileURL(item).href) } + return plugins +} + +export function pluginSpecifier(plugin: Spec): string { + return Array.isArray(plugin) ? plugin[0] : plugin +} + +export function pluginOptions(plugin: Spec): Options | undefined { + return Array.isArray(plugin) ? plugin[1] : undefined +} - // Path-like specs are resolved relative to the config file that declared them so merges later on do not - // accidentally reinterpret `./plugin.ts` relative to some other directory. - export async function resolvePluginSpec(plugin: Spec, configFilepath: string): Promise { - const spec = pluginSpecifier(plugin) - if (!isPathPluginSpec(spec)) return plugin +// Path-like specs are resolved relative to the config file that declared them so merges later on do not +// accidentally reinterpret `./plugin.ts` relative to some other directory. +export async function resolvePluginSpec(plugin: Spec, configFilepath: string): Promise { + const spec = pluginSpecifier(plugin) + if (!isPathPluginSpec(spec)) return plugin - const base = path.dirname(configFilepath) - const file = (() => { - if (spec.startsWith("file://")) return spec - if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href - return pathToFileURL(path.resolve(base, spec)).href - })() + const base = path.dirname(configFilepath) + const file = (() => { + if (spec.startsWith("file://")) return spec + if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href + return pathToFileURL(path.resolve(base, spec)).href + })() - const resolved = await resolvePathPluginTarget(file).catch(() => file) + const resolved = await resolvePathPluginTarget(file).catch(() => file) - if (Array.isArray(plugin)) return [resolved, plugin[1]] - return resolved - } + if (Array.isArray(plugin)) return [resolved, plugin[1]] + return resolved +} + +// Dedupe on the load identity (package name for npm specs, exact file URL for local specs), but keep the +// full Origin so downstream code still knows which config file won and where follow-up writes should go. +export function deduplicatePluginOrigins(plugins: Origin[]): Origin[] { + const seen = new Set() + const list: Origin[] = [] - // Dedupe on the load identity (package name for npm specs, exact file URL for local specs), but keep the - // full Origin so downstream code still knows which config file won and where follow-up writes should go. - export function deduplicatePluginOrigins(plugins: Origin[]): Origin[] { - const seen = new Set() - const list: Origin[] = [] - - for (const plugin of plugins.toReversed()) { - const spec = pluginSpecifier(plugin.spec) - const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg - if (seen.has(name)) continue - seen.add(name) - list.push(plugin) - } - - return list.toReversed() + for (const plugin of plugins.toReversed()) { + const spec = pluginSpecifier(plugin.spec) + const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg + if (seen.has(name)) continue + seen.add(name) + list.push(plugin) } + + return list.toReversed() } + +export * as ConfigPlugin from "./plugin"