Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions packages/cli/src/main.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,12 @@ import {
import { ensureApiKey } from "./utils/ensure-key.ts";
import { setupProxyFromEnv } from "./proxy.ts";
import { handleError } from "./error-handler.ts";
import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts";
import {
checkForUpdate,
getPendingUpdateNotification,
performAutoUpdate,
shouldAutoUpdate,
} from "./utils/update-checker.ts";
import { maybeShowStatusBar } from "./output/status-bar.ts";
import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
import { CLI_VERSION } from "./version.ts";
Expand DownExpand Up@@ -129,12 +134,20 @@ async function main() {
const isUpdateCommand = commandPath.length === 1 && commandPath[0] === "update";
const newVersion = getPendingUpdateNotification();
if (newVersion && !config.quiet && !isUpdateCommand) {
const isTTY = process.stderr.isTTY;
const yellow = isTTY ? "\x1b[33m" : "";
const cyan = isTTY ? "\x1b[36m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(`\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`);
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
if (shouldAutoUpdate(newVersion, CLI_VERSION)) {
// 大版本差距且目标为稳定版,自动更新
await performAutoUpdate(CLI_VERSION, newVersion);
} else {
// 普通小版本提示
const isTTY = process.stderr.isTTY;
const yellow = isTTY ? "\x1b[33m" : "";
const cyan = isTTY ? "\x1b[36m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(
`\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`,
);
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
}
}

// 进程退出前尽力等待在途的埋点完成。
Expand Down
246 changes: 237 additions & 9 deletions packages/cli/src/utils/update-checker.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,17 +10,122 @@ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4h
const FETCH_TIMEOUT_MS = 3000;

/**
* Simple semver comparison: returns true if a > b.
* Supports standard x.y.z format.
* 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`.
*/
function isNewerVersion(a: string, b: string): boolean {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
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 false; // equal
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 {
Expand DownExpand Up@@ -71,6 +176,129 @@ 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);
}

/**
* Perform auto-update: install latest version globally and update agent skill.
* Returns true if update succeeded, false otherwise.
*/
export async function performAutoUpdate(
currentVersion: string,
latestVersion: string,
): 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 [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`);

const cmd = `npm install -g ${NPM_PACKAGE}@latest`;

try {
const { execSync } = await import("child_process");
execSync(cmd, { stdio: "inherit" });

// Verify the actually-installed version by reading the global package.json.
// We must NOT rely on `bl --version`: the user may run via npx, a local
// install, or a custom bin name, in which case `bl` on PATH points at the
// wrong binary (or nothing at all). Reading the installed package directly
// is correct regardless of how the CLI was invoked.
let newVer: string | null = null;
try {
const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim();
const pkgPath = join(globalRoot, NPM_PACKAGE, "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`,
);
}

// Update cached state. writeState swallows errors internally: state caching
// is non-critical and must never break the CLI startup path.
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`);

// Update agent skill
try {
process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`);
execSync(`npx skills add modelstudioai/cli --all -g -y`, { stdio: "inherit" });
process.stderr.write(` ${green}✓ Agent skill updated.${reset}\n\n`);
} catch (err) {
// Surface the reason the skill sync failed rather than swallowing it
// silently, but keep degradation: the CLI itself already updated.
process.stderr.write(` ${yellow}⚠ Agent skill sync failed: ${errorMessage(err)}${reset}\n`);
process.stderr.write(
` ${yellow} Run manually: npx skills add modelstudioai/cli --all -g -y${reset}\n\n`,
);
}

