Skip to content

Commit 37bad40

Browse files
GoldVelenclaude
andcommitted
feat: add auto update checker with cmr update command
- Server startup: non-blocking check against npm registry once per day - CLI: cmr update checks for updates and offers one-command install - Rate-limited: stores last check timestamp, max once per 24h Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent bfaf5dc commit 37bad40

4 files changed

Lines changed: 153 additions & 1 deletion

File tree

bin/cmr.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,55 @@ async function cmdDashboard() {
769769
process.exit(0);
770770
}
771771

772+
// ─── Update ──────────────────────────────────────────────────────────
773+
774+
async function cmdUpdate() {
775+
console.log('Checking for updates...');
776+
777+
const pkgPath = join(PROJECT_DIR, 'package.json');
778+
let currentVersion = '0.0.0';
779+
try {
780+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
781+
currentVersion = pkg.version || '0.0.0';
782+
} catch { /* ignore */ }
783+
784+
const { checkForUpdate } = await import(
785+
pathToFileURL(join(PROJECT_DIR, 'dist', 'utils', 'update-checker.js')).href
786+
);
787+
const result = await checkForUpdate(currentVersion);
788+
789+
if (!result.updateAvailable) {
790+
console.log(`Already up to date (v${result.current})`);
791+
return;
792+
}
793+
794+
console.log(`\x1b[33mUpdate available: v${result.current} → v${result.latest}\x1b[0m`);
795+
796+
const rl = createInterface({ input: process.stdin, output: process.stdout });
797+
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
798+
const install = await ask('Install now? [Y/n]: ');
799+
rl.close();
800+
801+
if (install.toLowerCase() === 'n') {
802+
console.log('Skipped. Run `npm install -g claude-model-router` to update later.');
803+
return;
804+
}
805+
806+
console.log('Installing...');
807+
const child = spawn('npm', ['install', '-g', 'claude-model-router'], {
808+
stdio: 'inherit',
809+
shell: true,
810+
});
811+
812+
child.on('exit', (code) => {
813+
if (code === 0) {
814+
console.log(`\nUpdated to v${result.latest}. Run 'cmr restart' to restart the proxy.`);
815+
} else {
816+
console.log(`\nInstall failed (exit code ${code}). Try manually: npm install -g claude-model-router`);
817+
}
818+
});
819+
}
820+
772821
// ─── Help ───────────────────────────────────────────────────────────
773822

774823
function cmdHelp() {
@@ -790,6 +839,7 @@ Commands:
790839
dashboard Real-time monitoring dashboard (press 'q' to quit)
791840
logs Show last 50 log lines
792841
config Show current config (api keys redacted)
842+
update Check for updates and install
793843
run Run task through model pipeline
794844
cmr run <task> # Single-line task
795845
cmr run # Interactive multi-line input
@@ -826,6 +876,7 @@ async function main() {
826876
case 'dashboard': await cmdDashboard(); break;
827877
case 'logs': cmdLogs(); break;
828878
case 'config': cmdConfigShow(); break;
879+
case 'update': await cmdUpdate(); break;
829880
case 'setup': await cmdSetup(); break;
830881
case 'run': {
831882
const mode = process.argv[3];

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "claude-model-router",
3-
"version": "0.3.0",
3+
"version": "0.3.2",
44
"description": "Zero-dependency Anthropic-format API proxy for Claude Code. Multi-backend routing, hot reload, config validation, and detailed logging.",
55
"keywords": [
66
"claude-code",

src/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,26 @@ import { getConfig, reloadConfig, getConfigPath } from './config.js';
22
import { createServer } from './server.js';
33
import { startConfigWatcher } from './watcher.js';
44
import { ensureBackends } from './stats/stats-store.js';
5+
import { checkForUpdate } from './utils/update-checker.js';
6+
import { readFileSync } from 'node:fs';
7+
import { join, dirname } from 'node:path';
8+
import { fileURLToPath } from 'node:url';
59

610
const config = getConfig();
711
ensureBackends(Object.keys(config.backends));
812
const server = createServer();
913

14+
// Background update check — non-blocking
15+
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8')) as { version: string };
16+
checkForUpdate(pkg.version).then((result) => {
17+
if (result.updateAvailable) {
18+
console.log('');
19+
console.log(`\x1b[33m Update available: ${result.current}${result.latest}\x1b[0m`);
20+
console.log(`\x1b[33m Run: npm install -g claude-model-router\x1b[0m`);
21+
console.log('');
22+
}
23+
});
24+
1025
server.listen(config.port, () => {
1126
const deepseekBackend = Object.values(config.backends).find((b) => b.modelPattern?.includes('deepseek'));
1227
const deepseekHost = deepseekBackend ? new URL(deepseekBackend.url).hostname : 'unknown';

src/utils/update-checker.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import https from 'node:https';
2+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
3+
import { join } from 'node:path';
4+
import { homedir } from 'node:os';
5+
6+
const DATA_DIR = join(homedir(), '.local', 'share', 'claude-model-router');
7+
const CHECKED_PATH = join(DATA_DIR, '.last_update_check');
8+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
9+
const REGISTRY_URL = 'https://registry.npmjs.org/claude-model-router/latest';
10+
11+
interface CheckResult {
12+
updateAvailable: boolean;
13+
current: string;
14+
latest: string;
15+
}
16+
17+
function ensureDir(dir: string): void {
18+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
19+
}
20+
21+
function shouldCheck(): boolean {
22+
if (!existsSync(CHECKED_PATH)) return true;
23+
try {
24+
const ts = parseInt(readFileSync(CHECKED_PATH, 'utf-8').trim(), 10);
25+
return Date.now() - ts > CHECK_INTERVAL_MS;
26+
} catch {
27+
return true;
28+
}
29+
}
30+
31+
function markChecked(): void {
32+
ensureDir(DATA_DIR);
33+
writeFileSync(CHECKED_PATH, String(Date.now()), 'utf-8');
34+
}
35+
36+
function compareVersions(a: string, b: string): number {
37+
const pa = a.split('.').map(Number);
38+
const pb = b.split('.').map(Number);
39+
for (let i = 0; i < 3; i++) {
40+
const da = pa[i] ?? 0;
41+
const db = pb[i] ?? 0;
42+
if (da > db) return 1;
43+
if (da < db) return -1;
44+
}
45+
return 0;
46+
}
47+
48+
function fetchLatestVersion(): Promise<string> {
49+
return new Promise((resolve, reject) => {
50+
const req = https.get(REGISTRY_URL, { timeout: 5000 }, (res) => {
51+
let body = '';
52+
res.on('data', (chunk) => (body += chunk));
53+
res.on('end', () => {
54+
try {
55+
const parsed = JSON.parse(body) as { version?: string };
56+
resolve(parsed.version ?? '');
57+
} catch {
58+
reject(new Error('Failed to parse registry response'));
59+
}
60+
});
61+
});
62+
req.on('error', reject);
63+
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
64+
});
65+
}
66+
67+
export async function checkForUpdate(currentVersion: string): Promise<CheckResult> {
68+
if (!shouldCheck()) {
69+
return { updateAvailable: false, current: currentVersion, latest: currentVersion };
70+
}
71+
72+
const result = await fetchLatestVersion()
73+
.then((latest) => {
74+
markChecked();
75+
return {
76+
updateAvailable: compareVersions(latest, currentVersion) > 0,
77+
current: currentVersion,
78+
latest,
79+
};
80+
})
81+
.catch(() => {
82+
return { updateAvailable: false, current: currentVersion, latest: currentVersion };
83+
});
84+
85+
return result;
86+
}

0 commit comments

Comments
 (0)