fix(auto-update): sync cache package.json to opencode.json intent

When users switch from pinned version to tag in opencode.json (e.g.,
3.10.0 -> @latest), the cache package.json still contains the resolved
version. This causes bun install to reinstall the old version instead
of resolving the new tag.

This adds syncCachePackageJsonToIntent() which updates the cache
package.json to match user intent before running bun install. Uses
atomic writes (temp file + rename) with UUID-based temp names for
concurrent safety.

Critical changes:
- Treat all sync errors as abort conditions (file_not_found,
  plugin_not_in_deps, parse_error, write_error) to prevent corrupting
  a bad cache state further
- Remove dead code (unreachable revert branch for pinned versions)
- Add tests for all error paths and atomic write cleanup
This commit is contained in:
acamq
2026-03-11 07:42:08 -06:00
parent 17de67c7d1
commit 0764f0e563
6 changed files with 560 additions and 11 deletions

View File

@@ -6,3 +6,5 @@ export { getCachedVersion } from "./checker/cached-version"
export { updatePinnedVersion, revertPinnedVersion } from "./checker/pinned-version-updater" export { updatePinnedVersion, revertPinnedVersion } from "./checker/pinned-version-updater"
export { getLatestVersion } from "./checker/latest-version" export { getLatestVersion } from "./checker/latest-version"
export { checkForUpdate } from "./checker/check-for-update" export { checkForUpdate } from "./checker/check-for-update"
export { syncCachePackageJsonToIntent } from "./checker/sync-package-json"
export type { SyncResult } from "./checker/sync-package-json"

View File

@@ -11,9 +11,7 @@ export interface PluginEntryInfo {
configPath: string configPath: string
} }
function isExplicitVersionPin(pinnedVersion: string): boolean { const EXACT_SEMVER_REGEX = /^\d+\.\d+\.\d+(-[\w.]+)?$/
return /^\d+\.\d+\.\d+/.test(pinnedVersion)
}
export function findPluginEntry(directory: string): PluginEntryInfo | null { export function findPluginEntry(directory: string): PluginEntryInfo | null {
for (const configPath of getConfigPaths(directory)) { for (const configPath of getConfigPaths(directory)) {
@@ -29,7 +27,7 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null {
} }
if (entry.startsWith(`${PACKAGE_NAME}@`)) { if (entry.startsWith(`${PACKAGE_NAME}@`)) {
const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1) const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1)
const isPinned = isExplicitVersionPin(pinnedVersion) const isPinned = EXACT_SEMVER_REGEX.test(pinnedVersion.trim())
return { entry, isPinned, pinnedVersion, configPath } return { entry, isPinned, pinnedVersion, configPath }
} }
} }

View File

