Skip to content

Commit 07875c3

Browse files
authored
Merge pull request #77 from modelstudioai/feat/auto-update
feat: auto-update CLI on major version gap
2 parents fd9bba7 + 7d05649 commit 07875c3

3 files changed

Lines changed: 383 additions & 16 deletions

File tree

packages/cli/src/main.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import {
1010
import { ensureApiKey } from "./utils/ensure-key.ts";
1111
import { setupProxyFromEnv } from "./proxy.ts";
1212
import { handleError } from "./error-handler.ts";
13-
import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts";
13+
import {
14+
checkForUpdate,
15+
getPendingUpdateNotification,
16+
performAutoUpdate,
17+
shouldAutoUpdate,
18+
} from "./utils/update-checker.ts";
1419
import { maybeShowStatusBar } from "./output/status-bar.ts";
1520
import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
1621
import { CLI_VERSION } from "./version.ts";
@@ -129,12 +134,20 @@ async function main() {
129134
const isUpdateCommand = commandPath.length === 1 && commandPath[0] === "update";
130135
const newVersion = getPendingUpdateNotification();
131136
if (newVersion && !config.quiet && !isUpdateCommand) {
132-
const isTTY = process.stderr.isTTY;
133-
const yellow = isTTY ? "\x1b[33m" : "";
134-
const cyan = isTTY ? "\x1b[36m" : "";
135-
const reset = isTTY ? "\x1b[0m" : "";
136-
process.stderr.write(`\n ${yellow}Update available: ${CLI_VERSION}${newVersion}${reset}\n`);
137-
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
137+
if (shouldAutoUpdate(newVersion, CLI_VERSION)) {
138+
// 大版本差距且目标为稳定版,自动更新
139+
await performAutoUpdate(CLI_VERSION, newVersion);
140+
} else {
141+
// 普通小版本提示
142+
const isTTY = process.stderr.isTTY;
143+
const yellow = isTTY ? "\x1b[33m" : "";
144+
const cyan = isTTY ? "\x1b[36m" : "";
145+
const reset = isTTY ? "\x1b[0m" : "";
146+
process.stderr.write(
147+
`\n ${yellow}Update available: ${CLI_VERSION}${newVersion}${reset}\n`,
148+
);
149+
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
150+
}
138151
}
139152

140153
// 进程退出前尽力等待在途的埋点完成。

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

Lines changed: 237 additions & 9 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 {
@@ -71,6 +176,129 @@ export function getPendingUpdateNotification(): string | null {
71176
return pendingNotification;
72177
}
73178

179+
/**
180+
* Determines if the version gap is large enough to warrant auto-update.
181+
* Conditions (either triggers auto-update):
182+
* 1. New major > current major
183+
* 2. Same major, but new minor - current minor > 3
184+
*/
185+
export function isMajorUpgrade(latest: string, current: string): boolean {
186+
const [latestMajor, latestMinor] = parseVersion(latest);
187+
const [currentMajor, currentMinor] = parseVersion(current);
188+
189+
// Condition 1: major version bump
190+
if (latestMajor > currentMajor) return true;
191+
192+
// Condition 2: same major, minor gap > 3
193+
if (latestMajor === currentMajor && latestMinor - currentMinor > 3) return true;
194+
195+
return false;
196+
}
197+
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+
207+
/**
208+
* Perform auto-update: install latest version globally and update agent skill.
209+
* Returns true if update succeeded, false otherwise.
210+
*/
211+
export async function performAutoUpdate(
212+
currentVersion: string,
213+
latestVersion: string,
214+
): Promise<boolean> {
215+
const isTTY = process.stderr.isTTY;
216+
const green = isTTY ? "\x1b[32m" : "";
217+
const yellow = isTTY ? "\x1b[33m" : "";
218+
const cyan = isTTY ? "\x1b[36m" : "";
219+
const dim = isTTY ? "\x1b[2m" : "";
220+
const reset = isTTY ? "\x1b[0m" : "";
221+
222+
const [latestMajor] = parseVersion(latestVersion);
223+
const [currentMajor] = parseVersion(currentVersion);
224+
const isMajorBump = latestMajor > currentMajor;
225+
226+
process.stderr.write("\n");
227+
process.stderr.write(` ${yellow}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${reset}\n`);
228+
if (isMajorBump) {
229+
process.stderr.write(
230+
` ${yellow}⚡ Major update detected: ${currentVersion}${latestVersion}${reset}\n`,
231+
);
232+
} else {
233+
process.stderr.write(
234+
` ${yellow}⚡ Significant update detected: ${currentVersion}${latestVersion}${reset}\n`,
235+
);
236+
}
237+
process.stderr.write(` ${dim}Auto-updating to keep your CLI up to date...${reset}\n`);
238+
process.stderr.write(` ${yellow}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${reset}\n\n`);
239+
240+
const cmd = `npm install -g ${NPM_PACKAGE}@latest`;
241+
242+
try {
243+
const { execSync } = await import("child_process");
244+
execSync(cmd, { stdio: "inherit" });
245+
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.
251+
let newVer: string | null = null;
252+
try {
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`,
261+
);
262+
}
263+
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+
268+
process.stderr.write(
269+
` ${green}✓ Update complete: ${currentVersion}${newVer ?? latestVersion}${reset}\n`,
270+
);
271+
process.stderr.write(` ${dim}Run ${cyan}bl --version${reset}${dim} to verify.${reset}\n\n`);
272+
273+
// Update agent skill
274+
try {
275+
process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`);
276+
execSync(`npx skills add modelstudioai/cli --all -g -y`, { stdio: "inherit" });
277+
process.stderr.write(` ${green}✓ Agent skill updated.${reset}\n\n`);
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`);
282+
process.stderr.write(
283+
` ${yellow} Run manually: npx skills add modelstudioai/cli --all -g -y${reset}\n\n`,
284+
);
285+
}
286+
287+
// Clear pending notification
288+
pendingNotification = null;
289+
return true;
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`);
298+
return false;
299+
}
300+
}
301+
74302
export async function checkForUpdate(currentVersion: string): Promise<void> {
75303
// Skip in CI / non-TTY environments
76304
if (process.env.CI || !process.stderr.isTTY) return;

0 commit comments

Comments
 (0)