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:
YeonGyu-Kim
2026-02-21 02:24:43 +09:00
parent c0758ff20c
commit 7f4055ab37
6 changed files with 112 additions and 20 deletions
@@ -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
}