@@ -0,0 +1,315 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import type { PluginEntryInfo } from "./plugin-entry"
const TEST_CACHE_DIR = join(import.meta.dir, "__test-sync-cache__")
mock.module("../constants", () => ({
CACHE_DIR: TEST_CACHE_DIR,
PACKAGE_NAME: "oh-my-opencode",
NPM_REGISTRY_URL: "https://registry.npmjs.org/-/package/oh-my-opencode/dist-tags",
NPM_FETCH_TIMEOUT: 5000,
VERSION_FILE: join(TEST_CACHE_DIR, "version"),
USER_CONFIG_DIR: "/tmp/opencode-config",
USER_OPENCODE_CONFIG: "/tmp/opencode-config/opencode.json",
USER_OPENCODE_CONFIG_JSONC: "/tmp/opencode-config/opencode.jsonc",
INSTALLED_PACKAGE_JSON: join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"),
getWindowsAppdataDir: () => null,
}))
mock.module("../../../shared/logger", () => ({
log: () => {},
}))
function resetTestCache(currentVersion = "3.10.0"): void {
if (existsSync(TEST_CACHE_DIR)) {
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
}
mkdirSync(TEST_CACHE_DIR, { recursive: true })
writeFileSync(
join(TEST_CACHE_DIR, "package.json"),
JSON.stringify({ dependencies: { "oh-my-opencode": currentVersion, other: "1.0.0" } }, null, 2)
)
}
function cleanupTestCache(): void {
if (existsSync(TEST_CACHE_DIR)) {
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
}
}
function readCachePackageJsonVersion(): string | undefined {
const content = readFileSync(join(TEST_CACHE_DIR, "package.json"), "utf-8")
const pkg = JSON.parse(content) as { dependencies?: Record<string, string> }
return pkg.dependencies?.["oh-my-opencode"]
}
describe("syncCachePackageJsonToIntent", () => {
beforeEach(() => {
resetTestCache()
})
afterEach(() => {
cleanupTestCache()
})
describe("#given cache package.json with pinned semver version", () => {
describe("#when opencode.json intent is latest tag", () => {
it("#then updates package.json to use latest", async () => {
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(true)
expect(result.error).toBeNull()
expect(readCachePackageJsonVersion()).toBe("latest")
})
})
describe("#when opencode.json intent is next tag", () => {
it("#then updates package.json to use next", async () => {
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@next",
isPinned: false,
pinnedVersion: "next",
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(true)
expect(result.error).toBeNull()
expect(readCachePackageJsonVersion()).toBe("next")
})
})
describe("#when opencode.json has no version (implies latest)", () => {
it("#then updates package.json to use latest", async () => {
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode",
isPinned: false,
pinnedVersion: null,
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(true)
expect(result.error).toBeNull()
expect(readCachePackageJsonVersion()).toBe("latest")
})
})
})
describe("#given cache package.json already matches intent", () => {
it("#then returns synced false with no error", async () => {
resetTestCache("latest")
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(false)
expect(result.error).toBeNull()
expect(readCachePackageJsonVersion()).toBe("latest")
})
})
describe("#given cache package.json does not exist", () => {
it("#then returns file_not_found error", async () => {
cleanupTestCache()
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(false)
expect(result.error).toBe("file_not_found")
})
})
describe("#given plugin not in cache package.json dependencies", () => {
it("#then returns plugin_not_in_deps error", async () => {
cleanupTestCache()
mkdirSync(TEST_CACHE_DIR, { recursive: true })
writeFileSync(
join(TEST_CACHE_DIR, "package.json"),
JSON.stringify({ dependencies: { other: "1.0.0" } }, null, 2)
)
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(false)
expect(result.error).toBe("plugin_not_in_deps")
})
})
describe("#given user explicitly changed from one semver to another", () => {
it("#then updates package.json to new version", async () => {
resetTestCache("3.9.0")
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@3.10.0",
isPinned: true,
pinnedVersion: "3.10.0",
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(true)
expect(result.error).toBeNull()
expect(readCachePackageJsonVersion()).toBe("3.10.0")
})
})
describe("#given malformed JSON in cache package.json", () => {
it("#then returns parse_error", async () => {
cleanupTestCache()
mkdirSync(TEST_CACHE_DIR, { recursive: true })
writeFileSync(join(TEST_CACHE_DIR, "package.json"), "{ invalid json }")
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(false)
expect(result.error).toBe("parse_error")
})
})
describe("#given write permission denied", () => {
it("#then returns write_error", async () => {
cleanupTestCache()
mkdirSync(TEST_CACHE_DIR, { recursive: true })
writeFileSync(
join(TEST_CACHE_DIR, "package.json"),
JSON.stringify({ dependencies: { "oh-my-opencode": "3.10.0" } }, null, 2)
)
const fs = await import("node:fs")
const originalWriteFileSync = fs.writeFileSync
const originalRenameSync = fs.renameSync
mock.module("node:fs", () => ({
...fs,
writeFileSync: mock(() => {
throw new Error("EACCES: permission denied")
}),
renameSync: fs.renameSync,
}))
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(false)
expect(result.error).toBe("write_error")
mock.module("node:fs", () => ({
...fs,
writeFileSync: originalWriteFileSync,
renameSync: originalRenameSync,
}))
})
})
describe("#given rename fails after successful write", () => {
it("#then returns write_error and cleans up temp file", async () => {
cleanupTestCache()
mkdirSync(TEST_CACHE_DIR, { recursive: true })
writeFileSync(
join(TEST_CACHE_DIR, "package.json"),
JSON.stringify({ dependencies: { "oh-my-opencode": "3.10.0" } }, null, 2)
)
const fs = await import("node:fs")
const originalWriteFileSync = fs.writeFileSync
const originalRenameSync = fs.renameSync
let tempFilePath: string | null = null
mock.module("node:fs", () => ({
...fs,
writeFileSync: mock((path: string, data: string) => {
tempFilePath = path
return originalWriteFileSync(path, data)
}),
renameSync: mock(() => {
throw new Error("EXDEV: cross-device link not permitted")
}),
}))
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(false)
expect(result.error).toBe("write_error")
expect(tempFilePath).not.toBeNull()
expect(existsSync(tempFilePath!)).toBe(false)
mock.module("node:fs", () => ({
...fs,
writeFileSync: originalWriteFileSync,
renameSync: originalRenameSync,
}))
})
})
})

