mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 04:26:11 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ca8a8d2ff |
@@ -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) =>
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user