Skip to content

Commit e67615e

Browse files
committed
feat: auto update
1 parent ba78d13 commit e67615e

3 files changed

Lines changed: 287 additions & 41 deletions

File tree

packages/cli/src/main.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import { handleError } from "./error-handler.ts";
1313
import {
1414
checkForUpdate,
1515
getPendingUpdateNotification,
16-
isMajorUpgrade,
1716
performAutoUpdate,
17+
shouldAutoUpdate,
1818
} from "./utils/update-checker.ts";
1919
import { maybeShowStatusBar } from "./output/status-bar.ts";
2020
import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
@@ -134,8 +134,8 @@ async function main() {
134134
const isUpdateCommand = commandPath.length === 1 && commandPath[0] === "update";
135135
const newVersion = getPendingUpdateNotification();
136136
if (newVersion && !config.quiet && !isUpdateCommand) {
137-
if (isMajorUpgrade(newVersion, CLI_VERSION)) {
138-
// 大版本差距,自动更新
137+
if (shouldAutoUpdate(newVersion, CLI_VERSION)) {
138+
// 大版本差距且目标为稳定版,自动更新
139139
await performAutoUpdate(CLI_VERSION, newVersion);
140140
} else {
141141
// 普通小版本提示

packages/cli/src/utils/update-checker.ts

Lines changed: 158 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,122 @@ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4h
1010
const 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

26131
interface UpdateState {
@@ -78,8 +183,8 @@ export function getPendingUpdateNotification(): string | null {
78183
* 2. Same major, but new minor - current minor > 3
79184
*/
80185
export 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(/^bl\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

Comments
 (0)