core: add documentation comments to plugin configuration merge logic
Adds explanatory comments to config.ts and plugin.ts clarifying: - How plugin specs are stored and normalized during config loading - Why plugin_origins tracks provenance for location-sensitive decisions - Why path-like specs are resolved early to prevent reinterpretation during merges - How plugin deduplication works while keeping origin metadata for writes and diagnostics
This commit is contained in:
@@ -93,6 +93,7 @@ export const Info = z
|
|||||||
.describe(
|
.describe(
|
||||||
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
|
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
|
||||||
),
|
),
|
||||||
|
// User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
|
||||||
plugin: ConfigPlugin.Spec.array().optional(),
|
plugin: ConfigPlugin.Spec.array().optional(),
|
||||||
share: z
|
share: z
|
||||||
.enum(["manual", "auto", "disabled"])
|
.enum(["manual", "auto", "disabled"])
|
||||||
@@ -267,6 +268,8 @@ export const Info = z
|
|||||||
})
|
})
|
||||||
|
|
||||||
export type Info = z.output<typeof Info> & {
|
export type Info = z.output<typeof Info> & {
|
||||||
|
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together
|
||||||
|
// with the file and scope it came from so later runtime code can make location-sensitive decisions.
|
||||||
plugin_origins?: ConfigPlugin.Origin[]
|
plugin_origins?: ConfigPlugin.Origin[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,6 +423,8 @@ export const layer = Layer.effect(
|
|||||||
if (data.plugin && isFile) {
|
if (data.plugin && isFile) {
|
||||||
const list = data.plugin
|
const list = data.plugin
|
||||||
for (let i = 0; i < list.length; i++) {
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
// Normalize path-like plugin specs while we still know which config file declared them.
|
||||||
|
// This prevents `./plugin.ts` from being reinterpreted relative to some later merge location.
|
||||||
list[i] = yield* Effect.promise(() => ConfigPlugin.resolvePluginSpec(list[i], options.path))
|
list[i] = yield* Effect.promise(() => ConfigPlugin.resolvePluginSpec(list[i], options.path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -505,20 +510,26 @@ export const layer = Layer.effect(
|
|||||||
const consoleManagedProviders = new Set<string>()
|
const consoleManagedProviders = new Set<string>()
|
||||||
let activeOrgName: string | undefined
|
let activeOrgName: string | undefined
|
||||||
|
|
||||||
const scope = Effect.fnUntraced(function* (source: string) {
|
const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
|
||||||
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
|
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
|
||||||
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
|
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
|
||||||
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
|
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
|
||||||
return "global"
|
return "global"
|
||||||
})
|
})
|
||||||
|
|
||||||
const track = Effect.fnUntraced(function* (
|
const mergePluginOrigins = Effect.fnUntraced(function* (
|
||||||
source: string,
|
source: string,
|
||||||
|
// mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step
|
||||||
|
// is attached.
|
||||||
list: ConfigPlugin.Spec[] | undefined,
|
list: ConfigPlugin.Spec[] | undefined,
|
||||||
|
// Scope can be inferred from the source path, but some callers already know whether the config should
|
||||||
|
// behave as global or local and can pass that explicitly.
|
||||||
kind?: ConfigPlugin.Scope,
|
kind?: ConfigPlugin.Scope,
|
||||||
) {
|
) {
|
||||||
if (!list?.length) return
|
if (!list?.length) return
|
||||||
const hit = kind ?? (yield* scope(source))
|
const hit = kind ?? (yield* pluginScopeForSource(source))
|
||||||
|
// Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while
|
||||||
|
// keeping the winning source/scope metadata for downstream installs, writes, and diagnostics.
|
||||||
const plugins = ConfigPlugin.deduplicatePluginOrigins([
|
const plugins = ConfigPlugin.deduplicatePluginOrigins([
|
||||||
...(result.plugin_origins ?? []),
|
...(result.plugin_origins ?? []),
|
||||||
...list.map((spec) => ({ spec, source, scope: hit })),
|
...list.map((spec) => ({ spec, source, scope: hit })),
|
||||||
@@ -529,7 +540,7 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {
|
const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {
|
||||||
result = mergeConfigConcatArrays(result, next)
|
result = mergeConfigConcatArrays(result, next)
|
||||||
return track(source, next.plugin, kind)
|
return mergePluginOrigins(source, next.plugin, kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(auth)) {
|
for (const [key, value] of Object.entries(auth)) {
|
||||||
@@ -617,8 +628,10 @@ export const layer = Layer.effect(
|
|||||||
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir)))
|
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir)))
|
||||||
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir)))
|
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir)))
|
||||||
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir)))
|
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir)))
|
||||||
|
// Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load
|
||||||
|
// returns normalized Specs and we only need to attach origin metadata here.
|
||||||
const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
|
const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
|
||||||
yield* track(dir, list)
|
yield* mergePluginOrigins(dir, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
||||||
|
|||||||
@@ -8,11 +8,16 @@ export namespace ConfigPlugin {
|
|||||||
const Options = z.record(z.string(), z.unknown())
|
const Options = z.record(z.string(), z.unknown())
|
||||||
export type Options = z.infer<typeof Options>
|
export type Options = z.infer<typeof Options>
|
||||||
|
|
||||||
|
// 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 const Spec = z.union([z.string(), z.tuple([z.string(), Options])])
|
||||||
export type Spec = z.infer<typeof Spec>
|
export type Spec = z.infer<typeof Spec>
|
||||||
|
|
||||||
export type Scope = "global" | "local"
|
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 = {
|
export type Origin = {
|
||||||
spec: Spec
|
spec: Spec
|
||||||
source: string
|
source: string
|
||||||
@@ -33,7 +38,7 @@ export namespace ConfigPlugin {
|
|||||||
return plugins
|
return plugins
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pluginSpecifier(plugin: ConfigPlugin.Spec): string {
|
export function pluginSpecifier(plugin: Spec): string {
|
||||||
return Array.isArray(plugin) ? plugin[0] : plugin
|
return Array.isArray(plugin) ? plugin[0] : plugin
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +46,8 @@ export namespace ConfigPlugin {
|
|||||||
return Array.isArray(plugin) ? plugin[1] : 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<Spec> {
|
export async function resolvePluginSpec(plugin: Spec, configFilepath: string): Promise<Spec> {
|
||||||
const spec = pluginSpecifier(plugin)
|
const spec = pluginSpecifier(plugin)
|
||||||
if (!isPathPluginSpec(spec)) return plugin
|
if (!isPathPluginSpec(spec)) return plugin
|
||||||
@@ -58,6 +65,8 @@ export namespace ConfigPlugin {
|
|||||||
return resolved
|
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[] {
|
export function deduplicatePluginOrigins(plugins: Origin[]): Origin[] {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const list: Origin[] = []
|
const list: Origin[] = []
|
||||||
|
|||||||
Reference in New Issue
Block a user