fix(auto-updater): handle bun.lockb and add workspace validation

- Support binary bun.lockb format by deleting entire file (cannot parse)
- Add workspace check: verify package.json exists before bun install
- Quote paths in error messages for Windows/paths with spaces
This commit is contained in:
acamq
2026-03-08 09:15:13 -06:00
parent f89cc969ec
commit c4112f80db
3 changed files with 45 additions and 14 deletions
+6 -2
View File
@@ -1,4 +1,5 @@
import { beforeEach, afterEach, describe, expect, it, mock, spyOn } from "bun:test" import { beforeEach, afterEach, describe, expect, it, spyOn } from "bun:test"
import * as fs from "node:fs"
import * as dataPath from "../../shared/data-path" import * as dataPath from "../../shared/data-path"
import * as logger from "../../shared/logger" import * as logger from "../../shared/logger"
import * as spawnHelpers from "../../shared/spawn-with-windows-hide" import * as spawnHelpers from "../../shared/spawn-with-windows-hide"
@@ -8,6 +9,7 @@ describe("runBunInstallWithDetails", () => {
let getOpenCodeCacheDirSpy: ReturnType<typeof spyOn> let getOpenCodeCacheDirSpy: ReturnType<typeof spyOn>
let logSpy: ReturnType<typeof spyOn> let logSpy: ReturnType<typeof spyOn>
let spawnWithWindowsHideSpy: ReturnType<typeof spyOn> let spawnWithWindowsHideSpy: ReturnType<typeof spyOn>
let existsSyncSpy: ReturnType<typeof spyOn>
beforeEach(() => { beforeEach(() => {
getOpenCodeCacheDirSpy = spyOn(dataPath, "getOpenCodeCacheDir").mockReturnValue("/tmp/opencode-cache") getOpenCodeCacheDirSpy = spyOn(dataPath, "getOpenCodeCacheDir").mockReturnValue("/tmp/opencode-cache")
@@ -15,14 +17,16 @@ describe("runBunInstallWithDetails", () => {
spawnWithWindowsHideSpy = spyOn(spawnHelpers, "spawnWithWindowsHide").mockReturnValue({ spawnWithWindowsHideSpy = spyOn(spawnHelpers, "spawnWithWindowsHide").mockReturnValue({
exited: Promise.resolve(0), exited: Promise.resolve(0),
exitCode: 0, exitCode: 0,
kill: mock(() => {}), kill: () => {},
} as ReturnType<typeof spawnHelpers.spawnWithWindowsHide>) } as ReturnType<typeof spawnHelpers.spawnWithWindowsHide>)
existsSyncSpy = spyOn(fs, "existsSync").mockReturnValue(true)
}) })
afterEach(() => { afterEach(() => {
getOpenCodeCacheDirSpy.mockRestore() getOpenCodeCacheDirSpy.mockRestore()
logSpy.mockRestore() logSpy.mockRestore()
spawnWithWindowsHideSpy.mockRestore() spawnWithWindowsHideSpy.mockRestore()
existsSyncSpy.mockRestore()
}) })
it("runs bun install in the OpenCode cache directory", async () => { it("runs bun install in the OpenCode cache directory", async () => {
+10 -1
View File
@@ -1,3 +1,4 @@
import { existsSync } from "node:fs"
import { getOpenCodeCacheDir } from "../../shared/data-path" import { getOpenCodeCacheDir } from "../../shared/data-path"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide" import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
@@ -18,6 +19,14 @@ export async function runBunInstall(): Promise<boolean> {
export async function runBunInstallWithDetails(): Promise<BunInstallResult> { export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
const cacheDir = getOpenCodeCacheDir() const cacheDir = getOpenCodeCacheDir()
const packageJsonPath = `${cacheDir}/package.json`
if (!existsSync(packageJsonPath)) {
return {
success: false,
error: `Workspace not initialized: ${packageJsonPath} not found. OpenCode should create this on first run.`,
}
}
try { try {
const proc = spawnWithWindowsHide(["bun", "install"], { const proc = spawnWithWindowsHide(["bun", "install"], {
@@ -43,7 +52,7 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
return { return {
success: false, success: false,
timedOut: true, timedOut: true,
error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ${cacheDir} && bun i`, error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd "${cacheDir}" && bun i`,
} }
} }
+29 -11
View File
@@ -16,31 +16,49 @@ function stripTrailingCommas(json: string): string {
return json.replace(/,(\s*[}\]])/g, "$1") return json.replace(/,(\s*[}\]])/g, "$1")
} }
function removeFromBunLock(packageName: string): boolean { function removeFromTextBunLock(lockPath: string, packageName: string): boolean {
const lockPath = path.join(CACHE_DIR, "bun.lock")
if (!fs.existsSync(lockPath)) return false
try { try {
const content = fs.readFileSync(lockPath, "utf-8") const content = fs.readFileSync(lockPath, "utf-8")
const lock = JSON.parse(stripTrailingCommas(content)) as BunLockfile const lock = JSON.parse(stripTrailingCommas(content)) as BunLockfile
let modified = false
if (lock.packages?.[packageName]) { if (lock.packages?.[packageName]) {
delete lock.packages[packageName] delete lock.packages[packageName]
modified = true
}
if (modified) {
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2)) fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2))
log(`[auto-update-checker] Removed from bun.lock: ${packageName}`) log(`[auto-update-checker] Removed from bun.lock: ${packageName}`)
return true
} }
return false
return modified
} catch { } catch {
return false return false
} }
} }
function deleteBinaryBunLock(lockPath: string): boolean {
try {
fs.unlinkSync(lockPath)
log(`[auto-update-checker] Removed bun.lockb to force re-resolution`)
return true
} catch {
return false
}
}
function removeFromBunLock(packageName: string): boolean {
const textLockPath = path.join(CACHE_DIR, "bun.lock")
const binaryLockPath = path.join(CACHE_DIR, "bun.lockb")
if (fs.existsSync(textLockPath)) {
return removeFromTextBunLock(textLockPath, packageName)
}
// Binary lockfiles cannot be parsed; deletion forces bun to re-resolve
if (fs.existsSync(binaryLockPath)) {
return deleteBinaryBunLock(binaryLockPath)
}
return false
}
export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean { export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
try { try {
const pkgDirs = [ const pkgDirs = [