i18ntk
Version:
i18n Tool Kit - Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, auto translation, fixing, reporting, and runtime translation loading.
770 lines (661 loc) ⢠24.8 kB
JavaScript
;
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const crypto = require('crypto');
const SecurityUtils = require('../utils/security');
// Simple CLI argument parser
function parseArgs(args) {
const result = { _: [] };
let currentOption = null;
for (const arg of args) {
if (arg.startsWith('--')) {
// Handle --option=value
if (arg.includes('=')) {
const [key, value] = arg.split('=');
result[key.slice(2)] = value;
} else {
currentOption = arg.slice(2);
result[currentOption] = true;
}
} else if (arg.startsWith('-')) {
// Handle short options
currentOption = arg.slice(1);
result[currentOption] = true;
} else if (currentOption) {
// Handle option value
result[currentOption] = arg;
currentOption = null;
} else {
// Handle positional arguments
result._.push(arg);
}
}
return result;
}
function handleError(error) {
logger.error(error.message || 'Unknown error');
logger.debug(error.stack || error.message);
process.exit(1);
}
function computeFileHash(filePath) {
const content = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath));
if (content === null) return null;
try {
const parsed = JSON.parse(content);
const normalized = JSON.stringify(parsed);
return crypto.createHash('sha256').update(normalized).digest('hex');
} catch {
return crypto.createHash('sha256').update(content).digest('hex');
}
}
function computeContentHash(content) {
if (typeof content === 'object' && content !== null) {
const normalized = JSON.stringify(content);
return crypto.createHash('sha256').update(normalized).digest('hex');
}
const str = String(content);
return crypto.createHash('sha256').update(str).digest('hex');
}
async function collectJsonFiles(rootDir, currentDir = rootDir) {
const entries = await fsp.readdir(currentDir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
files.push(...await collectJsonFiles(rootDir, fullPath));
continue;
}
if (!entry.isFile() || !entry.name.endsWith('.json')) {
continue;
}
const relativePath = path.relative(rootDir, fullPath).split(path.sep).join('/');
files.push({ relativePath, fullPath });
}
return files;
}
async function findMostRecentBackup(backupDirPath) {
try {
const files = await fsp.readdir(backupDirPath);
const backups = [];
for (const file of files) {
if (file.startsWith('backup-') && file.endsWith('.json')) {
const filePath = path.join(backupDirPath, file);
const stats = await fsp.stat(filePath);
backups.push({ name: file, path: filePath, mtime: stats.mtime });
}
}
backups.sort((a, b) => b.mtime - a.mtime);
return backups[0] || null;
} catch (err) {
if (err.code === 'ENOENT') return null;
throw err;
}
}
function getParentHashes(parentData) {
if (parentData._meta && parentData._meta.hashes) {
return parentData._meta.hashes;
}
const hashes = {};
for (const [file, content] of Object.entries(parentData)) {
if (typeof content === 'object' && content !== null) {
hashes[file] = computeContentHash(content);
}
}
return hashes;
}
async function buildRestoreChain(startPath, startData) {
const chain = [{ path: startPath, data: startData }];
const visited = new Set();
visited.add(path.basename(startPath));
let current = startData;
let currentPath = startPath;
while (current._meta && current._meta.parent) {
if (chain.length >= 11) {
throw new Error('Chain broken: incremental backup chain exceeds the maximum depth of 10');
}
const parentName = current._meta.parent;
if (visited.has(parentName)) {
throw new Error('circular chain reference');
}
visited.add(parentName);
const parentDir = path.dirname(currentPath);
const parentPath = path.join(parentDir, parentName);
if (!SecurityUtils.safeExistsSync(parentPath, parentDir)) {
throw new Error(`Chain broken: parent backup '${parentName}' not found in ${parentDir}`);
}
const parentRaw = SecurityUtils.safeReadFileSync(parentPath, parentDir, 'utf8');
if (parentRaw === null) {
throw new Error(`Chain broken: cannot read parent backup '${parentName}'`);
}
current = JSON.parse(parentRaw);
currentPath = parentPath;
chain.push({ path: currentPath, data: current });
}
chain.reverse();
return chain;
}
function readBackupData(backupPath, baseDir) {
const raw = SecurityUtils.safeReadFileSync(backupPath, baseDir, 'utf8');
if (raw === null) {
throw new Error(`Unable to read backup: ${path.basename(backupPath)}`);
}
return JSON.parse(raw);
}
function validateBackupEntryName(fileName) {
if (!fileName || typeof fileName !== 'string') {
throw new Error('Invalid backup entry name');
}
if (fileName === '_meta') {
return null;
}
if (fileName.includes('\0') || /^[a-zA-Z]:/.test(fileName)) {
throw new Error(`Unsafe backup entry name: ${fileName}`);
}
const normalizedSeparators = fileName.replace(/\\/g, '/');
const rawSegments = normalizedSeparators.split('/');
const normalized = path.posix.normalize(normalizedSeparators);
const segments = normalized.split('/');
if (
path.posix.isAbsolute(normalizedSeparators) ||
rawSegments.some(segment => !segment || segment === '.' || segment === '..') ||
normalized === '.' ||
normalized === '..' ||
normalized.startsWith('../') ||
!normalized.endsWith('.json') ||
segments.some(segment => !segment || segment === '.' || segment === '..')
) {
throw new Error(`Unsafe backup entry name: ${fileName}`);
}
return normalized;
}
function restoreBackupEntry(outputDir, fileName, content) {
const safeName = validateBackupEntryName(fileName);
if (!safeName) return false;
const filePath = SecurityUtils.safeJoin(outputDir, safeName);
if (!filePath) {
throw new Error(`Backup entry escapes restore directory: ${fileName}`);
}
if (!SecurityUtils.safeWriteFileSync(filePath, JSON.stringify(content, null, 2), outputDir, 'utf8')) {
throw new Error(`Unable to restore backup entry: ${fileName}`);
}
return true;
}
function collectProtectedChainNames(backupDirPath, keptFiles) {
const protectedNames = new Set();
const byName = new Map();
for (const file of keptFiles) {
byName.set(file.name, file);
}
const queue = [];
for (const file of keptFiles) {
try {
const data = readBackupData(file.path, backupDirPath);
if (data._meta && data._meta.parent) {
queue.push(data._meta.parent);
}
} catch {}
}
while (queue.length > 0) {
const name = queue.shift();
if (protectedNames.has(name)) {
continue;
}
protectedNames.add(name);
const file = byName.get(name) || {
name,
path: path.join(backupDirPath, name)
};
if (!SecurityUtils.safeExistsSync(file.path, backupDirPath)) {
continue;
}
try {
const data = readBackupData(file.path, backupDirPath);
if (data._meta && data._meta.parent) {
queue.push(data._meta.parent);
}
} catch {}
}
return protectedNames;
}
const configManager = require('../utils/config-manager');
const { logger } = require('../utils/logger');
const { colors } = require('../utils/logger');
const prompt = require('../utils/prompt');
// Backup configuration
const config = configManager.getConfig();
const backupDir = path.join(process.cwd(), 'i18ntk-backups');
const maxBackups = Math.min(Math.max(parseInt(config.backup?.maxBackups, 10) || 1, 1), 3);
// Main function to handle commands
async function main() {
const args = parseArgs(process.argv.slice(2));
const command = args._[0];
// Show help if no command provided
if (!command) {
showHelp();
process.exit(0);
}
try {
switch (command) {
case 'create':
await handleCreate(args);
break;
case 'restore':
await handleRestore(args);
break;
case 'list':
await handleList();
break;
case 'verify':
await handleVerify(args);
break;
case 'cleanup':
await handleCleanup(args);
break;
default:
logger.error(`Unknown command: ${command}`);
showHelp();
process.exit(1);
}
} catch (error) {
handleError(error);
}
}
function showHelp() {
console.log(`
i18ntk-backup - Secure backup and restore for i18n translation files
Usage:
i18ntk-backup <command> [options]
Commands:
create <dir> Create a backup of translation files
restore <file> Restore from a backup
list List available backups
verify <file> Verify the integrity of a backup file
cleanup Remove old backups
Options:
--output <path> Output directory for backup/restore
--force Overwrite existing files without prompting
--keep <number> Number of backups to keep (default: 10)
--incremental Create an incremental backup (only changed files)
`);
}
// Command handlers
async function cleanupOldBackups(backupDirPath) {
try {
const files = await fsp.readdir(backupDirPath);
const backupFiles = files
.filter(file => file.startsWith('backup-') && file.endsWith('.json'))
.map(file => ({
name: file,
path: path.join(backupDirPath, file),
time: fs.statSync(path.join(backupDirPath, file)).mtime.getTime()
}))
.sort((a, b) => b.time - a.time);
const toDelete = backupFiles.slice(maxBackups);
const kept = backupFiles.slice(0, maxBackups);
const protectedChainNames = collectProtectedChainNames(backupDirPath, kept);
for (const file of toDelete) {
if (protectedChainNames.has(file.name)) {
logger.info(` Keeping ${file.name} (parent of a kept incremental backup)`);
continue;
}
try {
await fsp.unlink(file.path);
} catch (err) {
logger.warn(`Could not remove old backup ${file.name}: ${err.message}`);
}
}
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn(`Error during cleanup: ${err.message}`);
}
}
}
async function handleCreate(args) {
const rawSourceDir = args._[1] || path.join(__dirname, '..', 'locales');
const rawOutputDir = args.output || backupDir;
// Validate both paths against project root (cwd) for security
const sourceDir = path.resolve(rawSourceDir);
if (!SecurityUtils.validatePath(sourceDir, process.cwd())) {
throw new Error(`Source directory is outside the allowed project boundary.`);
}
const outputDir = path.resolve(rawOutputDir);
const validatedOutputDir = SecurityUtils.validatePath(outputDir, process.cwd());
if (!validatedOutputDir) {
throw new Error(`Output directory is outside the allowed project boundary.`);
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupName = `backup-${timestamp}.json`;
const backupPath = path.join(validatedOutputDir, backupName);
const isIncremental = args.incremental !== 'false' && args.incremental !== false;
logger.debug(`Source directory: ${sourceDir}`);
logger.debug(`Backup will be saved to: ${backupPath}`);
try {
SecurityUtils.safeMkdirSync(validatedOutputDir, process.cwd());
logger.debug(`Created backup directory: ${validatedOutputDir}`);
} catch (err) {
if (err.code !== 'EEXIST') {
logger.error(`Failed to create backup directory: ${err.message}`);
throw err;
}
logger.debug(`Using existing backup directory: ${validatedOutputDir}`);
}
logger.info('\nCreating backup...');
const files = await collectJsonFiles(sourceDir);
if (files.length === 0) {
logger.warn('No JSON files found in the specified directory');
process.exit(0);
}
const translations = {};
const hashes = {};
for (const file of files) {
const filePath = file.fullPath;
try {
const rawContent = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath), 'utf8');
if (rawContent === null) {
logger.error(`Could not read file ${file.relativePath}`);
continue;
}
translations[file.relativePath] = JSON.parse(rawContent);
hashes[file.relativePath] = computeFileHash(filePath);
} catch (error) {
logger.error(`Could not read file ${file.relativePath}: ${error.message}`);
}
}
let meta = {
type: 'full',
parent: null,
chainDepth: 0,
hashes: hashes
};
let filesToInclude = translations;
if (isIncremental) {
const parentBackup = await findMostRecentBackup(outputDir);
if (parentBackup) {
const parentRaw = SecurityUtils.safeReadFileSync(parentBackup.path, path.dirname(parentBackup.path), 'utf8');
const parentData = JSON.parse(parentRaw);
const parentDepth = (parentData._meta && parentData._meta.chainDepth) || 0;
if (parentDepth >= 10) {
logger.info(' Incremental chain depth limit reached; creating a new full backup');
} else {
const parentHashes = getParentHashes(parentData);
const changedFiles = {};
for (const [file, content] of Object.entries(translations)) {
if (!parentHashes[file] || hashes[file] !== parentHashes[file]) {
changedFiles[file] = content;
}
}
filesToInclude = changedFiles;
meta = {
type: 'incremental',
parent: parentBackup.name,
chainDepth: parentDepth + 1,
hashes: hashes
};
logger.info(` Incremental mode: found ${Object.keys(filesToInclude).length} changed files (parent: ${parentBackup.name}, depth: ${meta.chainDepth})`);
}
} else {
logger.info(' No previous backup found; creating full backup');
}
}
const backupData = { _meta: meta };
for (const [file, content] of Object.entries(filesToInclude)) {
backupData[file] = content;
}
SecurityUtils.safeWriteFileSync(backupPath, JSON.stringify(backupData, null, 2), validatedOutputDir);
const stats = await fsp.stat(backupPath);
logger.success('Backup created successfully');
logger.info(` Location: ${backupPath}`);
logger.info(` Size: ${(stats.size / 1024).toFixed(2)} KB`);
logger.info(` Timestamp: ${new Date().toLocaleString()}`);
logger.info(` Files included: ${Object.keys(filesToInclude).length}`);
logger.info(` Type: ${meta.type}`);
await cleanupOldBackups(outputDir);
}
async function handleRestore(args) {
const backupFile = args._[1];
if (!backupFile) {
throw new Error('Backup file path is required');
}
const backupPath = path.resolve(process.cwd(), backupFile);
const rawOutputDir = args.output || path.join(process.cwd(), 'restored');
const validatedOutputDir = SecurityUtils.validatePath(rawOutputDir, process.cwd());
if (!validatedOutputDir) {
throw new Error(`Restore output directory is outside the allowed project boundary.`);
}
if (!SecurityUtils.safeExistsSync(backupPath, process.cwd())) {
throw new Error(`Backup file not found: ${backupPath}`);
}
logger.info('\nRestoring backup...');
try {
const rawContent = SecurityUtils.safeReadFileSync(backupPath, process.cwd(), 'utf8');
if (!rawContent) throw new Error(`Could not read backup file: ${backupPath}`);
let backupData;
try {
backupData = JSON.parse(rawContent);
} catch (parseErr) {
throw new Error(`Corrupt backup file (invalid JSON): ${backupPath} ā ${parseErr.message}`);
}
if (!backupData || typeof backupData !== 'object') {
throw new Error(`Invalid backup file format: ${backupPath}`);
}
const isIncremental = backupData._meta && backupData._meta.type === 'incremental';
if (isIncremental) {
const chain = await buildRestoreChain(backupPath, backupData);
SecurityUtils.safeMkdirSync(validatedOutputDir, process.cwd());
const restoredFiles = new Set();
for (const entry of chain) {
for (const [file, content] of Object.entries(entry.data)) {
if (restoreBackupEntry(validatedOutputDir, file, content)) {
restoredFiles.add(file);
}
}
}
logger.success('Incremental backup restored successfully');
logger.info(` ${restoredFiles.size} files restored across ${chain.length} backup(s) to: ${validatedOutputDir}`);
} else {
SecurityUtils.safeMkdirSync(validatedOutputDir, process.cwd());
let count = 0;
for (const [file, content] of Object.entries(backupData)) {
if (restoreBackupEntry(validatedOutputDir, file, content)) {
count++;
}
}
logger.success('Backup restored successfully');
logger.info(` Restored ${count} files to: ${validatedOutputDir}`);
}
} catch (error) {
handleError(error);
}
}
async function handleList() {
try {
const validatedBackupDir = SecurityUtils.validatePath(backupDir, process.cwd());
if (!validatedBackupDir) {
logger.error('Backup directory is outside allowed project boundary.');
return;
}
if (!SecurityUtils.safeExistsSync(validatedBackupDir, process.cwd())) {
logger.warn('No backups found. The backup directory does not exist yet.');
return;
}
const files = SecurityUtils.safeReaddirSync(validatedBackupDir, process.cwd());
if (!files || files.length === 0) {
logger.warn('No valid backup files found in the backup directory.');
return;
}
const backups = [];
for (const file of files) {
if (Array.isArray(files) && file.startsWith('backup-') && file.endsWith('.json')) {
try {
const filePath = path.join(validatedBackupDir, file);
const stats = SecurityUtils.safeStatSync(filePath, process.cwd());
if (stats) {
backups.push({
name: file,
path: filePath,
size: stats.size,
createdAt: stats.mtime
});
}
} catch (err) {
logger.warn(`Skipping invalid backup file ${file}: ${err.message}`);
}
}
}
if (backups.length === 0) {
logger.warn('No valid backup files found in the backup directory.');
return;
}
// Sort by creation time (newest first)
backups.sort((a, b) => b.createdAt - a.createdAt);
logger.info('\nš Available Backups');
logger.info('='.repeat(50));
backups.forEach((backup, index) => {
const sizeKB = (backup.size / 1024).toFixed(2);
const formattedDate = backup.createdAt.toLocaleString();
logger.info(`š¹ ${index + 1}. ${backup.name}`);
logger.info(` š Size: ${sizeKB} KB`);
logger.info(` š
Created: ${formattedDate}`);
logger.info(` š Path: ${backup.path}\n`);
});
logger.info(`Total backups: ${backups.length}`);
} catch (err) {
if (err.code === 'ENOENT') {
logger.warn('No backups found.');
} else {
throw err;
}
}
}
async function handleVerify(args) {
const backupFile = args._[1];
if (!backupFile) {
throw new Error('Backup file path is required');
}
const backupPath = path.resolve(process.cwd(), backupFile);
if (!SecurityUtils.safeExistsSync(backupPath, process.cwd())) {
throw new Error(`Backup file not found: ${backupPath}`);
}
logger.info('\nVerifying backup...');
try {
const rawContent = SecurityUtils.safeReadFileSync(backupPath, process.cwd(), 'utf8');
if (!rawContent) throw new Error(`Could not read backup file: ${backupPath}`);
const data = JSON.parse(rawContent);
if (data._meta && data._meta.hashes) {
logger.info(' Performing hash chain verification...');
const chain = await buildRestoreChain(backupPath, data);
let allValid = true;
// Rebuild full state oldest->newest and verify each manifest against it.
const reconstructed = {};
for (const entry of chain) {
const entryMeta = entry.data._meta;
const entryHashes = entryMeta ? entryMeta.hashes : null;
const entryName = path.basename(entry.path);
for (const [file, content] of Object.entries(entry.data)) {
if (file !== '_meta') reconstructed[file] = content;
}
if (!entryHashes) {
logger.warn(` ${entryName}: no manifest hashes (legacy backup)`);
continue;
}
let entryValid = true;
for (const [file, expectedHash] of Object.entries(entryHashes)) {
if (!Object.prototype.hasOwnProperty.call(reconstructed, file)) {
logger.warn(` Missing file in reconstructed backup state: ${file}`);
entryValid = false;
continue;
}
const computedHash = computeContentHash(reconstructed[file]);
if (computedHash !== expectedHash) {
logger.error(` Hash mismatch: ${file} (expected ${expectedHash.slice(0, 12)}..., got ${computedHash.slice(0, 12)}...)`);
entryValid = false;
}
}
if (entryValid) {
logger.success(` ${entryName}: ${Object.keys(entryHashes).length} file(s) verified`);
} else {
allValid = false;
}
}
if (allValid) {
logger.success('\nBackup chain verification passed');
} else {
logger.error('\nBackup chain verification FAILED');
process.exitCode = 1;
}
} else {
const fileCount = Object.keys(data).filter(k => k !== '_meta').length;
logger.success('Backup is valid');
logger.info(` Contains ${fileCount} translation files`);
logger.info(` Last modified: ${(await fsp.stat(backupPath)).mtime.toLocaleString()}`);
}
} catch (error) {
logger.error('Backup verification failed!');
logger.error(` Error: ${error.message}`);
process.exit(1);
}
}
async function handleCleanup(args) {
const keep = args.keep ? parseInt(args.keep, 10) : maxBackups;
logger.info('\nCleaning up old backups...');
try {
const files = await fsp.readdir(backupDir);
const backupFiles = files
.filter(file => file.startsWith('backup-') && file.endsWith('.json'))
.map(file => ({
name: file,
path: path.join(backupDir, file),
time: fs.statSync(path.join(backupDir, file)).mtime.getTime()
}))
.sort((a, b) => b.time - a.time);
// Keep only the most recent 'keep' files
const toDelete = backupFiles.slice(keep);
const kept = backupFiles.slice(0, keep);
if (toDelete.length === 0) {
logger.info('No old backups to delete.');
return;
}
const protectedChainNames = collectProtectedChainNames(backupDir, kept);
let deletedCount = 0;
// Delete old backups, skipping parents of kept backups
for (const file of toDelete) {
if (protectedChainNames.has(file.name)) {
logger.info(` Keeping ${file.name} (parent of a kept incremental backup)`);
continue;
}
try {
await fsp.unlink(file.path);
logger.info(` - Deleted: ${file.name}`);
deletedCount++;
} catch (err) {
logger.error(` - Failed to delete ${file.name}: ${err.message}`);
}
}
logger.info(`\nRemoved ${deletedCount} old backups`);
logger.info(`Total backups kept: ${keep}`);
} catch (error) {
logger.error('Error cleaning up backups:');
logger.error(` ${error.message}`);
logger.debug(error.stack || error.message);
process.exit(1);
}
}
// Start the application
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
process.exit(1);
});
// Handle uncaught exceptions
process.on('uncaughtException', (error) => {
logger.error('Uncaught Exception:', error);
process.exit(1);
});
// Start the application
main().catch(error => {
logger.error('Unhandled error:');
logger.error(error.stack || error.message);
process.exit(1);
});