From 84b42a68f794406562e9555181dace41e28a0ae8 Mon Sep 17 00:00:00 2001 From: digua Date: Sun, 7 Jun 2026 21:49:59 +0800 Subject: [PATCH] feat(cli): block incompatible data directories at startup --- apps/cli/src/cli.ts | 4 +- apps/cli/src/http/index.ts | 4 +- apps/cli/src/mcp.ts | 6 +- apps/cli/src/runtime-compat-startup.test.ts | 74 +++++++++++++++++++++ apps/cli/src/runtime-compat.test.ts | 54 +++++++++++++++ apps/cli/src/runtime-compat.ts | 45 +++++++++++++ 6 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 apps/cli/src/runtime-compat-startup.test.ts create mode 100644 apps/cli/src/runtime-compat.test.ts create mode 100644 apps/cli/src/runtime-compat.ts diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index e9153cad..79ced883 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -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 } } diff --git a/apps/cli/src/http/index.ts b/apps/cli/src/http/index.ts index 55b62976..6acc76a1 100644 --- a/apps/cli/src/http/index.ts +++ b/apps/cli/src/http/index.ts @@ -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 }) diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts index ba5ca19e..4534c967 100644 --- a/apps/cli/src/mcp.ts +++ b/apps/cli/src/mcp.ts @@ -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 { diff --git a/apps/cli/src/runtime-compat-startup.test.ts b/apps/cli/src/runtime-compat-startup.test.ts new file mode 100644 index 00000000..fe8bc088 --- /dev/null +++ b/apps/cli/src/runtime-compat-startup.test.ts @@ -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(userDataDir: string, fn: () => Promise | T): Promise { + 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/) + }) +}) diff --git a/apps/cli/src/runtime-compat.test.ts b/apps/cli/src/runtime-compat.test.ts new file mode 100644 index 00000000..a2866b53 --- /dev/null +++ b/apps/cli/src/runtime-compat.test.ts @@ -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 + } + ) +}) diff --git a/apps/cli/src/runtime-compat.ts b/apps/cli/src/runtime-compat.ts new file mode 100644 index 00000000..25314b9c --- /dev/null +++ b/apps/cli/src/runtime-compat.ts @@ -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): RuntimeIdentity { + return { version: getVersion(), kind } +} + +export function assertCliDataDirCompatible( + pathProvider: PathProvider, + kind: Extract, + 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') +}