// Clear pending notification
pendingNotification = null;
return true;
} catch (err) {
// npm install failure — most commonly EACCES (global installs often need
// elevated permissions). Tell the user *why* it failed, not just *that*.
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): Promise<void> {
// Skip in CI / non-TTY environments
if (process.env.CI || !process.stderr.isTTY) return;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat: auto-update CLI on major version gap by ali-wljray · Pull Request #77 · modelstudioai/cli · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions packages/cli/src/main.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,12 @@ import {
import { ensureApiKey } from "./utils/ensure-key.ts";
import { setupProxyFromEnv } from "./proxy.ts";
import { handleError } from "./error-handler.ts";
import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts";
import {
checkForUpdate,
getPendingUpdateNotification,
performAutoUpdate,
shouldAutoUpdate,
} from "./utils/update-checker.ts";
import { maybeShowStatusBar } from "./output/status-bar.ts";
import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
import { CLI_VERSION } from "./version.ts";
Expand DownExpand Up@@ -129,12 +134,20 @@ async function main() {
const isUpdateCommand = commandPath.length === 1 && commandPath[0] === "update";
const newVersion = getPendingUpdateNotification();
if (newVersion && !config.quiet && !isUpdateCommand) {
const isTTY = process.stderr.isTTY;
const yellow = isTTY ? "\x1b[33m" : "";
const cyan = isTTY ? "\x1b[36m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(`\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`);
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
if (shouldAutoUpdate(newVersion, CLI_VERSION)) {
// 大版本差距且目标为稳定版,自动更新
await performAutoUpdate(CLI_VERSION, newVersion);
} else {
// 普通小版本提示
const isTTY = process.stderr.isTTY;
const yellow = isTTY ? "\x1b[33m" : "";
const cyan = isTTY ? "\x1b[36m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(
`\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`,
);
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
}
}

// 进程退出前尽力等待在途的埋点完成。
Expand Down
246 changes: 237 additions & 9 deletions packages/cli/src/utils/update-checker.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,17 +10,122 @@ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4h
const FETCH_TIMEOUT_MS = 3000;

/**
* Simple semver comparison: returns true if a > b.
* Supports standard x.y.z format.
* 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`.
*/
function isNewerVersion(a: string, b: string): boolean {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
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 false; // equal
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 {
Expand DownExpand Up@@ -71,6 +176,129 @@ 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);
}

/**
* Perform auto-update: install latest version globally and update agent skill.
* Returns true if update succeeded, false otherwise.
*/
export async function performAutoUpdate(
currentVersion: string,
latestVersion: string,
): 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 [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`);

const cmd = `npm install -g ${NPM_PACKAGE}@latest`;

try {
const { execSync } = await import("child_process");
execSync(cmd, { stdio: "inherit" });

// Verify the actually-installed version by reading the global package.json.
// We must NOT rely on `bl --version`: the user may run via npx, a local
// install, or a custom bin name, in which case `bl` on PATH points at the
// wrong binary (or nothing at all). Reading the installed package directly
// is correct regardless of how the CLI was invoked.
let newVer: string | null = null;
try {
const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim();
const pkgPath = join(globalRoot, NPM_PACKAGE, "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`,
);
}

// Update cached state. writeState swallows errors internally: state caching
// is non-critical and must never break the CLI startup path.
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`);

// Update agent skill
try {
process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`);
execSync(`npx skills add modelstudioai/cli --all -g -y`, { stdio: "inherit" });
process.stderr.write(` ${green}✓ Agent skill updated.${reset}\n\n`);
} catch (err) {
// Surface the reason the skill sync failed rather than swallowing it
// silently, but keep degradation: the CLI itself already updated.
process.stderr.write(` ${yellow}⚠ Agent skill sync failed: ${errorMessage(err)}${reset}\n`);
process.stderr.write(
` ${yellow} Run manually: npx skills add modelstudioai/cli --all -g -y${reset}\n\n`,
);
}

