diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index bdb7cff78e2..8fdf76f483b 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -82,9 +82,17 @@ export namespace BunProc { } 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 }) + // Validate cached version is a proper SemVer before checking if outdated + // Prevents errors from corrupted cache with invalid versions like "latest" + const isValidSemVer = /^\d+\.\d+\.\d+/.test(cachedVersion) + if (!isValidSemVer) { + log.warn("Cached version is not valid SemVer, removing and reinstalling", { pkg, cachedVersion }) + delete dependencies[pkg] + } else { + 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 }) + } } // Build command arguments @@ -119,19 +127,29 @@ export namespace BunProc { ) }) - // Resolve actual version from installed package when using "latest" - // This ensures subsequent starts use the cached version until explicitly updated + // Resolve actual version from installed package to cache a real SemVer + // This ensures subsequent starts use the cached version instead of querying npm each time let resolvedVersion = version if (version === "latest") { const installedPkgJson = Bun.file(path.join(mod, "package.json")) const installedPkg = await installedPkgJson.json().catch(() => null) if (installedPkg?.version) { resolvedVersion = installedPkg.version + log.info("Resolved 'latest' to actual version", { pkg, resolvedVersion }) + } else { + log.error("Failed to read version from installed package, cannot cache", { pkg, modPath: mod }) } } - parsed.dependencies[pkg] = resolvedVersion - await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2)) + // Only cache valid SemVer versions + // Never cache "latest" or any non-SemVer strings as they cause semver parsing errors + if (resolvedVersion !== "latest" && /^\d+\.\d+\.\d+/.test(resolvedVersion)) { + parsed.dependencies[pkg] = resolvedVersion + await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2)) + log.info("Cached resolved version", { pkg, version: resolvedVersion }) + } else if (resolvedVersion === "latest") { + log.warn("Could not resolve package version from installed package.json, will re-resolve on next run", { pkg }) + } return mod } } diff --git a/packages/opencode/src/bun/registry.ts b/packages/opencode/src/bun/registry.ts index c567668acd7..da50d015953 100644 --- a/packages/opencode/src/bun/registry.ts +++ b/packages/opencode/src/bun/registry.ts @@ -34,6 +34,14 @@ export namespace PackageRegistry { } export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise { + // Guard against non-SemVer cached versions (e.g., "latest", "unknown", etc.) + // These should never exist in cache but may from previous buggy versions + const isSemVer = /^\d+\.\d+\.\d+/.test(cachedVersion) + if (!isSemVer) { + log.warn("Cached version is not valid SemVer, treating as outdated for reinstall", { pkg, cachedVersion }) + return true + } + const latestVersion = await info(pkg, "version", cwd) if (!latestVersion) { log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion }) diff --git a/packages/opencode/test/plugin/version-resolution.test.ts b/packages/opencode/test/plugin/version-resolution.test.ts new file mode 100644 index 00000000000..3848e64d283 --- /dev/null +++ b/packages/opencode/test/plugin/version-resolution.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" + +describe("Plugin version handling (Issue #12143)", () => { + test("should never cache 'latest' as a version string", async () => { + const bunIndexPath = path.join(__dirname, "../../src/bun/index.ts") + const content = await fs.readFile(bunIndexPath, "utf-8") + + // Verify that the code explicitly prevents caching "latest" + expect(content).toContain("Only cache valid SemVer versions") + expect(content).toContain('if (resolvedVersion !== "latest"') + expect(content).toContain('Never cache "latest" or any non-SemVer strings') + }) + + test("should validate cached versions are valid SemVer before checking if outdated", async () => { + const bunIndexPath = path.join(__dirname, "../../src/bun/index.ts") + const content = await fs.readFile(bunIndexPath, "utf-8") + + // Verify SemVer validation exists before isOutdated check + expect(content).toContain("const isValidSemVer = /^\\d+\\.\\d+\\.\\d+/") + expect(content).toContain("Validate cached version is a proper SemVer") + expect(content).toContain("if (!isValidSemVer)") + }) + + test("should guard isOutdated against non-SemVer versions", async () => { + const registryPath = path.join(__dirname, "../../src/bun/registry.ts") + const content = await fs.readFile(registryPath, "utf-8") + + // Verify guard against non-SemVer cached versions + expect(content).toContain("Guard against non-SemVer cached versions") + expect(content).toContain("const isSemVer = /^\\d+\\.\\d+\\.\\d+/") + expect(content).toContain("if (!isSemVer)") + expect(content).toContain("return true") // Should treat invalid versions as outdated + }) + + test("should resolve 'latest' to actual version before caching", async () => { + const bunIndexPath = path.join(__dirname, "../../src/bun/index.ts") + const content = await fs.readFile(bunIndexPath, "utf-8") + + // Verify version resolution logic + expect(content).toContain("Resolve actual version from installed package") + expect(content).toContain('if (version === "latest"') + expect(content).toContain("package.json") + expect(content).toContain("installedPkg?.version") + }) + + test("should skip cache if version cannot be resolved", async () => { + const bunIndexPath = path.join(__dirname, "../../src/bun/index.ts") + const content = await fs.readFile(bunIndexPath, "utf-8") + + // Verify graceful handling when version resolution fails + expect(content).toContain("Could not resolve package version from installed package.json") + expect(content).toContain("will re-resolve on next run") + }) + + test("should clean up invalid cached versions", async () => { + const bunIndexPath = path.join(__dirname, "../../src/bun/index.ts") + const content = await fs.readFile(bunIndexPath, "utf-8") + + // Verify cleanup of corrupted cache + expect(content).toContain("delete dependencies[pkg]") + expect(content).toContain("removing and reinstalling") + }) +}) + +describe("Version resolution integration (Issue #12143)", () => { + test("should reject non-SemVer cached versions in isOutdated", async () => { + const registryPath = path.join(__dirname, "../../src/bun/registry.ts") + const content = await fs.readFile(registryPath, "utf-8") + + // Verify the guard logic checks SemVer format FIRST before calling semver.order() + // This is the critical fix - it prevents "latest" from ever reaching semver operations + const isOutdatedFunction = content.match(/export async function isOutdated[\s\S]*?^ \}/m)?.[0] + expect(isOutdatedFunction).toBeTruthy() + + if (isOutdatedFunction) { + // Verify the guard is BEFORE the semver.order call + const guardIndex = isOutdatedFunction.indexOf("const isSemVer") + const orderIndex = isOutdatedFunction.indexOf("semver.order") + + expect(guardIndex).toBeGreaterThan(-1) + expect(orderIndex).toBeGreaterThan(-1) + expect(guardIndex).toBeLessThan(orderIndex) // Guard comes first! + } + }) + + test("BunProc.install should prevent 'latest' from being cached", async () => { + const bunIndexPath = path.join(__dirname, "../../src/bun/index.ts") + const content = await fs.readFile(bunIndexPath, "utf-8") + + const installFunction = content.match(/export async function install[\s\S]*?^ \}$/m)?.[0] + expect(installFunction).toBeTruthy() + + if (installFunction) { + // Verify version resolution happens before caching + const resolutionIndex = installFunction.indexOf('if (version === "latest"') + const cachingIndex = installFunction.indexOf("parsed.dependencies[pkg]") + + expect(resolutionIndex).toBeGreaterThan(-1) + expect(cachingIndex).toBeGreaterThan(-1) + expect(resolutionIndex).toBeLessThan(cachingIndex) // Resolution before caching! + + // Verify the explicit check prevents "latest" from being cached + expect(installFunction).toContain('if (resolvedVersion !== "latest" && /^\\d+\\.\\d+\\.\\d+/') + } + }) + + test("cached version validation happens before outdated check", async () => { + const bunIndexPath = path.join(__dirname, "../../src/bun/index.ts") + const content = await fs.readFile(bunIndexPath, "utf-8") + + // Find the version === "latest" condition block + const latestBlockMatch = content.match(/else if \(version === "latest"\) \{[\s\S]*?\n \}/) + expect(latestBlockMatch).toBeTruthy() + + if (latestBlockMatch) { + const block = latestBlockMatch[0] + + // Verify SemVer validation happens before isOutdated call + const validationIndex = block.indexOf("const isValidSemVer") + const isOutdatedIndex = block.indexOf("PackageRegistry.isOutdated") + + expect(validationIndex).toBeGreaterThan(-1) + expect(isOutdatedIndex).toBeGreaterThan(-1) + expect(validationIndex).toBeLessThan(isOutdatedIndex) // Validation first! + } + }) + + test("caching only happens with valid SemVer versions", async () => { + const bunIndexPath = path.join(__dirname, "../../src/bun/index.ts") + const content = await fs.readFile(bunIndexPath, "utf-8") + + // Find the caching logic + const cachingLogicMatch = content.match(/Only cache valid SemVer versions[\s\S]*?log\.warn\("Could not resolve/) + expect(cachingLogicMatch).toBeTruthy() + + if (cachingLogicMatch) { + const logic = cachingLogicMatch[0] + + // Verify dual check: not "latest" AND valid SemVer format + expect(logic).toContain('resolvedVersion !== "latest"') + expect(logic).toContain("/^\\d+\\.\\d+\\.\\d+/") + expect(logic).toContain("&&") // Both conditions must be true + } + }) +})