Skip to content

Commit 8e11c7f

Browse files
anandgupta42claude
andauthored
fix: remove semver dependency from upgrade path to prevent user lock-in (#421)
The upgrade() function is the most critical code path — if it breaks, users get permanently locked on old versions with no way to self-heal. Changes: - Replace `import semver` with zero-dependency `compareVersions()` and `isValidVersion()` — 25 lines of inline code vs 14KB external package - Replace silent `.catch(() => {})` in worker.ts with error logging - Export both helpers for testing Tests (73 total): - `compareVersions`: 24 tests covering ordering, v-prefix, prerelease, missing parts, unparseable versions, real-world altimate-code versions - `isValidVersion`: 7 tests for valid/invalid/partial versions - Decision logic: 30 tests mirroring upgrade() control flow - Module health: 4 smoke tests verifying import works and semver is gone - Parity: 11 tests comparing our implementation against semver library Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 528af75 commit 8e11c7f

File tree

3 files changed

+278
-39
lines changed

3 files changed

+278
-39
lines changed

packages/opencode/src/cli/cmd/tui/worker.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,11 @@ export const rpc = {
286286
directory: input.directory,
287287
init: InstanceBootstrap,
288288
fn: async () => {
289-
await upgrade().catch(() => {})
289+
await upgrade().catch((err) => {
290+
// Never silently swallow upgrade errors — if this fails, users
291+
// get locked on old versions with no way to self-heal.
292+
console.error("[upgrade] check failed:", String(err))
293+
})
290294
},
291295
})
292296
},

packages/opencode/src/cli/upgrade.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,60 @@ import { Bus } from "@/bus"
22
import { Config } from "@/config/config"
33
import { Flag } from "@/flag/flag"
44
import { Installation } from "@/installation"
5-
// altimate_change start — robust upgrade notification
6-
import semver from "semver"
5+
// altimate_change start — robust upgrade notification with zero external dependencies
76
import { Log } from "@/util/log"
87

98
const log = Log.create({ service: "upgrade" })
109

10+
/**
11+
* Compare two semver-like version strings. Returns:
12+
* 1 if a > b
13+
* 0 if a === b
14+
* -1 if a < b
15+
*
16+
* Handles standard "major.minor.patch" and ignores prerelease suffixes
17+
* for the numeric comparison (prerelease is always < release).
18+
*
19+
* Zero external dependencies — this function MUST NOT import any package.
20+
* If it throws, the entire upgrade path breaks and users get locked on
21+
* old versions with no way to self-heal.
22+
*/
23+
export function compareVersions(a: string, b: string): -1 | 0 | 1 {
24+
// Strip leading "v" if present
25+
const cleanA = a.replace(/^v/, "")
26+
const cleanB = b.replace(/^v/, "")
27+
28+
// Split off prerelease suffix
29+
const [coreA, preA] = cleanA.split("-", 2)
30+
const [coreB, preB] = cleanB.split("-", 2)
31+
32+
const partsA = coreA.split(".").map(Number)
33+
const partsB = coreB.split(".").map(Number)
34+
35+
// Compare major.minor.patch numerically
36+
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
37+
const numA = partsA[i] ?? 0
38+
const numB = partsB[i] ?? 0
39+
if (isNaN(numA) || isNaN(numB)) return 0 // unparseable → treat as equal (safe default)
40+
if (numA > numB) return 1
41+
if (numA < numB) return -1
42+
}
43+
44+
// Same core version: release > prerelease (e.g., 1.0.0 > 1.0.0-beta.1)
45+
if (!preA && preB) return 1
46+
if (preA && !preB) return -1
47+
48+
return 0
49+
}
50+
51+
/**
52+
* Returns true if `version` looks like a valid semver string (x.y.z with optional pre).
53+
* Intentionally lenient — just checks for at least "N.N.N" pattern.
54+
*/
55+
export function isValidVersion(version: string): boolean {
56+
return /^\d+\.\d+\.\d+/.test(version.replace(/^v/, ""))
57+
}
58+
1159
export async function upgrade() {
1260
const config = await Config.global()
1361
const method = await Installation.method()
@@ -21,9 +69,9 @@ export async function upgrade() {
2169
// Prevent downgrade: if current version is already >= latest, skip
2270
if (
2371
Installation.VERSION !== "local" &&
24-
semver.valid(Installation.VERSION) &&
25-
semver.valid(latest) &&
26-
semver.gte(Installation.VERSION, latest)
72+
isValidVersion(Installation.VERSION) &&
73+
isValidVersion(latest) &&
74+
compareVersions(Installation.VERSION, latest) >= 0
2775
) {
2876
return
2977
}

0 commit comments

Comments
 (0)