// Clear pending notification
pendingNotification = null;
return true;
} catch (err) {
// npm install failure — most commonly EACCES (global installs often need
// elevated permissions). Tell the user *why* it failed, not just *that*.
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): Promise<void> {
// Skip in CI / non-TTY environments
if (process.env.CI || !process.stderr.isTTY) return;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: auto-update CLI on major version gap by ali-wljray · Pull Request #77 · modelstudioai/cli · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions packages/cli/src/main.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,12 @@ import {
import { ensureApiKey } from "./utils/ensure-key.ts";
import { setupProxyFromEnv } from "./proxy.ts";
import { handleError } from "./error-handler.ts";
import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts";
import {
checkForUpdate,
getPendingUpdateNotification,
performAutoUpdate,
shouldAutoUpdate,
} from "./utils/update-checker.ts";
import { maybeShowStatusBar } from "./output/status-bar.ts";
import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
import { CLI_VERSION } from "./version.ts";
Expand DownExpand Up@@ -129,12 +134,20 @@ async function main() {
const isUpdateCommand = commandPath.length === 1 && commandPath[0] === "update";
const newVersion = getPendingUpdateNotification();
if (newVersion && !config.quiet && !isUpdateCommand) {
const isTTY = process.stderr.isTTY;
const yellow = isTTY ? "\x1b[33m" : "";
const cyan = isTTY ? "\x1b[36m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(`\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`);
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
if (shouldAutoUpdate(newVersion, CLI_VERSION)) {
// 大版本差距且目标为稳定版,自动更新
await performAutoUpdate(CLI_VERSION, newVersion);
} else {
// 普通小版本提示
const isTTY = process.stderr.isTTY;
const yellow = isTTY ? "\x1b[33m" : "";
const cyan = isTTY ? "\x1b[36m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(
`\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`,
);
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
}
}

// 进程退出前尽力等待在途的埋点完成。
Expand Down
246 changes: 237 additions & 9 deletions packages/cli/src/utils/update-checker.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,17 +10,122 @@ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4h
const FETCH_TIMEOUT_MS = 3000;

/**
* Simple semver comparison: returns true if a > b.
* Supports standard x.y.z format.
* 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`.
*/
function isNewerVersion(a: string, b: string): boolean {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
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 false; // equal
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 {
Expand DownExpand Up@@ -71,6 +176,129 @@ 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);
}

/**
* Perform auto-update: install latest version globally and update agent skill.
* Returns true if update succeeded, false otherwise.
*/
export async function performAutoUpdate(
currentVersion: string,
latestVersion: string,
): 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 [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`);

const cmd = `npm install -g ${NPM_PACKAGE}@latest`;

try {
const { execSync } = await import("child_process");
execSync(cmd, { stdio: "inherit" });

// Verify the actually-installed version by reading the global package.json.
// We must NOT rely on `bl --version`: the user may run via npx, a local
// install, or a custom bin name, in which case `bl` on PATH points at the
// wrong binary (or nothing at all). Reading the installed package directly
// is correct regardless of how the CLI was invoked.
let newVer: string | null = null;
try {
const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim();
const pkgPath = join(globalRoot, NPM_PACKAGE, "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`,
);
}

// Update cached state. writeState swallows errors internally: state caching
// is non-critical and must never break the CLI startup path.
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`);

// Update agent skill
try {
process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`);
execSync(`npx skills add modelstudioai/cli --all -g -y`, { stdio: "inherit" });
process.stderr.write(` ${green}✓ Agent skill updated.${reset}\n\n`);
} catch (err) {
// Surface the reason the skill sync failed rather than swallowing it
// silently, but keep degradation: the CLI itself already updated.
process.stderr.write(` ${yellow}⚠ Agent skill sync failed: ${errorMessage(err)}${reset}\n`);
process.stderr.write(
` ${yellow} Run manually: npx skills add modelstudioai/cli --all -g -y${reset}\n\n`,
);
}

