Compare commits
1 Commits
dev
...
fix/plugin
| Author | SHA1 | Date |
|---|---|---|
|
|
3f90ffabee |
|
|
@ -31,6 +31,7 @@ import { Event } from "../server/event"
|
||||||
import { PackageRegistry } from "@/bun/registry"
|
import { PackageRegistry } from "@/bun/registry"
|
||||||
import { proxied } from "@/util/proxied"
|
import { proxied } from "@/util/proxied"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
|
import { BUILTIN_PLUGINS } from "@/plugin/builtin"
|
||||||
|
|
||||||
export namespace Config {
|
export namespace Config {
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
|
|
@ -147,7 +148,21 @@ export namespace Config {
|
||||||
|
|
||||||
const deps = []
|
const deps = []
|
||||||
|
|
||||||
|
// Collect npm plugins declared in each directory's config so we can
|
||||||
|
// install them into that directory (instead of ~/.cache/opencode/)
|
||||||
|
const dirPlugins = new Map<string, string[]>()
|
||||||
|
|
||||||
|
// Plugins from global/project configs that were loaded before the directory
|
||||||
|
// loop get assigned to the global config directory.
|
||||||
|
// Built-in plugins also get installed in the global config directory.
|
||||||
|
const preloopPlugins = [
|
||||||
|
...npmPlugins(result.plugin ?? []),
|
||||||
|
...(!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS ? BUILTIN_PLUGINS : []),
|
||||||
|
]
|
||||||
|
|
||||||
for (const dir of unique(directories)) {
|
for (const dir of unique(directories)) {
|
||||||
|
const pluginsBefore = [...(result.plugin ?? [])]
|
||||||
|
|
||||||
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
||||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
||||||
log.debug(`loading config from ${path.join(dir, file)}`)
|
log.debug(`loading config from ${path.join(dir, file)}`)
|
||||||
|
|
@ -159,10 +174,17 @@ export namespace Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine which npm plugins this directory's config added
|
||||||
|
const added = npmPlugins((result.plugin ?? []).filter((p) => !pluginsBefore.includes(p)))
|
||||||
|
|
||||||
|
// First directory (global config) also gets the pre-loop plugins
|
||||||
|
const plugins = dir === Global.Path.config ? [...preloopPlugins, ...added] : added
|
||||||
|
if (plugins.length) dirPlugins.set(dir, plugins)
|
||||||
|
|
||||||
deps.push(
|
deps.push(
|
||||||
iife(async () => {
|
iife(async () => {
|
||||||
const shouldInstall = await needsInstall(dir)
|
const shouldInstall = await needsInstall(dir, dirPlugins.get(dir))
|
||||||
if (shouldInstall) await installDependencies(dir)
|
if (shouldInstall) await installDependencies(dir, dirPlugins.get(dir))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -247,7 +269,19 @@ export namespace Config {
|
||||||
await Promise.all(deps)
|
await Promise.all(deps)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function installDependencies(dir: string) {
|
/** Extract npm plugin specifiers (non-file:// plugins) */
|
||||||
|
function npmPlugins(plugins: string[]): string[] {
|
||||||
|
return plugins.filter((p) => !p.startsWith("file://"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a plugin specifier into package name and version */
|
||||||
|
function parsePlugin(specifier: string): { pkg: string; version: string } {
|
||||||
|
const lastAt = specifier.lastIndexOf("@")
|
||||||
|
if (lastAt > 0) return { pkg: specifier.substring(0, lastAt), version: specifier.substring(lastAt + 1) }
|
||||||
|
return { pkg: specifier, version: "latest" }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installDependencies(dir: string, plugins?: string[]) {
|
||||||
const pkg = path.join(dir, "package.json")
|
const pkg = path.join(dir, "package.json")
|
||||||
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
|
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
|
||||||
|
|
||||||
|
|
@ -258,6 +292,16 @@ export namespace Config {
|
||||||
...json.dependencies,
|
...json.dependencies,
|
||||||
"@opencode-ai/plugin": targetVersion,
|
"@opencode-ai/plugin": targetVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add declared npm plugins to this directory's package.json
|
||||||
|
// so they get installed here instead of in ~/.cache/opencode/
|
||||||
|
if (plugins) {
|
||||||
|
for (const specifier of plugins) {
|
||||||
|
const parsed = parsePlugin(specifier)
|
||||||
|
json.dependencies[parsed.pkg] = parsed.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await Bun.write(pkg, JSON.stringify(json, null, 2))
|
await Bun.write(pkg, JSON.stringify(json, null, 2))
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||||
|
|
||||||
|
|
@ -265,8 +309,7 @@ export namespace Config {
|
||||||
const hasGitIgnore = await Bun.file(gitignore).exists()
|
const hasGitIgnore = await Bun.file(gitignore).exists()
|
||||||
if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
||||||
|
|
||||||
// Install any additional dependencies defined in the package.json
|
// Install all dependencies (including npm plugins) in this config directory
|
||||||
// This allows local plugins and custom tools to use external packages
|
|
||||||
await BunProc.run(
|
await BunProc.run(
|
||||||
[
|
[
|
||||||
"install",
|
"install",
|
||||||
|
|
@ -286,7 +329,7 @@ export namespace Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function needsInstall(dir: string) {
|
async function needsInstall(dir: string, plugins?: string[]) {
|
||||||
// Some config dirs may be read-only.
|
// Some config dirs may be read-only.
|
||||||
// Installing deps there will fail; skip installation in that case.
|
// Installing deps there will fail; skip installation in that case.
|
||||||
const writable = await isWritable(dir)
|
const writable = await isWritable(dir)
|
||||||
|
|
@ -311,15 +354,27 @@ export namespace Config {
|
||||||
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
|
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
|
||||||
if (targetVersion === "latest") {
|
if (targetVersion === "latest") {
|
||||||
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
|
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
|
||||||
if (!isOutdated) return false
|
if (isOutdated) {
|
||||||
log.info("Cached version is outdated, proceeding with install", {
|
log.info("Cached version is outdated, proceeding with install", {
|
||||||
pkg: "@opencode-ai/plugin",
|
pkg: "@opencode-ai/plugin",
|
||||||
cachedVersion: depVersion,
|
cachedVersion: depVersion,
|
||||||
})
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else if (depVersion !== targetVersion) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (depVersion === targetVersion) return false
|
|
||||||
return true
|
// Check if any declared plugins are missing from the installed dependencies
|
||||||
|
if (plugins) {
|
||||||
|
for (const specifier of plugins) {
|
||||||
|
const parsed = parsePlugin(specifier)
|
||||||
|
if (!dependencies[parsed.pkg]) return true
|
||||||
|
if (!existsSync(path.join(nodeModules, ...parsed.pkg.split("/")))) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function rel(item: string, patterns: string[]) {
|
function rel(item: string, patterns: string[]) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
/** Built-in npm plugins that are installed by default (unless OPENCODE_DISABLE_DEFAULT_PLUGINS is set) */
|
||||||
|
export const BUILTIN_PLUGINS = ["opencode-anthropic-auth@0.0.13"]
|
||||||
|
|
@ -4,7 +4,6 @@ import { Bus } from "../bus"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
import { Server } from "../server/server"
|
import { Server } from "../server/server"
|
||||||
import { BunProc } from "../bun"
|
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { CodexAuthPlugin } from "./codex"
|
import { CodexAuthPlugin } from "./codex"
|
||||||
|
|
@ -12,15 +11,26 @@ import { Session } from "../session"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { CopilotAuthPlugin } from "./copilot"
|
import { CopilotAuthPlugin } from "./copilot"
|
||||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
|
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
|
||||||
|
import path from "path"
|
||||||
|
import { existsSync } from "fs"
|
||||||
|
import { BUILTIN_PLUGINS } from "./builtin"
|
||||||
|
|
||||||
export namespace Plugin {
|
export namespace Plugin {
|
||||||
const log = Log.create({ service: "plugin" })
|
const log = Log.create({ service: "plugin" })
|
||||||
|
|
||||||
const BUILTIN = ["opencode-anthropic-auth@0.0.13"]
|
export const BUILTIN = BUILTIN_PLUGINS
|
||||||
|
|
||||||
// Built-in plugins that are directly imported (not installed from npm)
|
// Built-in plugins that are directly imported (not installed from npm)
|
||||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
|
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
|
||||||
|
|
||||||
|
/** Resolve an npm plugin from the config directories' node_modules */
|
||||||
|
function resolve(pkg: string, directories: string[]): string | undefined {
|
||||||
|
for (const dir of directories) {
|
||||||
|
const mod = path.join(dir, "node_modules", ...pkg.split("/"))
|
||||||
|
if (existsSync(mod)) return mod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const state = Instance.state(async () => {
|
const state = Instance.state(async () => {
|
||||||
const client = createOpencodeClient({
|
const client = createOpencodeClient({
|
||||||
baseUrl: "http://localhost:4096",
|
baseUrl: "http://localhost:4096",
|
||||||
|
|
@ -50,6 +60,10 @@ export namespace Plugin {
|
||||||
plugins.push(...BUILTIN)
|
plugins.push(...BUILTIN)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for dependencies so npm plugins are installed in their config directories
|
||||||
|
await Config.waitForDependencies()
|
||||||
|
const directories = await Config.directories()
|
||||||
|
|
||||||
for (let plugin of plugins) {
|
for (let plugin of plugins) {
|
||||||
// ignore old codex plugin since it is supported first party now
|
// ignore old codex plugin since it is supported first party now
|
||||||
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
|
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
|
||||||
|
|
@ -57,26 +71,24 @@ export namespace Plugin {
|
||||||
if (!plugin.startsWith("file://")) {
|
if (!plugin.startsWith("file://")) {
|
||||||
const lastAtIndex = plugin.lastIndexOf("@")
|
const lastAtIndex = plugin.lastIndexOf("@")
|
||||||
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
|
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
|
||||||
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
|
|
||||||
const builtin = BUILTIN.some((x) => x.startsWith(pkg + "@"))
|
const builtin = BUILTIN.some((x) => x.startsWith(pkg + "@"))
|
||||||
plugin = await BunProc.install(pkg, version).catch((err) => {
|
|
||||||
if (!builtin) throw err
|
|
||||||
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
// Resolve the plugin from config directories' node_modules
|
||||||
log.error("failed to install builtin plugin", {
|
const resolved = resolve(pkg, directories)
|
||||||
pkg,
|
if (!resolved) {
|
||||||
version,
|
const message = `Plugin ${plugin} not found in any config directory`
|
||||||
error: message,
|
if (!builtin) {
|
||||||
})
|
log.error("plugin not found", { plugin, directories })
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.error("failed to resolve builtin plugin", { plugin })
|
||||||
Bus.publish(Session.Event.Error, {
|
Bus.publish(Session.Event.Error, {
|
||||||
error: new NamedError.Unknown({
|
error: new NamedError.Unknown({ message }).toObject(),
|
||||||
message: `Failed to install built-in plugin ${pkg}@${version}: ${message}`,
|
|
||||||
}).toObject(),
|
|
||||||
})
|
})
|
||||||
|
continue
|
||||||
return ""
|
}
|
||||||
})
|
plugin = resolved
|
||||||
if (!plugin) continue
|
|
||||||
}
|
}
|
||||||
const mod = await import(plugin)
|
const mod = await import(plugin)
|
||||||
// Prevent duplicate initialization when plugins export the same function
|
// Prevent duplicate initialization when plugins export the same function
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue