i18ntk 3.3.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,11 @@
2
2
 
3
3
  'use strict';
4
4
 
5
- const fs = require('fs');
6
- const fsp = fs.promises;
5
+ const fs = require('fs');
6
+ const fsp = fs.promises;
7
7
  const path = require('path');
8
+ const crypto = require('crypto');
9
+ const SecurityUtils = require('../utils/security');
8
10
 
9
11
  // Simple CLI argument parser
10
12
  function parseArgs(args) {
@@ -37,6 +39,150 @@ function parseArgs(args) {
37
39
 
38
40
  return result;
39
41
  }
42
+ function handleError(error) {
43
+ logger.error(error.message || 'Unknown error');
44
+ logger.debug(error.stack || error.message);
45
+ process.exit(1);
46
+ }
47
+
48
+ function computeFileHash(filePath) {
49
+ const content = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath));
50
+ if (content === null) return null;
51
+ try {
52
+ const parsed = JSON.parse(content);
53
+ const normalized = JSON.stringify(parsed);
54
+ return crypto.createHash('sha256').update(normalized).digest('hex');
55
+ } catch {
56
+ return crypto.createHash('sha256').update(content).digest('hex');
57
+ }
58
+ }
59
+
60
+ function computeContentHash(content) {
61
+ if (typeof content === 'object' && content !== null) {
62
+ const normalized = JSON.stringify(content);
63
+ return crypto.createHash('sha256').update(normalized).digest('hex');
64
+ }
65
+ const str = String(content);
66
+ return crypto.createHash('sha256').update(str).digest('hex');
67
+ }
68
+
69
+ async function findMostRecentBackup(backupDirPath) {
70
+ try {
71
+ const files = await fsp.readdir(backupDirPath);
72
+ const backups = [];
73
+ for (const file of files) {
74
+ if (file.startsWith('backup-') && file.endsWith('.json')) {
75
+ const filePath = path.join(backupDirPath, file);
76
+ const stats = await fsp.stat(filePath);
77
+ backups.push({ name: file, path: filePath, mtime: stats.mtime });
78
+ }
79
+ }
80
+ backups.sort((a, b) => b.mtime - a.mtime);
81
+ return backups[0] || null;
82
+ } catch (err) {
83
+ if (err.code === 'ENOENT') return null;
84
+ throw err;
85
+ }
86
+ }
87
+
88
+ function getParentHashes(parentData) {
89
+ if (parentData._meta && parentData._meta.hashes) {
90
+ return parentData._meta.hashes;
91
+ }
92
+ const hashes = {};
93
+ for (const [file, content] of Object.entries(parentData)) {
94
+ if (typeof content === 'object' && content !== null) {
95
+ hashes[file] = computeContentHash(content);
96
+ }
97
+ }
98
+ return hashes;
99
+ }
100
+
101
+ async function buildRestoreChain(startPath, startData) {
102
+ const chain = [{ path: startPath, data: startData }];
103
+ const visited = new Set();
104
+ visited.add(path.basename(startPath));
105
+ let current = startData;
106
+ let currentPath = startPath;
107
+
108
+ while (current._meta && current._meta.parent) {
109
+ if (chain.length >= 11) {
110
+ throw new Error('Chain broken: incremental backup chain exceeds the maximum depth of 10');
111
+ }
112
+ const parentName = current._meta.parent;
113
+ if (visited.has(parentName)) {
114
+ throw new Error('circular chain reference');
115
+ }
116
+ visited.add(parentName);
117
+ const parentDir = path.dirname(currentPath);
118
+ const parentPath = path.join(parentDir, parentName);
119
+ if (!SecurityUtils.safeExistsSync(parentPath, parentDir)) {
120
+ throw new Error(`Chain broken: parent backup '${parentName}' not found in ${parentDir}`);
121
+ }
122
+ const parentRaw = SecurityUtils.safeReadFileSync(parentPath, parentDir, 'utf8');
123
+ if (parentRaw === null) {
124
+ throw new Error(`Chain broken: cannot read parent backup '${parentName}'`);
125
+ }
126
+ current = JSON.parse(parentRaw);
127
+ currentPath = parentPath;
128
+ chain.push({ path: currentPath, data: current });
129
+ }
130
+
131
+ chain.reverse();
132
+ return chain;
133
+ }
134
+
135
+ function readBackupData(backupPath, baseDir) {
136
+ const raw = SecurityUtils.safeReadFileSync(backupPath, baseDir, 'utf8');
137
+ if (raw === null) {
138
+ throw new Error(`Unable to read backup: ${path.basename(backupPath)}`);
139
+ }
140
+ return JSON.parse(raw);
141
+ }
142
+
143
+ function collectProtectedChainNames(backupDirPath, keptFiles) {
144
+ const protectedNames = new Set();
145
+ const byName = new Map();
146
+ for (const file of keptFiles) {
147
+ byName.set(file.name, file);
148
+ }
149
+
150
+ const queue = [];
151
+ for (const file of keptFiles) {
152
+ try {
153
+ const data = readBackupData(file.path, backupDirPath);
154
+ if (data._meta && data._meta.parent) {
155
+ queue.push(data._meta.parent);
156
+ }
157
+ } catch {}
158
+ }
159
+
160
+ while (queue.length > 0) {
161
+ const name = queue.shift();
162
+ if (protectedNames.has(name)) {
163
+ continue;
164
+ }
165
+ protectedNames.add(name);
166
+
167
+ const file = byName.get(name) || {
168
+ name,
169
+ path: path.join(backupDirPath, name)
170
+ };
171
+ if (!SecurityUtils.safeExistsSync(file.path, backupDirPath)) {
172
+ continue;
173
+ }
174
+
175
+ try {
176
+ const data = readBackupData(file.path, backupDirPath);
177
+ if (data._meta && data._meta.parent) {
178
+ queue.push(data._meta.parent);
179
+ }
180
+ } catch {}
181
+ }
182
+
183
+ return protectedNames;
184
+ }
185
+
40
186
  const configManager = require('../utils/config-manager');
