feat(installer): add version compatibility checking utilities

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-04-08 13:26:26 +09:00
parent a09b905b18
commit 7022a7e85d
2 changed files with 185 additions and 0 deletions
@@ -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")
})
})
@@ -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
}