Compare commits

...
Author SHA1 Message Date
Shoubhit Dash 2ca8a8d2ff refactor(core): consolidate plugin discovery and selection 2026-08-26 18:34:10 +05:30
5 changed files with 267 additions and 260 deletions
+7 -65
View File
@@ -1,28 +1,17 @@
export * as ConfigPluginSource from "./source.js"
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import type { Entry } from "@opencode-ai/schema/config"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
import { Context, Effect, Layer, PubSub, Scope, Stream } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { Config } from "../../config.js"
import { Watcher } from "../../filesystem/watcher.js"
import { Location } from "../../location.js"
import { PluginDiscovery } from "../../plugin/discovery.js"
import { PluginSourceDirectory } from "../../plugin/source-directory.js"
export type Operation =
| {
readonly type: "add"
readonly target: string
readonly options: Record<string, unknown>
readonly mtime?: number
}
| {
readonly type: "remove"
readonly target: string
}
export type Operation = PluginDiscovery.Operation
export interface Interface {
readonly operations: () => Effect.Effect<readonly Operation[], never, Scope.Scope>
@@ -38,6 +27,7 @@ export const layer = Layer.effect(
const watcher = yield* Watcher.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const discovery = yield* PluginDiscovery.Service
const configuredChanges = yield* PubSub.unbounded<void>()
const watched = new Set<string>()
@@ -73,7 +63,7 @@ export const layer = Layer.effect(
return Service.of({
operations: Effect.fn("ConfigPluginSource.operations")(function* () {
const entries = yield* config.entries()
const operations = yield* scan(fs, location, entries)
const operations = yield* discovery.operations(location.directory, entries)
yield* watchConfiguredSources(entries, operations)
return operations
}),
@@ -94,7 +84,7 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, FSUtil.node, Watcher.node, Location.node],
deps: [Config.node, FSUtil.node, Watcher.node, Location.node, PluginDiscovery.node],
})
export const empty = makeLocationNode({
@@ -109,54 +99,6 @@ export const empty = makeLocationNode({
deps: [],
})
function parse(input: ConfigPlugin.Plugin): Operation {
if (typeof input !== "string") {
return { type: "add", target: input.package, options: input.options ?? {} }
}
if (!input.startsWith("-")) return { type: "add", target: input, options: {} }
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
return { type: "remove", target: input.slice(1) }
}
const scan = Effect.fn("ConfigPluginSource.scan")(function* (
fs: FSUtil.Interface,
location: Location.Interface,
entries: readonly Entry[],
) {
const discovered = yield* Effect.forEach(
entries.filter((entry): entry is Directory => entry.type === "directory"),
(entry) =>
PluginSourceDirectory.discover(fs, entry.path).pipe(
Effect.map((targets) => targets.map((target): Operation => ({ type: "add", target, options: {} }))),
),
).pipe(Effect.map((items) => items.flat()))
const configured = entries
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) =>
(entry.info.plugins ?? []).map(parse).map((operation) => {
if (operation.type === "remove") return operation
const directory = entry.path ? path.dirname(entry.path) : location.directory
const target = operation.target.startsWith("file://")
? fileURLToPath(operation.target)
: operation.target.startsWith("./") || operation.target.startsWith("../")
? path.resolve(directory, operation.target)
: operation.target
return { ...operation, target }
}),
)
// Explicit config is applied last so it can remove auto-discovered packages.
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.orElseSucceed(() => operation),
)
})
})
function isPluginSource(entries: readonly Entry[], file: string) {
return entries.some(
(entry) =>
+201
View File
@@ -0,0 +1,201 @@
export * as PluginDiscovery from "./discovery.js"
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
import { parse, type ParseError } from "jsonc-parser"
import path from "path"
import { fileURLToPath } from "url"
import { Plugin } from "../plugin.js"
import { AbsolutePath } from "../schema.js"
import { PluginModule } from "./module.js"
import { PluginSourceDirectory } from "./source-directory.js"
export type Operation =
| {
readonly type: "add"
readonly target: string
readonly options: Record<string, unknown>
readonly mtime?: number
}
| {
readonly type: "remove"
readonly target: string
}
export interface Interface {
readonly operations: (directory: AbsolutePath, entries?: readonly Entry[]) => Effect.Effect<readonly Operation[]>
readonly resolve: (
pre: readonly Plugin.Versioned[],
post: readonly Plugin.Versioned[],
operations: readonly Operation[],
) => Effect.Effect<{
readonly plugins: readonly Plugin.Versioned[]
readonly failures: readonly Extract<Plugin.Info, { readonly status: "failed" }>[]
}>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginDiscovery") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const npm = yield* Npm.Service
const loaded = new Map<string, Plugin.Versioned | { readonly error: string }>()
const operations = Effect.fn("PluginDiscovery.operations")(function* (
directory: AbsolutePath,
entries?: readonly Entry[],
) {
const found = entries
? undefined
: yield* fs
.up({ targets: [".opencode", "opencode.json", "opencode.jsonc"], start: directory })
.pipe(Effect.orElseSucceed(() => []))
const roots = entries
? entries.filter((entry): entry is Directory => entry.type === "directory").map((entry) => entry.path)
: [global.config, ...(found ?? []).filter((value) => path.basename(value) === ".opencode").toReversed()]
const files = entries
? undefined
: [
...["opencode.json", "opencode.jsonc"].map((name) => path.join(global.config, name)),
...(found ?? []).filter((value) => path.basename(value) !== ".opencode").toReversed(),
...roots
.slice(1)
.flatMap((root) => ["opencode.json", "opencode.jsonc"].map((name) => path.join(root, name))),
]
const discovered = yield* Effect.forEach(roots, (root) => PluginSourceDirectory.discover(fs, root)).pipe(
Effect.map((groups) => groups.flat().map((target): Operation => ({ type: "add", target, options: {} }))),
)
const configured = entries
? entries
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) =>
(entry.info.plugins ?? []).map((plugin) =>
operation(plugin, entry.path ? path.dirname(entry.path) : directory),
),
)
: yield* Effect.forEach([...new Set(files)], (file) =>
Effect.gen(function* () {
const source = yield* fs.readFileStringSafe(file).pipe(Effect.orElseSucceed(() => undefined))
if (!source) return []
const errors: ParseError[] = []
const document: unknown = parse(source, errors, { allowTrailingComma: true })
if (errors.length || typeof document !== "object" || document === null || !("plugins" in document))
return []
const plugins = Schema.decodeUnknownOption(ConfigPlugin.Plugins)(document.plugins)
if (Option.isNone(plugins)) return []
return plugins.value.map((plugin) => operation(plugin, path.dirname(file)))
}),
).pipe(Effect.map((groups) => groups.flat()))
return yield* Effect.forEach([...discovered, ...configured], (item) => {
if (item.type === "remove" || !path.isAbsolute(item.target)) return Effect.succeed(item)
return fs.stat(item.target).pipe(
Effect.map((info) => ({ ...item, mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime() })),
Effect.orElseSucceed(() => item),
)
})
})
const resolve = Effect.fn("PluginDiscovery.resolve")(function* (
pre: readonly Plugin.Versioned[],
post: readonly Plugin.Versioned[],
operations: readonly Operation[],
) {
const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
const definitions = [...pre, ...post]
const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin.Versioned>()
const failures = new Map<string, Extract<Plugin.Info, { readonly status: "failed" }>>()
for (const item of operations) {
const plugins = [...definitions, ...packages.values()]
if (item.type === "remove") {
if (item.target === "*") failures.clear()
plugins.filter((plugin) => matches(item.target, plugin.id)).forEach((plugin) => enabled.delete(plugin.id))
continue
}
const matched = plugins.filter((plugin) => matches(item.target, plugin.id))
if (
matched.length > 0 ||
item.target === "*" ||
item.target.endsWith(".*") ||
item.target.startsWith("opencode.")
) {
matched.forEach((plugin) => enabled.add(plugin.id))
continue
}
const key = JSON.stringify(item)
const plugin = loaded.has(key)
? loaded.get(key)
: yield* PluginModule.load(item).pipe(
Effect.provideService(Npm.Service, npm),
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: item.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
),
),
Effect.tap((value) => Effect.sync(() => loaded.set(key, value))),
)
if (!plugin) continue
if ("error" in plugin) {
failures.set(item.target, {
source: path.isAbsolute(item.target)
? { type: "local", path: item.target }
: { type: "package", package: item.target },
status: "failed",
error: plugin.error,
tui: false,
})
continue
}
failures.delete(item.target)
const previous = packages.get(item.target)
if (previous) enabled.delete(previous.id)
packages.set(item.target, plugin)
enabled.add(plugin.id)
}
return {
plugins: [
...pre.filter((plugin) => enabled.has(plugin.id)),
...[...packages.values()].filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
}
})
return Service.of({ operations, resolve })
}),
)
function operation(input: ConfigPlugin.Plugin, directory: string): Operation {
if (typeof input === "string" && input.startsWith("-")) {
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
return { type: "remove", target: input.slice(1) }
}
const target = typeof input === "string" ? input : input.package
const options = typeof input === "string" ? {} : (input.options ?? {})
if (target.startsWith("file://")) return { type: "add", target: fileURLToPath(target), options }
if (target.startsWith("./") || target.startsWith("../")) {
return { type: "add", target: path.resolve(directory, target), options }
}
return { type: "add", target, options }
}
export const node = makeGlobalNode({
service: Service,
layer,
deps: [FSUtil.node, Global.node, Npm.node],
})
+5 -77
View File
@@ -2,90 +2,23 @@ export * as PluginSupervisor from "./supervisor.js"
export { Service, type Interface } from "./supervisor-service.js"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Latch, Layer, Stream } from "effect"
import path from "path"
import { Effect, Latch, Layer, Stream } from "effect"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
import { Npm } from "@opencode-ai/util/npm"
import { Plugin } from "../plugin.js"
import { PluginDiscovery } from "./discovery.js"
import { PluginInternal } from "./internal.js"
import { PluginModule } from "./module.js"
import { SdkPlugins } from "./sdk.js"
import { Service } from "./supervisor-service.js"
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
pre: readonly Plugin.Versioned[],
post: readonly Plugin.Versioned[],
operations: readonly ConfigPluginSource.Operation[],
) {
const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
const definitions = [...pre, ...post]
const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin.Versioned>()
const failures = new Map<string, Extract<Plugin.Info, { readonly status: "failed" }>>()
const plugins = () => [...definitions, ...packages.values()]
for (const operation of operations) {
if (operation.type === "remove") {
if (operation.target === "*") failures.clear()
plugins()
.filter((plugin) => matches(operation.target, plugin.id))
.forEach((plugin) => enabled.delete(plugin.id))
continue
}
const matched = plugins().filter((plugin) => matches(operation.target, plugin.id))
const selectsPlugins =
matched.length > 0 ||
operation.target === "*" ||
operation.target.endsWith(".*") ||
operation.target.startsWith("opencode.")
if (selectsPlugins) {
matched.forEach((plugin) => enabled.add(plugin.id))
continue
}
const plugin = yield* PluginModule.load(operation).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
),
),
)
if ("error" in plugin) {
failures.set(operation.target, {
source: pluginSource(operation.target),
status: "failed",
error: plugin.error,
tui: false,
})
continue
}
failures.delete(operation.target)
const previous = packages.get(operation.target)
if (previous) enabled.delete(previous.id)
packages.set(operation.target, plugin)
enabled.add(plugin.id)
}
return {
plugins: [
...pre.filter((plugin) => enabled.has(plugin.id)),
...[...packages.values()].filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
}
})
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const registry = yield* Plugin.Service
const sdk = yield* SdkPlugins.Service
const sources = yield* ConfigPluginSource.Service
const discovery = yield* PluginDiscovery.Service
const bus = yield* Bus.Service
const ready = yield* Latch.make()
let observed = 0
@@ -105,7 +38,7 @@ export const layer = Layer.effect(
}))
const operations = yield* sources.operations()
// Apply config operations and load enabled package plugins into one ordered generation.
const resolved = yield* resolve(pre, post, operations)
const resolved = yield* discovery.resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(resolved.plugins, resolved.failures)
})
@@ -140,13 +73,8 @@ const nodeDeps = [
SdkPlugins.node,
ConfigPluginSource.node,
Bus.node,
Npm.node,
PluginDiscovery.node,
PluginInternal.requirements,
] as const
function pluginSource(target: string): Plugin.Source {
if (path.isAbsolute(target)) return { type: "local", path: target }
return { type: "package", package: target }
}
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
+12 -118
View File
@@ -1,17 +1,10 @@
export * as ProjectMarkers from "./markers.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option } from "effect"
import { parse, type ParseError } from "jsonc-parser"
import { Context, Effect, Layer } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Versioned } from "../plugin.js"
import { PluginModule } from "../plugin/module.js"
import { PluginSourceDirectory } from "../plugin/source-directory.js"
import { PluginDiscovery } from "../plugin/discovery.js"
import { SdkPlugins } from "../plugin/sdk.js"
import { AbsolutePath } from "../schema.js"
@@ -32,89 +25,22 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const npm = yield* Npm.Service
const sdk = yield* SdkPlugins.Service
const discovery = yield* PluginDiscovery.Service
const known = new Set([".git", ".hg"])
const loaded = new Map<string, Versioned | undefined>()
const discover = Effect.fn("ProjectMarkers.discover")(function* (directory: AbsolutePath) {
const found = yield* fs
.up({ targets: [".opencode", "opencode.json", "opencode.jsonc"], start: directory })
.pipe(Effect.orElseSucceed(() => []))
const roots = [global.config, ...found.filter((value) => path.basename(value) === ".opencode").toReversed()]
const files = [
...["opencode.json", "opencode.jsonc"].map((name) => path.join(global.config, name)),
...found.filter((value) => path.basename(value) !== ".opencode").toReversed(),
...roots.slice(1).flatMap((root) => ["opencode.json", "opencode.jsonc"].map((name) => path.join(root, name))),
]
const automatic = yield* Effect.forEach(roots, (root) => PluginSourceDirectory.discover(fs, root)).pipe(
Effect.map((entries) => entries.flat()),
)
const configured = yield* Effect.forEach([...new Set(files)], (file) => read(fs, file)).pipe(
Effect.map((entries) => entries.flat()),
)
const operations = yield* Effect.forEach(
[
...automatic.map((target): ConfigPluginSource.Operation => ({ type: "add", target, options: {} })),
...configured,
],
(operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.orElseSucceed(() => operation),
)
},
)
const declarations = new Map<string, { readonly id: string; readonly markers: readonly string[] }>()
for (const plugin of sdk.all()) {
if (!plugin.vcs) continue
declarations.set(plugin.id, { id: plugin.vcs.id ?? plugin.id, markers: plugin.vcs.markers })
}
for (const operation of operations) {
if (operation.type === "remove") {
for (const id of declarations.keys()) {
if (
operation.target === "*" ||
(operation.target.endsWith(".*") ? id.startsWith(operation.target.slice(0, -1)) : operation.target === id)
) {
declarations.delete(id)
}
}
continue
}
if (operation.target === "*" || operation.target.endsWith(".*") || operation.target.startsWith("opencode."))
continue
const key = JSON.stringify(operation)
const plugin = loaded.has(key)
? loaded.get(key)
: yield* PluginModule.load(operation).pipe(
Effect.provideService(Npm.Service, npm),
Effect.catchCause((cause) =>
Effect.logDebug("failed to discover plugin repository markers", {
target: operation.target,
cause,
}).pipe(Effect.as(undefined)),
),
Effect.tap((value) => Effect.sync(() => loaded.set(key, value))),
)
if (!plugin?.vcs) continue
declarations.set(plugin.id, { id: plugin.vcs.id ?? plugin.id, markers: plugin.vcs.markers })
}
const operations = yield* discovery.operations(directory)
const resolved = yield* discovery.resolve(sdk.all(), [], operations)
const markers = new Map<string, string>()
for (const declaration of declarations.values()) {
if (!/^[a-z][a-z0-9._-]*$/.test(declaration.id)) continue
for (const marker of declaration.markers) {
for (const plugin of resolved.plugins) {
if (!plugin.vcs) continue
const id = plugin.vcs.id ?? plugin.id
if (!/^[a-z][a-z0-9._-]*$/.test(id)) continue
for (const marker of plugin.vcs.markers) {
if (!marker || marker === "." || marker === ".." || /[\\/]/.test(marker)) continue
known.add(marker)
markers.set(marker, declaration.id)
markers.set(marker, id)
}
}
if (!markers.size) return undefined
@@ -137,40 +63,8 @@ const layer = Layer.effect(
}),
)
function read(fs: FSUtil.Interface, file: string): Effect.Effect<ConfigPluginSource.Operation[]> {
return Effect.gen(function* () {
const source = yield* fs.readFileStringSafe(file).pipe(Effect.orElseSucceed(() => undefined))
if (!source) return []
const errors: ParseError[] = []
const document: unknown = parse(source, errors, { allowTrailingComma: true })
if (errors.length || typeof document !== "object" || document === null || !("plugins" in document)) return []
if (!Array.isArray(document.plugins)) return []
return document.plugins.flatMap<ConfigPluginSource.Operation>((entry) => {
if (typeof entry === "string" && entry.startsWith("-")) {
return [{ type: "remove", target: entry.slice(1) }]
}
if (
typeof entry !== "string" &&
(typeof entry !== "object" || entry === null || !("package" in entry) || typeof entry.package !== "string")
) {
return []
}
const target = typeof entry === "string" ? entry : entry.package
const options =
typeof entry !== "string" && "options" in entry && typeof entry.options === "object" && entry.options !== null
? Object.fromEntries(Object.entries(entry.options))
: {}
if (target.startsWith("file://")) return [{ type: "add", target: fileURLToPath(target), options }]
if (target.startsWith("./") || target.startsWith("../")) {
return [{ type: "add", target: path.resolve(path.dirname(file), target), options }]
}
return [{ type: "add", target, options }]
})
})
}
export const node = makeGlobalNode({
service: Service,
layer,
deps: [FSUtil.node, Global.node, Npm.node, SdkPlugins.node],
deps: [FSUtil.node, PluginDiscovery.node, SdkPlugins.node],
})
+42
View File
@@ -220,6 +220,48 @@ describe("Project.resolve", () => {
}),
)
it.live("applies plugin removal and reenabling selectors to repository markers", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, ".opencode", "plugins"), { recursive: true })
await fs.mkdir(path.join(tmp.path, ".svn"))
await Bun.write(
path.join(tmp.path, ".opencode", "plugins", "svn.ts"),
'export default { id: "svn", vcs: { markers: [".svn"] }, setup() {} }',
)
await Bun.write(path.join(tmp.path, "opencode.json"), JSON.stringify({ plugins: ["-svn", "svn"] }))
})
const project = yield* Project.Service
expect((yield* project.resolve(abs(tmp.path))).vcs?.type).toBe("svn")
}),
)
it.live("does not detect repository markers from disabled plugins", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, ".opencode", "plugins"), { recursive: true })
await fs.mkdir(path.join(tmp.path, ".svn"))
await Bun.write(
path.join(tmp.path, ".opencode", "plugins", "svn.ts"),
'export default { id: "svn", vcs: { markers: [".svn"] }, setup() {} }',
)
await Bun.write(path.join(tmp.path, "opencode.json"), JSON.stringify({ plugins: ["-svn"] }))
})
const project = yield* Project.Service
expect((yield* project.resolve(abs(tmp.path))).vcs).toBeUndefined()
}),
)
it.live("prefers a nested plugin repository over its parent git repository", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(