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,
})
}
}
+76 -20
View File
@@ -2,60 +2,100 @@
import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useToast } from '@/composables/useToast'
import { useSettingsStore } from '@/stores/settings'
import { usePlatformService } from '@/services'
import { IS_ELECTRON } from '@/utils/platform'
const { t } = useI18n()
const toast = useToast()
const settingsStore = useSettingsStore()
const { debugMode } = storeToRefs(settingsStore)
// 版本信息
const appVersion = ref(t('common.loading'))
const isCheckingUpdate = ref(false)
// 匿名统计开关
const analyticsEnabled = ref(true)
// 获取应用版本
async function loadAppVersion() {
try {
appVersion.value = await usePlatformService().getVersion()
} catch (error) {
console.error('获取版本号失败:', error)
console.error('Failed to get version:', error)
appVersion.value = t('settings.about.unknown')
}
}
// 加载统计开关状态
async function loadAnalyticsEnabled() {
try {
analyticsEnabled.value = await usePlatformService().getAnalyticsEnabled()
} catch (error) {
console.error('获取统计开关状态失败:', error)
console.error('Failed to get analytics status:', error)
}
}
// 切换统计开关
async function toggleAnalytics(enabled: boolean) {
try {
await usePlatformService().setAnalyticsEnabled(enabled)
analyticsEnabled.value = enabled
} catch (error) {
console.error('设置统计开关失败:', error)
console.error('Failed to set analytics:', error)
}
}
// 检查更新
function checkUpdate() {
const isUpdating = ref(false)
async function checkUpdate() {
isCheckingUpdate.value = true
usePlatformService().checkUpdate()
// 3 秒后恢复按钮状态(实际检查结果由主进程 dialog 显示)
setTimeout(() => {
isCheckingUpdate.value = false
}, 3000)
try {
const result = await usePlatformService().checkUpdate()
if (!result) return
if (result.error) {
toast.fail(t('settings.about.updateCheckFailed', { error: result.error }))
} else if (result.hasUpdate) {
toast.success(t('settings.about.newVersionAvailable', { version: result.latestVersion }), {
duration: 15_000,
actions: [
{
label: t('settings.about.updateNow'),
icon: 'i-heroicons-arrow-down-tray',
onClick: () => performUpdate(),
},
],
})
} else {
toast.success(t('settings.about.upToDate'))
}
} catch (error) {
console.error('Update check failed:', error)
} finally {
if (IS_ELECTRON) {
setTimeout(() => {
isCheckingUpdate.value = false
}, 3000)
} else {
isCheckingUpdate.value = false
}
}
}
async function performUpdate() {
isUpdating.value = true
toast.info(t('settings.about.updating'))
try {
const result = await usePlatformService().performUpdate()
if (result.success) {
toast.success(t('settings.about.updateSuccess'), { duration: 60_000 })
} else {
toast.fail(t('settings.about.updateFailed', { error: result.error || '' }))
}
} catch (error) {
toast.fail(t('settings.about.updateFailed', { error: String(error) }))
} finally {
isUpdating.value = false
}
}
// 组件挂载时加载数据
onMounted(() => {
loadAppVersion()
loadAnalyticsEnabled()
@@ -84,9 +124,25 @@ onMounted(() => {
<p class="mt-1 text-xs text-gray-400">{{ t('settings.about.version') }} {{ appVersion }}</p>
</div>
</div>
<UButton :disabled="isCheckingUpdate" color="primary" variant="soft" size="sm" @click="checkUpdate">
<UIcon name="i-heroicons-arrow-path" class="mr-1 h-4 w-4" :class="{ 'animate-spin': isCheckingUpdate }" />
{{ isCheckingUpdate ? t('settings.about.checking') : t('settings.about.checkUpdate') }}
<UButton
:disabled="isCheckingUpdate || isUpdating"
color="primary"
variant="soft"
size="sm"
@click="checkUpdate"
>
<UIcon
name="i-heroicons-arrow-path"
class="mr-1 h-4 w-4"
:class="{ 'animate-spin': isCheckingUpdate || isUpdating }"
/>
{{
isUpdating
? t('settings.about.updating')
: isCheckingUpdate
? t('settings.about.checking')
: t('settings.about.checkUpdate')
}}
</UButton>
</div>
</div>
+7
View File
@@ -376,6 +376,13 @@
"version": "Version",
"checkUpdate": "Check for Updates",
"checking": "Checking...",
"upToDate": "You are up to date",
"newVersionAvailable": "New version {version} available",
"updateNow": "Update Now",
"updating": "Updating...",
"updateSuccess": "Update successful, please restart the service to apply",
"updateFailed": "Update failed: {error}",
"updateCheckFailed": "Update check failed: {error}",
"unknown": "Unknown",
"privacy": {
"title": "Privacy Settings",
+7
View File
@@ -376,6 +376,13 @@
"version": "バージョン",
"checkUpdate": "アップデートを確認",
"checking": "確認中...",
"upToDate": "最新バージョンです",
"newVersionAvailable": "新しいバージョン {version} があります",
"updateNow": "今すぐ更新",
"updating": "更新中...",
"updateSuccess": "更新が完了しました。サービスを再起動してください",
"updateFailed": "更新に失敗しました:{error}",
"updateCheckFailed": "更新確認に失敗しました:{error}",
"unknown": "不明",
"privacy": {
"title": "プライバシー設定",
+7
View File
@@ -376,6 +376,13 @@
"version": "版本",
"checkUpdate": "检查更新",
"checking": "检查中...",
"upToDate": "已是最新版本",
"newVersionAvailable": "发现新版本 {version}",
"updateNow": "立即更新",
"updating": "更新中...",
"updateSuccess": "更新成功,请重启服务以生效",
"updateFailed": "更新失败:{error}",
"updateCheckFailed": "检查更新失败:{error}",
"unknown": "未知",
"privacy": {
"title": "隐私设置",
+7
View File
@@ -376,6 +376,13 @@
"version": "版本",
"checkUpdate": "檢查更新",
"checking": "檢查中...",
"upToDate": "已是最新版本",
"newVersionAvailable": "發現新版本 {version}",
"updateNow": "立即更新",
"updating": "更新中...",
"updateSuccess": "更新成功,請重啟服務以生效",
"updateFailed": "更新失敗:{error}",
"updateCheckFailed": "檢查更新失敗:{error}",
"unknown": "未知",
"privacy": {
"title": "隱私設定",
+5 -1
View File
@@ -41,10 +41,14 @@ export class ElectronPlatformAdapter implements PlatformAdapter {
return window.api.clipboard.copyImage(dataUrl)
}
checkUpdate(): void {
async checkUpdate(): Promise<void> {
window.api.app.checkUpdate()
}
async performUpdate(): Promise<{ success: boolean; error?: string }> {
return { success: false, error: 'Use built-in updater' }
}
relaunch(): Promise<void> {
return window.api.app.relaunch()
}
+14 -1
View File
@@ -24,6 +24,18 @@ export interface RemoteConfigResult {
error?: string
}
export interface CheckUpdateResult {
hasUpdate: boolean
currentVersion: string
latestVersion?: string
error?: string
}
export interface PerformUpdateResult {
success: boolean
error?: string
}
export interface PlatformAdapter {
getVersion(): Promise<string>
fetchRemoteConfig(url: string): Promise<RemoteConfigResult>
@@ -39,6 +51,7 @@ export interface PlatformAdapter {
showOpenDialog(options: OpenDialogOptions): Promise<OpenDialogResult>
copyImageToClipboard(dataUrl: string): Promise<{ success: boolean; error?: string }>
checkUpdate(): void
checkUpdate(): Promise<CheckUpdateResult | void>
performUpdate(): Promise<PerformUpdateResult>
relaunch(): Promise<void>
}
+28 -3
View File
@@ -4,7 +4,14 @@
* 大部分平台能力在 Web 模式下不可用,提供安全的降级行为。
*/
import type { PlatformAdapter, OpenDialogOptions, OpenDialogResult, RemoteConfigResult } from './types'
import type {
PlatformAdapter,
OpenDialogOptions,
OpenDialogResult,
RemoteConfigResult,
CheckUpdateResult,
PerformUpdateResult,
} from './types'
declare const __APP_VERSION__: string
@@ -52,8 +59,26 @@ export class WebPlatformAdapter implements PlatformAdapter {
return { success: false, error: 'Not available in web mode' }
}
checkUpdate(): void {
// No-op in web mode
async checkUpdate(): Promise<CheckUpdateResult> {
try {
const resp = await fetch('/_web/system/check-update')
return await resp.json()
} catch (err) {
return {
hasUpdate: false,
currentVersion: 'unknown',
error: err instanceof Error ? err.message : String(err),
}
}
}
async performUpdate(): Promise<PerformUpdateResult> {
try {
const resp = await fetch('/_web/system/update', { method: 'POST' })
return await resp.json()
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) }
}
}
async relaunch(): Promise<void> {