fix(auto-update): implement channel-based version fetching

Add support for npm dist-tag channels (@beta, @next, @canary) in auto-update mechanism. Users pinned to oh-my-opencode@beta now correctly fetch and compare against beta channel instead of stable latest.

- Add extractChannel() to detect channel from version string
- Modify getLatestVersion() to accept channel parameter
- Update auto-update flow to use channel-aware fetching
- Add comprehensive tests for channel detection and fetching
- Resolves #687

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
sisyphus-dev-ai
2026-01-11 09:56:09 +00:00
parent f27e93bcc8
commit 612e9b3e03
4 changed files with 161 additions and 29 deletions

View File

@@ -0,0 +1,24 @@
import { describe, test, expect } from "bun:test"
import { getLatestVersion } from "./checker"
describe("auto-update-checker/checker", () => {
describe("getLatestVersion", () => {
test("accepts channel parameter", async () => {
const result = await getLatestVersion("beta")
expect(typeof result === "string" || result === null).toBe(true)
})
test("accepts latest channel", async () => {
const result = await getLatestVersion("latest")
expect(typeof result === "string" || result === null).toBe(true)
})
test("works without channel (defaults to latest)", async () => {
const result = await getLatestVersion()
expect(typeof result === "string" || result === null).toBe(true)
})
})
})

View File

@@ -231,7 +231,7 @@ export function updatePinnedVersion(configPath: string, oldEntry: string, newVer
}
}
export async function getLatestVersion(): Promise<string | null> {
export async function getLatestVersion(channel: string = "latest"): Promise<string | null> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT)
@@ -244,7 +244,7 @@ export async function getLatestVersion(): Promise<string | null> {
if (!response.ok) return null
const data = (await response.json()) as NpmDistTags
return data.latest ?? null
return data[channel] ?? data.latest ?? null
} catch {
return null
} finally {
@@ -264,24 +264,21 @@ export async function checkForUpdate(directory: string): Promise<UpdateCheckResu
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: false }
}
if (pluginInfo.isPinned) {
log(`[auto-update-checker] Version pinned to ${pluginInfo.pinnedVersion}, skipping update check`)
return { needsUpdate: false, currentVersion: pluginInfo.pinnedVersion, latestVersion: null, isLocalDev: false, isPinned: true }
}
const currentVersion = getCachedVersion()
const currentVersion = getCachedVersion() ?? pluginInfo.pinnedVersion
if (!currentVersion) {
log("[auto-update-checker] No cached version found")
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: false }
}
const latestVersion = await getLatestVersion()
const { extractChannel } = await import("./index")
const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion)
const latestVersion = await getLatestVersion(channel)
if (!latestVersion) {
log("[auto-update-checker] Failed to fetch latest version")
return { needsUpdate: false, currentVersion, latestVersion: null, isLocalDev: false, isPinned: false }
log("[auto-update-checker] Failed to fetch latest version for channel:", channel)
return { needsUpdate: false, currentVersion, latestVersion: null, isLocalDev: false, isPinned: pluginInfo.isPinned }
}
const needsUpdate = currentVersion !== latestVersion
log(`[auto-update-checker] Current: ${currentVersion}, Latest: ${latestVersion}, NeedsUpdate: ${needsUpdate}`)
return { needsUpdate, currentVersion, latestVersion, isLocalDev: false, isPinned: false }
log(`[auto-update-checker] Current: ${currentVersion}, Latest (${channel}): ${latestVersion}, NeedsUpdate: ${needsUpdate}`)
return { needsUpdate, currentVersion, latestVersion, isLocalDev: false, isPinned: pluginInfo.isPinned }
}

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test"
import { isPrereleaseVersion, isDistTag, isPrereleaseOrDistTag } from "./index"
import { isPrereleaseVersion, isDistTag, isPrereleaseOrDistTag, extractChannel } from "./index"
describe("auto-update-checker", () => {
describe("isPrereleaseVersion", () => {
@@ -150,4 +150,105 @@ describe("auto-update-checker", () => {
expect(result).toBe(false)
})
})
describe("extractChannel", () => {
test("extracts beta from dist-tag", () => {
// #given beta dist-tag
const version = "beta"
// #when extracting channel
const result = extractChannel(version)
// #then returns beta
expect(result).toBe("beta")
})
test("extracts next from dist-tag", () => {
// #given next dist-tag
const version = "next"
// #when extracting channel
const result = extractChannel(version)
// #then returns next
expect(result).toBe("next")
})
test("extracts canary from dist-tag", () => {
// #given canary dist-tag
const version = "canary"
// #when extracting channel
const result = extractChannel(version)
// #then returns canary
expect(result).toBe("canary")
})
test("extracts beta from prerelease version", () => {
// #given beta prerelease version
const version = "3.0.0-beta.1"
// #when extracting channel
const result = extractChannel(version)
// #then returns beta
expect(result).toBe("beta")
})
test("extracts alpha from prerelease version", () => {
// #given alpha prerelease version
const version = "1.0.0-alpha"
// #when extracting channel
const result = extractChannel(version)
// #then returns alpha
expect(result).toBe("alpha")
})
test("extracts rc from prerelease version", () => {
// #given rc prerelease version
const version = "2.0.0-rc.1"
// #when extracting channel
const result = extractChannel(version)
// #then returns rc
expect(result).toBe("rc")
})
test("returns latest for stable version", () => {
// #given stable version
const version = "2.14.0"
// #when extracting channel
const result = extractChannel(version)
// #then returns latest
expect(result).toBe("latest")
})
test("returns latest for null", () => {
// #given null version
const version = null
// #when extracting channel
const result = extractChannel(version)
// #then returns latest
expect(result).toBe("latest")
})
test("handles complex prerelease identifiers", () => {
// #given complex prerelease
const version = "3.0.0-beta.1.experimental"
// #when extracting channel
const result = extractChannel(version)
// #then returns beta
expect(result).toBe("beta")
})
})
})