41
187
  const { logger } = require('../utils/logger');
42
188
  const { colors } = require('../utils/logger');
@@ -103,25 +249,59 @@ Options:
103
249
  --output <path> Output directory for backup/restore
104
250
  --force Overwrite existing files without prompting
105
251
  --keep <number> Number of backups to keep (default: 10)
252
+ --incremental Create an incremental backup (only changed files)
106
253
  `);
107
254
  }
108
255
 
109
256
  // Command handlers
257
+ async function cleanupOldBackups(backupDirPath) {
258
+ try {
259
+ const files = await fsp.readdir(backupDirPath);
260
+ const backupFiles = files
261
+ .filter(file => file.startsWith('backup-') && file.endsWith('.json'))
262
+ .map(file => ({
263
+ name: file,
264
+ path: path.join(backupDirPath, file),
265
+ time: fs.statSync(path.join(backupDirPath, file)).mtime.getTime()
266
+ }))
267
+ .sort((a, b) => b.time - a.time);
268
+
269
+ const toDelete = backupFiles.slice(maxBackups);
270
+ const kept = backupFiles.slice(0, maxBackups);
271
+
272
+ const protectedChainNames = collectProtectedChainNames(backupDirPath, kept);
273
+
274
+ for (const file of toDelete) {
275
+ if (protectedChainNames.has(file.name)) {
276
+ logger.info(` Keeping ${file.name} (parent of a kept incremental backup)`);
277
+ continue;
278
+ }
279
+ try {
280
+ await fsp.unlink(file.path);
281
+ } catch (err) {
282
+ logger.warn(`Could not remove old backup ${file.name}: ${err.message}`);
283
+ }
284
+ }
285
+ } catch (err) {
286
+ if (err.code !== 'ENOENT') {
287
+ logger.warn(`Error during cleanup: ${err.message}`);
288
+ }
289
+ }
290
+ }
291
+
110
292
  async function handleCreate(args) {
111
- // Use absolute path for the locales directory
112
293
  const dir = args._[1] || path.join(__dirname, '..', 'locales');
113
294
  const outputDir = args.output || backupDir;
114
295
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
115
296
  const backupName = `backup-${timestamp}.json`;
116
297
  const backupPath = path.join(outputDir, backupName);
298
+ const isIncremental = args.incremental !== 'false' && args.incremental !== false;
117
299
 
118
- // Log the paths for debugging
119
300
  logger.debug(`Source directory: ${dir}`);
120
301
  logger.debug(`Backup will be saved to: ${backupPath}`);
121
302
 
122
- // Create backup directory if it doesn't exist
123
303
  try {
124
- await fsp.mkdir(outputDir, { recursive: true });
304
+ await fsp.mkdir(outputDir, { recursive: true });
125
305
  logger.debug(`Created backup directory: ${outputDir}`);
126
306
  } catch (err) {
127
307
  if (err.code !== 'EEXIST') {
@@ -131,10 +311,9 @@ async function handleCreate(args) {
131
311
  logger.debug(`Using existing backup directory: ${outputDir}`);
132
312
  }
133
313
 
134
- // Validate directory
135
314
  const sourceDir = path.resolve(dir);
136
315
  try {
137
- const stats = await fsp.stat(sourceDir);
316
+ const stats = await fsp.stat(sourceDir);
138
317
  if (!stats.isDirectory()) {
139
318
  throw new Error(`Path exists but is not a directory: ${sourceDir}`);
140
319
  }
@@ -147,41 +326,91 @@ async function handleCreate(args) {
147
326
  }
148
327
 
149
328
  logger.info('\nCreating backup...');
150
-
151
- // Read all files in the directory
152
- const files = (await fsp.readdir(sourceDir, { withFileTypes: true }))
329
+
330
+ const files = (await fsp.readdir(sourceDir, { withFileTypes: true }))
153
331
  .filter(dirent => dirent.isFile() && dirent.name.endsWith('.json'))
154
332
  .map(dirent => dirent.name);
155
-
333
+
156
334
  if (files.length === 0) {
157
335
  logger.warn('No JSON files found in the specified directory');
158
336
  process.exit(0);
159
337
  }
160
-
161
- // Read all translation files
338
+
162
339
  const translations = {};
340
+ const hashes = {};
163
341
  for (const file of files) {
164
342
  const filePath = path.join(sourceDir, file);
165
343
  try {
166
- const content = JSON.parse(await fsp.readFile(filePath, 'utf8'));
167
- translations[file] = content;
344
+ const rawContent = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath), 'utf8');
345
+ if (rawContent === null) {
346
+ logger.error(`Could not read file ${file}`);
347
+ continue;
348
+ }
349
+ translations[file] = JSON.parse(rawContent);
350
+ hashes[file] = computeFileHash(filePath);
168
351
  } catch (error) {
169
352
  logger.error(`Could not read file ${file}: ${error.message}`);
170
353
  }
171
354
  }
172
-
173
- // Create the backup
174
- await fsp.writeFile(backupPath, JSON.stringify(translations, null, 2));
175
- const stats = await fsp.stat(backupPath);
176
-
355
+
356
+ let meta = {
357
+ type: 'full',
358
+ parent: null,
359
+ chainDepth: 0,
360
+ hashes: hashes
361
+ };
362
+ let filesToInclude = translations;
363
+
364
+ if (isIncremental) {
365
+ const parentBackup = await findMostRecentBackup(outputDir);
366
+ if (parentBackup) {
367
+ const parentRaw = SecurityUtils.safeReadFileSync(parentBackup.path, path.dirname(parentBackup.path), 'utf8');
368
+ const parentData = JSON.parse(parentRaw);
369
+ const parentDepth = (parentData._meta && parentData._meta.chainDepth) || 0;
370
+ if (parentDepth >= 10) {
371
+ logger.info(' Incremental chain depth limit reached; creating a new full backup');
372
+ } else {
373
+ const parentHashes = getParentHashes(parentData);
374
+
375
+ const changedFiles = {};
376
+ for (const [file, content] of Object.entries(translations)) {
377
+ if (!parentHashes[file] || hashes[file] !== parentHashes[file]) {
378
+ changedFiles[file] = content;
379
+ }
380
+ }
381
+ filesToInclude = changedFiles;
382
+
383
+ meta = {
384
+ type: 'incremental',
385
+ parent: parentBackup.name,
386
+ chainDepth: parentDepth + 1,
387
+ hashes: hashes
388
+ };
389
+
390
+ logger.info(` Incremental mode: found ${Object.keys(filesToInclude).length} changed files (parent: ${parentBackup.name}, depth: ${meta.chainDepth})`);
391
+ }
392
+ } else {
393
+ logger.info(' No previous backup found; creating full backup');
394
+ }
395
+ }
396
+
397
+ const backupData = { _meta: meta };
398
+ for (const [file, content] of Object.entries(filesToInclude)) {
399
+ backupData[file] = content;
400
+ }
401
+
402
+ await fsp.writeFile(backupPath, JSON.stringify(backupData, null, 2));
403
+ const stats = await fsp.stat(backupPath);
404
+
177
405
  logger.success('Backup created successfully');
178
406
  logger.info(` Location: ${backupPath}`);
179
407
  logger.info(` Size: ${(stats.size / 1024).toFixed(2)} KB`);
180
408
  logger.info(` Timestamp: ${new Date().toLocaleString()}`);
181
-
182
- // Clean up old backups
409
+ logger.info(` Files included: ${Object.keys(filesToInclude).length}`);
410
+ logger.info(` Type: ${meta.type}`);
411
+
183
412
  await cleanupOldBackups(outputDir);
184
- }
413
+ }
185
414
 
186
415
  async function handleRestore(args) {
187
416
  const backupFile = args._[1];
@@ -190,41 +419,54 @@ async function handleRestore(args) {
190
419
  }
191
420
 
192
421
  const backupPath = path.resolve(process.cwd(), backupFile);
193
- const outputDir = args.output
194
- ? path.resolve(process.cwd(), args.output)
422
+ const outputDir = args.output
423
+ ? path.resolve(process.cwd(), args.output)
195
424
  : path.join(process.cwd(), 'restored');
196
-
197
- // Validate backup file
425
+
198
426
  if (!SecurityUtils.safeExistsSync(backupPath, process.cwd())) {
199
427
  throw new Error(`Backup file not found: ${backupPath}`);
200
428
  }
201
-
429
+
202
430
  logger.info('\nRestoring backup...');
203
-
431
+
204
432
  try {
205
- // Read the backup file
206
- const backupData = await fsp.readFile(backupPath, 'utf8');
207
- const translations = JSON.parse(backupData);
208
-
209
- // Create output directory if it doesn't exist
210
- try {
211
- await fsp.mkdir(outputDir, { recursive: true });
212
- } catch (err) {
213
- if (err.code !== 'EEXIST') throw err;
214
- }
215
-
216
- // Write the restored files
217
- for (const [file, content] of Object.entries(translations)) {
218
- const filePath = path.join(outputDir, file);
219
- await fsp.writeFile(filePath, JSON.stringify(content, null, 2));
433
+ const backupData = JSON.parse(await fsp.readFile(backupPath, 'utf8'));
434
+ const isIncremental = backupData._meta && backupData._meta.type === 'incremental';
435
+
436
+ if (isIncremental) {
437
+ const chain = await buildRestoreChain(backupPath, backupData);
438
+ await fsp.mkdir(outputDir, { recursive: true });
439
+
440
+ const restoredFiles = new Set();
441
+ for (const entry of chain) {
442
+ for (const [file, content] of Object.entries(entry.data)) {
443
+ if (file === '_meta') continue;
444
+ const filePath = path.join(outputDir, file);
445
+ SecurityUtils.safeWriteFileSync(filePath, JSON.stringify(content, null, 2), path.dirname(filePath), 'utf8');
446
+ restoredFiles.add(file);
447
+ }
448
+ }
449
+
450
+ logger.success('Incremental backup restored successfully');
451
+ logger.info(` ${restoredFiles.size} files restored across ${chain.length} backup(s) to: ${outputDir}`);
452
+ } else {
453
+ await fsp.mkdir(outputDir, { recursive: true });
454
+
455
+ let count = 0;
456
+ for (const [file, content] of Object.entries(backupData)) {
457
+ if (file === '_meta') continue;
458
+ const filePath = path.join(outputDir, file);
459
+ SecurityUtils.safeWriteFileSync(filePath, JSON.stringify(content, null, 2), path.dirname(filePath), 'utf8');
460
+ count++;
461
+ }
462
+
463
+ logger.success('Backup restored successfully');
464
+ logger.info(` Restored ${count} files to: ${outputDir}`);
220
465
  }
221
-
222
- logger.success('Backup restored successfully');
223
- logger.info(` Restored ${Object.keys(translations).length} files to: ${outputDir}`);
224
466
  } catch (error) {
225
467
  handleError(error);
226
468
  }
227
- }
469
+ }
228
470
 
229
471
  async function handleList() {
230
472
  try {
@@ -298,32 +540,78 @@ async function handleVerify(args) {
298
540
  }
299
541
 
300
542
  const backupPath = path.resolve(process.cwd(), backupFile);
301
-
302
- // Validate backup file
543
+
303
544
  if (!SecurityUtils.safeExistsSync(backupPath, process.cwd())) {
304
545
  throw new Error(`Backup file not found: ${backupPath}`);
305
546
  }
306
-
547
+
307
548
  logger.info('\nVerifying backup...');
308
-
549
+
309
550
  try {
310
- const data = await fsp.readFile(backupPath, 'utf8');
311
- const content = JSON.parse(data);
312
-
313
- if (typeof content === 'object' && content !== null) {
314
- const fileCount = Object.keys(content).length;
551
+ const data = JSON.parse(await fsp.readFile(backupPath, 'utf8'));
552
+
553
+ if (data._meta && data._meta.hashes) {
554
+ logger.info(' Performing hash chain verification...');
555
+
556
+ const chain = await buildRestoreChain(backupPath, data);
557
+
558
+ let allValid = true;
559
+
560
+ // Rebuild full state oldest->newest and verify each manifest against it.
561
+ const reconstructed = {};
562
+ for (const entry of chain) {
563
+ const entryMeta = entry.data._meta;
564
+ const entryHashes = entryMeta ? entryMeta.hashes : null;
565
+ const entryName = path.basename(entry.path);
566
+
567
+ for (const [file, content] of Object.entries(entry.data)) {
568
+ if (file !== '_meta') reconstructed[file] = content;
569
+ }
570
+
571
+ if (!entryHashes) {
572
+ logger.warn(` ${entryName}: no manifest hashes (legacy backup)`);
573
+ continue;
574
+ }
575
+
576
+ let entryValid = true;
577
+ for (const [file, expectedHash] of Object.entries(entryHashes)) {
578
+ if (!Object.prototype.hasOwnProperty.call(reconstructed, file)) {
579
+ logger.warn(` Missing file in reconstructed backup state: ${file}`);
580
+ entryValid = false;
581
+ continue;
582
+ }
583
+ const computedHash = computeContentHash(reconstructed[file]);
584
+ if (computedHash !== expectedHash) {
585
+ logger.error(` Hash mismatch: ${file} (expected ${expectedHash.slice(0, 12)}..., got ${computedHash.slice(0, 12)}...)`);
586
+ entryValid = false;
587
+ }
588
+ }
589
+
590
+ if (entryValid) {
591
+ logger.success(` ${entryName}: ${Object.keys(entryHashes).length} file(s) verified`);
592
+ } else {
593
+ allValid = false;
594
+ }
595
+ }
596
+
597
+ if (allValid) {
598
+ logger.success('\nBackup chain verification passed');
599
+ } else {
600
+ logger.error('\nBackup chain verification FAILED');
601
+ process.exitCode = 1;
602
+ }
603
+ } else {
604
+ const fileCount = Object.keys(data).filter(k => k !== '_meta').length;
315
605
  logger.success('Backup is valid');
316
606
  logger.info(` Contains ${fileCount} translation files`);
317
- logger.info(` Last modified: ${(await fsp.stat(backupPath)).mtime.toLocaleString()}`);
318
- } else {
319
- throw new Error('Invalid backup format');
607
+ logger.info(` Last modified: ${(await fsp.stat(backupPath)).mtime.toLocaleString()}`);
320
608
  }
321
609
  } catch (error) {
322
610
  logger.error('Backup verification failed!');
323
611
  logger.error(` Error: ${error.message}`);
324
612
  process.exit(1);
325
613
  }
326
- }
614
+ }
327
615
 
328
616
  async function handleCleanup(args) {
329
617
  const keep = args.keep ? parseInt(args.keep, 10) : maxBackups;
@@ -343,24 +631,33 @@ async function handleCleanup(args) {
343
631
 
344
632
  // Keep only the most recent 'keep' files
345
633
  const toDelete = backupFiles.slice(keep);
634
+ const kept = backupFiles.slice(0, keep);
346
635
 
347
636
  if (toDelete.length === 0) {
348
637
  logger.info('No old backups to delete.');
349
638
  return;
350
639
  }
351
640
 
352
- // Delete old backups
353
- for (const file of toDelete) {
354
- try {
641
+ const protectedChainNames = collectProtectedChainNames(backupDir, kept);
642
+ let deletedCount = 0;
643
+
644
+ // Delete old backups, skipping parents of kept backups
645
+ for (const file of toDelete) {
646
+ if (protectedChainNames.has(file.name)) {
647
+ logger.info(` Keeping ${file.name} (parent of a kept incremental backup)`);
648
+ continue;
649
+ }
650
+ try {
355
651
  await fsp.unlink(file.path);
356
- logger.info(` - Deleted: ${file.name}`);
357
- } catch (err) {
358
- logger.error(` - Failed to delete ${file.name}: ${err.message}`);
359
- }
360
- }
361
-
362
- logger.info(`\nRemoved ${toDelete.length} old backups`);
363
- logger.info(`Total backups kept: ${keep}`);
652
+ logger.info(` - Deleted: ${file.name}`);
653
+ deletedCount++;
654
+ } catch (err) {
655
+ logger.error(` - Failed to delete ${file.name}: ${err.message}`);
656
+ }
657
+ }
658
+
659
+ logger.info(`\nRemoved ${deletedCount} old backups`);
660
+ logger.info(`Total backups kept: ${keep}`);
364
661
 
365
662
  } catch (error) {
366
663
  logger.error('Error cleaning up backups:');