// Clear pending notification
pendingNotification = null;
return true;
} catch (err) {
// npm install failure — most commonly EACCES (global installs often need
// elevated permissions). Tell the user *why* it failed, not just *that*.
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): Promise<void> {
// Skip in CI / non-TTY environments
if (process.env.CI || !process.stderr.isTTY) return;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: auto-update CLI on major version gap by ali-wljray · Pull Request #77 · modelstudioai/cli · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions packages/cli/src/main.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,12 @@ import {
import { ensureApiKey } from "./utils/ensure-key.ts";
import { setupProxyFromEnv } from "./proxy.ts";
import { handleError } from "./error-handler.ts";
import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts";
import {
checkForUpdate,
getPendingUpdateNotification,
performAutoUpdate,
shouldAutoUpdate,
} from "./utils/update-checker.ts";
import { maybeShowStatusBar } from "./output/status-bar.ts";
import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
import { CLI_VERSION } from "./version.ts";
Expand DownExpand Up@@ -129,12 +134,20 @@ async function main() {
const isUpdateCommand = commandPath.length === 1 && commandPath[0] === "update";
const newVersion = getPendingUpdateNotification();
if (newVersion && !config.quiet && !isUpdateCommand) {
const isTTY = process.stderr.isTTY;
const yellow = isTTY ? "\x1b[33m" : "";
const cyan = isTTY ? "\x1b[36m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(`\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`);
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
if (shouldAutoUpdate(newVersion, CLI_VERSION)) {
// 大版本差距且目标为稳定版,自动更新
await performAutoUpdate(CLI_VERSION, newVersion);
} else {
// 普通小版本提示
const isTTY = process.stderr.isTTY;
const yellow = isTTY ? "\x1b[33m" : "";
const cyan = isTTY ? "\x1b[36m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(
`\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`,
);
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
}
}

// 进程退出前尽力等待在途的埋点完成。
Expand Down
246 changes: 237 additions & 9 deletions packages/cli/src/utils/update-checker.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,17 +10,122 @@ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4h
const FETCH_TIMEOUT_MS = 3000;

/**
* Simple semver comparison: returns true if a > b.
* Supports standard x.y.z format.
* 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`.
*/
function isNewerVersion(a: string, b: string): boolean {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
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 false; // equal
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 {
Expand DownExpand Up@@ -71,6 +176,129 @@ 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);
}

/**
* Perform auto-update: install latest version globally and update agent skill.
* Returns true if update succeeded, false otherwise.
*/
export async function performAutoUpdate(
currentVersion: string,
latestVersion: string,
): 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 [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`);

const cmd = `npm install -g ${NPM_PACKAGE}@latest`;

try {
const { execSync } = await import("child_process");
execSync(cmd, { stdio: "inherit" });

// Verify the actually-installed version by reading the global package.json.
// We must NOT rely on `bl --version`: the user may run via npx, a local
// install, or a custom bin name, in which case `bl` on PATH points at the
// wrong binary (or nothing at all). Reading the installed package directly
// is correct regardless of how the CLI was invoked.
let newVer: string | null = null;
try {
const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim();
const pkgPath = join(globalRoot, NPM_PACKAGE, "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`,
);
}

// Update cached state. writeState swallows errors internally: state caching
// is non-critical and must never break the CLI startup path.
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`);

// Update agent skill
try {
process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`);
execSync(`npx skills add modelstudioai/cli --all -g -y`, { stdio: "inherit" });
process.stderr.write(` ${green}✓ Agent skill updated.${reset}\n\n`);
} catch (err) {
// Surface the reason the skill sync failed rather than swallowing it
// silently, but keep degradation: the CLI itself already updated.
process.stderr.write(` ${yellow}⚠ Agent skill sync failed: ${errorMessage(err)}${reset}\n`);
process.stderr.write(
` ${yellow} Run manually: npx skills add modelstudioai/cli --all -g -y${reset}\n\n`,
);
}

// Clear pending notification
pendingNotification = null;
return true;
} catch (err) {
// npm install failure — most commonly EACCES (global installs often need
// elevated permissions). Tell the user *why* it failed, not just *that*.
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): Promise<void> {
// Skip in CI / non-TTY environments
if (process.env.CI || !process.stderr.isTTY) return;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat: auto-update CLI on major version gap by ali-wljray · Pull Request #77 · modelstudioai/cli · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions packages/cli/src/main.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,12 @@ import {
import { ensureApiKey } from "./utils/ensure-key.ts";
import { setupProxyFromEnv } from "./proxy.ts";
import { handleError } from "./error-handler.ts";
import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts";
import {
checkForUpdate,
getPendingUpdateNotification,
performAutoUpdate,
shouldAutoUpdate,
} from "./utils/update-checker.ts";
import { maybeShowStatusBar } from "./output/status-bar.ts";
import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
import { CLI_VERSION } from "./version.ts";
Expand DownExpand Up@@ -129,12 +134,20 @@ async function main() {
const isUpdateCommand = commandPath.length === 1 && commandPath[0] === "update";
const newVersion = getPendingUpdateNotification();
if (newVersion && !config.quiet && !isUpdateCommand) {
const isTTY = process.stderr.isTTY;
const yellow = isTTY ? "\x1b[33m" : "";
const cyan = isTTY ? "\x1b[36m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(`\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`);
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
if (shouldAutoUpdate(newVersion, CLI_VERSION)) {
// 大版本差距且目标为稳定版,自动更新
await performAutoUpdate(CLI_VERSION, newVersion);
} else {
// 普通小版本提示
const isTTY = process.stderr.isTTY;
const yellow = isTTY ? "\x1b[33m" : "";
const cyan = isTTY ? "\x1b[36m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(
`\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`,
);
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
}
}

// 进程退出前尽力等待在途的埋点完成。
Expand Down
246 changes: 237 additions & 9 deletions packages/cli/src/utils/update-checker.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,17 +10,122 @@ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4h
const FETCH_TIMEOUT_MS = 3000;

/**
* Simple semver comparison: returns true if a > b.
* Supports standard x.y.z format.
* 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`.
*/
function isNewerVersion(a: string, b: string): boolean {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
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 false; // equal
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 {
Expand DownExpand Up@@ -71,6 +176,129 @@ 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);
}

/**
* Perform auto-update: install latest version globally and update agent skill.
* Returns true if update succeeded, false otherwise.
*/
export async function performAutoUpdate(
currentVersion: string,
latestVersion: string,
): 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 [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`);

const cmd = `npm install -g ${NPM_PACKAGE}@latest`;

try {
const { execSync } = await import("child_process");
execSync(cmd, { stdio: "inherit" });

// Verify the actually-installed version by reading the global package.json.
// We must NOT rely on `bl --version`: the user may run via npx, a local
// install, or a custom bin name, in which case `bl` on PATH points at the
// wrong binary (or nothing at all). Reading the installed package directly
// is correct regardless of how the CLI was invoked.
let newVer: string | null = null;
try {
const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim();
const pkgPath = join(globalRoot, NPM_PACKAGE, "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`,
);
}

// Update cached state. writeState swallows errors internally: state caching
// is non-critical and must never break the CLI startup path.
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`);

// Update agent skill
try {
process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`);
execSync(`npx skills add modelstudioai/cli --all -g -y`, { stdio: "inherit" });
process.stderr.write(` ${green}✓ Agent skill updated.${reset}\n\n`);
} catch (err) {
// Surface the reason the skill sync failed rather than swallowing it
// silently, but keep degradation: the CLI itself already updated.
process.stderr.write(` ${yellow}⚠ Agent skill sync failed: ${errorMessage(err)}${reset}\n`);
process.stderr.write(
` ${yellow} Run manually: npx skills add modelstudioai/cli --all -g -y${reset}\n\n`,
);
}