View File

@@ -0,0 +1,126 @@
import * as crypto from "node:crypto"
import * as fs from "node:fs"
import * as path from "node:path"
import { CACHE_DIR, PACKAGE_NAME } from "../constants"
import { log } from "../../../shared/logger"
import type { PluginEntryInfo } from "./plugin-entry"
interface CachePackageJson {
dependencies?: Record<string, string>
}
export interface SyncResult {
/** Whether the package.json was successfully synced/updated */
synced: boolean
/** Whether there was an error during sync (null if no error) */
error: "file_not_found" | "plugin_not_in_deps" | "parse_error" | "write_error" | null
/** Human-readable message describing what happened */
message?: string
}
const EXACT_SEMVER_REGEX = /^\d+\.\d+\.\d+(-[\w.]+)?$/
function safeUnlink(filePath: string): void {
try {
fs.unlinkSync(filePath)
} catch (err) {
log(`[auto-update-checker] Failed to cleanup temp file: ${filePath}`, err)
}
}
/**
* Determine the version specifier to use in cache package.json based on opencode.json intent.
*
* - "oh-my-opencode" (no version) → "latest"
* - "oh-my-opencode@latest" → "latest"
* - "oh-my-opencode@next" → "next"
* - "oh-my-opencode@3.10.0" → "3.10.0" (pinned, use as-is)
*/
function getIntentVersion(pluginInfo: PluginEntryInfo): string {
if (!pluginInfo.pinnedVersion) {
// No version specified in opencode.json, default to latest
return "latest"
}
return pluginInfo.pinnedVersion
}
/**
* Sync the cache package.json to match the opencode.json plugin intent.
*
* OpenCode pins resolved versions in cache package.json (e.g., "3.11.0" instead of "latest").
* This causes issues when users switch from pinned to tag in opencode.json:
* - User changes opencode.json from "oh-my-opencode@3.10.0" to "oh-my-opencode@latest"
* - Cache package.json still has "3.10.0"
* - bun install reinstalls 3.10.0 instead of resolving @latest
*
* This function updates cache package.json to match the user's intent before bun install.
*
* @returns SyncResult with synced status and any error information
*/
export function syncCachePackageJsonToIntent(pluginInfo: PluginEntryInfo): SyncResult {
const cachePackageJsonPath = path.join(CACHE_DIR, "package.json")
if (!fs.existsSync(cachePackageJsonPath)) {
log("[auto-update-checker] Cache package.json not found, nothing to sync")
return { synced: false, error: "file_not_found", message: "Cache package.json not found" }
}
let content: string
let pkgJson: CachePackageJson
try {
content = fs.readFileSync(cachePackageJsonPath, "utf-8")
} catch (err) {
log("[auto-update-checker] Failed to read cache package.json:", err)
return { synced: false, error: "parse_error", message: "Failed to read cache package.json" }
}
try {
pkgJson = JSON.parse(content) as CachePackageJson
} catch (err) {
log("[auto-update-checker] Failed to parse cache package.json:", err)
return { synced: false, error: "parse_error", message: "Failed to parse cache package.json (malformed JSON)" }
}
if (!pkgJson.dependencies?.[PACKAGE_NAME]) {
log("[auto-update-checker] Plugin not in cache package.json dependencies, nothing to sync")
return { synced: false, error: "plugin_not_in_deps", message: "Plugin not in cache package.json dependencies" }
}
const currentVersion = pkgJson.dependencies[PACKAGE_NAME]
const intentVersion = getIntentVersion(pluginInfo)
if (currentVersion === intentVersion) {
log("[auto-update-checker] Cache package.json already matches intent:", intentVersion)
return { synced: false, error: null, message: `Already matches intent: ${intentVersion}` }
}
// Check if this is a meaningful change:
// - If intent is a tag (latest, next, beta) and current is semver, we need to update
// - If both are semver but different, user explicitly changed versions
const intentIsTag = !EXACT_SEMVER_REGEX.test(intentVersion.trim())
const currentIsSemver = EXACT_SEMVER_REGEX.test(currentVersion.trim())
if (intentIsTag && currentIsSemver) {
log(
`[auto-update-checker] Syncing cache package.json: "${currentVersion}" → "${intentVersion}" (opencode.json intent)`
)
} else {
log(
`[auto-update-checker] Updating cache package.json: "${currentVersion}" → "${intentVersion}"`
)
}
pkgJson.dependencies[PACKAGE_NAME] = intentVersion
const tmpPath = `${cachePackageJsonPath}.${crypto.randomUUID()}`
try {
fs.writeFileSync(tmpPath, JSON.stringify(pkgJson, null, 2))
fs.renameSync(tmpPath, cachePackageJsonPath)
return { synced: true, error: null, message: `Updated: "${currentVersion}" → "${intentVersion}"` }
} catch (err) {
log("[auto-update-checker] Failed to write cache package.json:", err)
safeUnlink(tmpPath)
return { synced: false, error: "write_error", message: "Failed to write cache package.json" }
}
}

