fix(auto-update): sync cache package.json to opencode.json intent
When users switch opencode.json from pinned version to tag (e.g., 3.10.0 -> @latest), the cache package.json still contains the pinned 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 the user's declared intent in opencode.json before running bun install. Also fixes mock.module in test files to include all exported constants, preventing module pollution across parallel tests.
This commit is contained in:
@@ -10,6 +10,13 @@ mock.module("./constants", () => ({
|
||||
CACHE_DIR: TEST_OPENCODE_CACHE_DIR,
|
||||
USER_CONFIG_DIR: TEST_USER_CONFIG_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_OPENCODE_CACHE_DIR, "version"),
|
||||
USER_OPENCODE_CONFIG: join(TEST_USER_CONFIG_DIR, "opencode.json"),
|
||||
USER_OPENCODE_CONFIG_JSONC: join(TEST_USER_CONFIG_DIR, "opencode.jsonc"),
|
||||
INSTALLED_PACKAGE_JSON: join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"),
|
||||
getWindowsAppdataDir: () => null,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/logger", () => ({
|
||||
|
||||
@@ -6,3 +6,4 @@ export { getCachedVersion } from "./checker/cached-version"
|
||||
export { updatePinnedVersion, revertPinnedVersion } from "./checker/pinned-version-updater"
|
||||
export { getLatestVersion } from "./checker/latest-version"
|
||||
export { checkForUpdate } from "./checker/check-for-update"
|
||||
export { syncCachePackageJsonToIntent } from "./checker/sync-package-json"
|
||||
|
||||
193
src/hooks/auto-update-checker/checker/sync-package-json.test.ts
Normal file
193
src/hooks/auto-update-checker/checker/sync-package-json.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
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).toBe(true)
|
||||
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).toBe(true)
|
||||
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).toBe(true)
|
||||
expect(readCachePackageJsonVersion()).toBe("latest")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given cache package.json already matches intent", () => {
|
||||
it("#then returns false without modifying package.json", 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).toBe(false)
|
||||
expect(readCachePackageJsonVersion()).toBe("latest")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given cache package.json does not exist", () => {
|
||||
it("#then returns false", 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).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given plugin not in cache package.json dependencies", () => {
|
||||
it("#then returns false", 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).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
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).toBe(true)
|
||||
expect(readCachePackageJsonVersion()).toBe("3.10.0")
|
||||
})
|
||||
})
|
||||
})
|
||||
88
src/hooks/auto-update-checker/checker/sync-package-json.ts
Normal file
88
src/hooks/auto-update-checker/checker/sync-package-json.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
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>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 true if package.json was updated, false otherwise
|
||||
*/
|
||||
export function syncCachePackageJsonToIntent(pluginInfo: PluginEntryInfo): boolean {
|
||||
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 false
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(cachePackageJsonPath, "utf-8")
|
||||
const pkgJson = JSON.parse(content) as CachePackageJson
|
||||
|
||||
if (!pkgJson.dependencies?.[PACKAGE_NAME]) {
|
||||
log("[auto-update-checker] Plugin not in cache package.json dependencies, nothing to sync")
|
||||
return false
|
||||
}
|
||||
|
||||
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 false
|
||||
}
|
||||
|
||||
// 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 = !/^\d+\.\d+\.\d+/.test(intentVersion)
|
||||
const currentIsSemver = /^\d+\.\d+\.\d+/.test(currentVersion)
|
||||
|
||||
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
|
||||
fs.writeFileSync(cachePackageJsonPath, JSON.stringify(pkgJson, null, 2))
|
||||
return true
|
||||
} catch (err) {
|
||||
log("[auto-update-checker] Failed to sync cache package.json:", err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -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, revertPinnedVersion, syncCachePackageJsonToIntent } from "../checker"
|
||||
import { showAutoUpdatedToast, showUpdateAvailableToast } from "./update-toasts"
|
||||
|
||||
function getPinnedVersionToastMessage(latestVersion: string): string {
|
||||
@@ -65,6 +65,10 @@ 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)
|
||||
syncCachePackageJsonToIntent(pluginInfo)
|
||||
|
||||
invalidatePackage(PACKAGE_NAME)
|
||||
|
||||
const installSuccess = await runBunInstallSafe()
|
||||
|
||||
Reference in New Issue
Block a user