#!/usr/bin/env node const fs = require('fs') const path = require('path') /** * Manually maintained boot config items that aren't sourced from * classification.json. These get merged into the generator's normal * extraction pipeline so they flow through the same sort/emit code as * classification-derived items — the output schema is a single, flat, * fully auto-generated file. * * When classification.json learns to model new source kinds (e.g. a * 'configfile' category), these entries should move into classification.json * and this constant can shrink. */ const MANUAL_BOOT_CONFIG_ITEMS = [ { source: 'configfile', sourceCategory: 'legacy-home', originalKey: 'appDataPath', targetKey: 'app.user_data_path', type: 'Record', defaultValue: 'VALUE: {}', jsdoc: [ 'Custom user data directory, keyed by executable path.', '', 'Conceptually a single setting ("where user data lives"); stored as a', 'Record so the same machine can host multiple installations (stable / dev /', "portable) with independent user data locations — matching the v1 behavior", "of ~/.cherrystudio/config/config.json's `appDataPath` array.", '', "Key: executable path (matches Electron's `app.getPath('exe')`).", 'Value: absolute path to the chosen userData directory.', '', 'Migrated from v1 ~/.cherrystudio/config/config.json on first v1→v2 run', "via the 'configfile' source in BootConfigMigrator." ] }, { source: 'preboot', sourceCategory: 'transient', originalKey: 'userDataRelocation', targetKey: 'temp.user_data_relocation', type: "\n | { status: 'pending'; from: string; to: string }" + "\n | { status: 'failed'; from: string; to: string; error: string; failedAt: string }" + '\n | null', defaultValue: null, jsdoc: [ 'In-flight relocation of the Electron userData directory tree', "(the directory returned by `app.getPath('userData')`).", '', 'Lives under the `temp.*` top-level namespace — reserved for ephemeral', 'runtime state: single in-flight operations meant to be cleared once', 'consumed. **Never** backed up or synced: restoring a stale temp.* entry', 'on a different machine or at a different time can cause silent data', 'corruption (e.g. re-executing a relocation that already happened).', '', 'Lifecycle:', ' - null: no relocation in progress (default).', " - { status: 'pending', from, to }: an IPC handler wrote this request", ' and the next preboot should execute the copy.', " - { status: 'failed', from, to, error, failedAt }: a previous preboot", ' attempted the copy and it failed. The record stays in BootConfig', ' until a renderer recovery flow lets the user retry, abandon, or', ' investigate. The app continues running on the previous userData', ' location until then.', '', 'Note: "userData" here means the Electron OS directory', "(app.getPath('userData')), not the colloquial sense of user content.", 'The copy includes everything under that directory — user files,', 'Chromium runtime state, logs, etc.', '', 'Consumer: src/main/core/preboot/userDataLocation.ts' ] } ] class BootConfigGenerator { constructor() { this.dataDir = path.resolve(__dirname, '../data') this.targetFile = path.resolve(__dirname, '../../../../src/shared/data/bootConfig/bootConfigSchemas.ts') this.classificationFile = path.join(this.dataDir, 'classification.json') } generate() { console.log('Generating bootConfigSchemas.ts...') const classification = this.loadClassification() const bootConfigData = this.extractBootConfigData(classification) const content = this.generateTypeScriptCode(bootConfigData) this.writeFile(content) console.log('bootConfigSchemas.ts generated!') this.printSummary(bootConfigData) } loadClassification() { if (!fs.existsSync(this.classificationFile)) { throw new Error(`Classification file not found: ${this.classificationFile}`) } const content = fs.readFileSync(this.classificationFile, 'utf8') return JSON.parse(content) } extractBootConfigData(classification) { const allItems = [] const sources = ['electronStore', 'redux', 'localStorage', 'dexieSettings'] const extractItems = (items, source, category, parentKey = '') => { if (!Array.isArray(items)) return items.forEach((item) => { if (item.children && Array.isArray(item.children)) { extractItems(item.children, source, category, `${parentKey}${item.originalKey}.`) return } if (item.category === 'bootConfig' && item.status === 'classified' && item.targetKey) { allItems.push({ ...item, source, sourceCategory: category, originalKey: parentKey + item.originalKey, fullPath: `${source}/${category}/${parentKey}${item.originalKey}`, }) } }) } sources.forEach((source) => { if (classification.classifications[source]) { Object.keys(classification.classifications[source]).forEach((category) => { const items = classification.classifications[source][category] extractItems(items, source, category) }) } }) console.log(`Extracted ${allItems.length} bootConfig items`) // Deduplicate by targetKey: redux(4) > dexieSettings(3) > localStorage(2) > electronStore(1) const targetKeyGroups = {} allItems.forEach((item) => { if (!targetKeyGroups[item.targetKey]) { targetKeyGroups[item.targetKey] = [] } targetKeyGroups[item.targetKey].push(item) }) const sourcePriority = { redux: 4, dexieSettings: 3, localStorage: 2, electronStore: 1 } const deduplicatedData = [] Object.keys(targetKeyGroups).forEach((targetKey) => { const items = targetKeyGroups[targetKey] if (items.length > 1) { console.log(`Duplicate targetKey: ${targetKey}, ${items.length} items`) items.forEach((item) => console.log(` - ${item.fullPath}`)) items.sort((a, b) => sourcePriority[b.source] - sourcePriority[a.source]) const selected = items[0] console.log(` Selected: ${selected.fullPath}`) deduplicatedData.push(selected) } else { deduplicatedData.push(items[0]) } }) // Append manually maintained items (config-file source for v1 legacy // home config). These bypass classification.json deliberately — see the // MANUAL_BOOT_CONFIG_ITEMS comment at the top of this file. for (const manual of MANUAL_BOOT_CONFIG_ITEMS) { deduplicatedData.push({ ...manual, fullPath: `${manual.source}/${manual.sourceCategory}/${manual.originalKey}` }) } console.log(`After deduplication + manual items: ${deduplicatedData.length} bootConfig items`) return deduplicatedData } mapType(itemType, defaultValue) { if (itemType && itemType !== 'unknown') { if (itemType === 'boolean') return 'boolean' if (itemType === 'string') return 'string' if (itemType === 'number') return 'number' return itemType } if (defaultValue !== null && defaultValue !== undefined) { const valueType = typeof defaultValue if (valueType === 'boolean' || valueType === 'string' || valueType === 'number') { return valueType } } return 'unknown' } formatDefaultValue(value) { if (value === null || value === undefined) { return 'null' } if (typeof value === 'string') { if (value.startsWith('VALUE: ')) { return value.substring(7) } return `'${value.replace(/'/g, "\\'")}'` } if (typeof value === 'boolean' || typeof value === 'number') { return String(value) } if (Array.isArray(value)) { return `[${value.map((item) => this.formatDefaultValue(item)).join(', ')}]` } if (typeof value === 'object') { const entries = Object.entries(value).map(([k, v]) => `${k}: ${this.formatDefaultValue(v)}`) return `{ ${entries.join(', ')} }` } return JSON.stringify(value) } generateTypeScriptCode(bootConfigData) { const sortedData = [...bootConfigData].sort((a, b) => a.targetKey.localeCompare(b.targetKey)) const header = `/** * Auto-generated boot config schema * Generated at: ${new Date().toISOString()} * * This file is automatically generated from classification.json (plus a * small MANUAL_BOOT_CONFIG_ITEMS list in generate-boot-config.js for keys * that don't fit classification.json's model yet, e.g. config-file sources). * * To update this file, either modify classification.json or the manual list * in the generator, then run: * node v2-refactor-temp/tools/data-classify/scripts/generate-boot-config.js * * === AUTO-GENERATED CONTENT START === */` let interfaceCode = 'export interface BootConfigSchema {\n' sortedData.forEach((item, index) => { const tsType = this.mapType(item.type, item.defaultValue) // Optional JSDoc block (currently only emitted for manual items). if (Array.isArray(item.jsdoc) && item.jsdoc.length > 0) { if (index > 0) interfaceCode += '\n' interfaceCode += ' /**\n' for (const line of item.jsdoc) { interfaceCode += line.length > 0 ? ` * ${line}\n` : ' *\n' } interfaceCode += ' */\n' } interfaceCode += ` // ${item.source}/${item.sourceCategory}/${item.originalKey}\n` interfaceCode += ` '${item.targetKey}': ${tsType}\n` }) interfaceCode += '}' let defaultsCode = 'export const DefaultBootConfig: BootConfigSchema = {\n' sortedData.forEach((item, index) => { const defaultVal = this.formatDefaultValue(item.defaultValue) const isLast = index === sortedData.length - 1 defaultsCode += ` '${item.targetKey}': ${defaultVal}${isLast ? '' : ','}\n` }) defaultsCode += '}' const footer = '// === AUTO-GENERATED CONTENT END ===' return [header, '', interfaceCode, '', defaultsCode, '', footer, ''].join('\n') } writeFile(content) { const targetDir = path.dirname(this.targetFile) if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, { recursive: true }) } fs.writeFileSync(this.targetFile, content, 'utf8') console.log(`Written to: ${this.targetFile}`) } printSummary(bootConfigData) { console.log(`\nSummary:`) console.log(`- Total items: ${bootConfigData.length}`) console.log(`- electronStore: ${bootConfigData.filter((p) => p.source === 'electronStore').length}`) console.log(`- redux: ${bootConfigData.filter((p) => p.source === 'redux').length}`) console.log(`- localStorage: ${bootConfigData.filter((p) => p.source === 'localStorage').length}`) console.log(`- dexieSettings: ${bootConfigData.filter((p) => p.source === 'dexieSettings').length}`) console.log(`- Output: ${this.targetFile}`) } } if (require.main === module) { try { const generator = new BootConfigGenerator() generator.generate() } catch (error) { console.error('Generation failed:', error.message) process.exit(1) } } module.exports = BootConfigGenerator