fix(core): plugins are always reinstalled (#9675)
This commit is contained in:
@@ -6,6 +6,7 @@ import { Filesystem } from "../util/filesystem"
|
|||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { readableStreamToText } from "bun"
|
import { readableStreamToText } from "bun"
|
||||||
import { Lock } from "../util/lock"
|
import { Lock } from "../util/lock"
|
||||||
|
import { PackageRegistry } from "./registry"
|
||||||
|
|
||||||
export namespace BunProc {
|
export namespace BunProc {
|
||||||
const log = Log.create({ service: "bun" })
|
const log = Log.create({ service: "bun" })
|
||||||
@@ -73,7 +74,17 @@ export namespace BunProc {
|
|||||||
const dependencies = parsed.dependencies ?? {}
|
const dependencies = parsed.dependencies ?? {}
|
||||||
if (!parsed.dependencies) parsed.dependencies = dependencies
|
if (!parsed.dependencies) parsed.dependencies = dependencies
|
||||||
const modExists = await Filesystem.exists(mod)
|
const modExists = await Filesystem.exists(mod)
|
||||||
if (dependencies[pkg] === version && modExists) return mod
|
const cachedVersion = dependencies[pkg]
|
||||||
|
|
||||||
|
if (!modExists || !cachedVersion) {
|
||||||
|
// continue to install
|
||||||
|
} else if (version !== "latest" && cachedVersion === version) {
|
||||||
|
return mod
|
||||||
|
} else if (version === "latest") {
|
||||||
|
const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
|
||||||
|
if (!isOutdated) return mod
|
||||||
|
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
|
||||||
|
}
|
||||||
|
|
||||||
const proxied = !!(
|
const proxied = !!(
|
||||||
process.env.HTTP_PROXY ||
|
process.env.HTTP_PROXY ||
|
||||||
|
|||||||
48
packages/opencode/src/bun/registry.ts
Normal file
48
packages/opencode/src/bun/registry.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { readableStreamToText, semver } from "bun"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
|
export namespace PackageRegistry {
|
||||||
|
const log = Log.create({ service: "bun" })
|
||||||
|
|
||||||
|
function which() {
|
||||||
|
return process.execPath
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
|
||||||
|
const result = Bun.spawn([which(), "info", pkg, field], {
|
||||||
|
cwd,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
BUN_BE_BUN: "1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const code = await result.exited
|
||||||
|
const stdout = result.stdout ? await readableStreamToText(result.stdout) : ""
|
||||||
|
const stderr = result.stderr ? await readableStreamToText(result.stderr) : ""
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
log.warn("bun info failed", { pkg, field, code, stderr })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = stdout.trim()
|
||||||
|
if (!value) return null
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
|
||||||
|
const latestVersion = await info(pkg, "version", cwd)
|
||||||
|
if (!latestVersion) {
|
||||||
|
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||||
|
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
|
||||||
|
|
||||||
|
return semver.order(cachedVersion, latestVersion) === -1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import { existsSync } from "fs"
|
|||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { Event } from "../server/event"
|
import { Event } from "../server/event"
|
||||||
|
import { PackageRegistry } from "@/bun/registry"
|
||||||
|
|
||||||
export namespace Config {
|
export namespace Config {
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
@@ -154,9 +155,10 @@ export namespace Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const exists = existsSync(path.join(dir, "node_modules"))
|
const shouldInstall = await needsInstall(dir)
|
||||||
const installing = installDependencies(dir)
|
if (shouldInstall) {
|
||||||
if (!exists) await installing
|
await installDependencies(dir)
|
||||||
|
}
|
||||||
|
|
||||||
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
|
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
|
||||||
result.agent = mergeDeep(result.agent, await loadAgent(dir))
|
result.agent = mergeDeep(result.agent, await loadAgent(dir))
|
||||||
@@ -235,6 +237,7 @@ export namespace Config {
|
|||||||
|
|
||||||
export async function installDependencies(dir: string) {
|
export async function installDependencies(dir: string) {
|
||||||
const pkg = path.join(dir, "package.json")
|
const pkg = path.join(dir, "package.json")
|
||||||
|
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
|
||||||
|
|
||||||
if (!(await Bun.file(pkg).exists())) {
|
if (!(await Bun.file(pkg).exists())) {
|
||||||
await Bun.write(pkg, "{}")
|
await Bun.write(pkg, "{}")
|
||||||
@@ -244,18 +247,43 @@ 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"))
|
||||||
|
|
||||||
await BunProc.run(
|
await BunProc.run(["add", `@opencode-ai/plugin@${targetVersion}`, "--exact"], {
|
||||||
["add", "@opencode-ai/plugin@" + (Installation.isLocal() ? "latest" : Installation.VERSION), "--exact"],
|
cwd: dir,
|
||||||
{
|
}).catch(() => {})
|
||||||
cwd: dir,
|
|
||||||
},
|
|
||||||
).catch(() => {})
|
|
||||||
|
|
||||||
// Install any additional dependencies defined in the package.json
|
// Install any additional dependencies defined in the package.json
|
||||||
// This allows local plugins and custom tools to use external packages
|
// This allows local plugins and custom tools to use external packages
|
||||||
await BunProc.run(["install"], { cwd: dir }).catch(() => {})
|
await BunProc.run(["install"], { cwd: dir }).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function needsInstall(dir: string) {
|
||||||
|
const nodeModules = path.join(dir, "node_modules")
|
||||||
|
if (!existsSync(nodeModules)) return true
|
||||||
|
|
||||||
|
const pkg = path.join(dir, "package.json")
|
||||||
|
const pkgFile = Bun.file(pkg)
|
||||||
|
const pkgExists = await pkgFile.exists()
|
||||||
|
if (!pkgExists) return true
|
||||||
|
|
||||||
|
const parsed = await pkgFile.json().catch(() => null)
|
||||||
|
const dependencies = parsed?.dependencies ?? {}
|
||||||
|
const depVersion = dependencies["@opencode-ai/plugin"]
|
||||||
|
if (!depVersion) return true
|
||||||
|
|
||||||
|
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
|
||||||
|
if (targetVersion === "latest") {
|
||||||
|
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
|
||||||
|
if (!isOutdated) return false
|
||||||
|
log.info("Cached version is outdated, proceeding with install", {
|
||||||
|
pkg: "@opencode-ai/plugin",
|
||||||
|
cachedVersion: depVersion,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (depVersion === targetVersion) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function rel(item: string, patterns: string[]) {
|
function rel(item: string, patterns: string[]) {
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
const index = item.indexOf(pattern)
|
const index = item.indexOf(pattern)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ let openCalledWith: string | undefined
|
|||||||
mock.module("open", () => ({
|
mock.module("open", () => ({
|
||||||
default: async (url: string) => {
|
default: async (url: string) => {
|
||||||
openCalledWith = url
|
openCalledWith = url
|
||||||
|
|
||||||
// Return a mock subprocess that emits an error if openShouldFail is true
|
// Return a mock subprocess that emits an error if openShouldFail is true
|
||||||
const subprocess = new EventEmitter()
|
const subprocess = new EventEmitter()
|
||||||
if (openShouldFail) {
|
if (openShouldFail) {
|
||||||
@@ -133,20 +134,17 @@ test("BrowserOpenFailed event is published when open() throws", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Run authenticate with a timeout to avoid waiting forever for the callback
|
// Run authenticate with a timeout to avoid waiting forever for the callback
|
||||||
const authPromise = MCP.authenticate("test-oauth-server")
|
// Attach a handler immediately so callback shutdown rejections
|
||||||
|
// don't show up as unhandled between tests.
|
||||||
|
const authPromise = MCP.authenticate("test-oauth-server").catch(() => undefined)
|
||||||
|
|
||||||
// Wait for the browser open attempt (error fires at 10ms, but we wait for event to be published)
|
// Config.get() can be slow in tests, so give it plenty of time.
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
await new Promise((resolve) => setTimeout(resolve, 2_000))
|
||||||
|
|
||||||
// Stop the callback server and cancel any pending auth
|
// Stop the callback server and cancel any pending auth
|
||||||
await McpOAuthCallback.stop()
|
await McpOAuthCallback.stop()
|
||||||
|
|
||||||
// Wait for authenticate to reject (due to server stopping)
|
await authPromise
|
||||||
try {
|
|
||||||
await authPromise
|
|
||||||
} catch {
|
|
||||||
// Expected to fail
|
|
||||||
}
|
|
||||||
|
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
|
|
||||||
@@ -187,20 +185,15 @@ test("BrowserOpenFailed event is NOT published when open() succeeds", async () =
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Run authenticate with a timeout to avoid waiting forever for the callback
|
// Run authenticate with a timeout to avoid waiting forever for the callback
|
||||||
const authPromise = MCP.authenticate("test-oauth-server-2")
|
const authPromise = MCP.authenticate("test-oauth-server-2").catch(() => undefined)
|
||||||
|
|
||||||
// Wait for the browser open attempt and the 500ms error detection timeout
|
// Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
|
||||||
await new Promise((resolve) => setTimeout(resolve, 700))
|
await new Promise((resolve) => setTimeout(resolve, 2_000))
|
||||||
|
|
||||||
// Stop the callback server and cancel any pending auth
|
// Stop the callback server and cancel any pending auth
|
||||||
await McpOAuthCallback.stop()
|
await McpOAuthCallback.stop()
|
||||||
|
|
||||||
// Wait for authenticate to reject (due to server stopping)
|
await authPromise
|
||||||
try {
|
|
||||||
await authPromise
|
|
||||||
} catch {
|
|
||||||
// Expected to fail
|
|
||||||
}
|
|
||||||
|
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
|
|
||||||
@@ -237,20 +230,15 @@ test("open() is called with the authorization URL", async () => {
|
|||||||
openCalledWith = undefined
|
openCalledWith = undefined
|
||||||
|
|
||||||
// Run authenticate with a timeout to avoid waiting forever for the callback
|
// Run authenticate with a timeout to avoid waiting forever for the callback
|
||||||
const authPromise = MCP.authenticate("test-oauth-server-3")
|
const authPromise = MCP.authenticate("test-oauth-server-3").catch(() => undefined)
|
||||||
|
|
||||||
// Wait for the browser open attempt and the 500ms error detection timeout
|
// Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
|
||||||
await new Promise((resolve) => setTimeout(resolve, 700))
|
await new Promise((resolve) => setTimeout(resolve, 2_000))
|
||||||
|
|
||||||
// Stop the callback server and cancel any pending auth
|
// Stop the callback server and cancel any pending auth
|
||||||
await McpOAuthCallback.stop()
|
await McpOAuthCallback.stop()
|
||||||
|
|
||||||
// Wait for authenticate to reject (due to server stopping)
|
await authPromise
|
||||||
try {
|
|
||||||
await authPromise
|
|
||||||
} catch {
|
|
||||||
// Expected to fail
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify open was called with a URL
|
// Verify open was called with a URL
|
||||||
expect(openCalledWith).toBeDefined()
|
expect(openCalledWith).toBeDefined()
|
||||||
|
|||||||
Reference in New Issue
Block a user