// Clear pending notification
pendingNotification = null;
return true;
} catch (err) {
// npm install failure — most commonly EACCES (global installs often need
// elevated permissions). Tell the user *why* it failed, not just *that*.
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): Promise<void> {
// Skip in CI / non-TTY environments
if (process.env.CI || !process.stderr.isTTY) return;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: auto-update CLI on major version gap by ali-wljray · Pull Request #77 · modelstudioai/cli · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions packages/cli/src/main.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,12 @@ import {
import { ensureApiKey } from "./utils/ensure-key.ts";
import { setupProxyFromEnv } from "./proxy.ts";
import { handleError } from "./error-handler.ts";
import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts";
import {
checkForUpdate,
getPendingUpdateNotification,
performAutoUpdate,
shouldAutoUpdate,
} from "./utils/update-checker.ts";
import { maybeShowStatusBar } from "./output/status-bar.ts";
import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
import { CLI_VERSION } from "./version.ts";
Expand DownExpand Up@@ -129,12 +134,20 @@ async function main() {
const isUpdateCommand = commandPath.length === 1 && commandPath[0] === "update";
const newVersion = getPendingUpdateNotification();
if (newVersion && !config.quiet && !isUpdateCommand) {
const isTTY = process.stderr.isTTY;
const yellow = isTTY ? "\x1b[33m" : "";
const cyan = isTTY ? "\x1b[36m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(`\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`);
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
if (shouldAutoUpdate(newVersion, CLI_VERSION)) {
// 大版本差距且目标为稳定版,自动更新
await performAutoUpdate(CLI_VERSION, newVersion);
} else {
// 普通小版本提示
const isTTY = process.stderr.isTTY;
const yellow = isTTY ? "\x1b[33m" : "";
const cyan = isTTY ? "\x1b[36m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(
`\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`,
);
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
}
}

// 进程退出前尽力等待在途的埋点完成。
Expand Down
246 changes: 237 additions & 9 deletions packages/cli/src/utils/update-checker.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,17 +10,122 @@ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4h
const FETCH_TIMEOUT_MS = 3000;

/**
* Simple semver comparison: returns true if a > b.
* Supports standard x.y.z format.
* 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`.
*/
function isNewerVersion(a: string, b: string): boolean {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
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 false; // equal
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 {
Expand DownExpand Up@@ -71,6 +176,129 @@ 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);
}

/**
* Perform auto-update: install latest version globally and update agent skill.
* Returns true if update succeeded, false otherwise.
*/
export async function performAutoUpdate(
currentVersion: string,
latestVersion: string,
): 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 [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`);

const cmd = `npm install -g ${NPM_PACKAGE}@latest`;

try {
const { execSync } = await import("child_process");
execSync(cmd, { stdio: "inherit" });

// Verify the actually-installed version by reading the global package.json.
// We must NOT rely on `bl --version`: the user may run via npx, a local
// install, or a custom bin name, in which case `bl` on PATH points at the
// wrong binary (or nothing at all). Reading the installed package directly
// is correct regardless of how the CLI was invoked.
let newVer: string | null = null;
try {
const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim();
const pkgPath = join(globalRoot, NPM_PACKAGE, "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`,
);
}

// Update cached state. writeState swallows errors internally: state caching
// is non-critical and must never break the CLI startup path.
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`);

// Update agent skill
try {
process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`);
execSync(`npx skills add modelstudioai/cli --all -g -y`, { stdio: "inherit" });
process.stderr.write(` ${green}✓ Agent skill updated.${reset}\n\n`);
} catch (err) {
// Surface the reason the skill sync failed rather than swallowing it
// silently, but keep degradation: the CLI itself already updated.
process.stderr.write(` ${yellow}⚠ Agent skill sync failed: ${errorMessage(err)}${reset}\n`);
process.stderr.write(
` ${yellow} Run manually: npx skills add modelstudioai/cli --all -g -y${reset}\n\n`,
);
}

