diff --git a/src/cli/config-manager.test.ts b/src/cli/config-manager.test.ts index 97a4eddb8..ad73c3d44 100644 --- a/src/cli/config-manager.test.ts +++ b/src/cli/config-manager.test.ts @@ -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 () => { diff --git a/src/cli/config-manager/plugin-name-with-version.ts b/src/cli/config-manager/plugin-name-with-version.ts index a80ada643..7bffce842 100644 --- a/src/cli/config-manager/plugin-name-with-version.ts +++ b/src/cli/config-manager/plugin-name-with-version.ts @@ -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 { const distTags = await fetchNpmDistTags(PACKAGE_NAME) @@ -15,5 +24,5 @@ export async function getPluginNameWithVersion(currentVersion: string): Promise< } } - return `${PACKAGE_NAME}@${currentVersion}` + return getFallbackEntry(currentVersion) } diff --git a/src/hooks/auto-update-checker/checker/plugin-entry.test.ts b/src/hooks/auto-update-checker/checker/plugin-entry.test.ts new file mode 100644 index 000000000..34431239d --- /dev/null +++ b/src/hooks/auto-update-checker/checker/plugin-entry.test.ts @@ -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") + }) +}) diff --git a/src/hooks/auto-update-checker/checker/plugin-entry.ts b/src/hooks/auto-update-checker/checker/plugin-entry.ts index eb9f198d7..7aa79cc1e 100644 --- a/src/hooks/auto-update-checker/checker/plugin-entry.ts +++ b/src/hooks/auto-update-checker/checker/plugin-entry.ts @@ -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 { diff --git a/src/hooks/auto-update-checker/hook/background-update-check.test.ts b/src/hooks/auto-update-checker/hook/background-update-check.test.ts index 4a770b305..69d514211 100644 --- a/src/hooks/auto-update-checker/hook/background-update-check.test.ts +++ b/src/hooks/auto-update-checker/hook/background-update-check.test.ts @@ -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 () => { diff --git a/src/hooks/auto-update-checker/hook/background-update-check.ts b/src/hooks/auto-update-checker/hook/background-update-check.ts index 16de7628a..161031839 100644 --- a/src/hooks/auto-update-checker/hook/background-update-check.ts +++ b/src/hooks/auto-update-checker/hook/background-update-check.ts @@ -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 { 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 }