i18ntk 2.5.1 → 3.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +385 -0
  2. package/README.md +56 -47
  3. package/main/i18ntk-analyze.js +4 -4
  4. package/main/i18ntk-scanner.js +14 -12
  5. package/main/i18ntk-translate.js +502 -0
  6. package/main/i18ntk-validate.js +25 -18
  7. package/main/manage/commands/AnalyzeCommand.js +7 -4
  8. package/main/manage/commands/CommandRouter.js +7 -1
  9. package/main/manage/commands/FixerCommand.js +11 -1
  10. package/main/manage/commands/ScannerCommand.js +12 -10
  11. package/main/manage/commands/TranslateCommand.js +242 -0
  12. package/main/manage/commands/ValidateCommand.js +21 -17
  13. package/main/manage/index.js +17 -12
  14. package/package.json +13 -3
  15. package/runtime/enhanced.js +64 -10
  16. package/runtime/i18ntk.d.ts +10 -6
  17. package/runtime/index.js +45 -22
  18. package/ui-locales/de.json +3 -0
  19. package/ui-locales/en.json +3 -0
  20. package/ui-locales/es.json +3 -0
  21. package/ui-locales/fr.json +3 -0
  22. package/ui-locales/ja.json +3 -0
  23. package/ui-locales/ru.json +3 -1
  24. package/ui-locales/zh.json +3 -0
  25. package/utils/admin-auth.js +4 -1
  26. package/utils/config-helper.js +43 -37
  27. package/utils/config-manager.js +59 -49
  28. package/utils/config.js +13 -4
  29. package/utils/env-manager.js +3 -1
  30. package/utils/i18n-helper.js +41 -13
  31. package/utils/init-helper.js +23 -21
  32. package/utils/secure-errors.js +10 -6
  33. package/utils/security.js +30 -4
  34. package/utils/setup-enforcer.js +22 -33
  35. package/utils/translate/api.js +168 -0
  36. package/utils/translate/cli.js +91 -0
  37. package/utils/translate/placeholder.js +93 -0
  38. package/utils/translate/report.js +90 -0
  39. package/utils/translate/traverse.js +148 -0
  40. package/utils/watch-locales.js +12 -5