// Clear pending notification
pendingNotification = null;
return true;
} catch (err) {
// npm install failure — most commonly EACCES (global installs often need
// elevated permissions). Tell the user *why* it failed, not just *that*.
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): Promise<void> {
// Skip in CI / non-TTY environments
if (process.env.CI || !process.stderr.isTTY) return;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: auto-update CLI on major version gap by ali-wljray · Pull Request #77 · modelstudioai/cli · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions packages/cli/src/main.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,12 @@ import {
import { ensureApiKey } from "./utils/ensure-key.ts";
import { setupProxyFromEnv } from "./proxy.ts";
import { handleError } from "./error-handler.ts";
import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts";
import {
checkForUpdate,
getPendingUpdateNotification,
performAutoUpdate,
shouldAutoUpdate,
} from "./utils/update-checker.ts";
import { maybeShowStatusBar } from "./output/status-bar.ts";
import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
import { CLI_VERSION } from "./version.ts";
Expand DownExpand Up@@ -129,12 +134,20 @@ async function main() {
const isUpdateCommand = commandPath.length === 1 && commandPath[0] === "update";
const newVersion = getPendingUpdateNotification();
if (newVersion && !config.quiet && !isUpdateCommand) {
const isTTY = process.stderr.isTTY;
const yellow = isTTY ? "\x1b[33m" : "";
const cyan = isTTY ? "\x1b[36m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(`\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`);
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
if (shouldAutoUpdate(newVersion, CLI_VERSION)) {
// 大版本差距且目标为稳定版,自动更新
await performAutoUpdate(CLI_VERSION, newVersion);
} else {
// 普通小版本提示
const isTTY = process.stderr.isTTY;
const yellow = isTTY ? "\x1b[33m" : "";
const cyan = isTTY ? "\x1b[36m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(
`\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`,
);
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
}
}

// 进程退出前尽力等待在途的埋点完成。
Expand Down
246 changes: 237 additions & 9 deletions packages/cli/src/utils/update-checker.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,17 +10,122 @@ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4h
const FETCH_TIMEOUT_MS = 3000;

/**
* Simple semver comparison: returns true if a > b.
* Supports standard x.y.z format.
* 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`.
*/
function isNewerVersion(a: string, b: string): boolean {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
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 false; // equal
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 {
Expand DownExpand Up@@ -71,6 +176,129 @@ 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);
}

/**
* Perform auto-update: install latest version globally and update agent skill.
* Returns true if update succeeded, false otherwise.
*/
export async function performAutoUpdate(
currentVersion: string,
latestVersion: string,
): 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 [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`);

const cmd = `npm install -g ${NPM_PACKAGE}@latest`;

try {
const { execSync } = await import("child_process");
execSync(cmd, { stdio: "inherit" });

// Verify the actually-installed version by reading the global package.json.
// We must NOT rely on `bl --version`: the user may run via npx, a local
// install, or a custom bin name, in which case `bl` on PATH points at the
// wrong binary (or nothing at all). Reading the installed package directly
// is correct regardless of how the CLI was invoked.
let newVer: string | null = null;
try {
const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim();
const pkgPath = join(globalRoot, NPM_PACKAGE, "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`,
);
}

// Update cached state. writeState swallows errors internally: state caching
// is non-critical and must never break the CLI startup path.
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`);

// Update agent skill
try {
process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`);
execSync(`npx skills add modelstudioai/cli --all -g -y`, { stdio: "inherit" });
process.stderr.write(` ${green}✓ Agent skill updated.${reset}\n\n`);
} catch (err) {
// Surface the reason the skill sync failed rather than swallowing it
// silently, but keep degradation: the CLI itself already updated.
process.stderr.write(` ${yellow}⚠ Agent skill sync failed: ${errorMessage(err)}${reset}\n`);
process.stderr.write(
` ${yellow} Run manually: npx skills add modelstudioai/cli --all -g -y${reset}\n\n`,
);
}

