i18ntk 3.2.0 → 4.0.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,93 @@ 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
+ let current = startData;
104
+ let currentPath = startPath;
105
+
106
+ while (current._meta && current._meta.parent) {
107
+ if (chain.length >= 11) {
108
+ throw new Error('Chain broken: incremental backup chain exceeds the maximum depth of 10');
109
+ }
110
+ const parentName = current._meta.parent;
111
+ const parentDir = path.dirname(currentPath);
112
+ const parentPath = path.join(parentDir, parentName);
113
+ if (!SecurityUtils.safeExistsSync(parentPath, parentDir)) {
114
+ throw new Error(`Chain broken: parent backup '${parentName}' not found in ${parentDir}`);
115
+ }
116
+ const parentRaw = SecurityUtils.safeReadFileSync(parentPath, parentDir, 'utf8');
117
+ if (parentRaw === null) {
118
+ throw new Error(`Chain broken: cannot read parent backup '${parentName}'`);
119
+ }
120
+ current = JSON.parse(parentRaw);
121
+ currentPath = parentPath;
122
+ chain.push({ path: currentPath, data: current });
123
+ }
124
+
125
+ chain.reverse();
126
+ return chain;
127
+ }
128
+
40
129
  const configManager = require('../utils/config-manager');
41
130
  const { logger } = require('../utils/logger');
42
131
  const { colors } = require('../utils/logger');
@@ -103,25 +192,51 @@ Options:
103
192
  --output <path> Output directory for backup/restore
104
193
  --force Overwrite existing files without prompting
105
194
  --keep <number> Number of backups to keep (default: 10)
