Files
ChatLab/apps/cli/src/update-checker.ts
T

276 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* CLI startup update checker — npm registry version comparison
* with interactive prompt (Codex-style).
*
* Caches check results to avoid hitting npm on every invocation.
* Respects user "skip until next version" preference.
*/
import * as fs from 'fs'
import * as path from 'path'
import * as os from 'os'
import { execFile } from 'child_process'
import { getVersion } from './version'
const PACKAGE_NAME = 'chatlab-cli'
const CACHE_STALE_MS = 6 * 60 * 60 * 1000 // 6h — startup cache TTL
const PERIODIC_CHECK_MS = 24 * 60 * 60 * 1000 // 24h — long-running service interval
const CACHE_FILE = path.join(os.homedir(), '.chatlab', 'update-check.json')
interface UpdateCache {
lastCheckTime: number
latestVersion: string | null
skippedVersion?: string
}
export interface UpdateCommandResult {
success: boolean
error?: string
}
export interface PerformCliSelfUpdateOptions {
runCommand?: (command: string, args: string[]) => Promise<UpdateCommandResult>
write?: (text: string) => void
platform?: NodeJS.Platform
}
function readCache(): UpdateCache | null {
try {
if (fs.existsSync(CACHE_FILE)) {
return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'))
}
} catch {
// corrupted cache
}
return null
}
function writeCache(cache: UpdateCache): void {
try {
const dir = path.dirname(CACHE_FILE)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(CACHE_FILE, JSON.stringify(cache), 'utf-8')
} catch {
// non-critical
}
}
function isNewerVersion(latest: string, current: string): boolean {
const parse = (v: string) => {
const [core, pre] = v.split('-', 2)
const parts = core.split('.').map(Number)
return { major: parts[0] || 0, minor: parts[1] || 0, patch: parts[2] || 0, pre }
}
const l = parse(latest)
const c = parse(current)
if (l.major !== c.major) return l.major > c.major
if (l.minor !== c.minor) return l.minor > c.minor
if (l.patch !== c.patch) return l.patch > c.patch
// Design note: prerelease users are not prompted for prerelease-to-prerelease updates.
// Only a stable release with the same core version should supersede a prerelease build.
if (c.pre && !l.pre) return true
return false
}
async function fetchLatestVersion(): Promise<string | null> {
try {
const resp = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(8_000),
})
if (!resp.ok) return null
const data = (await resp.json()) as { version?: string }
return data.version || null
} catch {
return null
}
}
function promptUser(question: string, choices: string[]): Promise<number> {
return new Promise((resolve) => {
let selected = 0
const render = () => {
// Move cursor up to rewrite choices (skip on first draw)
if (rendered) process.stderr.write(`\x1b[${choices.length}A`)
choices.forEach((c, i) => {
process.stderr.write(`\x1b[2K${i === selected ? '› ' : ' '}${i + 1}. ${c}\n`)
})
}
process.stderr.write(`${question}\n\n`)
if (!process.stdin.isTTY || !process.stdin.setRawMode) {
resolve(2)
return
}
let rendered = false
render()
rendered = true
process.stderr.write('\n')
process.stdin.setRawMode(true)
process.stdin.resume()
const cleanup = () => {
process.stdin.removeListener('data', onData)
process.stdin.setRawMode!(false)
process.stdin.pause()
}
const onData = (data: Buffer) => {
const key = data.toString()
if (key === '\x03') {
cleanup()
process.exit(0)
}
// Arrow up / k
if (key === '\x1b[A' || key === 'k') {
selected = (selected - 1 + choices.length) % choices.length
render()
return
}
// Arrow down / j
if (key === '\x1b[B' || key === 'j') {
selected = (selected + 1) % choices.length
render()
return
}
// Enter — confirm current selection
if (key === '\r' || key === '\n') {
cleanup()
process.stderr.write('\n')
resolve(selected + 1)
return
}
// Digit key — direct choice
if (key.length === 1) {
const num = parseInt(key)
if (num >= 1 && num <= choices.length) {
cleanup()
process.stderr.write('\n')
resolve(num)
}
}
}
process.stdin.on('data', onData)
})
}
function runNpmUpdateCommand(
command: string,
args: string[],
write: (text: string) => void
): Promise<UpdateCommandResult> {
return new Promise((resolve) => {
const child = execFile(command, args, { timeout: 120_000 }, (err, _stdout, stderr) => {
if (err) {
resolve({ success: false, error: stderr || err.message })
} else {
resolve({ success: true })
}
})
child.stdout?.on('data', (chunk) => write(String(chunk)))
child.stderr?.on('data', (chunk) => write(String(chunk)))
})
}
export function performCliSelfUpdate(options: PerformCliSelfUpdateOptions = {}): Promise<UpdateCommandResult> {
const platformName = options.platform ?? process.platform
const npmCmd = platformName === 'win32' ? 'npm.cmd' : 'npm'
const args = ['install', '-g', `${PACKAGE_NAME}@latest`]
const write = options.write ?? ((text) => process.stderr.write(text))
const runCommand = options.runCommand ?? ((command, commandArgs) => runNpmUpdateCommand(command, commandArgs, write))
write(`\n Running: ${npmCmd} ${args.join(' ')}\n\n`)
return runCommand(npmCmd, args)
}
function isDevEnvironment(): boolean {
if (process.env.CHATLAB_SKIP_UPDATE_CHECK) return true
if (process.env.NODE_ENV === 'development') return true
const entryFile = process.argv[1] || ''
return entryFile.endsWith('.ts') || entryFile.endsWith('.mts')
}
/**
* Fire-and-forget: fetch latest version from npm and update local cache.
* Runs in background so CLI startup is never blocked by network IO.
*/
function refreshCacheInBackground(existingCache: UpdateCache | null): void {
fetchLatestVersion()
.then((latestVersion) => {
writeCache({
lastCheckTime: Date.now(),
latestVersion,
skippedVersion: existingCache?.skippedVersion,
})
})
.catch(() => {})
}
/**
* Start a periodic background cache refresh for long-running services.
* Immediately refreshes once on startup, then every 24h thereafter.
* The update prompt will appear on the *next* CLI startup.
* The timer is unref'd so it won't prevent process exit.
*/
export function startPeriodicUpdateCheck(): void {
if (isDevEnvironment()) return
refreshCacheInBackground(readCache())
const timer = setInterval(() => {
refreshCacheInBackground(readCache())
}, PERIODIC_CHECK_MS)
timer.unref()
}
/**
* Check for updates and prompt user interactively.
*
* Strategy (Codex-style):
* - Read local cache synchronously (instant, no network).
* - If cache is stale, kick off a background fetch for *next* run.
* - Prompt is only shown when cache already contains a newer version.
* - This means the first run after a new release sees no prompt;
* the second run (with fresh cache) shows it — zero startup delay.
*/
export async function checkForUpdatesInteractive(): Promise<void> {
if (!process.stdin.isTTY || process.env.CI || isDevEnvironment()) return
const currentVersion = getVersion()
const cache = readCache()
// Kick off background refresh if cache is missing or stale
if (!cache || Date.now() - cache.lastCheckTime >= CACHE_STALE_MS) {
refreshCacheInBackground(cache)
}
// Prompt is based on cached data only — no network wait
const latestVersion = cache?.latestVersion
if (!latestVersion || !isNewerVersion(latestVersion, currentVersion)) return
if (cache?.skippedVersion === latestVersion) return
const choice = await promptUser(` ✨ Update available! ${currentVersion} → ${latestVersion}`, [
`Update now (runs \`npm install -g ${PACKAGE_NAME}\`)`,
'Skip',
'Skip until next version',
])
if (choice === 1) {
const result = await performCliSelfUpdate()
if (result.success) {
process.stderr.write(` \x1b[32m🎉 Updated successfully! Please restart clb to use the new version.\x1b[0m\n\n`)
process.exit(0)
} else {
process.stderr.write(` ❌ Update failed: ${result.error}\n\n`)
}
} else if (choice === 3) {
writeCache({
lastCheckTime: Date.now(),
latestVersion,
skippedVersion: latestVersion,
})
}
}