// Clear pending notification
pendingNotification = null;
return true;
} catch (err) {
// npm install failure — most commonly EACCES (global installs often need
// elevated permissions). Tell the user *why* it failed, not just *that*.
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): Promise<void> {
// Skip in CI / non-TTY environments
if (process.env.CI || !process.stderr.isTTY) return;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat: auto-update CLI on major version gap by ali-wljray · Pull Request #77 · modelstudioai/cli · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions packages/cli/src/main.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,12 @@ import {
import { ensureApiKey } from "./utils/ensure-key.ts";
import { setupProxyFromEnv } from "./proxy.ts";
import { handleError } from "./error-handler.ts";
import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts";
import {
checkForUpdate,
getPendingUpdateNotification,
performAutoUpdate,
shouldAutoUpdate,
} from "./utils/update-checker.ts";
import { maybeShowStatusBar } from "./output/status-bar.ts";
import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
import { CLI_VERSION } from "./version.ts";
Expand DownExpand Up@@ -129,12 +134,20 @@ async function main() {
const isUpdateCommand = commandPath.length === 1 && commandPath[0] === "update";
const newVersion = getPendingUpdateNotification();
if (newVersion && !config.quiet && !isUpdateCommand) {
const isTTY = process.stderr.isTTY;
const yellow = isTTY ? "\x1b[33m" : "";
const cyan = isTTY ? "\x1b[36m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(`\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`);
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
if (shouldAutoUpdate(newVersion, CLI_VERSION)) {
// 大版本差距且目标为稳定版,自动更新
await performAutoUpdate(CLI_VERSION, newVersion);
} else {
// 普通小版本提示
const isTTY = process.stderr.isTTY;
const yellow = isTTY ? "\x1b[33m" : "";
const cyan = isTTY ? "\x1b[36m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(
`\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`,
);
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
}
}

// 进程退出前尽力等待在途的埋点完成。
Expand Down
246 changes: 237 additions & 9 deletions packages/cli/src/utils/update-checker.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,17 +10,122 @@ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4h
const FETCH_TIMEOUT_MS = 3000;

/**
* Simple semver comparison: returns true if a > b.
* Supports standard x.y.z format.
* 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`.
*/
function isNewerVersion(a: string, b: string): boolean {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
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 false; // equal
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 {
Expand DownExpand Up@@ -71,6 +176,129 @@ 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);
}

/**
* Perform auto-update: install latest version globally and update agent skill.
* Returns true if update succeeded, false otherwise.
*/
export async function performAutoUpdate(
currentVersion: string,
latestVersion: string,
): 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 [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`);

const cmd = `npm install -g ${NPM_PACKAGE}@latest`;

try {
const { execSync } = await import("child_process");
execSync(cmd, { stdio: "inherit" });

// Verify the actually-installed version by reading the global package.json.
// We must NOT rely on `bl --version`: the user may run via npx, a local
// install, or a custom bin name, in which case `bl` on PATH points at the
// wrong binary (or nothing at all). Reading the installed package directly
// is correct regardless of how the CLI was invoked.
let newVer: string | null = null;
try {
const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim();
const pkgPath = join(globalRoot, NPM_PACKAGE, "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`,
);
}

// Update cached state. writeState swallows errors internally: state caching
// is non-critical and must never break the CLI startup path.
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`);

// Update agent skill
try {
process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`);
execSync(`npx skills add modelstudioai/cli --all -g -y`, { stdio: "inherit" });
process.stderr.write(` ${green}✓ Agent skill updated.${reset}\n\n`);
} catch (err) {
// Surface the reason the skill sync failed rather than swallowing it
// silently, but keep degradation: the CLI itself already updated.
process.stderr.write(` ${yellow}⚠ Agent skill sync failed: ${errorMessage(err)}${reset}\n`);
process.stderr.write(
` ${yellow} Run manually: npx skills add modelstudioai/cli --all -g -y${reset}\n\n`,
);
}

// Clear pending notification
pendingNotification = null;
return true;
} catch (err) {
// npm install failure — most commonly EACCES (global installs often need
// elevated permissions). Tell the user *why* it failed, not just *that*.
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): Promise<void> {
// Skip in CI / non-TTY environments
if (process.env.CI || !process.stderr.isTTY) return;
Expand Down
Loading