From 7022a7e85d393ecb5717772bd92143c7b1bab0e9 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:26:26 +0900 Subject: [PATCH] feat(installer): add version compatibility checking utilities Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../version-compatibility.test.ts | 82 ++++++++++++++ .../config-manager/version-compatibility.ts | 103 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 src/cli/config-manager/version-compatibility.test.ts create mode 100644 src/cli/config-manager/version-compatibility.ts diff --git a/src/cli/config-manager/version-compatibility.test.ts b/src/cli/config-manager/version-compatibility.test.ts new file mode 100644 index 000000000..95f743452 --- /dev/null +++ b/src/cli/config-manager/version-compatibility.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test" +import { + checkVersionCompatibility, + extractVersionFromPluginEntry, +} from "./version-compatibility" + +describe("checkVersionCompatibility", () => { + it("allows fresh install when no current version", () => { + const result = checkVersionCompatibility(null, "3.15.0") + expect(result.canUpgrade).toBe(true) + expect(result.isDowngrade).toBe(false) + expect(result.requiresMigration).toBe(false) + }) + + it("detects same version as already installed", () => { + const result = checkVersionCompatibility("3.15.0", "3.15.0") + expect(result.canUpgrade).toBe(true) + expect(result.reason).toContain("already installed") + }) + + it("blocks downgrade from higher to lower version", () => { + const result = checkVersionCompatibility("3.15.0", "3.14.0") + expect(result.canUpgrade).toBe(false) + expect(result.isDowngrade).toBe(true) + expect(result.reason).toContain("Downgrade") + }) + + it("allows patch version upgrade", () => { + const result = checkVersionCompatibility("3.15.0", "3.15.1") + expect(result.canUpgrade).toBe(true) + expect(result.isMajorBump).toBe(false) + expect(result.requiresMigration).toBe(false) + }) + + it("allows minor version upgrade", () => { + const result = checkVersionCompatibility("3.15.0", "3.16.0") + expect(result.canUpgrade).toBe(true) + expect(result.isMajorBump).toBe(false) + expect(result.requiresMigration).toBe(false) + }) + + it("detects major version bump requiring migration", () => { + const result = checkVersionCompatibility("3.15.0", "4.0.0") + expect(result.canUpgrade).toBe(true) + expect(result.isMajorBump).toBe(true) + expect(result.requiresMigration).toBe(true) + expect(result.reason).toContain("Major version upgrade") + }) + + it("handles v prefix in versions", () => { + const result = checkVersionCompatibility("v3.15.0", "v3.16.0") + expect(result.canUpgrade).toBe(true) + expect(result.isDowngrade).toBe(false) + }) + + it("handles mixed v prefix", () => { + const result = checkVersionCompatibility("3.15.0", "v3.16.0") + expect(result.canUpgrade).toBe(true) + }) +}) + +describe("extractVersionFromPluginEntry", () => { + it("extracts version from canonical plugin entry", () => { + const version = extractVersionFromPluginEntry("oh-my-openagent@3.15.0") + expect(version).toBe("3.15.0") + }) + + it("extracts version from legacy plugin entry", () => { + const version = extractVersionFromPluginEntry("oh-my-opencode@3.14.0") + expect(version).toBe("3.14.0") + }) + + it("returns null for bare plugin entry", () => { + const version = extractVersionFromPluginEntry("oh-my-openagent") + expect(version).toBeNull() + }) + + it("handles prerelease versions", () => { + const version = extractVersionFromPluginEntry("oh-my-openagent@3.16.0-beta.1") + expect(version).toBe("3.16.0-beta.1") + }) +}) diff --git a/src/cli/config-manager/version-compatibility.ts b/src/cli/config-manager/version-compatibility.ts new file mode 100644 index 000000000..1042dc1d6 --- /dev/null +++ b/src/cli/config-manager/version-compatibility.ts @@ -0,0 +1,103 @@ +export interface VersionCompatibility { + canUpgrade: boolean + reason?: string + isDowngrade: boolean + isMajorBump: boolean + requiresMigration: boolean +} + +function parseVersion(version: string): number[] { + const clean = version.replace(/^v/, "").split("-")[0] + return clean.split(".").map(Number) +} + +function compareVersions(a: string, b: string): number { + const partsA = parseVersion(a) + const partsB = parseVersion(b) + const maxLen = Math.max(partsA.length, partsB.length) + + for (let i = 0; i < maxLen; i++) { + const numA = partsA[i] ?? 0 + const numB = partsB[i] ?? 0 + if (numA !== numB) { + return numA - numB + } + } + + return 0 +} + +export function checkVersionCompatibility( + currentVersion: string | null, + newVersion: string +): VersionCompatibility { + if (!currentVersion) { + return { + canUpgrade: true, + isDowngrade: false, + isMajorBump: false, + requiresMigration: false, + } + } + + const cleanCurrent = currentVersion.replace(/^v/, "") + const cleanNew = newVersion.replace(/^v/, "") + + try { + const comparison = compareVersions(cleanNew, cleanCurrent) + + if (comparison < 0) { + return { + canUpgrade: false, + reason: `Downgrade from ${currentVersion} to ${newVersion} is not allowed`, + isDowngrade: true, + isMajorBump: false, + requiresMigration: false, + } + } + + if (comparison === 0) { + return { + canUpgrade: true, + reason: `Version ${newVersion} is already installed`, + isDowngrade: false, + isMajorBump: false, + requiresMigration: false, + } + } + + const currentMajor = cleanCurrent.split(".")[0] + const newMajor = cleanNew.split(".")[0] + const isMajorBump = currentMajor !== newMajor + + if (isMajorBump) { + return { + canUpgrade: true, + reason: `Major version upgrade from ${currentVersion} to ${newVersion} - configuration migration may be required`, + isDowngrade: false, + isMajorBump: true, + requiresMigration: true, + } + } + + return { + canUpgrade: true, + isDowngrade: false, + isMajorBump: false, + requiresMigration: false, + } + } catch { + return { + canUpgrade: true, + reason: `Unable to compare versions ${currentVersion} and ${newVersion} - proceeding with caution`, + isDowngrade: false, + isMajorBump: false, + requiresMigration: false, + } + } +} + +export function extractVersionFromPluginEntry(entry: string): string | null { + const match = entry.match(/@(.+)$/) + return match ? match[1] : null +}