feat(data): support CLI web data directory migration

This commit is contained in:
digua committed 2026-06-03 00:46:34 +08:00
1 parent a94986f117
commit e68ce77266
18 files changed
+801 -171

No files matched your search

@@ -0,0 +1,210 @@
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 {
applyPendingNodeDataDirMigration,
createPendingDataDirMigration,
createNodeDataDirSwitch,
getPendingNodeDataDirMigration,
isExistingUserDataDir,
runPendingDataDirMigration,
} from './data-dir-switch'
import { applyPendingNodeDataDirMigrationIfNeeded } from './node-path-provider'
function makeTempDir(): string {
const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()
return fs.mkdtempSync(path.join(baseDir, 'chatlab-data-switch-'))
}
function writeFile(filePath: string, content = 'data'): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, content, 'utf-8')
}
test('createPendingDataDirMigration records a restart-time migration without mutating config', () => {
const pending = createPendingDataDirMigration({
from: '/old/data',
to: '/new/data',
migrate: true,
targetWasEmpty: true,
})
assert.equal(pending.from, '/old/data')
assert.equal(pending.to, '/new/data')
assert.equal(pending.migrate, true)
assert.equal(pending.deleteSourceOnSuccess, true)
assert.match(pending.createdAt, /^\d{4}-\d{2}-\d{2}T/)
})
test('runPendingDataDirMigration writes config only after copy succeeds', () => {
const root = makeTempDir()
const source = path.join(root, 'source')
const target = path.join(root, 'target')
writeFile(path.join(source, 'databases', 'session.db'), 'sqlite')
let configuredDir = source
let pendingCleared = false
let pendingDeleteDir: string | null = null
const result = runPendingDataDirMigration(
createPendingDataDirMigration({ from: source, to: target, migrate: true, targetWasEmpty: true }),
{
writeUserDataDir(dir) {
configuredDir = dir
},
clearPendingMigration() {
pendingCleared = true
},
markPendingDeleteDir(dir) {
pendingDeleteDir = dir
},
}
)
assert.equal(result.success, true)
assert.equal(configuredDir, target)
assert.equal(pendingCleared, true)
assert.equal(pendingDeleteDir, source)
assert.equal(fs.readFileSync(path.join(target, 'databases', 'session.db'), 'utf-8'), 'sqlite')
})
test('runPendingDataDirMigration keeps old config and pending task when copy fails', () => {
const root = makeTempDir()
const source = path.join(root, 'source')
const target = path.join(root, 'target')
writeFile(path.join(source, 'databases', 'session.db'), 'sqlite')
let configuredDir = source
let pendingCleared = false
const result = runPendingDataDirMigration(
createPendingDataDirMigration({ from: source, to: target, migrate: true, targetWasEmpty: true }),
{
copyDirMerge() {
return { copied: 0, skipped: 0, errors: ['copy failed'] }
},
writeUserDataDir(dir) {
configuredDir = dir
},
clearPendingMigration() {
pendingCleared = true
},
}
)
assert.equal(result.success, false)
assert.equal(configuredDir, source)
assert.equal(pendingCleared, false)
assert.equal(fs.existsSync(path.join(target, 'databases', 'session.db')), false)
})
test('runPendingDataDirMigration fails when source directory is missing', () => {
const root = makeTempDir()
const source = path.join(root, 'missing-source')
const target = path.join(root, 'target')
let configuredDir = source
let pendingCleared = false
const result = runPendingDataDirMigration(
createPendingDataDirMigration({ from: source, to: target, migrate: true, targetWasEmpty: true }),
{
writeUserDataDir(dir) {
configuredDir = dir
},
clearPendingMigration() {
pendingCleared = true
},
}
)
assert.equal(result.success, false)
assert.equal(configuredDir, source)
assert.equal(pendingCleared, false)
assert.equal(fs.existsSync(target), false)
})
test('isExistingUserDataDir accepts current user data layout without settings directory', () => {
const root = makeTempDir()
const dataDir = path.join(root, 'data')
writeFile(path.join(dataDir, '.chatlab'), 'ChatLab Data Directory')
fs.mkdirSync(path.join(dataDir, 'databases'), { recursive: true })
assert.equal(isExistingUserDataDir(dataDir), true)
})
test('createNodeDataDirSwitch writes pending migration under the system settings directory', () => {
const root = makeTempDir()
const systemDir = path.join(root, 'system')
const currentDir = path.join(root, 'current')
const targetDir = path.join(root, 'target')
writeFile(path.join(currentDir, 'databases', 'session.db'), 'sqlite')
const result = createNodeDataDirSwitch({ systemDir, currentDir, targetDir, migrate: true })
const pending = getPendingNodeDataDirMigration(systemDir)
assert.equal(result.success, true)
assert.equal(result.requiresRelaunch, true)
assert.equal(pending?.from, currentDir)
assert.equal(pending?.to, targetDir)
})
test('applyPendingNodeDataDirMigration deletes old data directory after successful migration to empty target', () => {
const root = makeTempDir()
const systemDir = path.join(root, 'system')
const currentDir = path.join(root, 'current')
const targetDir = path.join(root, 'target')
writeFile(path.join(currentDir, '.chatlab'), 'ChatLab Data Directory')
writeFile(path.join(currentDir, 'databases', 'session.db'), 'sqlite')
const switchResult = createNodeDataDirSwitch({ systemDir, currentDir, targetDir, migrate: true })
assert.equal(switchResult.success, true)
const writes: Array<{ section: string; key: string; value: unknown }> = []
const result = applyPendingNodeDataDirMigration(systemDir, {
writeConfigField(section, key, value) {
writes.push({ section, key, value })
},
})
assert.equal(result.success, true)
assert.equal(fs.existsSync(currentDir), false)
assert.equal(fs.readFileSync(path.join(targetDir, 'databases', 'session.db'), 'utf-8'), 'sqlite')
assert.equal(getPendingNodeDataDirMigration(systemDir), null)
assert.deepEqual(writes, [
{ section: 'data', key: 'user_data_dir', value: targetDir },
{ section: 'data', key: 'electron_migration_done', value: true },
])
})
test('createNodeDataDirSwitch rejects data directory changes while CHATLAB_DATA_DIR is active', () => {
const root = makeTempDir()
const result = createNodeDataDirSwitch({
systemDir: path.join(root, 'system'),
currentDir: path.join(root, 'current'),
targetDir: path.join(root, 'target'),
migrate: true,
envDataDir: '/env/data',
})
assert.equal(result.success, false)
})
test('applyPendingNodeDataDirMigrationIfNeeded skips while CHATLAB_DATA_DIR is active', () => {
const originalEnvDir = process.env.CHATLAB_DATA_DIR
process.env.CHATLAB_DATA_DIR = '/env/data'
try {
const result = applyPendingNodeDataDirMigrationIfNeeded()
assert.equal(result.success, true)
assert.equal(result.skipped, true)
} finally {
if (originalEnvDir === undefined) {
delete process.env.CHATLAB_DATA_DIR
} else {
process.env.CHATLAB_DATA_DIR = originalEnvDir
}
}
})
@@ -0,0 +1,364 @@
import * as fs from 'fs'
import * as path from 'path'
import { writeConfigField } from '@openchatlab/config'
const CHATLAB_MARKER_FILE = '.chatlab'
const USER_DATA_REQUIRED_DIRS = ['databases']
const PENDING_MIGRATION_FILE = 'data-dir-migration.json'
const DANGEROUS_PATHS = [
'C:\\Windows',
'C:\\Program Files',
'C:\\Program Files (x86)',
'C:\\ProgramData',
'/usr',
'/etc',
'/bin',
'/sbin',
'/lib',
'/var',
'/boot',
'/root',
'/System',
'/Library',
]
export interface CopyStats {
copied: number
skipped: number
errors: string[]
}
export interface PendingDataDirMigration {
from: string
to: string
migrate: boolean
deleteSourceOnSuccess: boolean
createdAt: string
}
export interface RunPendingDataDirMigrationDeps {
copyDirMerge?: typeof copyDirMerge
ensureDir?: (dirPath: string) => void
writeUserDataDir: (dir: string) => void
clearPendingMigration: () => void
markPendingDeleteDir?: (dir: string) => void
log?: (message: string) => void
}
export interface RunPendingDataDirMigrationResult {
success: boolean
from: string
to: string
copied: number
skipped: number
errors: string[]
}
export interface DataDirSwitchResult {
success: boolean
error?: string
from?: string
to?: string
requiresRelaunch?: boolean
}
export interface ApplyPendingNodeDataDirMigrationDeps {
writeConfigField?: typeof writeConfigField
removeDir?: (dir: string) => void
}
function normalizePathForCompare(input: string): string {
const resolved = path.resolve(input)
const normalized = path.normalize(resolved)
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
}
function isSubPath(parent: string, child: string): boolean {
const parentPath = normalizePathForCompare(parent)
const childPath = normalizePathForCompare(child)
if (parentPath === childPath) return false
return childPath.startsWith(`${parentPath}${path.sep}`)
}
function isPathSafe(targetPath: string): boolean {
const normalizedTarget = targetPath.toLowerCase().replace(/\//g, '\\')
for (const dangerous of DANGEROUS_PATHS) {
const normalizedDangerous = dangerous.toLowerCase().replace(/\//g, '\\')
if (normalizedTarget.startsWith(normalizedDangerous)) {
return false
}
}
return true
}
function ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true })
}
}
function hasChatLabUserDataStructure(entries: string[]): boolean {
return entries.includes(CHATLAB_MARKER_FILE) && USER_DATA_REQUIRED_DIRS.every((dir) => entries.includes(dir))
}
export function isExistingUserDataDir(dirPath: string): boolean {
if (!fs.existsSync(dirPath)) return false
try {
return hasChatLabUserDataStructure(fs.readdirSync(dirPath))
} catch {
return false
}
}
export function isUserDataDirSafeToUse(dirPath: string): boolean {
if (!fs.existsSync(dirPath)) return true
try {
const entries = fs.readdirSync(dirPath)
if (entries.length === 0) return true
return hasChatLabUserDataStructure(entries)
} catch {
return false
}
}
export function isDirectoryEmptyOrMissing(dirPath: string): boolean {
if (!fs.existsSync(dirPath)) return true
try {
return fs.readdirSync(dirPath).length === 0
} catch {
return false
}
}
export function copyDirMerge(
src: string,
dest: string,
mkdir: (dirPath: string) => void = ensureDir,
stats: CopyStats = { copied: 0, skipped: 0, errors: [] }
): CopyStats {
if (!fs.existsSync(src)) return stats
try {
mkdir(dest)
const entries = fs.readdirSync(src, { withFileTypes: true })
for (const entry of entries) {
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
try {
if (entry.isDirectory()) {
copyDirMerge(srcPath, destPath, mkdir, stats)
} else if (!fs.existsSync(destPath)) {
fs.copyFileSync(srcPath, destPath)
stats.copied++
} else {
stats.skipped++
}
} catch (error) {
stats.errors.push(`复制失败: ${srcPath} -> ${error instanceof Error ? error.message : String(error)}`)
}
}
} catch (error) {
stats.errors.push(`读取目录失败: ${src} -> ${error instanceof Error ? error.message : String(error)}`)
}
return stats
}
export function createPendingDataDirMigration(input: {
from: string
to: string
migrate: boolean
targetWasEmpty: boolean
}): PendingDataDirMigration {
return {
from: input.from,
to: input.to,
migrate: input.migrate,
deleteSourceOnSuccess: input.migrate && input.targetWasEmpty,
createdAt: new Date().toISOString(),
}
}
export function runPendingDataDirMigration(
pending: PendingDataDirMigration,
deps: RunPendingDataDirMigrationDeps
): RunPendingDataDirMigrationResult {
const copy = deps.copyDirMerge ?? copyDirMerge
const mkdir = deps.ensureDir ?? ensureDir
let stats: CopyStats = { copied: 0, skipped: 0, errors: [] }
if (pending.migrate && path.resolve(pending.from) !== path.resolve(pending.to)) {
if (!fs.existsSync(pending.from)) {
return {
success: false,
from: pending.from,
to: pending.to,
copied: 0,
skipped: 0,
errors: [`源数据目录不存在: ${pending.from}`],
}
}
stats = copy(pending.from, pending.to, mkdir)
deps.log?.(
`数据目录迁移完成: 从 ${pending.from} 到 ${pending.to},复制 ${stats.copied} 项,跳过 ${stats.skipped} 项,错误 ${stats.errors.length} 项`
)
if (stats.errors.length > 0) {
return {
success: false,
from: pending.from,
to: pending.to,
copied: stats.copied,
skipped: stats.skipped,
errors: stats.errors,
}
}
} else {
mkdir(pending.to)
}
deps.writeUserDataDir(pending.to)
deps.clearPendingMigration()
if (pending.deleteSourceOnSuccess && path.resolve(pending.from) !== path.resolve(pending.to)) {
deps.markPendingDeleteDir?.(pending.from)
}
return {
success: true,
from: pending.from,
to: pending.to,
copied: stats.copied,
skipped: stats.skipped,
errors: [],
}
}
function getPendingMigrationPath(systemDir: string): string {
return path.join(systemDir, 'settings', PENDING_MIGRATION_FILE)
}
export function getPendingNodeDataDirMigration(systemDir: string): PendingDataDirMigration | null {
const filePath = getPendingMigrationPath(systemDir)
if (!fs.existsSync(filePath)) return null
try {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as PendingDataDirMigration
if (!parsed.from || !parsed.to || typeof parsed.migrate !== 'boolean') return null
return parsed
} catch {
return null
}
}
export function clearPendingNodeDataDirMigration(systemDir: string): void {
const filePath = getPendingMigrationPath(systemDir)
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath)
}
}
// 只删除确认仍是 ChatLab 用户数据目录的旧路径,避免误删用户选择的普通目录。
export function deleteOldUserDataDirIfSafe(
dirPath: string,
currentDir: string,
removeDir?: (dir: string) => void
): boolean {
if (path.resolve(dirPath) === path.resolve(currentDir)) return false
if (!isPathSafe(dirPath)) return false
if (!fs.existsSync(dirPath)) return false
if (!isExistingUserDataDir(dirPath)) return false
const remove = removeDir ?? ((dir: string) => fs.rmSync(dir, { recursive: true, force: true }))
remove(dirPath)
return true
}
function writePendingNodeDataDirMigration(systemDir: string, pending: PendingDataDirMigration): void {
const filePath = getPendingMigrationPath(systemDir)
ensureDir(path.dirname(filePath))
fs.writeFileSync(filePath, JSON.stringify(pending, null, 2), 'utf-8')
}
export function createNodeDataDirSwitch(input: {
systemDir: string
currentDir: string
targetDir: string | null
defaultDir?: string
migrate?: boolean
envDataDir?: string
}): DataDirSwitchResult {
if (input.envDataDir) {
return { success: false, error: 'CHATLAB_DATA_DIR is set, data directory cannot be changed from Web UI' }
}
const newDir = (input.targetDir?.trim() || input.defaultDir || '').trim()
if (!newDir) return { success: false, error: 'Data directory is required' }
if (!path.isAbsolute(newDir)) return { success: false, error: 'Data directory must be an absolute path' }
if (!isPathSafe(newDir)) return { success: false, error: 'System directories cannot be used as data directory' }
const migrate = input.migrate !== false
if (migrate && path.resolve(input.currentDir) !== path.resolve(newDir) && isSubPath(input.currentDir, newDir)) {
return { success: false, error: 'Target directory cannot be inside current data directory' }
}
if (!isUserDataDirSafeToUse(newDir)) {
return { success: false, error: 'Target directory is not empty and is not a ChatLab data directory' }
}
if (path.resolve(input.currentDir) === path.resolve(newDir)) {
clearPendingNodeDataDirMigration(input.systemDir)
return { success: true, from: input.currentDir, to: newDir, requiresRelaunch: false }
}
const pending = createPendingDataDirMigration({
from: input.currentDir,
to: newDir,
migrate,
targetWasEmpty: isDirectoryEmptyOrMissing(newDir),
})
writePendingNodeDataDirMigration(input.systemDir, pending)
return { success: true, from: input.currentDir, to: newDir, requiresRelaunch: true }
}
export function applyPendingNodeDataDirMigration(
systemDir: string,
deps: ApplyPendingNodeDataDirMigrationDeps = {}
): {
success: boolean
skipped?: boolean
error?: string
} {
const pending = getPendingNodeDataDirMigration(systemDir)
if (!pending) return { success: true, skipped: true }
const writeConfig = deps.writeConfigField ?? writeConfigField
const result = runPendingDataDirMigration(pending, {
writeUserDataDir(dir) {
writeConfig('data', 'user_data_dir', dir)
writeConfig('data', 'electron_migration_done', true)
},
clearPendingMigration() {
clearPendingNodeDataDirMigration(systemDir)
},
markPendingDeleteDir(dir) {
deleteOldUserDataDirIfSafe(dir, pending.to, deps.removeDir)
},
})
if (!result.success) {
return { success: false, error: result.errors.join('; ') || 'Data directory migration failed' }
}
return { success: true }
}
+26 -1
View File
@@ -52,9 +52,34 @@ export { hasFtsTable, createFtsTable, buildFtsIndex, rebuildFtsIndex, insertFtsE
// AI Logger & Error formatting
export { AiLogger, extractErrorInfo, extractErrorStack, formatAIError } from './ai'
export type { FormatAIErrorOptions } from './ai'
export { NodePathProvider, hasPendingElectronDataWarning } from './node-path-provider'
export {
NodePathProvider,
applyPendingNodeDataDirMigrationIfNeeded,
getDefaultNodeUserDataDir,
hasPendingElectronDataWarning,
} from './node-path-provider'
export { DatabaseManager } from './database-manager'
export { createJiebaNlpProvider } from './jieba-nlp-provider'
export {
applyPendingNodeDataDirMigration,
clearPendingNodeDataDirMigration,
copyDirMerge as copyDataDirMerge,
createNodeDataDirSwitch,
createPendingDataDirMigration,
getPendingNodeDataDirMigration,
isDirectoryEmptyOrMissing,
isExistingUserDataDir,
isUserDataDirSafeToUse,
runPendingDataDirMigration,
} from './data-dir-switch'
export type {
ApplyPendingNodeDataDirMigrationDeps,
CopyStats as DataDirCopyStats,
DataDirSwitchResult,
PendingDataDirMigration,
RunPendingDataDirMigrationDeps,
RunPendingDataDirMigrationResult,
} from './data-dir-switch'
// NLP 分词引擎、词频统计、词库管理
export {
@@ -20,6 +20,7 @@ import * as path from 'path'
import type { PathProvider } from '@openchatlab/core'
import { writeConfigField, loadConfig } from '@openchatlab/config'
import { migrateFromElectronIfNeeded } from './migrations/electron-data-migration'
import { applyPendingNodeDataDirMigration } from './data-dir-switch'
const SYSTEM_DIR = path.join(os.homedir(), '.chatlab')
@@ -33,6 +34,11 @@ export function hasPendingElectronDataWarning(): boolean {
return _pendingElectronDataWarning
}
export function applyPendingNodeDataDirMigrationIfNeeded(): { success: boolean; skipped?: boolean; error?: string } {
if (process.env.CHATLAB_DATA_DIR) return { success: true, skipped: true }
return applyPendingNodeDataDirMigration(SYSTEM_DIR)
}
export class NodePathProvider implements PathProvider {
private systemDir: string
private userDataDir: string
@@ -117,7 +123,7 @@ function resolveUserDataDir(): string {
return result.userDataDir
}
const defaultDir = getDefaultUserDataDir()
const defaultDir = getDefaultNodeUserDataDir()
if (result.electronDetected) {
// Electron was used but we couldn't find databases — likely a custom data directory.
@@ -130,7 +136,7 @@ function resolveUserDataDir(): string {
return defaultDir
}
function getDefaultUserDataDir(): string {
export function getDefaultNodeUserDataDir(): string {
return path.join(os.homedir(), '.chatlab', 'data')
}