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
@@ -33,11 +33,14 @@ const mockShowAutoUpdatedToast = mock(
async (_ctx: PluginInput, _fromVersion: string, _toVersion: string): Promise<void> => {}
)
const mockSyncCachePackageJsonToIntent = mock(() => false)
mock.module("../checker", () => ({
findPluginEntry: mockFindPluginEntry,
getCachedVersion: mockGetCachedVersion,
getLatestVersion: mockGetLatestVersion,
revertPinnedVersion: mock(() => false),
syncCachePackageJsonToIntent: mockSyncCachePackageJsonToIntent,
}))
mock.module("../version-channel", () => ({ extractChannel: mockExtractChannel }))
mock.module("../cache", () => ({ invalidatePackage: mockInvalidatePackage }))
@@ -65,12 +68,14 @@ describe("runBackgroundUpdateCheck", () => {
mockRunBunInstall.mockReset()
mockShowUpdateAvailableToast.mockReset()
mockShowAutoUpdatedToast.mockReset()
mockSyncCachePackageJsonToIntent.mockReset()
mockFindPluginEntry.mockReturnValue(createPluginEntry())
mockGetCachedVersion.mockReturnValue("3.4.0")
mockGetLatestVersion.mockResolvedValue("3.5.0")
mockExtractChannel.mockReturnValue("latest")
mockRunBunInstall.mockResolvedValue(true)
mockSyncCachePackageJsonToIntent.mockReturnValue({ synced: true, error: null })
})
describe("#given no plugin entry found", () => {
@@ -180,17 +185,38 @@ describe("runBackgroundUpdateCheck", () => {
})
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
mockRunBunInstall.mockResolvedValue(true)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockSyncCachePackageJsonToIntent).toHaveBeenCalledTimes(1)
expect(mockInvalidatePackage).toHaveBeenCalledTimes(1)
expect(mockRunBunInstall).toHaveBeenCalledTimes(1)
expect(mockShowAutoUpdatedToast).toHaveBeenCalledWith(mockCtx, "3.4.0", "3.5.0")
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", () => {
@@ -205,4 +231,80 @@ describe("runBackgroundUpdateCheck", () => {
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()
})
})
})
@@ -4,7 +4,7 @@ import { log } from "../../../shared/logger"
import { invalidatePackage } from "../cache"
import { PACKAGE_NAME } from "../constants"
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"
function getPinnedVersionToastMessage(latestVersion: string): string {
@@ -65,6 +65,17 @@ export async function runBackgroundUpdateCheck(
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)
const installSuccess = await runBunInstallSafe()
@@ -75,11 +86,6 @@ export async function runBackgroundUpdateCheck(
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)
log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)")
}