View File

@@ -23,6 +23,26 @@ export function isPrereleaseOrDistTag(pinnedVersion: string | null): boolean {
return isPrereleaseVersion(pinnedVersion) || isDistTag(pinnedVersion)
}
export function extractChannel(version: string | null): string {
if (!version) return "latest"
if (isDistTag(version)) {
return version
}
if (isPrereleaseVersion(version)) {
const prereleasePart = version.split("-")[1]
if (prereleasePart) {
const channelMatch = prereleasePart.match(/^(alpha|beta|rc|canary|next)/)
if (channelMatch) {
return channelMatch[1]
}
}
}
return "latest"
}
export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdateCheckerOptions = {}) {
const { showStartupToast = true, isSisyphusEnabled = false, autoUpdate = true } = options
@@ -94,18 +114,19 @@ async function runBackgroundUpdateCheck(
return
}
const latestVersion = await getLatestVersion()
const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion)
const latestVersion = await getLatestVersion(channel)
if (!latestVersion) {
log("[auto-update-checker] Failed to fetch latest version")
log("[auto-update-checker] Failed to fetch latest version for channel:", channel)
return
}
if (currentVersion === latestVersion) {
log("[auto-update-checker] Already on latest version")
log("[auto-update-checker] Already on latest version for channel:", channel)
return
}
log(`[auto-update-checker] Update available: ${currentVersion}${latestVersion}`)
log(`[auto-update-checker] Update available (${channel}): ${currentVersion}${latestVersion}`)
if (!autoUpdate) {
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
@@ -113,18 +134,7 @@ async function runBackgroundUpdateCheck(
return
}
// Check if current version is a prerelease - don't auto-downgrade prerelease to stable
if (isPrereleaseVersion(currentVersion)) {
log(`[auto-update-checker] Skipping auto-update for prerelease version: ${currentVersion}`)
return
}
if (pluginInfo.isPinned) {
if (isPrereleaseOrDistTag(pluginInfo.pinnedVersion)) {
log(`[auto-update-checker] Skipping auto-update for prerelease/dist-tag: ${pluginInfo.pinnedVersion}`)
return
}
const updated = updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion)
if (!updated) {
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)