/** * Config migration system * * When config.json schema changes between versions, register a migration * function here. On load, the loader detects the stored version, runs all * migrations from that version forward, and writes the result back. * * Migration format: * migrations[] = (rawConfig) => { ...mutations, _version: toVersion } * * Each migration is responsible for transforming the previous version's * shape into the next version's shape. They run sequentially, so v1→v2→v3 * all execute in order. * * For first-time users with no config file, the loader creates a fresh * config with CURRENT_VERSION, so they start at the latest schema. */ const fs = require('fs'); const path = require('path'); const platformPaths = require('../../platform-paths'); const CURRENT_VERSION = 2; /** * Migrations: keys are the version they PRODUCE. * Each migration takes a raw config object and returns the next version. */ const migrations = { // v0 (unversioned) → v1: add _version field, normalize dns structure 1: (raw) => { const migrated = { ...raw }; if (!migrated._version) migrated._version = 1; // Normalize: older configs may have dns as a string IP, convert to object if (typeof migrated.dns === 'string') { migrated.dns = { ip: migrated.dns, port: 5380 }; } else if (!migrated.dns) { migrated.dns = { ip: '', port: 5380 }; } return migrated; }, // v1 → v2: add dns.provider field (default: 'technitium' for backwards compat) 2: (raw) => { const migrated = { ...raw }; if (migrated.dns && !migrated.dns.provider) { migrated.dns.provider = 'technitium'; } migrated._version = 2; return migrated; } }; /** * Run all migrations from `fromVersion` (or detected) to CURRENT_VERSION. * @param {object} raw - The raw config object (may or may not have _version) * @returns {object} The migrated config */ function migrate(raw) { if (!raw || typeof raw !== 'object') { // First-time load: return minimal config at current version return { _version: CURRENT_VERSION }; } const fromVersion = raw._version || 0; if (fromVersion > CURRENT_VERSION) { // Config from a future version — bail out, don't corrupt it // The validation step will catch any actual issues return raw; } let current = { ...raw }; for (let v = fromVersion + 1; v <= CURRENT_VERSION; v++) { if (migrations[v]) { current = migrations[v](current); } else { // No migration defined for this version, just bump _version current._version = v; } } return current; } /** * Load config from disk, run migrations if needed, and write back the * migrated version. Safe to call on every startup. * @param {string} configFile - Absolute path to config.json * @param {object} log - Logger instance * @returns {object} The migrated config object */ function loadAndMigrate(configFile, log) { let raw = null; let fileExisted = false; if (fs.existsSync(configFile)) { fileExisted = true; try { raw = JSON.parse(fs.readFileSync(configFile, 'utf8')); } catch (e) { if (log && log.error) { log.error('config-migration', 'Failed to parse config.json, using defaults', { error: e.message }); } raw = null; } } const fromVersion = raw && raw._version ? raw._version : 0; const migrated = migrate(raw); // Only write back to disk if: // 1. The file already existed (we don't create configs on fresh installs — // the loader's defaults handle that case), AND // 2. The version actually changed (no point rewriting identical content) if (fileExisted && fromVersion < CURRENT_VERSION) { if (log && log.info) { log.info('config-migration', `Migrated config v${fromVersion} → v${CURRENT_VERSION}`, { from: fromVersion, to: CURRENT_VERSION, path: configFile }); } // Write back the migrated config try { // Ensure parent dir exists const dir = path.dirname(configFile); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(configFile, JSON.stringify(migrated, null, 2)); } catch (e) { if (log && log.warn) { log.warn('config-migration', 'Failed to write migrated config back to disk', { error: e.message }); } } } return migrated; } module.exports = { CURRENT_VERSION, migrations, migrate, loadAndMigrate };