mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(cli): implement update checking and auto-update for CLI and CLI Web
This commit is contained in:
1 parent
1a35d2bb9a
commit
a105d7ab8e
11 files changed
+399
-25
No files matched your search
@@ -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')
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -376,6 +376,13 @@
|
||||
"version": "バージョン",
|
||||
"checkUpdate": "アップデートを確認",
|
||||
"checking": "確認中...",
|
||||
"upToDate": "最新バージョンです",
|
||||
"newVersionAvailable": "新しいバージョン {version} があります",
|
||||
"updateNow": "今すぐ更新",
|
||||
"updating": "更新中...",
|
||||
"updateSuccess": "更新が完了しました。サービスを再起動してください",
|
||||
"updateFailed": "更新に失敗しました:{error}",
|
||||
"updateCheckFailed": "更新確認に失敗しました:{error}",
|
||||
"unknown": "不明",
|
||||
"privacy": {
|
||||
"title": "プライバシー設定",
|
||||
|
||||
@@ -376,6 +376,13 @@
|
||||
"version": "版本",
|
||||
"checkUpdate": "检查更新",
|
||||
"checking": "检查中...",
|
||||
"upToDate": "已是最新版本",
|
||||
"newVersionAvailable": "发现新版本 {version}",
|
||||
"updateNow": "立即更新",
|
||||
"updating": "更新中...",
|
||||
"updateSuccess": "更新成功,请重启服务以生效",
|
||||
"updateFailed": "更新失败:{error}",
|
||||
"updateCheckFailed": "检查更新失败:{error}",
|
||||
"unknown": "未知",
|
||||
"privacy": {
|
||||
"title": "隐私设置",
|
||||
|
||||
@@ -376,6 +376,13 @@
|
||||
"version": "版本",
|
||||
"checkUpdate": "檢查更新",
|
||||
"checking": "檢查中...",
|
||||
"upToDate": "已是最新版本",
|
||||
"newVersionAvailable": "發現新版本 {version}",
|
||||
"updateNow": "立即更新",
|
||||
"updating": "更新中...",
|
||||
"updateSuccess": "更新成功,請重啟服務以生效",
|
||||
"updateFailed": "更新失敗:{error}",
|
||||
"updateCheckFailed": "檢查更新失敗:{error}",
|
||||
"unknown": "未知",
|
||||
"privacy": {
|
||||
"title": "隱私設定",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user