feat(cli): implement update checking and auto-update for CLI and CLI Web

This commit is contained in:
digua authored and digua committed 2026-05-26 23:19:47 +08:00
1 parent 1a35d2bb9a
commit a105d7ab8e
11 files changed
+399 -25

No files matched your search

+5
View File
@@ -29,6 +29,11 @@ const program = new Command()
program.name('chatlab').description('ChatLab - Chat history analysis tool').version(getVersion())
program.hook('preAction', async () => {
const { checkForUpdatesInteractive } = await import('./update-checker')
await checkForUpdatesInteractive()
})
program
.command('sessions')
.description('List all imported chat sessions')
+62
View File
@@ -19,6 +19,7 @@
import * as os from 'os'
import * as path from 'path'
import { execFile } from 'child_process'
import type { FastifyInstance } from 'fastify'
import type { PathProvider } from '@openchatlab/core'
import type { DatabaseManager } from '@openchatlab/node-runtime'
@@ -34,6 +35,27 @@ import { registerImportRoutes } from './import'
import { registerMergeRoutes } from './merge'
import { registerExportRoutes } from './export'
import { registerCacheRoutes } from './cache'
import { getVersion } from '../../../version'
/**
* Semver comparison: returns true if `latest` is strictly newer than `current`.
* Handles pre-release tags (e.g. 0.22.0-beta.1 < 0.22.0).
*/
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
// Same core version: stable > pre-release
if (c.pre && !l.pre) return true
return false
}
export function registerWebRoutes(
server: FastifyInstance,
@@ -72,4 +94,44 @@ export function registerWebRoutes(
}
registerExportRoutes(server, adapter)
registerCacheRoutes(server, resolvedPathProvider)
server.get('/_web/system/check-update', async () => {
const currentVersion = getVersion()
const packageName = 'chatlab-cli'
try {
const resp = await fetch(`https://registry.npmjs.org/${packageName}/latest`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10_000),
})
if (!resp.ok) {
return { hasUpdate: false, currentVersion, error: `npm registry HTTP ${resp.status}` }
}
const data = (await resp.json()) as { version?: string }
const latestVersion = data.version || currentVersion
const hasUpdate = isNewerVersion(latestVersion, currentVersion)
return { hasUpdate, currentVersion, latestVersion }
} catch (err) {
return {
hasUpdate: false,
currentVersion,
error: err instanceof Error ? err.message : String(err),
}
}
})
server.post('/_web/system/update', async () => {
const packageName = 'chatlab-cli'
try {
await new Promise<void>((resolve, reject) => {
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'
execFile(npmCmd, ['install', '-g', `${packageName}@latest`], { timeout: 120_000 }, (err, _stdout, stderr) => {
if (err) return reject(new Error(stderr || err.message))
resolve()
})
})
return { success: true }
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) }
}
})
}
+181
View File
@@ -0,0 +1,181 @@
/**
* 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 CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000 // 4 hours
const CACHE_FILE = path.join(os.homedir(), '.chatlab', 'update-check.json')
interface UpdateCache {
lastCheckTime: number
latestVersion: string | null
skippedVersion?: string
}
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
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) => {
process.stderr.write(`${question}\n\n`)
choices.forEach((c, i) => {
process.stderr.write(i === 0 ? `› ${i + 1}. ${c}\n` : ` ${i + 1}. ${c}\n`)
})
process.stderr.write('\n')
if (!process.stdin.isTTY || !process.stdin.setRawMode) {
resolve(2) // default to "Skip" in non-interactive
return
}
process.stdin.setRawMode(true)
process.stdin.resume()
process.stdin.once('data', (data) => {
process.stdin.setRawMode!(false)
process.stdin.pause()
const key = data.toString()
if (key === '\x03') process.exit(0) // Ctrl+C
const num = parseInt(key)
process.stderr.write('\n')
resolve(num >= 1 && num <= choices.length ? num : 2)
})
})
}
function runNpmUpdate(): Promise<{ success: boolean; error?: string }> {
return new Promise((resolve) => {
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'
process.stderr.write(`\n Running: npm install -g ${PACKAGE_NAME}@latest\n\n`)
const child = execFile(
npmCmd,
['install', '-g', `${PACKAGE_NAME}@latest`],
{ timeout: 120_000 },
(err, _stdout, stderr) => {
if (err) {
resolve({ success: false, error: stderr || err.message })
} else {
resolve({ success: true })
}
}
)
child.stdout?.pipe(process.stderr)
child.stderr?.pipe(process.stderr)
})
}
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')
}
/**
* Check for updates and prompt user interactively.
* Returns quickly if no update, check was recent, or running in dev.
*/
export async function checkForUpdatesInteractive(): Promise<void> {
if (!process.stdin.isTTY || process.env.CI || isDevEnvironment()) return
const currentVersion = getVersion()
const cache = readCache()
let latestVersion: string | null = null
// Use cached result if recent enough
if (cache && Date.now() - cache.lastCheckTime < CHECK_INTERVAL_MS) {
latestVersion = cache.latestVersion
} else {
latestVersion = await fetchLatestVersion()
writeCache({
lastCheckTime: Date.now(),
latestVersion,
skippedVersion: cache?.skippedVersion,
})
}
if (!latestVersion || !isNewerVersion(latestVersion, currentVersion)) return
// User previously chose "skip until next version" for this exact version
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 runNpmUpdate()
if (result.success) {
process.stderr.write(` 🎉 Updated successfully! Please restart chatlab.\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,
})
}
}