UNPKG

i18ntk

Version:

i18n Tool Kit - Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, auto translation, fixing, reporting, and runtime translation loading.

787 lines (711 loc) 24.4 kB
const fs = require('fs'); const path = require('path'); const os = require('os'); const crypto = require('crypto'); const SecurityUtils = require('./security'); const { logger } = require('./logger'); const { envManager } = require('./env-manager'); // Determine package directory and user project root const packageDir = path.resolve(__dirname, '..'); function getUserProjectRoot() { return path.resolve(process.cwd()); } function getProjectConfigPath() { const root = getUserProjectRoot(); const configPath = SecurityUtils.safeJoin(root, '.i18ntk-config'); if (!configPath) { throw new Error('Invalid project config path - potential path traversal attempt'); } return configPath; } const CONFIG_LOCK_TIMEOUT_MS = 5000; const CONFIG_LOCK_STALE_MS = 15000; const CONFIG_LOCK_RETRY_MS = 50; let autosaveDisabledWarned = false; let defaultConfigNoticeShown = false; let configFallbackNoticeShown = false; function logInfo(message, details) { logger.info(message, details); } function logWarn(message, details) { logger.warn(message, details); } function logError(message, details) { logger.error(message, details); } function notifyDefaultConfig(reason, error) { if (defaultConfigNoticeShown) { return; } defaultConfigNoticeShown = true; logger.info('Using default configuration (reason: configuration error)', { reason }); if (logger.isDebugMode() && error) { logger.debug(`Default configuration reason details: ${error.message}`, { stack: error.stack }); } } function notifyConfigFallback(error) { if (!configFallbackNoticeShown) { configFallbackNoticeShown = true; logger.info('Using default configuration (reason: configuration error)', { reason: 'configuration error' }); } logger.recordFirstError('config:load-fallback', { message: error && error.message ? error.message : 'Unknown configuration error', stack: error && error.stack ? error.stack : null }); if (logger.isDebugMode() && error) { logger.debug(`Configuration load fallback details: ${error.message}`, { stack: error.stack }); } } // Setup tracking file const getSetupCompletedFile = () => path.join(path.dirname(getProjectConfigPath()), 'setup.json'); // Legacy home directory config (for migration only) const LEGACY_CONFIG_DIR = path.join(os.homedir(), '.i18ntk'); const LEGACY_CONFIG_PATH = path.join(LEGACY_CONFIG_DIR, 'i18ntk-config.json'); // Package defaults fallback (read-only) - always points to package internals const PACKAGE_SETTINGS_DIR = path.join(packageDir, 'settings'); const PACKAGE_CONFIG_PATH = path.join(PACKAGE_SETTINGS_DIR, 'i18ntk-config.json'); // Back-compat: expose CONFIG_DIR/CONFIG_PATH but point to project settings const getConfigDir = () => path.dirname(getProjectConfigPath()); const getConfigPath = getProjectConfigPath; // Default configuration values - comprehensive configuration const DEFAULT_CONFIG = { "language": "en", "uiLanguage": "en", "theme": "system", "projectRoot": ".", "sourceDir": "./locales", "i18nDir": "./locales", "outputDir": "./i18ntk-reports", "setup": { "completed": false, "completedAt": null, "version": null, "setupId": null }, "framework": { "preference": "auto", "fallback": "vanilla", "detect": true }, "scriptDirectories": { "init": null, "analyze": null, "validate": null, "usage": null, "sizing": null, "summary": null, "complete": null, "manage": null }, "processing": { "batchSize": 2000, "concurrency": 32, "maxFileSize": 524288, "timeout": 3000, "retryAttempts": 0, "retryDelay": 0, "cacheEnabled": true, "cacheTTL": 180000, "validateOnSave": false, "autoBackup": false, "validateOnLoad": false, "fileFilter": "**/*.json", "notTranslatedMarker": "NOT_TRANSLATED", "excludeFiles": [ ".DS_Store", "Thumbs.db", "*.tmp", "*.bak", "*.log", "~*", "*.swp" ], "performanceMode": "ultra-extreme", "memoryLimit": "256MB", "gcInterval": 250, "streaming": true, "compression": "brotli", "parallelProcessing": true, "minimalLogging": true }, "autoTranslate": { "placeholderMode": "preserve", "concurrency": 12, "batchSize": 100, "progressInterval": 25, "retryCount": 3, "retryDelay": 1000, "timeout": 15000, "dryRunFirst": true, "onlyMissingOrEnglish": true, "reportStdout": true, "bom": false, "protectionEnabled": true, "protectionFile": "./i18ntk-auto-translate.json", "promptProtectionSetup": true, "promptProtectionUpdate": true }, "reports": { "format": "markdown", "includeStats": true, "includeMissingKeys": true, "includeUnusedKeys": true, "includeUsageStats": true, "includeValidationErrors": true, "outputFormat": "both", "generateSummary": true, "generateDetailed": true, "saveToFile": true, "filenameTemplate": "i18n-report-{timestamp}" }, "ui": { "showProgress": true, "showDetailedOutput": false, "colorOutput": true, "interactive": true, "confirmActions": true, "autoSave": true, "autoLoad": true }, "behavior": { "autoDetectLanguage": true, "strictMode": false, "caseSensitive": true, "ignoreComments": false, "ignoreWhitespace": true, "normalizeKeys": true, "validateOnStartup": false }, "notifications": { "enabled": true, "types": { "success": true, "warning": true, "error": true, "info": true, "warnings": true, "errors": true, "progress": true }, "sound": false, "desktop": false, "webhook": null }, "dateTime": { "format": "YYYY-MM-DD HH:mm:ss", "timezone": "local", "locale": "en-US" }, "advanced": { "backupBeforeChanges": true, "validateSettings": true, "logLevel": "error", "performanceTracking": true, "memoryLimit": "256MB", "enableExperimental": true, "batchSize": 2000, "maxConcurrentFiles": 32, "enableProgressBars": false, "enableColorOutput": false, "strictMode": false, "enableAuditLog": false, "validateOnSave": false, "sizingThreshold": 50, "sizingFormat": "table", "timeout": 3000, "performanceMode": "ultra-extreme", "gcInterval": 250, "streaming": true, "compression": "brotli", "parallelProcessing": true, "aggressiveGC": true, "memoryPooling": true, "stringInterning": true }, "backup": { "enabled": false, "location": "./i18ntk-backups", "singleFileMode": false, "singleBackupFile": "i18ntk-central-backup.json", "retentionDays": 30, "maxBackups": 1 }, "security": { "adminPinEnabled": false, "adminPinPromptOnInit": true, "keepAuthenticatedUntilExit": true, "sessionTimeout": 30, "maxFailedAttempts": 3, "lockoutDuration": 15, "enablePathValidation": true, "maxFileSize": 10485760, "allowedExtensions": [ ".json", ".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte" ], "notTranslatedMarker": "[NOT TRANSLATED]", "excludeFiles": [ "node_modules", ".git", "dist", "build" ], "strictMode": false, "pinProtection": { "enabled": true, "protectedScripts": { "debugMenu": true, "deleteReports": true, "summaryReports": true, "settingsMenu": true, "init": false, "analyze": false, "validate": false, "complete": false, "manage": false, "sizing": false, "usage": false } } }, "debug": { "enabled": false, "showSecurityLogs": false, "verboseLogging": false, "logLevel": "info", "saveDebugLogs": false, "debugLogPath": "./debug.log" }, "sourceLanguage": "en", "defaultLanguages": [ "en", "de", "es", "fr", "ru" ], "reportLanguage": "auto", "autoSave": true, "dateFormat": "DD/MM/YYYY", "timeFormat": "24h", "timezone": "auto" }; // Environment variable support has been removed in favor of exclusive .i18ntk-config configuration let currentConfig = null; let configLoadInProgress = false; let recursionDepth = 0; const MAX_RECURSION_DEPTH = 15; // Increased to handle legitimate sequential calls let configSaveQueue = Promise.resolve(); function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function acquireConfigLock(timeoutMs = CONFIG_LOCK_TIMEOUT_MS) { const lockPath = `${getProjectConfigPath()}.lock`; const start = Date.now(); let lockHandle = null; while (!lockHandle) { try { lockHandle = await fs.promises.open(lockPath, 'wx'); await lockHandle.writeFile(String(process.pid), 'utf8'); break; } catch (error) { if (!error || error.code !== 'EEXIST') { throw error; } // Recover from stale lock files left by crashed processes. try { const stats = await fs.promises.stat(lockPath); if (Date.now() - stats.mtimeMs > CONFIG_LOCK_STALE_MS) { await fs.promises.unlink(lockPath); continue; } } catch (_) { // Lock may have been released between exists check and stat/unlink. } if (Date.now() - start >= timeoutMs) { throw new Error(`Timed out waiting for config lock after ${timeoutMs}ms`); } await sleep(CONFIG_LOCK_RETRY_MS); } } return async function releaseConfigLock() { try { if (lockHandle) { await lockHandle.close(); } } catch (_) { // Best-effort close only. } try { await fs.promises.unlink(lockPath); } catch (_) { // Best-effort cleanup only. } }; } function isAutosaveDisabled() { const raw = process.env.I18NTK_DISABLE_AUTOSAVE; if (raw !== undefined && raw !== null && raw !== '') { return ['true', '1', 'yes'].includes(String(raw).trim().toLowerCase()); } return envManager.getBoolean('I18NTK_DISABLE_AUTOSAVE'); } function clone(obj) { return JSON.parse(JSON.stringify(obj)); } function checkRecursionGuard() { // Disabled recursion detection to prevent false positives on legitimate sequential calls // TODO: Implement more sophisticated recursion detection in future versions return null; } function resetRecursionGuard() { recursionDepth = 0; configLoadInProgress = false; } function ensureProjectSettingsDir() { const settingsDir = path.dirname(getProjectConfigPath()); try { fs.mkdirSync(settingsDir, { recursive: true }); } catch (_) { // Directory may already exist or be unwritable } } function normalizePathValue(keyPath, value) { if (typeof value !== 'string') return value; const last = keyPath.split('.').pop(); if (/dir|directory|root|path$/i.test(last)) { // Always resolve paths relative to user's project root, not package directory const userProjectRoot = process.cwd(); const abs = path.resolve(userProjectRoot, value); return toRelative(abs); } return value; } // Deep merge that also normalizes path-like leaves based on their key path function deepMerge(target, source, basePath = '') { if (!target || typeof target !== 'object') target = {}; for (const [key, val] of Object.entries(source || {})) { const pathKey = basePath ? `${basePath}.${key}` : key; if (val && typeof val === 'object' && !Array.isArray(val)) { if (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key])) { target[key] = {}; } deepMerge(target[key], val, pathKey); } else { target[key] = normalizePathValue(pathKey, val); } } return target; } function applyEnvOverrides(cfg) { // Environment variable support has been removed in favor of exclusive .i18ntk-config configuration // This function is kept for backward compatibility but does nothing return cfg; } function tryReadJson(filePath) { try { if (!SecurityUtils.safeExistsSync(filePath)) { return null; } const data = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath), 'utf8'); if (!data || data.trim() === '') { logWarn(`[i18ntk] Warning: Empty or invalid JSON file at ${filePath}`); return null; } const parsed = SecurityUtils.safeParseJSON(data); if (parsed && typeof parsed === 'object') { return parsed; } logError(`[i18ntk] Error parsing JSON from ${filePath}: Invalid JSON content`); // Create a backup of the corrupted file const backupPath = `${filePath}.corrupted-${Date.now()}.bak`; try { SecurityUtils.safeWriteFileSync(backupPath, data, path.dirname(backupPath), 'utf8'); logWarn(`[i18ntk] Created backup of corrupted config at ${backupPath}`); } catch (backupError) { logError(`[i18ntk] Failed to create backup of corrupted config: ${backupError.message}`); } return null; } catch (error) { logError(`[i18ntk] Error reading config file at ${filePath}: ${error.message}`); return null; } } async function migrateLegacyIfNeeded(baseCfg) { // If project config does not exist but legacy exists, migrate once const configPath = getProjectConfigPath(); if (!SecurityUtils.safeExistsSync(configPath) && SecurityUtils.safeExistsSync(LEGACY_CONFIG_PATH)) { const legacy = tryReadJson(LEGACY_CONFIG_PATH); if (legacy && typeof legacy === 'object') { const merged = deepMerge(clone(baseCfg), legacy); // Mark migration completion in the merged config (QoL telemetry) try { merged.migrationComplete = true; } catch (_) {} ensureProjectSettingsDir(); try { await fs.promises.writeFile(configPath, JSON.stringify(merged, null, 2), 'utf8'); // Best-effort removal of legacy file to prevent future use try { SecurityUtils.safeUnlinkSync(LEGACY_CONFIG_PATH, path.dirname(LEGACY_CONFIG_PATH)); } catch (_) {} // Deprecation notice logWarn('[i18ntk] Deprecated config location detected (~/.i18ntk). Configuration was migrated to project .i18ntk-config.'); return merged; } catch (_) { // If write fails, fall back to in-memory config without deleting legacy logWarn('[i18ntk] Deprecated config location detected (~/.i18ntk). Using migrated settings in memory; failed to persist to .i18ntk-config.'); return merged; } } } return null; } function loadConfig() { // Check for recursion const recursionFallback = checkRecursionGuard(); if (recursionFallback) return recursionFallback; // Return cached config if available if (currentConfig) { resetRecursionGuard(); return currentConfig; } // Prevent concurrent loading if (configLoadInProgress) { logWarn('[i18ntk] Configuration loading already in progress, returning defaults'); resetRecursionGuard(); return clone(DEFAULT_CONFIG); } configLoadInProgress = true; try { let cfg = clone(DEFAULT_CONFIG); const configPath = getProjectConfigPath(); // 1) Project config (primary) const projectCfg = tryReadJson(configPath); if (projectCfg) { cfg = deepMerge(clone(DEFAULT_CONFIG), projectCfg); } else { // 2) Package default (read-only) const pkgCfg = tryReadJson(PACKAGE_CONFIG_PATH); if (pkgCfg) { cfg = deepMerge(clone(DEFAULT_CONFIG), pkgCfg); } // 3) Legacy migration (read-only source) if (!projectCfg) { const fromLegacy = tryReadJson(LEGACY_CONFIG_PATH); if (fromLegacy) { cfg = deepMerge(clone(DEFAULT_CONFIG), fromLegacy); // Attempt to migrate to project settings // Ignore migration errors; we still return merged cfg in memory // eslint-disable-next-line no-unused-vars logWarn('[i18ntk] Detected legacy config at ~/.i18ntk. Migrating to project settings directory...'); // Fire migration asynchronously with error handling migrateLegacyIfNeeded(DEFAULT_CONFIG).catch((err) => { logWarn('[i18ntk] Legacy config migration failed', { error: err.message }); }); } } } applyEnvOverrides(cfg); currentConfig = cfg; return currentConfig; } catch (error) { logError('[i18ntk] Error in loadConfig', { error: error.message }); notifyConfigFallback(error); currentConfig = clone(DEFAULT_CONFIG); return currentConfig; } finally { configLoadInProgress = false; recursionDepth = Math.max(0, recursionDepth - 1); } } async function saveConfig(cfg = currentConfig) { if (!cfg || typeof cfg !== 'object') return; // Runtime/server safety valve: allow disabling disk writes entirely. if (isAutosaveDisabled()) { currentConfig = cfg; if (!autosaveDisabledWarned) { autosaveDisabledWarned = true; logWarn('[i18ntk] Autosave disabled by I18NTK_DISABLE_AUTOSAVE. Keeping configuration in memory only.'); } return false; } configSaveQueue = configSaveQueue.then(async () => { let tempPath = null; let releaseLock = null; try { const settingsDir = path.dirname(getProjectConfigPath()); const configFilePath = getProjectConfigPath(); // Ensure settings directory exists before any lock/file operations. await fs.promises.mkdir(settingsDir, { recursive: true }); releaseLock = await acquireConfigLock(); const serialized = JSON.stringify(cfg, null, 2); if (typeof serialized !== 'string' || serialized.length === 0) { throw new Error('Cannot save empty configuration payload'); } // Use a unique temp file to avoid concurrent writer races. // Create temp files in the same directory as the config file to ensure they're safe // Use a simpler naming pattern to avoid triggering security warnings const nonce = `${process.pid}.${Date.now()}`; const tempFileName = `.i18ntk-config.temp-${nonce}`; tempPath = path.join(settingsDir, tempFileName); await fs.promises.writeFile(tempPath, serialized, 'utf8'); try { await fs.promises.rename(tempPath, configFilePath); } catch (renameError) { // If destination dir disappeared between checks, recreate and retry once. if (renameError && renameError.code === 'ENOENT') { await fs.promises.mkdir(settingsDir, { recursive: true }); await fs.promises.rename(tempPath, configFilePath); } else { throw renameError; } } currentConfig = cfg; return true; } catch (error) { logError('[i18ntk] Error saving configuration', { error: error.message }); return false; } finally { if (releaseLock) { await releaseLock(); } if (tempPath) { try { SecurityUtils.safeUnlinkSync(tempPath, path.dirname(tempPath)); } catch (_) { // Best-effort temp cleanup only. } } } }); return configSaveQueue; } function getConfig() { // Check for recursion const recursionFallback = checkRecursionGuard(); if (recursionFallback) return resolvePaths(recursionFallback); if (currentConfig) { resetRecursionGuard(); return resolvePaths(currentConfig); } try { const configPath = getProjectConfigPath(); const settingsDir = path.dirname(configPath); // Ensure settings directory exists if (!SecurityUtils.safeExistsSync(settingsDir)) { fs.mkdirSync(settingsDir, { recursive: true }); } // Setup is now handled automatically by the unified config system // No need to check here - handled by getUnifiedConfig // Check if config file exists if (SecurityUtils.safeExistsSync(configPath)) { const rawConfig = SecurityUtils.safeReadFileSync(configPath, settingsDir, 'utf8'); const config = SecurityUtils.safeParseJSON(rawConfig); if (config && typeof config === 'object') { currentConfig = config; return resolvePaths(config); } throw new Error('Invalid project configuration JSON'); } // Check for legacy config for migration if (SecurityUtils.safeExistsSync(LEGACY_CONFIG_PATH)) { logInfo('Migrating legacy configuration'); const legacyRaw = SecurityUtils.safeReadFileSync(LEGACY_CONFIG_PATH, path.dirname(LEGACY_CONFIG_PATH), 'utf8'); const legacyConfig = SecurityUtils.safeParseJSON(legacyRaw); if (!legacyConfig || typeof legacyConfig !== 'object') { throw new Error('Invalid legacy configuration JSON'); } const migratedConfig = { ...DEFAULT_CONFIG, ...legacyConfig }; saveConfig(migratedConfig).catch((err) => { logWarn('[i18ntk] Warning: failed to persist migrated configuration', { error: err.message }); }); currentConfig = migratedConfig; // Clean up legacy config try { SecurityUtils.safeUnlinkSync(LEGACY_CONFIG_PATH, path.dirname(LEGACY_CONFIG_PATH)); if (SecurityUtils.safeReaddirSync(LEGACY_CONFIG_DIR, path.dirname(LEGACY_CONFIG_DIR)).length === 0) { SecurityUtils.safeRmdirSync(LEGACY_CONFIG_DIR, path.dirname(LEGACY_CONFIG_DIR)); } } catch (cleanupError) { // Ignore cleanup errors } return resolvePaths(migratedConfig); } // Use package defaults for new installation notifyDefaultConfig('default initialization'); saveConfig(DEFAULT_CONFIG).catch((err) => { logWarn('[i18ntk] Warning: failed to persist default configuration', { error: err.message }); }); currentConfig = DEFAULT_CONFIG; return resolvePaths(DEFAULT_CONFIG); } catch (error) { notifyConfigFallback(error); currentConfig = DEFAULT_CONFIG; return resolvePaths(DEFAULT_CONFIG); } } async function setConfig(cfg) { currentConfig = deepMerge(clone(DEFAULT_CONFIG), cfg || {}); // Don't save to disk - use in-memory config only return currentConfig; } async function updateConfig(patch) { const cfg = loadConfig(); const cloned = clone(cfg); deepMerge(cloned, patch); currentConfig = cloned; return cloned; } async function resetToDefaults() { currentConfig = clone(DEFAULT_CONFIG); // Don't save to disk - use in-memory config only return currentConfig; } function resolvePaths(cfg) { if (!cfg) { cfg = clone(DEFAULT_CONFIG); } const root = path.resolve(getUserProjectRoot(), cfg.projectRoot || '.'); const resolved = clone(cfg); resolved.projectRoot = root; ['sourceDir', 'i18nDir', 'outputDir'].forEach(key => { if (resolved[key]) resolved[key] = path.resolve(root, resolved[key]); }); if (resolved.scriptDirectories) { resolved.scriptDirectories = { ...resolved.scriptDirectories }; for (const [k, v] of Object.entries(resolved.scriptDirectories)) { if (v) resolved.scriptDirectories[k] = path.resolve(root, v); } } return resolved; } function toRelative(absPath) { if (!absPath) return absPath; const rel = path.relative(getUserProjectRoot(), absPath); const normalized = rel ? `./${rel.replace(/\\/g, '/')}` : '.'; return normalized; } module.exports = { get CONFIG_PATH() { return getProjectConfigPath(); }, DEFAULT_CONFIG, loadConfig, saveConfig, loadSettings: loadConfig, saveSettings: saveConfig, getConfig, updateConfig, setConfig, resetToDefaults, resolvePaths, toRelative, normalizePathValue, migrateLegacyIfNeeded, }