View File

@@ -33,11 +33,14 @@ const mockShowAutoUpdatedToast = mock(
async (_ctx: PluginInput, _fromVersion: string, _toVersion: string): Promise<void> => {} async (_ctx: PluginInput, _fromVersion: string, _toVersion: string): Promise<void> => {}
) )
const mockSyncCachePackageJsonToIntent = mock(() => false)
mock.module("../checker", () => ({ mock.module("../checker", () => ({
findPluginEntry: mockFindPluginEntry, findPluginEntry: mockFindPluginEntry,
getCachedVersion: mockGetCachedVersion, getCachedVersion: mockGetCachedVersion,
getLatestVersion: mockGetLatestVersion, getLatestVersion: mockGetLatestVersion,
revertPinnedVersion: mock(() => false), revertPinnedVersion: mock(() => false),
syncCachePackageJsonToIntent: mockSyncCachePackageJsonToIntent,
})) }))
mock.module("../version-channel", () => ({ extractChannel: mockExtractChannel })) mock.module("../version-channel", () => ({ extractChannel: mockExtractChannel }))
mock.module("../cache", () => ({ invalidatePackage: mockInvalidatePackage })) mock.module("../cache", () => ({ invalidatePackage: mockInvalidatePackage }))
@@ -65,12 +68,14 @@ describe("runBackgroundUpdateCheck", () => {
mockRunBunInstall.mockReset() mockRunBunInstall.mockReset()
mockShowUpdateAvailableToast.mockReset() mockShowUpdateAvailableToast.mockReset()
mockShowAutoUpdatedToast.mockReset() mockShowAutoUpdatedToast.mockReset()
mockSyncCachePackageJsonToIntent.mockReset()
mockFindPluginEntry.mockReturnValue(createPluginEntry()) mockFindPluginEntry.mockReturnValue(createPluginEntry())
mockGetCachedVersion.mockReturnValue("3.4.0") mockGetCachedVersion.mockReturnValue("3.4.0")
mockGetLatestVersion.mockResolvedValue("3.5.0") mockGetLatestVersion.mockResolvedValue("3.5.0")
mockExtractChannel.mockReturnValue("latest") mockExtractChannel.mockReturnValue("latest")
mockRunBunInstall.mockResolvedValue(true) mockRunBunInstall.mockResolvedValue(true)
mockSyncCachePackageJsonToIntent.mockReturnValue({ synced: true, error: null })
}) })
describe("#given no plugin entry found", () => { describe("#given no plugin entry found", () => {
@@ -180,17 +185,38 @@ describe("runBackgroundUpdateCheck", () => {
}) })
describe("#given unpinned with auto-update and install succeeds", () => { describe("#given unpinned with auto-update and install succeeds", () => {
it("invalidates cache, installs, and shows auto-updated toast", async () => { it("syncs cache, invalidates, installs, and shows auto-updated toast", async () => {
//#given //#given
mockRunBunInstall.mockResolvedValue(true) mockRunBunInstall.mockResolvedValue(true)
//#when //#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage) await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then //#then
expect(mockSyncCachePackageJsonToIntent).toHaveBeenCalledTimes(1)
expect(mockInvalidatePackage).toHaveBeenCalledTimes(1) expect(mockInvalidatePackage).toHaveBeenCalledTimes(1)
expect(mockRunBunInstall).toHaveBeenCalledTimes(1) expect(mockRunBunInstall).toHaveBeenCalledTimes(1)
expect(mockShowAutoUpdatedToast).toHaveBeenCalledWith(mockCtx, "3.4.0", "3.5.0") expect(mockShowAutoUpdatedToast).toHaveBeenCalledWith(mockCtx, "3.4.0", "3.5.0")
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled() expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
}) })
it("syncs before invalidate and install (correct order)", async () => {
//#given
const callOrder: string[] = []
mockSyncCachePackageJsonToIntent.mockImplementation(() => {
callOrder.push("sync")
return { synced: true, error: null }
})
mockInvalidatePackage.mockImplementation(() => {
callOrder.push("invalidate")
})
mockRunBunInstall.mockImplementation(async () => {
callOrder.push("install")
return true
})
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(callOrder).toEqual(["sync", "invalidate", "install"])
})
}) })
describe("#given unpinned with auto-update and install fails", () => { describe("#given unpinned with auto-update and install fails", () => {
@@ -205,4 +231,80 @@ describe("runBackgroundUpdateCheck", () => {
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled() expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
}) })
}) })
describe("#given sync fails with file_not_found", () => {
it("aborts update and shows notification-only toast", async () => {
//#given
mockSyncCachePackageJsonToIntent.mockReturnValue({
synced: false,
error: "file_not_found",
message: "Cache package.json not found",
})
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockSyncCachePackageJsonToIntent).toHaveBeenCalledTimes(1)
expect(mockInvalidatePackage).not.toHaveBeenCalled()
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage)
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
describe("#given sync fails with plugin_not_in_deps", () => {
it("aborts update and shows notification-only toast", async () => {
//#given
mockSyncCachePackageJsonToIntent.mockReturnValue({
synced: false,
error: "plugin_not_in_deps",
message: "Plugin not in cache package.json dependencies",
})
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockSyncCachePackageJsonToIntent).toHaveBeenCalledTimes(1)
expect(mockInvalidatePackage).not.toHaveBeenCalled()
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage)
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
describe("#given sync fails with parse_error", () => {
it("aborts update and shows notification-only toast", async () => {
//#given
mockSyncCachePackageJsonToIntent.mockReturnValue({
synced: false,
error: "parse_error",
message: "Failed to parse cache package.json (malformed JSON)",
})
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockSyncCachePackageJsonToIntent).toHaveBeenCalledTimes(1)
expect(mockInvalidatePackage).not.toHaveBeenCalled()
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage)
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
describe("#given sync fails with write_error", () => {
it("aborts update and shows notification-only toast", async () => {
//#given
mockSyncCachePackageJsonToIntent.mockReturnValue({
synced: false,
error: "write_error",
message: "Failed to write cache package.json",
})
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockSyncCachePackageJsonToIntent).toHaveBeenCalledTimes(1)
expect(mockInvalidatePackage).not.toHaveBeenCalled()
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage)
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
}) })

View File

@@ -4,7 +4,7 @@ import { log } from "../../../shared/logger"
import { invalidatePackage } from "../cache" import { invalidatePackage } from "../cache"
import { PACKAGE_NAME } from "../constants" import { PACKAGE_NAME } from "../constants"
import { extractChannel } from "../version-channel" import { extractChannel } from "../version-channel"
import { findPluginEntry, getCachedVersion, getLatestVersion, revertPinnedVersion } from "../checker" import { findPluginEntry, getCachedVersion, getLatestVersion, syncCachePackageJsonToIntent } from "../checker"
import { showAutoUpdatedToast, showUpdateAvailableToast } from "./update-toasts" import { showAutoUpdatedToast, showUpdateAvailableToast } from "./update-toasts"
function getPinnedVersionToastMessage(latestVersion: string): string { function getPinnedVersionToastMessage(latestVersion: string): string {
@@ -65,6 +65,17 @@ export async function runBackgroundUpdateCheck(
return return
} }
// Sync cache package.json to match opencode.json intent before updating
// This handles the case where user switched from pinned version to tag (e.g., 3.10.0 -> @latest)
const syncResult = syncCachePackageJsonToIntent(pluginInfo)
// Abort on ANY sync error to prevent corrupting a bad state further
if (syncResult.error) {
log(`[auto-update-checker] Sync failed with error: ${syncResult.error}`, syncResult.message)
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
return
}
invalidatePackage(PACKAGE_NAME) invalidatePackage(PACKAGE_NAME)
const installSuccess = await runBunInstallSafe() const installSuccess = await runBunInstallSafe()
@@ -75,11 +86,6 @@ export async function runBackgroundUpdateCheck(
return return
} }
if (pluginInfo.isPinned) {
revertPinnedVersion(pluginInfo.configPath, latestVersion, pluginInfo.entry)
log("[auto-update-checker] Config reverted due to install failure")
}
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)") log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)")
} }