@@ -0,0 +1,502 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * I18NTK TRANSLATION GENERATOR
5
+ *
6
+ * Zero-dependency translation utility that converts English source JSON
7
+ * language files into any target language via Google's free Translate API.
8
+ *
9
+ * Usage:
10
+ * i18ntk-translate <source-file> <target-lang> [options]
11
+ * i18ntk-translate locales/en/common.json de
12
+ * i18ntk-translate locales/en/common.json fr --no-confirm --skip-placeholders
13
+ * i18ntk-translate locales/en/common.json es --dry-run
14
+ *
15
+ * Options:
16
+ * --source-dir <dir> Source directory (default: ./locales/en)
17
+ * --output-dir <dir> Output directory (default: ./locales/<lang>)
18
+ * --custom-regex <regex> Additional placeholder regex pattern
19
+ * --no-confirm Skip all confirmation dialogs
20
+ * --skip-placeholders Skip all strings containing placeholders
21
+ * --send-placeholders Translate all strings including placeholders
22
+ * --concurrency <n> Max concurrent API requests (default: 3)
23
+ * --dry-run Preview mode without API calls
24
+ * --report-file <path> Write report to file
25
+ * --report-stdout Print report to stdout
26
+ * --bom Output UTF-8 with BOM
27
+ * --translate-fn <module> Path to custom translation function module
28
+ * --retry-count <n> Max retries per request (default: 3)
29
+ * --retry-delay <ms> Base delay for retry backoff (default: 1000)
30
+ * --timeout <ms> HTTP request timeout (default: 15000)
31
+ * --source-lang <code> Source language code (default: en)
32
+ * --files <pattern> Glob pattern for multiple files (e.g. *.json)
33
+ * -h, --help Show help
34
+ */
35
+
36
+ const fs = require('fs');
37
+ const path = require('path');
38
+ const packageJson = require('../package.json');
39
+ const ExitCodes = require('../utils/exit-codes');
40
+ const { isInteractive } = require('../utils/prompt-helper');
41
+ const { detectPlaceholders, maskPlaceholders, unmaskPlaceholders } = require('../utils/translate/placeholder');
42
+ const { translateBatch } = require('../utils/translate/api');
43
+ const { collectLeaves, setLeaf, deepClone } = require('../utils/translate/traverse');
44
+ const { generateReport, writeReport, formatSummaryLine } = require('../utils/translate/report');
45
+ const {
46
+ confirmGlobalChoice,
47
+ confirmPerKey,
48
+ previewSkipped,
49
+ } = require('../utils/translate/cli');
50
+
51
+ const BOM = '\uFEFF';
52
+
53
+ function printHelp() {
54
+ console.log([
55
+ '',
56
+ `I18NTK Translation Generator - v${packageJson.version}`,
57
+ '',
58
+ 'Usage:',
59
+ ' i18ntk-translate <source-file> <target-lang> [options]',
60
+ ' i18ntk-translate <source-file> <target-lang> --source-dir <dir> [options]',
61
+ '',
62
+ 'Examples:',
63
+ ' i18ntk-translate locales/en/common.json de',
64
+ ' i18ntk-translate locales/en/common.json fr --dry-run',
65
+ ' i18ntk-translate locales/en/ es --files "*.json"',
66
+ ' i18ntk-translate locales/en/common.json ja --no-confirm --skip-placeholders',
67
+ ' i18ntk-translate locales/en/common.json ko --report-file report.txt',
68
+ '',
69
+ 'Options:',
70
+ ' --source-dir <dir> Source directory containing locale files',
71
+ ' --output-dir <dir> Output directory for translated files',
72
+ ' --source-lang <code> Source language code (default: en)',
73
+ ' --custom-regex <regex> Additional placeholder regex pattern',
74
+ ' --no-confirm Automate: skip confirmation dialogs',
75
+ ' --skip-placeholders Skip all strings with placeholder tokens',
76
+ ' --send-placeholders Translate all strings including placeholders',
77
+ ' --concurrency <n> Max concurrent API requests (default: 3)',
78
+ ' --dry-run Preview: show what would be skipped',
79
+ ' --report-file <path> Write post-translation report to file',
80
+ ' --report-stdout Print post-translation report to stdout',
81
+ ' --bom Write output files with UTF-8 BOM',
82
+ ' --translate-fn <module> Path to custom translation function module',
83
+ ' --retry-count <n> Max retries per failed request (default: 3)',
84
+ ' --retry-delay <ms> Base backoff delay in ms (default: 1000)',
85
+ ' --timeout <ms> HTTP request timeout in ms (default: 15000)',
86
+ ' -h, --help Show this help',
87
+ ].join('\n'));
88
+ }
89
+
90
+ function parseArgs(argv) {
91
+ const args = {
92
+ sourceFile: null,
93
+ targetLang: null,
94
+ sourceDir: null,
95
+ outputDir: null,
96
+ sourceLang: 'en',
97
+ customRegex: [],
98
+ noConfirm: false,
99
+ skipPlaceholders: false,
100
+ sendPlaceholders: false,
101
+ concurrency: 3,
102
+ dryRun: false,
103
+ reportFile: null,
104
+ reportStdout: false,
105
+ bom: false,
106
+ translateFnPath: null,
107
+ retryCount: 3,
108
+ retryDelay: 1000,
109
+ timeout: 15000,
110
+ filesPattern: null,
111
+ help: false,
112
+ unknown: [],
113
+ };
114
+
115
+ const positional = [];
116
+ for (let i = 2; i < argv.length; i++) {
117
+ const arg = argv[i];
118
+ if (arg === '-h' || arg === '--help') { args.help = true; }
119
+ else if (arg === '--no-confirm') { args.noConfirm = true; }
120
+ else if (arg === '--skip-placeholders') { args.skipPlaceholders = true; }
121
+ else if (arg === '--send-placeholders') { args.sendPlaceholders = true; }
122
+ else if (arg === '--dry-run') { args.dryRun = true; }
123
+ else if (arg === '--report-stdout') { args.reportStdout = true; }
124
+ else if (arg === '--bom') { args.bom = true; }
125
+ else if (arg === '--source-dir' && i + 1 < argv.length) { args.sourceDir = argv[++i]; }
126
+ else if (arg === '--output-dir' && i + 1 < argv.length) { args.outputDir = argv[++i]; }
127
+ else if (arg === '--source-lang' && i + 1 < argv.length) { args.sourceLang = argv[++i]; }
128
+ else if (arg === '--custom-regex' && i + 1 < argv.length) { args.customRegex.push(argv[++i]); }
129
+ else if (arg === '--concurrency' && i + 1 < argv.length) { args.concurrency = parseInt(argv[++i], 10) || 3; }
130
+ else if (arg === '--report-file' && i + 1 < argv.length) { args.reportFile = argv[++i]; }
131
+ else if (arg === '--translate-fn' && i + 1 < argv.length) { args.translateFnPath = argv[++i]; }
132
+ else if (arg === '--retry-count' && i + 1 < argv.length) { args.retryCount = parseInt(argv[++i], 10) || 3; }
133
+ else if (arg === '--retry-delay' && i + 1 < argv.length) { args.retryDelay = parseInt(argv[++i], 10) || 1000; }
134
+ else if (arg === '--timeout' && i + 1 < argv.length) { args.timeout = parseInt(argv[++i], 10) || 15000; }
135
+ else if (arg === '--files' && i + 1 < argv.length) { args.filesPattern = argv[++i]; }
136
+ else if (arg.startsWith('-')) { args.unknown.push(arg); }
137
+ else { positional.push(arg); }
138
+ }
139
+
140
+ if (positional.length >= 1) args.sourceFile = positional[0];
141
+ if (positional.length >= 2) args.targetLang = positional[1];
142
+
143
+ if (args.sendPlaceholders && args.skipPlaceholders) {
144
+ console.error('Error: --skip-placeholders and --send-placeholders are mutually exclusive.');
145
+ process.exit(1);
146
+ }
147
+
148
+ return args;
149
+ }
150
+
151
+ function loadCustomTranslateFn(modulePath) {
152
+ if (!modulePath) return null;
153
+ try {
154
+ const resolved = path.isAbsolute(modulePath) ? modulePath : path.resolve(process.cwd(), modulePath);
155
+ const mod = require(resolved);
156
+ if (typeof mod === 'function') return mod;
157
+ if (mod && typeof mod.translate === 'function') return mod.translate;
158
+ if (mod && typeof mod.default === 'function') return mod.default;
159
+ console.error(`Warning: Custom translate module "${modulePath}" does not export a function.`);
160
+ return null;
161
+ } catch (e) {
162
+ console.error(`Error: Failed to load translate function module "${modulePath}": ${e.message}`);
163
+ process.exit(1);
164
+ }
165
+ }
166
+
167
+ function resolveSourceFiles(sourceFile, sourceDir, filesPattern) {
168
+ if (sourceDir) {
169
+ const resolvedDir = path.resolve(process.cwd(), sourceDir);
170
+ if (!fs.existsSync(resolvedDir)) {
171
+ console.error(`Error: Source directory "${resolvedDir}" does not exist.`);
172
+ process.exit(1);
173
+ }
174
+ const entries = fs.readdirSync(resolvedDir);
175
+ const pattern = filesPattern || '*.json';
176
+ const regex = new RegExp('^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
177
+ const files = entries.filter((f) => regex.test(f) && f.endsWith('.json')).sort();
178
+ if (files.length === 0) {
179
+ console.error(`Error: No JSON files matching "${pattern}" found in "${resolvedDir}".`);
180
+ process.exit(1);
181
+ }
182
+ return files.map((f) => path.join(resolvedDir, f));
183
+ }
184
+
185
+ if (sourceFile) {
186
+ const resolved = path.resolve(process.cwd(), sourceFile);
187
+ if (!fs.existsSync(resolved)) {
188
+ console.error(`Error: Source file "${resolved}" does not exist.`);
189
+ process.exit(1);
190
+ }
191
+ return [resolved];
192
+ }
193
+
194
+ console.error('Error: No source file specified. Use --source-dir or provide a source file.');
195
+ process.exit(1);
196
+ }
197
+
198
+ function classifyLeaves(leaves, customRegex) {
199
+ const withPlaceholders = [];
200
+ const withoutPlaceholders = [];
201
+
202
+ for (const leaf of leaves) {
203
+ const placeholders = detectPlaceholders(leaf.value, customRegex);
204
+ if (placeholders.length > 0) {
205
+ withPlaceholders.push({ ...leaf, placeholders });
206
+ } else {
207
+ withoutPlaceholders.push(leaf);
208
+ }
209
+ }
210
+
211
+ return { withPlaceholders, withoutPlaceholders };
212
+ }
213
+
214
+ async function resolvePlaceholderStrategy(args) {
215
+ const interactive = isInteractive({ noPrompt: args.noConfirm });
216
+
217
+ if (args.sendPlaceholders) {
218
+ return { strategy: 'send', interactiveMode: false };
219
+ }
220
+ if (args.skipPlaceholders) {
221
+ return { strategy: 'skip', interactiveMode: false };
222
+ }
223
+ if (args.noConfirm) {
224
+ return { strategy: 'skip', interactiveMode: false };
225
+ }
226
+ if (!interactive) {
227
+ return { strategy: 'skip', interactiveMode: false };
228
+ }
229
+
230
+ const choice = await confirmGlobalChoice();
231
+ return { strategy: choice.strategy, interactiveMode: choice.interactive };
232
+ }
233
+
234
+ async function resolvePerKeyDecisions(withPlaceholders, interactive) {
235
+ const decisions = {};
236
+
237
+ if (!interactive) {
238
+ for (const leaf of withPlaceholders) {
239
+ decisions[leaf.keyPath] = 'skip';
240
+ }
241
+ return decisions;
242
+ }
243
+
244
+ let bulkDecision = null;
245
+ for (const leaf of withPlaceholders) {
246
+ if (bulkDecision) {
247
+ decisions[leaf.keyPath] = bulkDecision;
248
+ continue;
249
+ }
250
+ const choice = await confirmPerKey(leaf.keyPath, leaf.value, leaf.placeholders);
251
+ if (choice === 'skip-all') {
252
+ bulkDecision = 'skip';
253
+ decisions[leaf.keyPath] = 'skip';
254
+ } else if (choice === 'send-all') {
255
+ bulkDecision = 'send';
256
+ decisions[leaf.keyPath] = 'send';
257
+ } else {
258
+ decisions[leaf.keyPath] = choice;
259
+ }
260
+ }
261
+
262
+ return decisions;
263
+ }
264
+
265
+ function buildTranslateList(withPlaceholders, withoutPlaceholders, strategy, decisions) {
266
+ const toTranslate = [];
267
+ const toSkip = [];
268
+
269
+ for (const leaf of withoutPlaceholders) {
270
+ toTranslate.push(leaf);
271
+ }
272
+
273
+ if (strategy === 'send') {
274
+ for (const leaf of withPlaceholders) {
275
+ toTranslate.push(leaf);
276
+ }
277
+ } else {
278
+ for (const leaf of withPlaceholders) {
279
+ const decision = decisions[leaf.keyPath] || 'skip';
280
+ if (decision === 'send') {
281
+ toTranslate.push(leaf);
282
+ } else {
283
+ toSkip.push(leaf);
284
+ }
285
+ }
286
+ }
287
+
288
+ return { toTranslate, toSkip };
289
+ }
290
+
291
+ function maskAllForTranslation(toTranslate, customRegex) {
292
+ return toTranslate.map((leaf) => {
293
+ const { masked, map } = maskPlaceholders(leaf.value, customRegex);
294
+ return { ...leaf, masked, placeholderMap: map, needsUnmask: map.size > 0 };
295
+ });
296
+ }
297
+
298
+ async function runTranslation(maskedBatch, targetLang, options) {
299
+ const batchItems = maskedBatch.map((item) => ({ value: item.masked, keyPath: item.keyPath }));
300
+ const results = await translateBatch(batchItems, targetLang, options);
301
+ return results;
302
+ }
303
+
304
+ function applyResults(sourceData, translatedResults, maskedBatch, toSkip, bom) {
305
+ const output = deepClone(sourceData);
306
+
307
+ for (let i = 0; i < maskedBatch.length; i++) {
308
+ const item = maskedBatch[i];
309
+ const translated = translatedResults[i];
310
+ let finalValue;
311
+ if (item.needsUnmask) {
312
+ finalValue = unmaskPlaceholders(translated, item.placeholderMap);
313
+ } else {
314
+ finalValue = translated;
315
+ }
316
+ setLeaf(output, item.keyPath, finalValue);
317
+ }
318
+
319
+ for (const leaf of toSkip) {
320
+ setLeaf(output, leaf.keyPath, leaf.value);
321
+ }
322
+
323
+ return output;
324
+ }
325
+
326
+ function writeOutput(outputData, outputPath, bom) {
327
+ const dir = path.dirname(outputPath);
328
+ if (!fs.existsSync(dir)) {
329
+ fs.mkdirSync(dir, { recursive: true });
330
+ }
331
+ let content = JSON.stringify(outputData, null, 2) + '\n';
332
+ if (bom) {
333
+ content = BOM + content;
334
+ }
335
+ fs.writeFileSync(outputPath, content, 'utf-8');
336
+ }
337
+
338
+ async function processFile(sourcePath, targetLang, args) {
339
+ const fileName = path.basename(sourcePath);
340
+ const targetDir = args.outputDir || path.join(path.dirname(path.dirname(sourcePath)), targetLang);
341
+ const targetPath = path.join(targetDir, fileName);
342
+
343
+ let sourceData;
344
+ try {
345
+ const raw = fs.readFileSync(sourcePath, 'utf-8');
346
+ sourceData = JSON.parse(raw);
347
+ } catch (e) {
348
+ console.error(`Error reading "${sourcePath}": ${e.message}`);
349
+ return null;
350
+ }
351
+
352
+ const leaves = collectLeaves(sourceData);
353
+ if (leaves.length === 0) {
354
+ console.log(`[${fileName}] No translatable strings found.`);
355
+ writeOutput(sourceData, targetPath, args.bom);
356
+ return { total: 0, translated: 0, skipped: 0, skippedKeys: [] };
357
+ }
358
+
359
+ const { withPlaceholders, withoutPlaceholders } = classifyLeaves(leaves, args.customRegex);
360
+
361
+ if (args.dryRun && withPlaceholders.length > 0) {
362
+ await previewSkipped(withPlaceholders);
363
+ return {
364
+ total: leaves.length,
365
+ translated: withoutPlaceholders.length,
366
+ skipped: withPlaceholders.length,
367
+ skippedKeys: withPlaceholders,
368
+ dryRun: true,
369
+ };
370
+ }
371
+
372
+ if (args.dryRun) {
373
+ console.log(`[${fileName}] Dry-run: ${leaves.length} strings, all would be translated.`);
374
+ return {
375
+ total: leaves.length,
376
+ translated: leaves.length,
377
+ skipped: 0,
378
+ skippedKeys: [],
379
+ dryRun: true,
380
+ };
381
+ }
382
+
383
+ const { strategy, interactiveMode } = await resolvePlaceholderStrategy(args);
384
+ const decisions = await resolvePerKeyDecisions(withPlaceholders, interactiveMode);
385
+ const { toTranslate, toSkip } = buildTranslateList(withPlaceholders, withoutPlaceholders, strategy, decisions);
386
+
387
+ if (toSkip.length > 0) {
388
+ console.log(`[${fileName}] Skipping ${toSkip.length} keys with placeholders.`);
389
+ }
390
+
391
+ const maskedBatch = maskAllForTranslation(toTranslate, args.customRegex);
392
+
393
+ const translateOptions = {
394
+ sourceLang: args.sourceLang,
395
+ concurrency: args.concurrency,
396
+ retryCount: args.retryCount,
397
+ retryDelay: args.retryDelay,
398
+ timeout: args.timeout,
399
+ customFn: args.translateFn,
400
+ onProgress: (info) => {
401
+ if (info.completed % 10 === 0 || info.completed === info.total) {
402
+ process.stdout.write(`\r[${fileName}] Translating... ${info.completed}/${info.total}`);
403
+ }
404
+ },
405
+ onError: (err) => {
406
+ console.error(`\n[${fileName}] Warning: Failed to translate key "${err.item.keyPath}": ${err.message}`);
407
+ },
408
+ };
409
+
410
+ let translatedResults;
411
+ if (maskedBatch.length > 0) {
412
+ translatedResults = await runTranslation(maskedBatch, targetLang, translateOptions);
413
+ process.stdout.write('\n');
414
+ } else {
415
+ translatedResults = [];
416
+ }
417
+
418
+ const output = applyResults(sourceData, translatedResults, maskedBatch, toSkip, args.bom);
419
+ writeOutput(output, targetPath, args.bom);
420
+
421
+ console.log(`[${fileName}] Written: ${targetPath}`);
422
+
423
+ return {
424
+ total: leaves.length,
425
+ translated: translatedResults.length,
426
+ skipped: toSkip.length,
427
+ skippedKeys: toSkip,
428
+ };
429
+ }
430
+
431
+ async function main() {
432
+ const args = parseArgs(process.argv);
433
+
434
+ if (args.help) {
435
+ printHelp();
436
+ process.exit(ExitCodes.SUCCESS);
437
+ }
438
+
439
+ if (args.unknown.length > 0) {
440
+ console.error(`Unknown options: ${args.unknown.join(', ')}`);
441
+ console.error('Use --help for usage information.');
442
+ process.exit(1);
443
+ }
444
+
445
+ if (!args.targetLang) {
446
+ console.error('Error: Target language code is required.');
447
+ console.error('Usage: i18ntk-translate <source-file> <target-lang> [options]');
448
+ process.exit(1);
449
+ }
450
+
451
+ if (args.translateFnPath) {
452
+ args.translateFn = loadCustomTranslateFn(args.translateFnPath);
453
+ }
454
+
455
+ const sourceFiles = resolveSourceFiles(args.sourceFile, args.sourceDir, args.filesPattern);
456
+
457
+ const allSkippedKeys = [];
458
+ let grandTotal = 0;
459
+ let grandTranslated = 0;
460
+ let grandSkipped = 0;
461
+
462
+ for (const srcPath of sourceFiles) {
463
+ const result = await processFile(srcPath, args.targetLang, args);
464
+ if (result) {
465
+ grandTotal += result.total;
466
+ grandTranslated += result.translated;
467
+ grandSkipped += result.skipped;
468
+ if (result.skippedKeys && result.skippedKeys.length > 0) {
469
+ allSkippedKeys.push(...result.skippedKeys);
470
+ }
471
+ }
472
+ }
473
+
474
+ console.log('');
475
+ console.log(formatSummaryLine(grandSkipped, grandTranslated, grandTotal));
476
+
477
+ if (allSkippedKeys.length > 0 || args.reportFile || args.reportStdout) {
478
+ const report = generateReport(allSkippedKeys, grandTranslated, grandTotal, {
479
+ sourceFile: sourceFiles.length === 1 ? sourceFiles[0] : `${sourceFiles.length} files`,
480
+ targetLang: args.targetLang,
481
+ dryRun: args.dryRun,
482
+ });
483
+
484
+ if (args.reportStdout || (!args.reportFile && allSkippedKeys.length > 0)) {
485
+ console.log('');
486
+ console.log(report);
487
+ }
488
+
489
+ if (args.reportFile) {
490
+ const reportPath = path.resolve(process.cwd(), args.reportFile);
491
+ writeReport(report, reportPath);
492
+ console.log(`Report written: ${reportPath}`);
493
+ }
494
+ }
495
+
496
+ process.exit(ExitCodes.SUCCESS);
497
+ }
498
+
499
+ main().catch((err) => {
500
+ console.error('Fatal error:', err.message);
501
+ process.exit(1);
502
+ });
@@ -33,7 +33,7 @@ if (isUppercase) {
33
33
  console.error(' npm run i18ntk:manage');
34
34
  console.error('');
35
35
  console.error('📖 For more information, run: npx i18ntk --help');
36
- process.exit(ExitCodes.CONFIG_ERROR);
36
+ process.exit(1);
37
37
  }
38
38
 
39
39
  const fs = require('fs');
@@ -112,8 +112,8 @@ class I18nValidator {
112
112
  } else {
113
113
  console.warn(t('config.dirFallbackWarning', { dir: this.sourceDir, fallback: this.sourceLanguageDir }) ||
114
114
  `Warning: Directory ${this.sourceDir} not found. Using ${this.sourceLanguageDir}.`);
115
- if (!SecurityUtils.safeExistsSync(this.sourceLanguageDir)) {
116
- fs.mkdirSync(this.sourceLanguageDir, { recursive: true });
115
+ if (!SecurityUtils.safeExistsSync(this.sourceLanguageDir, process.cwd())) {
116
+ SecurityUtils.safeMkdirSync(this.sourceLanguageDir, process.cwd(), { recursive: true });
117
117
  }
118
118
  }
119
119
  }
@@ -204,13 +204,17 @@ class I18nValidator {
204
204
  throw new Error(`Source directory not found: ${this.sourceDir}`);
205
205
  }
206
206
 
207
- const languages = fs.readdirSync(this.sourceDir)
208
- .filter(item => {
209
- const itemPath = path.join(this.sourceDir, item);
210
- return fs.statSync(itemPath).isDirectory() &&
211
- item !== this.config.sourceLanguage &&
212
- !this.isExcludedLanguageDirectory(item);
213
- });
207
+ const items = SecurityUtils.safeReaddirSync(this.sourceDir, process.cwd(), { withFileTypes: true });
208
+ if (!items) {
209
+ throw new Error(`Source directory not found: ${this.sourceDir}`);
210
+ }
211
+
212
+ const languages = items
213
+ .filter(item => {
214
+ return item.isDirectory() &&
215
+ item.name !== this.config.sourceLanguage &&
216
+ !this.isExcludedLanguageDirectory(item.name);
217
+ }).map(item => item.name);
214
218
 
215
219
  return languages;
216
220
  } catch (error) {
@@ -228,11 +232,14 @@ class I18nValidator {
228
232
  return [];
229
233
  }
230
234
 
231
- const files = fs.readdirSync(languageDir)
232
- .filter(file => {
233
- return file.endsWith('.json') &&
234
- !this.config.excludeFiles.includes(file);
235
- });
235
+ const items = SecurityUtils.safeReaddirSync(languageDir, process.cwd(), { withFileTypes: true });
236
+ if (!items) return [];
237
+
238
+ const files = items
239
+ .filter(item => {
240
+ return item.isFile() && item.name.endsWith('.json') &&
241
+ !this.config.excludeFiles.includes(item.name);
242
+ }).map(item => item.name);
236
243
 
237
244
  return files;
238
245
  } catch (error) {
@@ -682,10 +689,10 @@ class I18nValidator {
682
689
 
683
690
  // Delete old validation report if it exists
684
691
  const reportPath = path.join(process.cwd(), 'validation-report.txt');
685
- SecurityUtils.validatePath(reportPath);
692
+ const validatedPath = SecurityUtils.validatePath(reportPath, process.cwd());
686
693
 
687
- if (SecurityUtils.safeExistsSync(reportPath)) {
688
- fs.unlinkSync(reportPath);
694
+ if (validatedPath && SecurityUtils.safeExistsSync(validatedPath, process.cwd())) {
695
+ SecurityUtils.safeUnlinkSync(validatedPath, process.cwd());
689
696
  console.log(t('validate.deletedOldReport'));
690
697
 
691
698
  SecurityUtils.logSecurityEvent(t('validate.fileDeleted'), 'info', {
@@ -129,10 +129,13 @@ class AnalyzeCommand {
129
129
  throw new Error(`Source directory does not exist or is not a directory: ${safePath}`);
130
130
  }
131
131
 
132
- try {
133
- fs.accessSync(safePath, fs.constants.R_OK | fs.constants.W_OK);
134
- } catch {
135
- throw new Error(`Insufficient permissions for source directory: ${safePath}`);
132
+ try {
133
+ const stat = SecurityUtils.safeStatSync(safePath, process.cwd());
134
+ if (!stat || !stat.isDirectory()) {
135
+ throw new Error(`Source directory is not accessible: ${safePath}`);
136
+ }
137
+ } catch (error) {
138
+ throw new Error(`Insufficient permissions for source directory: ${safePath}`);
136
139
  }
137
140
  }
138
141
 
@@ -23,6 +23,7 @@ const BackupCommand = require('./BackupCommand');
23
23
  const DoctorCommand = require('./DoctorCommand');
24
24
  const FixerCommand = require('./FixerCommand');
25
25
  const ScannerCommand = require('./ScannerCommand');
26
+ const TranslateCommand = require('./TranslateCommand');
26
27
 
27
28
  class CommandRouter {
28
29
  constructor(config = {}, ui = null, adminAuth = null) {
@@ -45,7 +46,8 @@ class CommandRouter {
45
46
  'backup': new BackupCommand(config, ui),
46
47
  'doctor': new DoctorCommand(config, ui),
47
48
  'fix': new FixerCommand(config, ui),
48
- 'scanner': new ScannerCommand(config, ui)
49
+ 'scanner': new ScannerCommand(config, ui),
50
+ 'translate': new TranslateCommand(config, ui)
49
51
  };
50
52
  }
51
53
 
@@ -232,6 +234,9 @@ class CommandRouter {
232
234
  case 'scanner':
233
235
  return await this.commandHandlers.scanner.execute(options);
234
236
 
237
+ case 'translate':
238
+ return await this.commandHandlers.translate.execute(options);
239
+
235
240
  case 'debug':
236
241
  console.log('Debug functionality is not available in this version.');
237
242
  return { success: false, message: 'Debug not available' };
@@ -278,6 +283,7 @@ class CommandRouter {
278
283
  console.log(t('help.summaryCommand'));
279
284
  console.log(t('help.debugCommand'));
280
285
  console.log(t('help.scannerCommand'));
286
+ console.log(t('help.translateCommand'));
281
287
  }
282
288
 
283
289
  /**
@@ -373,7 +373,17 @@ class FixerCommand {
373
373
  if (backupDirs.length <= keepCount) return;
374
374
 
375
375
  for (const staleDir of backupDirs.slice(keepCount)) {
376
- fs.rmSync(staleDir.path, { recursive: true, force: true });
376
+ try {
377
+ SecurityUtils.safeUnlinkSync(staleDir.path, process.cwd());
378
+ } catch (_) {
379
+ // Directory not empty, use recursive removal
380
+ try {
381
+ const { rmSync } = require('fs');
382
+ rmSync(staleDir.path, { recursive: true, force: true });
383
+ } catch (_) {
384
+ // Best-effort cleanup
385
+ }
386
+ }
377
387
  }
378
388
  } catch (error) {
379
389
  console.warn(`Failed to clean old backups: ${error.message}`);