mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
import { join } from "path";
|
|
import { readFileSync, writeFileSync } from "fs";
|
|
import {
|
|
BailianError,
|
|
DEFAULT_INSTALL_PS1_URL,
|
|
DEFAULT_INSTALL_SCRIPT_URL,
|
|
getConfigDir,
|
|
getUpdateInstallMethod,
|
|
} from "bailian-cli-core";
|
|
|
|
export const NPM_REGISTRY = "https://registry.npmjs.org";
|
|
/** Default npm package; products override per-call via the `npmPackage` argument. */
|
|
export const NPM_PACKAGE = "bailian-cli";
|
|
|
|
const STATE_FILE = () => join(getConfigDir(), "update-state.json");
|
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
const FETCH_TIMEOUT_MS = 3000;
|
|
|
|
/**
|
|
* Parse a version string into a numeric [major, minor, patch] tuple.
|
|
*
|
|
* Pre-release (`-beta.1`) and build (`+build.42`) metadata are stripped
|
|
* first, and any non-numeric segment is coerced to 0. This guarantees we
|
|
* never produce `NaN` (which makes every comparison silently false) for
|
|
* versions like `2.0.0-beta.1` where `Number("0-beta")` would otherwise be
|
|
* `NaN`.
|
|
*/
|
|
export function parseVersion(version: string): [number, number, number] {
|
|
const core = String(version).split("+")[0].split("-")[0].trim();
|
|
const parts = core.split(".").map((s) => {
|
|
const n = Number(s);
|
|
return Number.isFinite(n) ? n : 0;
|
|
});
|
|
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
|
}
|
|
|
|
/**
|
|
* Extract the pre-release suffix of a version (e.g. `1.4.2-beta.1` -> `beta.1`),
|
|
* ignoring build metadata. Returns `""` for a plain release (`1.4.2`).
|
|
*/
|
|
function prereleaseOf(version: string): string {
|
|
const core = String(version).split("+")[0];
|
|
const idx = core.indexOf("-");
|
|
return idx >= 0 ? core.slice(idx + 1) : "";
|
|
}
|
|
|
|
/**
|
|
* True if the version is a pre-release (carries a `-suffix`), e.g.
|
|
* `1.4.2-beta.1` or `0.0.0-beta-e0a7c86`. Build metadata (`+build`) is ignored.
|
|
*/
|
|
export function isPrerelease(version: string): boolean {
|
|
return prereleaseOf(version) !== "";
|
|
}
|
|
|
|
function isNumericIdentifier(s: string): boolean {
|
|
return s.length > 0 && /^[0-9]+$/.test(s);
|
|
}
|
|
|
|
/**
|
|
* Compare two pre-release suffixes per semver precedence rules.
|
|
* Returns >0 if `a` has higher precedence, <0 if lower, 0 if equal.
|
|
*
|
|
* A release (empty suffix) has HIGHER precedence than any pre-release, so:
|
|
* comparePrerelease("", "beta.1") -> >0 (1.4.2 > 1.4.2-beta.1)
|
|
* comparePrerelease("beta.1", "") -> <0
|
|
*
|
|
* When both are pre-releases, dot-separated identifiers are compared left to
|
|
* right: numeric identifiers numerically, alphanumeric lexically (ASCII), and
|
|
* a numeric identifier has lower precedence than an alphanumeric one.
|
|
*/
|
|
function comparePrerelease(aPre: string, bPre: string): number {
|
|
const aIds = aPre ? aPre.split(".") : [];
|
|
const bIds = bPre ? bPre.split(".") : [];
|
|
if (aIds.length === 0 && bIds.length === 0) return 0;
|
|
// A version without a pre-release outranks one with a pre-release.
|
|
if (aIds.length === 0) return 1;
|
|
if (bIds.length === 0) return -1;
|
|
const len = Math.max(aIds.length, bIds.length);
|
|
for (let i = 0; i < len; i++) {
|
|
const ai = aIds[i];
|
|
const bi = bIds[i];
|
|
if (ai === undefined) return -1; // fewer identifiers -> lower precedence
|
|
if (bi === undefined) return 1;
|
|
const aNum = isNumericIdentifier(ai);
|
|
const bNum = isNumericIdentifier(bi);
|
|
if (aNum && bNum) {
|
|
const diff = Number(ai) - Number(bi);
|
|
if (diff !== 0) return diff > 0 ? 1 : -1;
|
|
} else if (aNum !== bNum) {
|
|
// Numeric identifier has lower precedence than a non-numeric one.
|
|
return aNum ? -1 : 1;
|
|
} else if (ai !== bi) {
|
|
return ai > bi ? 1 : -1;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Full semver precedence comparison.
|
|
* Returns >0 if `a > b`, <0 if `a < b`, 0 if equal.
|
|
* Respects pre-release precedence (release > pre-release).
|
|
*/
|
|
export function compareVersion(a: string, b: string): number {
|
|
const [pa0, pa1, pa2] = parseVersion(a);
|
|
const [pb0, pb1, pb2] = parseVersion(b);
|
|
if (pa0 !== pb0) return pa0 > pb0 ? 1 : -1;
|
|
if (pa1 !== pb1) return pa1 > pb1 ? 1 : -1;
|
|
if (pa2 !== pb2) return pa2 > pb2 ? 1 : -1;
|
|
return comparePrerelease(prereleaseOf(a), prereleaseOf(b));
|
|
}
|
|
|
|
/**
|
|
* Semver comparison: returns true if a > b.
|
|
* Handles pre-release and build metadata with correct precedence, so a stable
|
|
* release is correctly detected as newer than its own pre-release
|
|
* (`isNewerVersion("1.4.2", "1.4.2-beta.1")` -> true).
|
|
*/
|
|
export function isNewerVersion(a: string, b: string): boolean {
|
|
return compareVersion(a, b) > 0;
|
|
}
|
|
|
|
/**
|
|
* Policy gate for unattended auto-update.
|
|
*
|
|
* Auto-update runs `npm install -g @latest` without supervision, so it must
|
|
* only target a stable release — never a pre-release (`2.0.0-beta.1`), since
|
|
* silently jumping a user onto a beta channel is unsafe. A pre-release latest
|
|
* is reported as a notification instead.
|
|
*
|
|
* Combined with `isMajorUpgrade`, the rule is: a significant version gap
|
|
* (major bump or minor gap > 3) AND the target is a stable release.
|
|
*/
|
|
export function shouldAutoUpdate(latest: string, current: string): boolean {
|
|
return isMajorUpgrade(latest, current) && !isPrerelease(latest);
|
|
}
|
|
|
|
interface UpdateState {
|
|
lastChecked: number;
|
|
latestVersion: string;
|
|
}
|
|
|
|
function readState(): UpdateState | null {
|
|
try {
|
|
const raw = readFileSync(STATE_FILE(), "utf-8");
|
|
return JSON.parse(raw) as UpdateState;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeState(state: UpdateState): void {
|
|
try {
|
|
writeFileSync(STATE_FILE(), JSON.stringify(state));
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
export async function fetchLatestVersion(
|
|
timeoutMs: number = FETCH_TIMEOUT_MS,
|
|
npmPackage: string = NPM_PACKAGE,
|
|
): Promise<string | null> {
|
|
try {
|
|
const encoded = npmPackage.replace("/", "%2f");
|
|
const res = await fetch(`${NPM_REGISTRY}/${encoded}/latest`, {
|
|
headers: { Accept: "application/json" },
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
if (!res.ok) return null;
|
|
const data = (await res.json()) as { version?: string };
|
|
return data.version ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
let pendingNotification: string | null = null;
|
|
|
|
export function getPendingUpdateNotification(): string | null {
|
|
return pendingNotification;
|
|
}
|
|
|
|
/**
|
|
* Determines if the version gap is large enough to warrant auto-update.
|
|
* Conditions (either triggers auto-update):
|
|
* 1. New major > current major
|
|
* 2. Same major, but new minor - current minor > 3
|
|
*/
|
|
export function isMajorUpgrade(latest: string, current: string): boolean {
|
|
const [latestMajor, latestMinor] = parseVersion(latest);
|
|
const [currentMajor, currentMinor] = parseVersion(current);
|
|
|
|
// Condition 1: major version bump
|
|
if (latestMajor > currentMajor) return true;
|
|
|
|
// Condition 2: same major, minor gap > 3
|
|
if (latestMajor === currentMajor && latestMinor - currentMinor > 3) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Extract a single-line error message from an unknown thrown value,
|
|
* so failures can be surfaced to the user instead of swallowed.
|
|
*/
|
|
function errorMessage(err: unknown): string {
|
|
if (err instanceof Error) return err.message;
|
|
return String(err);
|
|
}
|
|
|
|
async function syncAgentSkillsAfterUpdate(
|
|
dim: string,
|
|
green: string,
|
|
yellow: string,
|
|
reset: string,
|
|
): Promise<void> {
|
|
try {
|
|
process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`);
|
|
const { execSync } = await import("child_process");
|
|
execSync("bl skill init", { stdio: "inherit" });
|
|
process.stderr.write(` ${green}\u2713 Agent skill updated.${reset}\n\n`);
|
|
} catch (error) {
|
|
process.stderr.write(
|
|
` ${yellow}\u26a0 Agent skill sync failed: ${errorMessage(error)}${reset}\n`,
|
|
);
|
|
process.stderr.write(` ${yellow} Run manually: bl skill init${reset}\n\n`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform auto-update for npm or binary installs.
|
|
* Returns true if update succeeded, false otherwise.
|
|
*/
|
|
export async function performAutoUpdate(
|
|
currentVersion: string,
|
|
latestVersion: string,
|
|
npmPackage: string = NPM_PACKAGE,
|
|
clientName: string = NPM_PACKAGE,
|
|
): Promise<boolean> {
|
|
const isTTY = process.stderr.isTTY;
|
|
const green = isTTY ? "\x1b[32m" : "";
|
|
const yellow = isTTY ? "\x1b[33m" : "";
|
|
const cyan = isTTY ? "\x1b[36m" : "";
|
|
const dim = isTTY ? "\x1b[2m" : "";
|
|
const reset = isTTY ? "\x1b[0m" : "";
|
|
|
|
const method = getUpdateInstallMethod({ clientName, npmPackage });
|
|
if (method === "brew" || method === "winget") {
|
|
return false;
|
|
}
|
|
|
|
const [latestMajor] = parseVersion(latestVersion);
|
|
const [currentMajor] = parseVersion(currentVersion);
|
|
const isMajorBump = latestMajor > currentMajor;
|
|
|
|
process.stderr.write("\n");
|
|
process.stderr.write(` ${yellow}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${reset}\n`);
|
|
if (isMajorBump) {
|
|
process.stderr.write(
|
|
` ${yellow}⚡ Major update detected: ${currentVersion} → ${latestVersion}${reset}\n`,
|
|
);
|
|
} else {
|
|
process.stderr.write(
|
|
` ${yellow}⚡ Significant update detected: ${currentVersion} → ${latestVersion}${reset}\n`,
|
|
);
|
|
}
|
|
process.stderr.write(` ${dim}Auto-updating to keep your CLI up to date...${reset}\n`);
|
|
process.stderr.write(` ${yellow}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${reset}\n\n`);
|
|
|
|
if (method === "binary") {
|
|
try {
|
|
const { performBinaryUpdate } = await import("./binary-update.ts");
|
|
const newVer = await performBinaryUpdate(latestVersion);
|
|
writeState({ lastChecked: Date.now(), latestVersion: newVer });
|
|
process.stderr.write(` ${green}✓ Update complete: ${currentVersion} → ${newVer}${reset}\n`);
|
|
process.stderr.write(` ${dim}Run ${cyan}bl --version${reset}${dim} to verify.${reset}\n\n`);
|
|
await syncAgentSkillsAfterUpdate(dim, green, yellow, reset);
|
|
pendingNotification = null;
|
|
return true;
|
|
} catch (err) {
|
|
process.stderr.write(` ${yellow}⚠ Auto-update failed: ${errorMessage(err)}${reset}\n`);
|
|
const reinstall =
|
|
err instanceof BailianError && err.hint
|
|
? err.hint.replace(/^Re-run:\s*/i, "")
|
|
: process.platform === "win32"
|
|
? `irm ${DEFAULT_INSTALL_PS1_URL} | iex`
|
|
: `curl -fsSL ${DEFAULT_INSTALL_SCRIPT_URL} | bash`;
|
|
process.stderr.write(` ${yellow} Re-run:${reset} ${cyan}${reinstall}${reset}\n\n`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const cmd = `npm install -g ${npmPackage}@latest`;
|
|
|
|
try {
|
|
const { execSync } = await import("child_process");
|
|
execSync(cmd, { stdio: "inherit" });
|
|
|
|
let newVer: string | null = null;
|
|
try {
|
|
const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim();
|
|
const pkgPath = join(globalRoot, npmPackage, "package.json");
|
|
const rawPkg = readFileSync(pkgPath, "utf-8");
|
|
const pkg = JSON.parse(rawPkg) as { version?: string };
|
|
newVer = pkg.version ?? null;
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
` ${yellow}⚠ Could not verify installed version: ${errorMessage(err)}${reset}\n`,
|
|
);
|
|
}
|
|
|
|
writeState({ lastChecked: Date.now(), latestVersion: newVer ?? latestVersion });
|
|
|
|
process.stderr.write(
|
|
` ${green}✓ Update complete: ${currentVersion} → ${newVer ?? latestVersion}${reset}\n`,
|
|
);
|
|
process.stderr.write(` ${dim}Run ${cyan}bl --version${reset}${dim} to verify.${reset}\n\n`);
|
|
|
|
await syncAgentSkillsAfterUpdate(dim, green, yellow, reset);
|
|
|
|
pendingNotification = null;
|
|
return true;
|
|
} catch (err) {
|
|
process.stderr.write(` ${yellow}⚠ Auto-update failed: ${errorMessage(err)}${reset}\n`);
|
|
process.stderr.write(
|
|
` ${yellow} If this is a permissions error (EACCES), retry with sudo or fix npm perms.${reset}\n`,
|
|
);
|
|
process.stderr.write(` ${yellow} Run manually:${reset} ${cyan}${cmd}${reset}\n\n`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function checkForUpdate(
|
|
currentVersion: string,
|
|
npmPackage: string = NPM_PACKAGE,
|
|
clientName: string = NPM_PACKAGE,
|
|
): Promise<void> {
|
|
const state = readState();
|
|
const now = Date.now();
|
|
|
|
if (state && now - state.lastChecked < CHECK_INTERVAL_MS) return;
|
|
|
|
const method = getUpdateInstallMethod({ clientName, npmPackage });
|
|
let latest: string | null = null;
|
|
if (method === "binary") {
|
|
try {
|
|
const { fetchBinaryChannelVersion } = await import("./binary-update.ts");
|
|
latest = await fetchBinaryChannelVersion("latest", FETCH_TIMEOUT_MS);
|
|
} catch {
|
|
latest = null;
|
|
}
|
|
if (!latest) latest = await fetchLatestVersion(FETCH_TIMEOUT_MS, npmPackage);
|
|
} else {
|
|
latest = await fetchLatestVersion(FETCH_TIMEOUT_MS, npmPackage);
|
|
}
|
|
if (!latest) return;
|
|
|
|
writeState({ lastChecked: now, latestVersion: latest });
|
|
|
|
if (latest && isNewerVersion(latest, currentVersion)) {
|
|
pendingNotification = latest;
|
|
}
|
|
}
|