import * as fs from 'fs/promises' import * as os from 'os' import * as path from 'path' import { randomBytes } from 'node:crypto' import { normalizeLegacyDeepSeekManagedEnv } from '../../utils/providerManagedEnvCompat.js' import { isOpenAIOfficialProviderId } from './openaiOfficialProvider.js' export const CURRENT_PROVIDER_INDEX_SCHEMA_VERSION = 1 type MigrationReport = { migratedEntries: string[] failures: string[] } type JsonObject = Record type LegacyProviderModel = { id: string name?: string } type LegacyRootProvider = { id: string name: string baseUrl: string apiKey: string models: LegacyProviderModel[] isActive?: boolean notes?: string } let migrationPromise: Promise | null = null let migrationConfigDir: string | null = null function getConfigDir(): string { return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') } function isRecord(value: unknown): value is JsonObject { return !!value && typeof value === 'object' && !Array.isArray(value) } function isProviderModels(value: unknown): value is JsonObject { return ( isRecord(value) && typeof value.main === 'string' && typeof value.haiku === 'string' && typeof value.sonnet === 'string' && typeof value.opus === 'string' ) } function isSavedProvider(value: unknown): value is JsonObject { return ( isRecord(value) && typeof value.id === 'string' && typeof value.presetId === 'string' && typeof value.name === 'string' && typeof value.apiKey === 'string' && typeof value.baseUrl === 'string' && isProviderModels(value.models) ) } function isLegacyProviderModel(value: unknown): value is LegacyProviderModel { return isRecord(value) && typeof value.id === 'string' } function isLegacyRootProvider(value: unknown): value is LegacyRootProvider { return ( isRecord(value) && typeof value.id === 'string' && typeof value.name === 'string' && typeof value.baseUrl === 'string' && typeof value.apiKey === 'string' && Array.isArray(value.models) && value.models.every(isLegacyProviderModel) ) } function errnoCode(error: unknown): string | undefined { return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' ? error.code : undefined } function stableStringify(value: unknown): string { return JSON.stringify(value, null, 2) + '\n' } async function readJsonFile(filePath: string): Promise<{ missing: boolean; value: unknown; raw: string }> { try { const raw = await fs.readFile(filePath, 'utf-8') return { missing: false, value: JSON.parse(raw), raw } } catch (error) { if (errnoCode(error) === 'ENOENT') { return { missing: true, value: undefined, raw: '' } } throw error } } async function backupFile(filePath: string, suffix: string): Promise { const backupPath = `${filePath}.${suffix}-${Date.now()}-${randomBytes(3).toString('hex')}` await fs.copyFile(filePath, backupPath) } async function writeJsonFile(filePath: string, value: unknown): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }) const tmpPath = `${filePath}.tmp.${Date.now()}-${randomBytes(3).toString('hex')}` try { await fs.writeFile(tmpPath, stableStringify(value), 'utf-8') await fs.rename(tmpPath, filePath) } catch (error) { await fs.unlink(tmpPath).catch(() => {}) throw error } } async function quarantineMalformedFile(filePath: string): Promise { const invalidPath = `${filePath}.invalid-${Date.now()}-${randomBytes(3).toString('hex')}` await fs.rename(filePath, invalidPath) } function migrateProvidersIndex(value: unknown): JsonObject { if (!isRecord(value) || !Array.isArray(value.providers)) { return { schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION, activeId: null, providers: [], } } const { activeProviderId: _legacyActiveProviderId, ...rest } = value const providers = value.providers.filter(isSavedProvider) const rawActiveId = typeof value.activeId === 'string' ? value.activeId : typeof _legacyActiveProviderId === 'string' ? _legacyActiveProviderId : null const activeId = rawActiveId && ( providers.some((provider) => provider.id === rawActiveId) || isOpenAIOfficialProviderId(rawActiveId) ) ? rawActiveId : null return { ...rest, schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION, activeId, providers, } } function migrateManagedSettings(value: unknown): JsonObject { if (!isRecord(value)) return {} if (value.env !== undefined && !isRecord(value.env)) { return { ...value, env: {} } } if (isRecord(value.env)) { const { env, changed } = normalizeLegacyDeepSeekManagedEnv(value.env as Record) if (changed) return { ...value, env } } return value } async function migrateJsonEntry( filePath: string, entryName: string, report: MigrationReport, migrate: (value: unknown) => JsonObject, ): Promise { try { const current = await readJsonFile(filePath) if (current.missing) return const migrated = migrate(current.value) if (stableStringify(migrated) === stableStringify(current.value)) return await backupFile(filePath, 'bak-before-migration') await writeJsonFile(filePath, migrated) report.migratedEntries.push(entryName) } catch (error) { if (error instanceof SyntaxError) { try { await quarantineMalformedFile(filePath) await writeJsonFile(filePath, {}) report.migratedEntries.push(entryName) return } catch (recoveryError) { report.failures.push(`${entryName}: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`) return } } report.failures.push(`${entryName}: ${error instanceof Error ? error.message : String(error)}`) } } function legacyProviderModelId( provider: LegacyRootProvider, preferredModelId: unknown, ): string { if ( typeof preferredModelId === 'string' && provider.models.some((model) => model.id === preferredModelId) ) { return preferredModelId } return provider.models[0]?.id ?? '' } function migrateLegacyRootProvidersConfig(value: unknown): JsonObject | null { if (!isRecord(value) || !Array.isArray(value.providers)) { return null } const providers = value.providers .filter(isLegacyRootProvider) .map((provider) => { const main = legacyProviderModelId(provider, value.activeModel) return { id: provider.id, presetId: 'custom', name: provider.name, apiKey: provider.apiKey, baseUrl: provider.baseUrl, apiFormat: 'anthropic', models: { main, haiku: main, sonnet: main, opus: main, }, ...(provider.notes !== undefined && { notes: provider.notes }), } }) if (providers.length === 0) { return null } const activeLegacyProvider = value.providers .filter(isLegacyRootProvider) .find((provider) => provider.isActive === true || (typeof value.activeModel === 'string' && provider.models.some((model) => model.id === value.activeModel)), ) const activeId = activeLegacyProvider && providers.some((provider) => provider.id === activeLegacyProvider.id) ? activeLegacyProvider.id : null return { schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION, activeId, providers, } } function buildManagedSettingsForMigratedProvider(provider: JsonObject | undefined): JsonObject | null { if (!provider || !isProviderModels(provider.models)) return null const apiKey = typeof provider.apiKey === 'string' ? provider.apiKey : '' const baseUrl = typeof provider.baseUrl === 'string' ? provider.baseUrl : '' if (!apiKey || !baseUrl) return null return { env: { ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_AUTH_TOKEN: apiKey, ANTHROPIC_MODEL: provider.models.main, ANTHROPIC_DEFAULT_HAIKU_MODEL: provider.models.haiku, ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.sonnet, ANTHROPIC_DEFAULT_OPUS_MODEL: provider.models.opus, }, } } async function migrateLegacyRootProviders( configDir: string, ccHahaDir: string, report: MigrationReport, ): Promise { const targetPath = path.join(ccHahaDir, 'providers.json') try { await fs.access(targetPath) return } catch (error) { if (errnoCode(error) !== 'ENOENT') { report.failures.push(`cc-haha/providers.json: ${error instanceof Error ? error.message : String(error)}`) return } } const legacyPath = path.join(configDir, 'providers.json') try { const legacy = await readJsonFile(legacyPath) if (legacy.missing) return const migrated = migrateLegacyRootProvidersConfig(legacy.value) if (!migrated) return await writeJsonFile(targetPath, migrated) report.migratedEntries.push('providers.json -> cc-haha/providers.json') const settingsPath = path.join(ccHahaDir, 'settings.json') const settings = await readJsonFile(settingsPath).catch(() => ({ missing: false, value: undefined, raw: '' })) if (!settings.missing) return const activeId = typeof migrated.activeId === 'string' ? migrated.activeId : null const activeProvider = Array.isArray(migrated.providers) ? migrated.providers.find((provider) => isRecord(provider) && provider.id === activeId) : undefined const managedSettings = buildManagedSettingsForMigratedProvider( isRecord(activeProvider) ? activeProvider : undefined, ) if (managedSettings) { await writeJsonFile(settingsPath, managedSettings) report.migratedEntries.push('providers.json -> cc-haha/settings.json') } } catch (error) { if (error instanceof SyntaxError) { report.failures.push(`providers.json: ${error.message}`) return } report.failures.push(`providers.json: ${error instanceof Error ? error.message : String(error)}`) } } async function runPersistentStorageMigrations(configDir: string): Promise { const report: MigrationReport = { migratedEntries: [], failures: [] } const ccHahaDir = path.join(configDir, 'cc-haha') await migrateLegacyRootProviders(configDir, ccHahaDir, report) await migrateJsonEntry( path.join(ccHahaDir, 'providers.json'), 'cc-haha/providers.json', report, migrateProvidersIndex, ) await migrateJsonEntry( path.join(ccHahaDir, 'settings.json'), 'cc-haha/settings.json', report, migrateManagedSettings, ) return report } export function ensurePersistentStorageUpgraded(): Promise { const configDir = getConfigDir() if (!migrationPromise || migrationConfigDir !== configDir) { migrationConfigDir = configDir migrationPromise = runPersistentStorageMigrations(configDir) } return migrationPromise } export function resetPersistentStorageMigrationsForTests(): void { migrationPromise = null migrationConfigDir = null }