fix(auto-update): treat only explicit semver pins as user-pinned
Fixes #1920 Installer-written exact versions (e.g., oh-my-opencode@3.5.2) were incorrectly treated as user-pinned, blocking auto-updates for all installer users. Fix isPinned to only block auto-update when pinnedVersion is an explicit semver string (user's intent). Channel tags (latest, beta, next) and bare package name all allow auto-update. Fix installer fallback to return bare PACKAGE_NAME for stable versions and PACKAGE_NAME@{channel} for prerelease versions, preserving channel tracking.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test"
|
||||
import { describe, expect, test, mock, afterEach } from "bun:test"
|
||||
|
||||
import { ANTIGRAVITY_PROVIDER_CONFIG, getPluginNameWithVersion, fetchNpmDistTags, generateOmoConfig } from "./config-manager"
|
||||
import type { InstallConfig } from "./types"
|
||||
@@ -58,7 +58,7 @@ describe("getPluginNameWithVersion", () => {
|
||||
expect(result).toBe("oh-my-opencode@next")
|
||||
})
|
||||
|
||||
test("returns pinned version when no tag matches", async () => {
|
||||
test("returns prerelease channel tag when no dist-tag matches prerelease version", async () => {
|
||||
// #given npm dist-tags with beta=3.0.0-beta.3
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
@@ -70,22 +70,22 @@ describe("getPluginNameWithVersion", () => {
|
||||
// #when current version is old beta 3.0.0-beta.2
|
||||
const result = await getPluginNameWithVersion("3.0.0-beta.2")
|
||||
|
||||
// #then should pin to specific version
|
||||
expect(result).toBe("oh-my-opencode@3.0.0-beta.2")
|
||||
// #then should preserve prerelease channel
|
||||
expect(result).toBe("oh-my-opencode@beta")
|
||||
})
|
||||
|
||||
test("returns pinned version when fetch fails", async () => {
|
||||
test("returns prerelease channel tag when fetch fails", async () => {
|
||||
// #given network failure
|
||||
globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch
|
||||
|
||||
// #when current version is 3.0.0-beta.3
|
||||
const result = await getPluginNameWithVersion("3.0.0-beta.3")
|
||||
|
||||
// #then should fall back to pinned version
|
||||
expect(result).toBe("oh-my-opencode@3.0.0-beta.3")
|
||||
// #then should preserve prerelease channel
|
||||
expect(result).toBe("oh-my-opencode@beta")
|
||||
})
|
||||
|
||||
test("returns pinned version when npm returns non-ok response", async () => {
|
||||
test("returns bare package name when npm returns non-ok response for stable version", async () => {
|
||||
// #given npm returns 404
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
@@ -97,8 +97,8 @@ describe("getPluginNameWithVersion", () => {
|
||||
// #when current version is 2.14.0
|
||||
const result = await getPluginNameWithVersion("2.14.0")
|
||||
|
||||
// #then should fall back to pinned version
|
||||
expect(result).toBe("oh-my-opencode@2.14.0")
|
||||
// #then should fall back to bare package entry
|
||||
expect(result).toBe("oh-my-opencode")
|
||||
})
|
||||
|
||||
test("prioritizes latest over other tags when version matches multiple", async () => {
|
||||
|
||||
@@ -3,6 +3,15 @@ import { fetchNpmDistTags } from "./npm-dist-tags"
|
||||
const PACKAGE_NAME = "oh-my-opencode"
|
||||
const PRIORITIZED_TAGS = ["latest", "beta", "next"] as const
|
||||
|
||||
function getFallbackEntry(version: string): string {
|
||||
const prereleaseMatch = version.match(/-([a-zA-Z][a-zA-Z0-9]*)\./)
|
||||
if (prereleaseMatch) {
|
||||
return `${PACKAGE_NAME}@${prereleaseMatch[1]}`
|
||||
}
|
||||
|
||||
return PACKAGE_NAME
|
||||
}
|
||||
|
||||
export async function getPluginNameWithVersion(currentVersion: string): Promise<string> {
|
||||
const distTags = await fetchNpmDistTags(PACKAGE_NAME)
|
||||
|
||||
@@ -15,5 +24,5 @@ export async function getPluginNameWithVersion(currentVersion: string): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
return `${PACKAGE_NAME}@${currentVersion}`
|
||||
return getFallbackEntry(currentVersion)
|
||||
}
|
||||
|
||||
73
src/hooks/auto-update-checker/checker/plugin-entry.test.ts
Normal file
73
src/hooks/auto-update-checker/checker/plugin-entry.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import * as fs from "node:fs"
|
||||
import * as os from "node:os"
|
||||
import * as path from "node:path"
|
||||
import { findPluginEntry } from "./plugin-entry"
|
||||
|
||||
describe("findPluginEntry", () => {
|
||||
let temporaryDirectory: string
|
||||
let configPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "omo-plugin-entry-test-"))
|
||||
const opencodeDirectory = path.join(temporaryDirectory, ".opencode")
|
||||
fs.mkdirSync(opencodeDirectory, { recursive: true })
|
||||
configPath = path.join(opencodeDirectory, "opencode.json")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(temporaryDirectory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("returns unpinned for bare package name", () => {
|
||||
// #given plugin is configured without a tag
|
||||
fs.writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode"] }))
|
||||
|
||||
// #when plugin entry is detected
|
||||
const pluginInfo = findPluginEntry(temporaryDirectory)
|
||||
|
||||
// #then entry is not pinned
|
||||
expect(pluginInfo).not.toBeNull()
|
||||
expect(pluginInfo?.isPinned).toBe(false)
|
||||
expect(pluginInfo?.pinnedVersion).toBeNull()
|
||||
})
|
||||
|
||||
test("returns unpinned for latest dist-tag", () => {
|
||||
// #given plugin is configured with latest dist-tag
|
||||
fs.writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@latest"] }))
|
||||
|
||||
// #when plugin entry is detected
|
||||
const pluginInfo = findPluginEntry(temporaryDirectory)
|
||||
|
||||
// #then latest is treated as channel, not pin
|
||||
expect(pluginInfo).not.toBeNull()
|
||||
expect(pluginInfo?.isPinned).toBe(false)
|
||||
expect(pluginInfo?.pinnedVersion).toBe("latest")
|
||||
})
|
||||
|
||||
test("returns unpinned for beta dist-tag", () => {
|
||||
// #given plugin is configured with beta dist-tag
|
||||
fs.writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@beta"] }))
|
||||
|
||||
// #when plugin entry is detected
|
||||
const pluginInfo = findPluginEntry(temporaryDirectory)
|
||||
|
||||
// #then beta is treated as channel, not pin
|
||||
expect(pluginInfo).not.toBeNull()
|
||||
expect(pluginInfo?.isPinned).toBe(false)
|
||||
expect(pluginInfo?.pinnedVersion).toBe("beta")
|
||||
})
|
||||
|
||||
test("returns pinned for explicit semver", () => {
|
||||
// #given plugin is configured with explicit version
|
||||
fs.writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@3.5.2"] }))
|
||||
|
||||
// #when plugin entry is detected
|
||||
const pluginInfo = findPluginEntry(temporaryDirectory)
|
||||
|
||||
// #then explicit semver is treated as pin
|
||||
expect(pluginInfo).not.toBeNull()
|
||||
expect(pluginInfo?.isPinned).toBe(true)
|
||||
expect(pluginInfo?.pinnedVersion).toBe("3.5.2")
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,10 @@ export interface PluginEntryInfo {
|
||||
configPath: string
|
||||
}
|
||||
|
||||
function isExplicitVersionPin(pinnedVersion: string): boolean {
|
||||
return /^\d+\.\d+\.\d+/.test(pinnedVersion)
|
||||
}
|
||||
|
||||
export function findPluginEntry(directory: string): PluginEntryInfo | null {
|
||||
for (const configPath of getConfigPaths(directory)) {
|
||||
try {
|
||||
@@ -25,8 +29,8 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null {
|
||||
}
|
||||
if (entry.startsWith(`${PACKAGE_NAME}@`)) {
|
||||
const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1)
|
||||
const isPinned = pinnedVersion !== "latest"
|
||||
return { entry, isPinned, pinnedVersion: isPinned ? pinnedVersion : null, configPath }
|
||||
const isPinned = isExplicitVersionPin(pinnedVersion)
|
||||
return { entry, isPinned, pinnedVersion, configPath }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -80,14 +80,16 @@ describe("runBackgroundUpdateCheck", () => {
|
||||
expect(mockUpdatePinnedVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("#then should show update-available toast instead", async () => {
|
||||
it("#then should show manual-update toast message", async () => {
|
||||
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
|
||||
|
||||
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(
|
||||
mockCtx,
|
||||
"3.5.0",
|
||||
mockGetToastMessage
|
||||
)
|
||||
expect(mockShowUpdateAvailableToast).toHaveBeenCalledTimes(1)
|
||||
|
||||
const [toastContext, latestVersion, getToastMessage] = mockShowUpdateAvailableToast.mock.calls[0] ?? []
|
||||
expect(toastContext).toBe(mockCtx)
|
||||
expect(latestVersion).toBe("3.5.0")
|
||||
expect(typeof getToastMessage).toBe("function")
|
||||
expect(getToastMessage(true, "3.5.0")).toBe("Update available: 3.5.0 (version pinned, update manually)")
|
||||
})
|
||||
|
||||
it("#then should NOT run bun install", async () => {
|
||||
|
||||
@@ -7,6 +7,10 @@ import { extractChannel } from "../version-channel"
|
||||
import { findPluginEntry, getCachedVersion, getLatestVersion, revertPinnedVersion } from "../checker"
|
||||
import { showAutoUpdatedToast, showUpdateAvailableToast } from "./update-toasts"
|
||||
|
||||
function getPinnedVersionToastMessage(latestVersion: string): string {
|
||||
return `Update available: ${latestVersion} (version pinned, update manually)`
|
||||
}
|
||||
|
||||
async function runBunInstallSafe(): Promise<boolean> {
|
||||
try {
|
||||
return await runBunInstall()
|
||||
@@ -56,7 +60,7 @@ export async function runBackgroundUpdateCheck(
|
||||
}
|
||||
|
||||
if (pluginInfo.isPinned) {
|
||||
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
|
||||
await showUpdateAvailableToast(ctx, latestVersion, () => getPinnedVersionToastMessage(latestVersion))
|
||||
log(`[auto-update-checker] User-pinned version detected (${pluginInfo.entry}), skipping auto-update. Notification only.`)
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user