195
+ --incremental Create an incremental backup (only changed files)
106
196
  `);
107
197
  }
108
198
 
109
199
  // Command handlers
200
+ async function cleanupOldBackups(backupDirPath) {
201
+ try {
202
+ const files = await fsp.readdir(backupDirPath);
203
+ const backupFiles = files
204
+ .filter(file => file.startsWith('backup-') && file.endsWith('.json'))
205
+ .map(file => ({
206
+ name: file,
207
+ path: path.join(backupDirPath, file),
208
+ time: fs.statSync(path.join(backupDirPath, file)).mtime.getTime()
209
+ }))
210
+ .sort((a, b) => b.time - a.time);
211
+
212
+ const toDelete = backupFiles.slice(maxBackups);
213
+ for (const file of toDelete) {
214
+ try {
215
+ await fsp.unlink(file.path);
216
+ } catch (err) {
217
+ logger.warn(`Could not remove old backup ${file.name}: ${err.message}`);
218
+ }
219
+ }
220
+ } catch (err) {
221
+ if (err.code !== 'ENOENT') {
222
+ logger.warn(`Error during cleanup: ${err.message}`);
223
+ }
224
+ }
225
+ }
226
+
110
227
  async function handleCreate(args) {
111
- // Use absolute path for the locales directory
112
228
  const dir = args._[1] || path.join(__dirname, '..', 'locales');
113
229
  const outputDir = args.output || backupDir;
114
230
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
115
231
  const backupName = `backup-${timestamp}.json`;
116
232
  const backupPath = path.join(outputDir, backupName);
233
+ const isIncremental = !!args.incremental;
117
234
 
118
- // Log the paths for debugging
119
235
  logger.debug(`Source directory: ${dir}`);
120
236
  logger.debug(`Backup will be saved to: ${backupPath}`);
121
237
 
122
- // Create backup directory if it doesn't exist
123
238
  try {
124
- await fsp.mkdir(outputDir, { recursive: true });
239
+ await fsp.mkdir(outputDir, { recursive: true });
125
240
  logger.debug(`Created backup directory: ${outputDir}`);
126
241
  } catch (err) {
127
242
  if (err.code !== 'EEXIST') {
@@ -131,10 +246,9 @@ async function handleCreate(args) {
131
246
  logger.debug(`Using existing backup directory: ${outputDir}`);
132
247
  }
133
248
 
134
- // Validate directory
135
249
  const sourceDir = path.resolve(dir);
136
250
  try {
137
- const stats = await fsp.stat(sourceDir);
251
+ const stats = await fsp.stat(sourceDir);
138
252
  if (!stats.isDirectory()) {
139
253
  throw new Error(`Path exists but is not a directory: ${sourceDir}`);
140
254
  }
@@ -147,41 +261,91 @@ async function handleCreate(args) {
147
261
  }
148
262
 
149
263
  logger.info('\nCreating backup...');
150
-
151
- // Read all files in the directory
152
- const files = (await fsp.readdir(sourceDir, { withFileTypes: true }))
264
+
265
+ const files = (await fsp.readdir(sourceDir, { withFileTypes: true }))
153
266
  .filter(dirent => dirent.isFile() && dirent.name.endsWith('.json'))
154
267
  .map(dirent => dirent.name);
155
-
268
+
156
269
  if (files.length === 0) {
157
270
  logger.warn('No JSON files found in the specified directory');
158
271
  process.exit(0);
159
272
  }
160
-
161
- // Read all translation files
273
+
162
274
  const translations = {};
275
+ const hashes = {};
163
276
  for (const file of files) {
164
277
  const filePath = path.join(sourceDir, file);
165
278
  try {
166
- const content = JSON.parse(await fsp.readFile(filePath, 'utf8'));
167
- translations[file] = content;
279
+ const rawContent = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath), 'utf8');
280
+ if (rawContent === null) {
281
+ logger.error(`Could not read file ${file}`);
282
+ continue;
283
+ }
284
+ translations[file] = JSON.parse(rawContent);
285
+ hashes[file] = computeFileHash(filePath);
168
286
  } catch (error) {
169
287
  logger.error(`Could not read file ${file}: ${error.message}`);
170
288
  }
171
289
  }
172
-
173
- // Create the backup
174
- await fsp.writeFile(backupPath, JSON.stringify(translations, null, 2));
175
- const stats = await fsp.stat(backupPath);
176
-
290
+
291
+ let meta = {
292
+ type: 'full',
293
+ parent: null,
294
+ chainDepth: 0,
295
+ hashes: hashes
296
+ };
297
+ let filesToInclude = translations;
298
+
299
+ if (isIncremental) {
300
+ const parentBackup = await findMostRecentBackup(outputDir);
301
+ if (parentBackup) {
302
+ const parentRaw = SecurityUtils.safeReadFileSync(parentBackup.path, path.dirname(parentBackup.path), 'utf8');
303
+ const parentData = JSON.parse(parentRaw);
304
+ const parentDepth = (parentData._meta && parentData._meta.chainDepth) || 0;
305
+ if (parentDepth >= 10) {
306
+ logger.info(' Incremental chain depth limit reached; creating a new full backup');
307
+ } else {
308
+ const parentHashes = getParentHashes(parentData);
309
+
310
+ const changedFiles = {};
311
+ for (const [file, content] of Object.entries(translations)) {
312
+ if (!parentHashes[file] || hashes[file] !== parentHashes[file]) {
313
+ changedFiles[file] = content;
314
+ }
315
+ }
316
+ filesToInclude = changedFiles;
317
+
318
+ meta = {
319
+ type: 'incremental',
320
+ parent: parentBackup.name,
321
+ chainDepth: parentDepth + 1,
322
+ hashes: hashes
323
+ };
324
+
325
+ logger.info(` Incremental mode: found ${Object.keys(filesToInclude).length} changed files (parent: ${parentBackup.name}, depth: ${meta.chainDepth})`);
326
+ }
327
+ } else {
328
+ logger.info(' No previous backup found; creating full backup');
329
+ }
330
+ }
331
+
332
+ const backupData = { _meta: meta };
333
+ for (const [file, content] of Object.entries(filesToInclude)) {
334
+ backupData[file] = content;
335
+ }
336
+
337
+ await fsp.writeFile(backupPath, JSON.stringify(backupData, null, 2));
338
+ const stats = await fsp.stat(backupPath);
339
+
177
340
  logger.success('Backup created successfully');
178
341
  logger.info(` Location: ${backupPath}`);
179
342
  logger.info(` Size: ${(stats.size / 1024).toFixed(2)} KB`);
180
343
  logger.info(` Timestamp: ${new Date().toLocaleString()}`);
181
-
182
- // Clean up old backups
344
+ logger.info(` Files included: ${Object.keys(filesToInclude).length}`);
345
+ logger.info(` Type: ${meta.type}`);
346
+
183
347
  await cleanupOldBackups(outputDir);
184
- }
348
+ }
185
349
 
186
350
  async function handleRestore(args) {
187
351
  const backupFile = args._[1];
@@ -190,41 +354,54 @@ async function handleRestore(args) {
190
354
  }
191
355
 
192
356
  const backupPath = path.resolve(process.cwd(), backupFile);
193
- const outputDir = args.output
194
- ? path.resolve(process.cwd(), args.output)
357
+ const outputDir = args.output
358
+ ? path.resolve(process.cwd(), args.output)
195
359
  : path.join(process.cwd(), 'restored');
196
-
197
- // Validate backup file
360
+
198
361
  if (!SecurityUtils.safeExistsSync(backupPath, process.cwd())) {
199
362
  throw new Error(`Backup file not found: ${backupPath}`);
200
363
  }
201
-
364
+
202
365
  logger.info('\nRestoring backup...');
203
-
366
+
204
367
  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));
368
+ const backupData = JSON.parse(await fsp.readFile(backupPath, 'utf8'));
369
+ const isIncremental = backupData._meta && backupData._meta.type === 'incremental';
370
+
371
+ if (isIncremental) {
372
+ const chain = await buildRestoreChain(backupPath, backupData);
373
+ await fsp.mkdir(outputDir, { recursive: true });
374
+
375
+ const restoredFiles = new Set();
376
+ for (const entry of chain) {
377
+ for (const [file, content] of Object.entries(entry.data)) {
378
+ if (file === '_meta') continue;
379
+ const filePath = path.join(outputDir, file);
380
+ SecurityUtils.safeWriteFileSync(filePath, JSON.stringify(content, null, 2), path.dirname(filePath), 'utf8');
381
+ restoredFiles.add(file);
382
+ }
383
+ }
384
+
385
+ logger.success('Incremental backup restored successfully');
386
+ logger.info(` ${restoredFiles.size} files restored across ${chain.length} backup(s) to: ${outputDir}`);
387
+ } else {
388
+ await fsp.mkdir(outputDir, { recursive: true });
389
+
390
+ let count = 0;
391
+ for (const [file, content] of Object.entries(backupData)) {
392
+ if (file === '_meta') continue;
393
+ const filePath = path.join(outputDir, file);
394
+ SecurityUtils.safeWriteFileSync(filePath, JSON.stringify(content, null, 2), path.dirname(filePath), 'utf8');
395
+ count++;
396
+ }
397
+
398
+ logger.success('Backup restored successfully');
399
+ logger.info(` Restored ${count} files to: ${outputDir}`);
220
400
  }
221
-
222
- logger.success('Backup restored successfully');
223
- logger.info(` Restored ${Object.keys(translations).length} files to: ${outputDir}`);
224
401
  } catch (error) {
225
402
  handleError(error);
226
403
  }
227
- }
404
+ }
228
405
 
229
406
  async function handleList() {
230
407
  try {
@@ -298,32 +475,98 @@ async function handleVerify(args) {
298
475
  }
299
476
 
300
477
  const backupPath = path.resolve(process.cwd(), backupFile);
301
-
302
- // Validate backup file
478
+
303
479
  if (!SecurityUtils.safeExistsSync(backupPath, process.cwd())) {
304
480
  throw new Error(`Backup file not found: ${backupPath}`);
305
481
  }
306
-
482
+
307
483
  logger.info('\nVerifying backup...');
308
-
484
+
309
485
  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;
486
+ const data = JSON.parse(await fsp.readFile(backupPath, 'utf8'));
487
+
488
+ if (data._meta && data._meta.hashes) {
489
+ logger.info(' Performing hash chain verification...');
490
+
491
+ const chain = [{ path: backupPath, data: data }];
492
+ let current = data;
493
+ let currentPath = backupPath;
494
+ while (current._meta && current._meta.parent) {
495
+ if (chain.length >= 11) {
496
+ logger.warn(' Chain broken: maximum incremental depth of 10 exceeded');
497
+ break;
498
+ }
499
+ const parentName = current._meta.parent;
500
+ const parentDir = path.dirname(currentPath);
501
+ const parentPath = path.join(parentDir, parentName);
502
+ if (!SecurityUtils.safeExistsSync(parentPath, parentDir)) {
503
+ logger.warn(` Chain broken: parent '${parentName}' not found`);
504
+ break;
505
+ }
506
+ const parentRaw = SecurityUtils.safeReadFileSync(parentPath, parentDir, 'utf8');
507
+ if (parentRaw === null) {
508
+ logger.warn(` Chain broken: cannot read parent '${parentName}'`);
509
+ break;
510
+ }
511
+ current = JSON.parse(parentRaw);
512
+ currentPath = parentPath;
513
+ chain.push({ path: currentPath, data: current });
514
+ }
515
+
516
+ let allValid = true;
517
+ const reconstructed = {};
518
+ for (const entry of chain) {
519
+ const entryMeta = entry.data._meta;
520
+ const entryHashes = entryMeta ? entryMeta.hashes : null;
521
+ const entryName = path.basename(entry.path);
522
+
523
+ for (const [file, content] of Object.entries(entry.data)) {
524
+ if (file !== '_meta') reconstructed[file] = content;
525
+ }
526
+
527
+ if (!entryHashes) {
528
+ logger.warn(` ${entryName}: no manifest hashes (legacy backup)`);
529
+ continue;
530
+ }
531
+
532
+ let entryValid = true;
533
+ for (const [file, expectedHash] of Object.entries(entryHashes)) {
534
+ if (!Object.prototype.hasOwnProperty.call(reconstructed, file)) {
535
+ logger.warn(` Missing file in manifest: ${file}`);
536
+ entryValid = false;
537
+ continue;
538
+ }
539
+ const computedHash = computeContentHash(reconstructed[file]);
540
+ if (computedHash !== expectedHash) {
541
+ logger.error(` Hash mismatch: ${file} (expected ${expectedHash.slice(0, 12)}..., got ${computedHash.slice(0, 12)}...)`);
542
+ entryValid = false;
543
+ }
544
+ }
545
+
546
+ if (entryValid) {
547
+ logger.success(` ${entryName}: ${Object.keys(entryHashes).length} file(s) verified`);
548
+ } else {
549
+ allValid = false;
550
+ }
551
+ }
552
+
553
+ if (allValid) {
554
+ logger.success('\nBackup chain verification passed');
555
+ } else {
556
+ logger.error('\nBackup chain verification FAILED');
557
+ }
558
+ } else {
559
+ const fileCount = Object.keys(data).filter(k => k !== '_meta').length;
315
560
  logger.success('Backup is valid');
316
561
  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');
562
+ logger.info(` Last modified: ${(await fsp.stat(backupPath)).mtime.toLocaleString()}`);
320
563
  }
321
564
  } catch (error) {
322
565
  logger.error('Backup verification failed!');
323
566
  logger.error(` Error: ${error.message}`);
324
567
  process.exit(1);
325
568
  }
326
- }
569
+ }
327
570
 
328
571
  async function handleCleanup(args) {
329
572
  const keep = args.keep ? parseInt(args.keep, 10) : maxBackups;