mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user