mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
276 lines
8.4 KiB
TypeScript
276 lines
8.4 KiB
TypeScript
/**
|
||
* 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,
|
||
})
|
||
}
|
||
}
|