feat(cli): block incompatible data directories at startup

This commit is contained in:
digua committed 2026-06-08 10:14:42 +08:00
1 parent c518a4d968
commit 84b42a68f7
6 files changed
+183 -4

No files matched your search

+3 -1
View File
@@ -27,6 +27,7 @@ import {
import { getVersion } from './version'
import { resolveCliPath } from './paths'
import { isPortAvailable, formatPortInUseError } from './http/port'
import { assertCliDataDirCompatible } from './runtime-compat'
const program = new Command()
@@ -519,6 +520,7 @@ function initRuntime() {
const userDataDir = config.data.user_data_dir || undefined
const pathProvider = new NodePathProvider(userDataDir)
pathProvider.ensureAllDirs()
const runtime = assertCliDataDirCompatible(pathProvider, 'cli')
if (hasPendingElectronDataWarning() || !verifyCliDataPath(pathProvider.getDatabaseDir())) {
printElectronDataError()
@@ -526,7 +528,7 @@ function initRuntime() {
}
const nativeBinding = resolveNativeBinding()
const dbManager = new DatabaseManager(pathProvider, { nativeBinding })
const dbManager = new DatabaseManager(pathProvider, { nativeBinding, runtime })
return { config, pathProvider, dbManager }
}
+3 -1
View File
@@ -32,6 +32,7 @@ import { createCliRunAgentStream } from '../ai/agent-stream-runner'
import { initSync, cleanupSync } from '../sync'
import { resolveCliPath } from '../paths'
import { resolveApiKey, writeAuthProfile } from '@openchatlab/config'
import { assertCliDataDirCompatible } from '../runtime-compat'
let server: FastifyInstance | null = null
let dbManager: DatabaseManager | null = null
@@ -112,6 +113,7 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
const userDataDir = config.data.user_data_dir || undefined
const pathProvider = new NodePathProvider(userDataDir)
pathProvider.ensureAllDirs()
const runtime = assertCliDataDirCompatible(pathProvider, 'cli')
if (hasPendingElectronDataWarning() || !verifyCliDataPath(pathProvider.getDatabaseDir())) {
console.error(
@@ -147,7 +149,7 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
})
await migrationRunner.run()
const nativeBinding = resolveNativeBinding()
dbManager = new DatabaseManager(pathProvider, { nativeBinding })
dbManager = new DatabaseManager(pathProvider, { nativeBinding, runtime })
const aiDataDir = pathProvider.getAiDataDir()
aiChatManager = new AIChatManager(aiDataDir, { nativeBinding })
+4 -2
View File
@@ -15,6 +15,7 @@ import {
import { startMcpServer } from 'chatlab-mcp'
import { getVersion } from './version'
import { resolveCliPath } from './paths'
import { assertCliDataDirCompatible } from './runtime-compat'
function resolveNativeBinding(): string | undefined {
if (process.versions.electron) return undefined
@@ -23,11 +24,12 @@ function resolveNativeBinding(): string | undefined {
return undefined
}
function initMcpRuntime(): DatabaseManager {
export function initMcpRuntime(): DatabaseManager {
const config = loadConfig()
const userDataDir = config.data.user_data_dir || undefined
const pathProvider = new NodePathProvider(userDataDir)
pathProvider.ensureAllDirs()
const runtime = assertCliDataDirCompatible(pathProvider, 'mcp')
if (hasPendingElectronDataWarning() || !verifyCliDataPath(pathProvider.getDatabaseDir())) {
console.error('[MCP] Electron desktop data detected but databases not found.')
@@ -36,7 +38,7 @@ function initMcpRuntime(): DatabaseManager {
}
const nativeBinding = resolveNativeBinding()
return new DatabaseManager(pathProvider, { nativeBinding })
return new DatabaseManager(pathProvider, { nativeBinding, runtime })
}
export async function startCliMcpServer(): Promise<void> {
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { startHttpServer, stopHttpServer } from './http'
import { initMcpRuntime } from './mcp'
function makeTempDir(): string {
const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()
return fs.mkdtempSync(path.join(baseDir, 'chatlab-cli-startup-'))
}
function writeIncompatibleMeta(userDataDir: string): void {
fs.mkdirSync(userDataDir, { recursive: true })
fs.writeFileSync(
path.join(userDataDir, '.chatlab-meta.json'),
JSON.stringify({
formatVersion: 1,
minRuntimeVersion: '999.0.0',
dataCompatibilityVersion: 1,
reasons: ['segment-schema'],
updatedBy: { runtime: 'desktop', module: 'chat-db-migration', version: '999.0.0' },
updatedAt: 1780830000,
}),
'utf-8'
)
}
async function withDataDirEnv<T>(userDataDir: string, fn: () => Promise<T> | T): Promise<T> {
const previousDataDir = process.env.CHATLAB_DATA_DIR
const previousOverride = process.env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR
process.env.CHATLAB_DATA_DIR = userDataDir
delete process.env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR
try {
return await fn()
} finally {
if (previousDataDir === undefined) {
delete process.env.CHATLAB_DATA_DIR
} else {
process.env.CHATLAB_DATA_DIR = previousDataDir
}
if (previousOverride === undefined) {
delete process.env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR
} else {
process.env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR = previousOverride
}
}
}
test('startHttpServer fails before serving when data directory requires a newer runtime', async () => {
const userDataDir = makeTempDir()
writeIncompatibleMeta(userDataDir)
await withDataDirEnv(userDataDir, async () => {
await assert.rejects(
() => startHttpServer({ port: 0, host: '127.0.0.1', token: 'test_token', requireAuth: false }),
/requires ChatLab 999\.0\.0 or newer/
)
})
await stopHttpServer()
})
test('initMcpRuntime fails before starting MCP when data directory requires a newer runtime', async () => {
const userDataDir = makeTempDir()
writeIncompatibleMeta(userDataDir)
await withDataDirEnv(userDataDir, () => {
assert.throws(() => initMcpRuntime(), /requires ChatLab 999\.0\.0 or newer/)
})
})
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import type { PathProvider } from '@openchatlab/core'
import { assertCliDataDirCompatible } from './runtime-compat'
function makeTempDir(): string {
const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()
return fs.mkdtempSync(path.join(baseDir, 'chatlab-cli-compat-'))
}
function makePathProvider(userDataDir: string): PathProvider {
return {
getSystemDir: () => path.join(userDataDir, '..', 'system'),
getUserDataDir: () => userDataDir,
getDatabaseDir: () => path.join(userDataDir, 'databases'),
getAiDataDir: () => path.join(userDataDir, '..', 'system', 'ai'),
getSettingsDir: () => path.join(userDataDir, '..', 'system', 'settings'),
getCacheDir: () => path.join(userDataDir, '..', 'system', 'cache'),
getTempDir: () => path.join(userDataDir, '..', 'system', 'temp'),
getLogsDir: () => path.join(userDataDir, '..', 'system', 'logs'),
getDownloadsDir: () => path.join(userDataDir, '..', 'downloads'),
}
}
test('assertCliDataDirCompatible formats a startup error for older CLI runtimes', () => {
const userDataDir = makeTempDir()
fs.writeFileSync(
path.join(userDataDir, '.chatlab-meta.json'),
JSON.stringify({
formatVersion: 1,
minRuntimeVersion: '999.0.0',
dataCompatibilityVersion: 1,
reasons: ['segment-schema'],
updatedBy: { runtime: 'desktop', module: 'chat-db-migration', version: '999.0.0' },
updatedAt: 1780830000,
}),
'utf-8'
)
assert.throws(
() => assertCliDataDirCompatible(makePathProvider(userDataDir), 'cli', { version: '0.25.0' }),
(error) => {
assert.ok(error instanceof Error)
assert.match(error.message, /requires ChatLab 999\.0\.0 or newer/)
assert.match(error.message, /Current cli version: 0\.25\.0/)
assert.match(error.message, new RegExp(userDataDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
assert.match(error.message, /npm install -g chatlab-cli@latest/)
return true
}
)
})
+45
View File
@@ -0,0 +1,45 @@
import type { PathProvider } from '@openchatlab/core'
import {
assertDataDirCompatible,
DataDirCompatibilityError,
type RuntimeIdentity,
type RuntimeKind,
} from '@openchatlab/node-runtime'
import { getVersion } from './version'
export function createCliRuntimeIdentity(kind: Extract<RuntimeKind, 'cli' | 'mcp'>): RuntimeIdentity {
return { version: getVersion(), kind }
}
export function assertCliDataDirCompatible(
pathProvider: PathProvider,
kind: Extract<RuntimeKind, 'cli' | 'mcp'>,
options?: { version?: string }
): RuntimeIdentity {
const runtime: RuntimeIdentity = { version: options?.version ?? getVersion(), kind }
try {
assertDataDirCompatible(pathProvider, runtime)
} catch (error) {
if (
error instanceof DataDirCompatibilityError &&
error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME' &&
error.minRuntimeVersion
) {
throw new Error(formatCliDataDirCompatibilityError(error, runtime), { cause: error })
}
throw error
}
return runtime
}
function formatCliDataDirCompatibilityError(error: DataDirCompatibilityError, runtime: RuntimeIdentity): string {
return [
`ChatLab data directory requires ChatLab ${error.minRuntimeVersion} or newer.`,
`Current ${runtime.kind} version: ${runtime.version}.`,
`Data directory: ${error.userDataDir}.`,
'Upgrade: npm install -g chatlab-cli@latest',
].join('\n')
}