@@ -10,17 +10,122 @@ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4h
1010const FETCH_TIMEOUT_MS = 3000 ;
1111
1212/**
13- * Simple semver comparison: returns true if a > b.
14- * Supports standard x.y.z format.
13+ * Parse a version string into a numeric [major, minor, patch] tuple.
14+ *
15+ * Pre-release (`-beta.1`) and build (`+build.42`) metadata are stripped
16+ * first, and any non-numeric segment is coerced to 0. This guarantees we
17+ * never produce `NaN` (which makes every comparison silently false) for
18+ * versions like `2.0.0-beta.1` where `Number("0-beta")` would otherwise be
19+ * `NaN`.
1520 */
16- function isNewerVersion ( a : string , b : string ) : boolean {
17- const pa = a . split ( "." ) . map ( Number ) ;
18- const pb = b . split ( "." ) . map ( Number ) ;
19- for ( let i = 0 ; i < 3 ; i ++ ) {
20- if ( ( pa [ i ] ?? 0 ) > ( pb [ i ] ?? 0 ) ) return true ;
21- if ( ( pa [ i ] ?? 0 ) < ( pb [ i ] ?? 0 ) ) return false ;
21+ export function parseVersion ( version : string ) : [ number , number , number ] {
22+ const core = String ( version ) . split ( "+" ) [ 0 ] . split ( "-" ) [ 0 ] . trim ( ) ;
23+ const parts = core . split ( "." ) . map ( ( s ) => {
24+ const n = Number ( s ) ;
25+ return Number . isFinite ( n ) ? n : 0 ;
26+ } ) ;
27+ return [ parts [ 0 ] ?? 0 , parts [ 1 ] ?? 0 , parts [ 2 ] ?? 0 ] ;
28+ }
29+
30+ /**
31+ * Extract the pre-release suffix of a version (e.g. `1.4.2-beta.1` -> `beta.1`),
32+ * ignoring build metadata. Returns `""` for a plain release (`1.4.2`).
33+ */
34+ function prereleaseOf ( version : string ) : string {
35+ const core = String ( version ) . split ( "+" ) [ 0 ] ;
36+ const idx = core . indexOf ( "-" ) ;
37+ return idx >= 0 ? core . slice ( idx + 1 ) : "" ;
38+ }
39+
40+ /**
41+ * True if the version is a pre-release (carries a `-suffix`), e.g.
42+ * `1.4.2-beta.1` or `0.0.0-beta-e0a7c86`. Build metadata (`+build`) is ignored.
43+ */
44+ export function isPrerelease ( version : string ) : boolean {
45+ return prereleaseOf ( version ) !== "" ;
46+ }
47+
48+ function isNumericIdentifier ( s : string ) : boolean {
49+ return s . length > 0 && / ^ [ 0 - 9 ] + $ / . test ( s ) ;
50+ }
51+
52+ /**
53+ * Compare two pre-release suffixes per semver precedence rules.
54+ * Returns >0 if `a` has higher precedence, <0 if lower, 0 if equal.
55+ *
56+ * A release (empty suffix) has HIGHER precedence than any pre-release, so:
57+ * comparePrerelease("", "beta.1") -> >0 (1.4.2 > 1.4.2-beta.1)
58+ * comparePrerelease("beta.1", "") -> <0
59+ *
60+ * When both are pre-releases, dot-separated identifiers are compared left to
61+ * right: numeric identifiers numerically, alphanumeric lexically (ASCII), and
62+ * a numeric identifier has lower precedence than an alphanumeric one.
63+ */
64+ function comparePrerelease ( aPre : string , bPre : string ) : number {
65+ const aIds = aPre ? aPre . split ( "." ) : [ ] ;
66+ const bIds = bPre ? bPre . split ( "." ) : [ ] ;
67+ if ( aIds . length === 0 && bIds . length === 0 ) return 0 ;
68+ // A version without a pre-release outranks one with a pre-release.
69+ if ( aIds . length === 0 ) return 1 ;
70+ if ( bIds . length === 0 ) return - 1 ;
71+ const len = Math . max ( aIds . length , bIds . length ) ;
72+ for ( let i = 0 ; i < len ; i ++ ) {
73+ const ai = aIds [ i ] ;
74+ const bi = bIds [ i ] ;
75+ if ( ai === undefined ) return - 1 ; // fewer identifiers -> lower precedence
76+ if ( bi === undefined ) return 1 ;
77+ const aNum = isNumericIdentifier ( ai ) ;
78+ const bNum = isNumericIdentifier ( bi ) ;
79+ if ( aNum && bNum ) {
80+ const diff = Number ( ai ) - Number ( bi ) ;
81+ if ( diff !== 0 ) return diff > 0 ? 1 : - 1 ;
82+ } else if ( aNum !== bNum ) {
83+ // Numeric identifier has lower precedence than a non-numeric one.
84+ return aNum ? - 1 : 1 ;
85+ } else if ( ai !== bi ) {
86+ return ai > bi ? 1 : - 1 ;
87+ }
2288 }
23- return false ; // equal
89+ return 0 ;
90+ }
91+
92+ /**
93+ * Full semver precedence comparison.
94+ * Returns >0 if `a > b`, <0 if `a < b`, 0 if equal.
95+ * Respects pre-release precedence (release > pre-release).
96+ */
97+ export function compareVersion ( a : string , b : string ) : number {
98+ const [ pa0 , pa1 , pa2 ] = parseVersion ( a ) ;
99+ const [ pb0 , pb1 , pb2 ] = parseVersion ( b ) ;
100+ if ( pa0 !== pb0 ) return pa0 > pb0 ? 1 : - 1 ;
101+ if ( pa1 !== pb1 ) return pa1 > pb1 ? 1 : - 1 ;
102+ if ( pa2 !== pb2 ) return pa2 > pb2 ? 1 : - 1 ;
103+ return comparePrerelease ( prereleaseOf ( a ) , prereleaseOf ( b ) ) ;
104+ }
105+
106+ /**
107+ * Semver comparison: returns true if a > b.
108+ * Handles pre-release and build metadata with correct precedence, so a stable
109+ * release is correctly detected as newer than its own pre-release
110+ * (`isNewerVersion("1.4.2", "1.4.2-beta.1")` -> true).
111+ */
112+ export function isNewerVersion ( a : string , b : string ) : boolean {
113+ return compareVersion ( a , b ) > 0 ;
114+ }
115+
116+ /**
117+ * Policy gate for unattended auto-update.
118+ *
119+ * Auto-update runs `npm install -g @latest` without supervision, so it must
120+ * only target a stable release — never a pre-release (`2.0.0-beta.1`), since
121+ * silently jumping a user onto a beta channel is unsafe. A pre-release latest
122+ * is reported as a notification instead.
123+ *
124+ * Combined with `isMajorUpgrade`, the rule is: a significant version gap
125+ * (major bump or minor gap > 3) AND the target is a stable release.
126+ */
127+ export function shouldAutoUpdate ( latest : string , current : string ) : boolean {
128+ return isMajorUpgrade ( latest , current ) && ! isPrerelease ( latest ) ;
24129}
25130
26131interface UpdateState {
@@ -78,8 +183,8 @@ export function getPendingUpdateNotification(): string | null {
78183 * 2. Same major, but new minor - current minor > 3
79184 */
80185export function isMajorUpgrade ( latest : string , current : string ) : boolean {
81- const [ latestMajor , latestMinor ] = latest . split ( "." ) . map ( Number ) ;
82- const [ currentMajor , currentMinor ] = current . split ( "." ) . map ( Number ) ;
186+ const [ latestMajor , latestMinor ] = parseVersion ( latest ) ;
187+ const [ currentMajor , currentMinor ] = parseVersion ( current ) ;
83188
84189 // Condition 1: major version bump
85190 if ( latestMajor > currentMajor ) return true ;
@@ -90,6 +195,15 @@ export function isMajorUpgrade(latest: string, current: string): boolean {
90195 return false ;
91196}
92197
198+ /**
199+ * Extract a single-line error message from an unknown thrown value,
200+ * so failures can be surfaced to the user instead of swallowed.
201+ */
202+ function errorMessage ( err : unknown ) : string {
203+ if ( err instanceof Error ) return err . message ;
204+ return String ( err ) ;
205+ }
206+
93207/**
94208 * Perform auto-update: install latest version globally and update agent skill.
95209 * Returns true if update succeeded, false otherwise.
@@ -105,8 +219,8 @@ export async function performAutoUpdate(
105219 const dim = isTTY ? "\x1b[2m" : "" ;
106220 const reset = isTTY ? "\x1b[0m" : "" ;
107221
108- const [ latestMajor ] = latestVersion . split ( "." ) . map ( Number ) ;
109- const [ currentMajor ] = currentVersion . split ( "." ) . map ( Number ) ;
222+ const [ latestMajor ] = parseVersion ( latestVersion ) ;
223+ const [ currentMajor ] = parseVersion ( currentVersion ) ;
110224 const isMajorBump = latestMajor > currentMajor ;
111225
112226 process . stderr . write ( "\n" ) ;
@@ -129,52 +243,58 @@ export async function performAutoUpdate(
129243 const { execSync } = await import ( "child_process" ) ;
130244 execSync ( cmd , { stdio : "inherit" } ) ;
131245
132- // Verify installed version
246+ // Verify the actually-installed version by reading the global package.json.
247+ // We must NOT rely on `bl --version`: the user may run via npx, a local
248+ // install, or a custom bin name, in which case `bl` on PATH points at the
249+ // wrong binary (or nothing at all). Reading the installed package directly
250+ // is correct regardless of how the CLI was invoked.
133251 let newVer : string | null = null ;
134252 try {
135- const rawVer = execSync ( "bl --version 2>/dev/null" , { encoding : "utf-8" } ) . trim ( ) ;
136- newVer = rawVer . replace ( / ^ b l \s + / , "" ) ;
137- } catch {
138- /* ignore */
139- }
140-
141- // Update cached state
142- try {
143- const { writeFileSync } = await import ( "fs" ) ;
144- const { join } = await import ( "path" ) ;
145- const { getConfigDir } = await import ( "bailian-cli-core" ) ;
146- const stateFile = join ( getConfigDir ( ) , "update-state.json" ) ;
147- writeFileSync (
148- stateFile ,
149- JSON . stringify ( { lastChecked : Date . now ( ) , latestVersion : newVer ?? latestVersion } ) ,
253+ const globalRoot = execSync ( "npm root -g" , { encoding : "utf-8" } ) . trim ( ) ;
254+ const pkgPath = join ( globalRoot , NPM_PACKAGE , "package.json" ) ;
255+ const rawPkg = readFileSync ( pkgPath , "utf-8" ) ;
256+ const pkg = JSON . parse ( rawPkg ) as { version ?: string } ;
257+ newVer = pkg . version ?? null ;
258+ } catch ( err ) {
259+ process . stderr . write (
260+ ` ${ yellow } ⚠ Could not verify installed version: ${ errorMessage ( err ) } ${ reset } \n` ,
150261 ) ;
151- } catch {
152- /* ignore */
153262 }
154263
264+ // Update cached state. writeState swallows errors internally: state caching
265+ // is non-critical and must never break the CLI startup path.
266+ writeState ( { lastChecked : Date . now ( ) , latestVersion : newVer ?? latestVersion } ) ;
267+
155268 process . stderr . write (
156269 ` ${ green } ✓ Update complete: ${ currentVersion } → ${ newVer ?? latestVersion } ${ reset } \n` ,
157270 ) ;
158271 process . stderr . write ( ` ${ dim } Run ${ cyan } bl --version${ reset } ${ dim } to verify.${ reset } \n\n` ) ;
159272
160273 // Update agent skill
161274 try {
162- const { execSync : exec } = await import ( "child_process" ) ;
163275 process . stderr . write ( ` ${ dim } Syncing agent skill...${ reset } \n` ) ;
164- exec ( `npx skills add modelstudioai/cli --all -g -y` , { stdio : "inherit" } ) ;
276+ execSync ( `npx skills add modelstudioai/cli --all -g -y` , { stdio : "inherit" } ) ;
165277 process . stderr . write ( ` ${ green } ✓ Agent skill updated.${ reset } \n\n` ) ;
166- } catch {
278+ } catch ( err ) {
279+ // Surface the reason the skill sync failed rather than swallowing it
280+ // silently, but keep degradation: the CLI itself already updated.
281+ process . stderr . write ( ` ${ yellow } ⚠ Agent skill sync failed: ${ errorMessage ( err ) } ${ reset } \n` ) ;
167282 process . stderr . write (
168- ` ${ yellow } Agent skill sync skipped (run manually: npx skills add modelstudioai/cli --all -g -y) ${ reset } \n\n` ,
283+ ` ${ yellow } Run manually: npx skills add modelstudioai/cli --all -g -y${ reset } \n\n` ,
169284 ) ;
170285 }
171286
172287 // Clear pending notification
173288 pendingNotification = null ;
174289 return true ;
175- } catch {
176- process . stderr . write ( ` ${ yellow } ⚠ Auto-update failed. Please run manually:${ reset } \n` ) ;
177- process . stderr . write ( ` ${ cyan } ${ cmd } ${ reset } \n\n` ) ;
290+ } catch ( err ) {
291+ // npm install failure — most commonly EACCES (global installs often need
292+ // elevated permissions). Tell the user *why* it failed, not just *that*.
293+ process . stderr . write ( ` ${ yellow } ⚠ Auto-update failed: ${ errorMessage ( err ) } ${ reset } \n` ) ;
294+ process . stderr . write (
295+ ` ${ yellow } If this is a permissions error (EACCES), retry with sudo or fix npm perms.${ reset } \n` ,
296+ ) ;
297+ process . stderr . write ( ` ${ yellow } Run manually:${ reset } ${ cyan } ${ cmd } ${ reset } \n\n` ) ;
178298 return false ;
179299 }
180300}
0 commit comments