i18nsmith 0.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.
Files changed (213) hide show
  1. package/dist/commands/audit.d.ts +3 -0
  2. package/dist/commands/audit.d.ts.map +1 -0
  3. package/dist/commands/audit.js +180 -0
  4. package/dist/commands/audit.js.map +1 -0
  5. package/dist/commands/backup.d.ts +6 -0
  6. package/dist/commands/backup.d.ts.map +1 -0
  7. package/dist/commands/backup.js +85 -0
  8. package/dist/commands/backup.js.map +1 -0
  9. package/dist/commands/check.d.ts +3 -0
  10. package/dist/commands/check.d.ts.map +1 -0
  11. package/dist/commands/check.js +151 -0
  12. package/dist/commands/check.js.map +1 -0
  13. package/dist/commands/config.d.ts +3 -0
  14. package/dist/commands/config.d.ts.map +1 -0
  15. package/dist/commands/config.js +235 -0
  16. package/dist/commands/config.js.map +1 -0
  17. package/dist/commands/debug-patterns.d.ts +3 -0
  18. package/dist/commands/debug-patterns.d.ts.map +1 -0
  19. package/dist/commands/debug-patterns.js +192 -0
  20. package/dist/commands/debug-patterns.js.map +1 -0
  21. package/dist/commands/debug-patterns.test.d.ts +2 -0
  22. package/dist/commands/debug-patterns.test.d.ts.map +1 -0
  23. package/dist/commands/debug-patterns.test.js +109 -0
  24. package/dist/commands/debug-patterns.test.js.map +1 -0
  25. package/dist/commands/diagnose.d.ts +3 -0
  26. package/dist/commands/diagnose.d.ts.map +1 -0
  27. package/dist/commands/diagnose.js +117 -0
  28. package/dist/commands/diagnose.js.map +1 -0
  29. package/dist/commands/init.d.ts +8 -0
  30. package/dist/commands/init.d.ts.map +1 -0
  31. package/dist/commands/init.js +450 -0
  32. package/dist/commands/init.js.map +1 -0
  33. package/dist/commands/init.test.d.ts +2 -0
  34. package/dist/commands/init.test.d.ts.map +1 -0
  35. package/dist/commands/init.test.js +74 -0
  36. package/dist/commands/init.test.js.map +1 -0
  37. package/dist/commands/install-hooks.d.ts +3 -0
  38. package/dist/commands/install-hooks.d.ts.map +1 -0
  39. package/dist/commands/install-hooks.js +52 -0
  40. package/dist/commands/install-hooks.js.map +1 -0
  41. package/dist/commands/preflight.d.ts +7 -0
  42. package/dist/commands/preflight.d.ts.map +1 -0
  43. package/dist/commands/preflight.js +417 -0
  44. package/dist/commands/preflight.js.map +1 -0
  45. package/dist/commands/preflight.test.d.ts +5 -0
  46. package/dist/commands/preflight.test.d.ts.map +1 -0
  47. package/dist/commands/preflight.test.js +108 -0
  48. package/dist/commands/preflight.test.js.map +1 -0
  49. package/dist/commands/rename.d.ts +6 -0
  50. package/dist/commands/rename.d.ts.map +1 -0
  51. package/dist/commands/rename.js +204 -0
  52. package/dist/commands/rename.js.map +1 -0
  53. package/dist/commands/scaffold-adapter.d.ts +3 -0
  54. package/dist/commands/scaffold-adapter.d.ts.map +1 -0
  55. package/dist/commands/scaffold-adapter.js +204 -0
  56. package/dist/commands/scaffold-adapter.js.map +1 -0
  57. package/dist/commands/scaffold-adapter.test.d.ts +2 -0
  58. package/dist/commands/scaffold-adapter.test.d.ts.map +1 -0
  59. package/dist/commands/scaffold-adapter.test.js +102 -0
  60. package/dist/commands/scaffold-adapter.test.js.map +1 -0
  61. package/dist/commands/scan.d.ts +3 -0
  62. package/dist/commands/scan.d.ts.map +1 -0
  63. package/dist/commands/scan.js +93 -0
  64. package/dist/commands/scan.js.map +1 -0
  65. package/dist/commands/sync-seed.test.d.ts +2 -0
  66. package/dist/commands/sync-seed.test.d.ts.map +1 -0
  67. package/dist/commands/sync-seed.test.js +86 -0
  68. package/dist/commands/sync-seed.test.js.map +1 -0
  69. package/dist/commands/sync.d.ts +3 -0
  70. package/dist/commands/sync.d.ts.map +1 -0
  71. package/dist/commands/sync.js +590 -0
  72. package/dist/commands/sync.js.map +1 -0
  73. package/dist/commands/transform.d.ts +3 -0
  74. package/dist/commands/transform.d.ts.map +1 -0
  75. package/dist/commands/transform.js +114 -0
  76. package/dist/commands/transform.js.map +1 -0
  77. package/dist/commands/translate/csv-handler.d.ts +21 -0
  78. package/dist/commands/translate/csv-handler.d.ts.map +1 -0
  79. package/dist/commands/translate/csv-handler.js +270 -0
  80. package/dist/commands/translate/csv-handler.js.map +1 -0
  81. package/dist/commands/translate/executor.d.ts +31 -0
  82. package/dist/commands/translate/executor.d.ts.map +1 -0
  83. package/dist/commands/translate/executor.js +117 -0
  84. package/dist/commands/translate/executor.js.map +1 -0
  85. package/dist/commands/translate/index.d.ts +10 -0
  86. package/dist/commands/translate/index.d.ts.map +1 -0
  87. package/dist/commands/translate/index.js +170 -0
  88. package/dist/commands/translate/index.js.map +1 -0
  89. package/dist/commands/translate/reporter.d.ts +29 -0
  90. package/dist/commands/translate/reporter.d.ts.map +1 -0
  91. package/dist/commands/translate/reporter.js +103 -0
  92. package/dist/commands/translate/reporter.js.map +1 -0
  93. package/dist/commands/translate/types.d.ts +50 -0
  94. package/dist/commands/translate/types.d.ts.map +1 -0
  95. package/dist/commands/translate/types.js +5 -0
  96. package/dist/commands/translate/types.js.map +1 -0
  97. package/dist/commands/translate.d.ts +7 -0
  98. package/dist/commands/translate.d.ts.map +1 -0
  99. package/dist/commands/translate.js +7 -0
  100. package/dist/commands/translate.js.map +1 -0
  101. package/dist/commands/translate.test.d.ts +2 -0
  102. package/dist/commands/translate.test.d.ts.map +1 -0
  103. package/dist/commands/translate.test.js +118 -0
  104. package/dist/commands/translate.test.js.map +1 -0
  105. package/dist/e2e.test.d.ts +6 -0
  106. package/dist/e2e.test.d.ts.map +1 -0
  107. package/dist/e2e.test.js +376 -0
  108. package/dist/e2e.test.js.map +1 -0
  109. package/dist/index.d.ts +4 -0
  110. package/dist/index.d.ts.map +1 -0
  111. package/dist/index.js +39 -0
  112. package/dist/index.js.map +1 -0
  113. package/dist/integration.test.d.ts +6 -0
  114. package/dist/integration.test.d.ts.map +1 -0
  115. package/dist/integration.test.js +320 -0
  116. package/dist/integration.test.js.map +1 -0
  117. package/dist/utils/diagnostics-exit.d.ts +12 -0
  118. package/dist/utils/diagnostics-exit.d.ts.map +1 -0
  119. package/dist/utils/diagnostics-exit.js +49 -0
  120. package/dist/utils/diagnostics-exit.js.map +1 -0
  121. package/dist/utils/diagnostics-exit.test.d.ts +2 -0
  122. package/dist/utils/diagnostics-exit.test.d.ts.map +1 -0
  123. package/dist/utils/diagnostics-exit.test.js +40 -0
  124. package/dist/utils/diagnostics-exit.test.js.map +1 -0
  125. package/dist/utils/diff-utils.d.ts +4 -0
  126. package/dist/utils/diff-utils.d.ts.map +1 -0
  127. package/dist/utils/diff-utils.js +30 -0
  128. package/dist/utils/diff-utils.js.map +1 -0
  129. package/dist/utils/diff-utils.test.d.ts +2 -0
  130. package/dist/utils/diff-utils.test.d.ts.map +1 -0
  131. package/dist/utils/diff-utils.test.js +30 -0
  132. package/dist/utils/diff-utils.test.js.map +1 -0
  133. package/dist/utils/exit-codes.d.ts +142 -0
  134. package/dist/utils/exit-codes.d.ts.map +1 -0
  135. package/dist/utils/exit-codes.js +168 -0
  136. package/dist/utils/exit-codes.js.map +1 -0
  137. package/dist/utils/package-manager.d.ts +4 -0
  138. package/dist/utils/package-manager.d.ts.map +1 -0
  139. package/dist/utils/package-manager.js +40 -0
  140. package/dist/utils/package-manager.js.map +1 -0
  141. package/dist/utils/pkg.d.ts +3 -0
  142. package/dist/utils/pkg.d.ts.map +1 -0
  143. package/dist/utils/pkg.js +24 -0
  144. package/dist/utils/pkg.js.map +1 -0
  145. package/dist/utils/provider-injector.d.ts +36 -0
  146. package/dist/utils/provider-injector.d.ts.map +1 -0
  147. package/dist/utils/provider-injector.js +223 -0
  148. package/dist/utils/provider-injector.js.map +1 -0
  149. package/dist/utils/provider-injector.test.d.ts +2 -0
  150. package/dist/utils/provider-injector.test.d.ts.map +1 -0
  151. package/dist/utils/provider-injector.test.js +67 -0
  152. package/dist/utils/provider-injector.test.js.map +1 -0
  153. package/dist/utils/scaffold.d.ts +20 -0
  154. package/dist/utils/scaffold.d.ts.map +1 -0
  155. package/dist/utils/scaffold.js +197 -0
  156. package/dist/utils/scaffold.js.map +1 -0
  157. package/package.json +35 -0
  158. package/src/commands/audit.ts +234 -0
  159. package/src/commands/backup.ts +96 -0
  160. package/src/commands/check.ts +191 -0
  161. package/src/commands/config.ts +263 -0
  162. package/src/commands/debug-patterns.test.ts +134 -0
  163. package/src/commands/debug-patterns.ts +257 -0
  164. package/src/commands/diagnose.ts +136 -0
  165. package/src/commands/init.test.ts +82 -0
  166. package/src/commands/init.ts +536 -0
  167. package/src/commands/install-hooks.ts +66 -0
  168. package/src/commands/preflight.test.ts +139 -0
  169. package/src/commands/preflight.ts +488 -0
  170. package/src/commands/rename.ts +264 -0
  171. package/src/commands/scaffold-adapter.test.ts +110 -0
  172. package/src/commands/scaffold-adapter.ts +250 -0
  173. package/src/commands/scan.ts +125 -0
  174. package/src/commands/sync-seed.test.ts +116 -0
  175. package/src/commands/sync.ts +736 -0
  176. package/src/commands/transform.ts +151 -0
  177. package/src/commands/translate/README.md +75 -0
  178. package/src/commands/translate/csv-handler.ts +301 -0
  179. package/src/commands/translate/executor.ts +188 -0
  180. package/src/commands/translate/index.ts +220 -0
  181. package/src/commands/translate/reporter.ts +138 -0
  182. package/src/commands/translate/types.ts +56 -0
  183. package/src/commands/translate.test.ts +173 -0
  184. package/src/commands/translate.ts +6 -0
  185. package/src/e2e.test.ts +479 -0
  186. package/src/fixtures/README.md +61 -0
  187. package/src/fixtures/basic-react/i18n.config.json +15 -0
  188. package/src/fixtures/basic-react/locales/de.json +8 -0
  189. package/src/fixtures/basic-react/locales/en.json +8 -0
  190. package/src/fixtures/basic-react/locales/fr.json +8 -0
  191. package/src/fixtures/basic-react/src/App.tsx +15 -0
  192. package/src/fixtures/basic-react/src/Messages.tsx +12 -0
  193. package/src/fixtures/nested-locales/i18n.config.json +9 -0
  194. package/src/fixtures/nested-locales/locales/en.json +23 -0
  195. package/src/fixtures/nested-locales/locales/fr.json +23 -0
  196. package/src/fixtures/nested-locales/src/HomePage.tsx +13 -0
  197. package/src/fixtures/suspicious-keys/i18n.config.json +9 -0
  198. package/src/fixtures/suspicious-keys/locales/en.json +11 -0
  199. package/src/fixtures/suspicious-keys/locales/fr.json +11 -0
  200. package/src/fixtures/suspicious-keys/src/BadKeys.tsx +19 -0
  201. package/src/index.ts +43 -0
  202. package/src/integration.test.ts +438 -0
  203. package/src/utils/diagnostics-exit.test.ts +47 -0
  204. package/src/utils/diagnostics-exit.ts +63 -0
  205. package/src/utils/diff-utils.test.ts +36 -0
  206. package/src/utils/diff-utils.ts +42 -0
  207. package/src/utils/exit-codes.ts +201 -0
  208. package/src/utils/package-manager.ts +44 -0
  209. package/src/utils/pkg.ts +23 -0
  210. package/src/utils/provider-injector.test.ts +79 -0
  211. package/src/utils/provider-injector.ts +315 -0
  212. package/src/utils/scaffold.ts +240 -0
  213. package/tsconfig.json +17 -0
@@ -0,0 +1,96 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import inquirer from 'inquirer';
4
+ import { listBackups, restoreBackup } from '@i18nsmith/core';
5
+
6
+ /**
7
+ * Registers backup-related commands (backup-list, backup-restore)
8
+ */
9
+ export function registerBackup(program: Command): void {
10
+ program
11
+ .command('backup-list')
12
+ .description('List available locale file backups')
13
+ .option('--backup-dir <path>', 'Custom backup directory (default: .i18nsmith-backup)')
14
+ .action(async (options: { backupDir?: string }) => {
15
+ try {
16
+ const workspaceRoot = process.cwd();
17
+ const backups = await listBackups(workspaceRoot, { backupDir: options.backupDir });
18
+
19
+ if (backups.length === 0) {
20
+ console.log(chalk.yellow('No backups found.'));
21
+ console.log(chalk.gray('Backups are created automatically when using --write --prune'));
22
+ return;
23
+ }
24
+
25
+ console.log(chalk.blue(`Found ${backups.length} backup(s):\n`));
26
+
27
+ for (const backup of backups) {
28
+ const date = new Date(backup.createdAt);
29
+ const formattedDate = date.toLocaleString();
30
+ console.log(` ${chalk.cyan(backup.timestamp)} ${formattedDate} (${backup.fileCount} files)`);
31
+ }
32
+
33
+ console.log(chalk.gray(`\nRestore a backup with: i18nsmith backup-restore <timestamp>`));
34
+ } catch (err) {
35
+ console.error(chalk.red('Error listing backups:'), err instanceof Error ? err.message : err);
36
+ process.exitCode = 1;
37
+ }
38
+ });
39
+
40
+ program
41
+ .command('backup-restore')
42
+ .description('Restore locale files from a previous backup')
43
+ .argument('<timestamp>', 'Backup timestamp (from backup-list) or "latest" for most recent')
44
+ .option('--backup-dir <path>', 'Custom backup directory (default: .i18nsmith-backup)')
45
+ .action(async (timestamp: string, options: { backupDir?: string }) => {
46
+ try {
47
+ const workspaceRoot = process.cwd();
48
+ const backups = await listBackups(workspaceRoot, { backupDir: options.backupDir });
49
+
50
+ if (backups.length === 0) {
51
+ console.error(chalk.red('No backups found.'));
52
+ process.exitCode = 1;
53
+ return;
54
+ }
55
+
56
+ let targetBackup = timestamp === 'latest'
57
+ ? backups[0]
58
+ : backups.find((b) => b.timestamp === timestamp);
59
+
60
+ if (!targetBackup) {
61
+ console.error(chalk.red(`Backup not found: ${timestamp}`));
62
+ console.log(chalk.gray('Available backups:'));
63
+ for (const b of backups.slice(0, 5)) {
64
+ console.log(` ${b.timestamp}`);
65
+ }
66
+ process.exitCode = 1;
67
+ return;
68
+ }
69
+
70
+ // Confirm restore
71
+ const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([
72
+ {
73
+ type: 'confirm',
74
+ name: 'confirmed',
75
+ message: `Restore ${targetBackup.fileCount} locale files from backup ${targetBackup.timestamp}? This will overwrite current locale files.`,
76
+ default: false,
77
+ },
78
+ ]);
79
+
80
+ if (!confirmed) {
81
+ console.log(chalk.yellow('Restore cancelled.'));
82
+ return;
83
+ }
84
+
85
+ const result = await restoreBackup(targetBackup.path, workspaceRoot);
86
+
87
+ console.log(chalk.green(`\n✅ ${result.summary}`));
88
+ for (const file of result.restored) {
89
+ console.log(chalk.gray(` Restored: ${file}`));
90
+ }
91
+ } catch (err) {
92
+ console.error(chalk.red('Error restoring backup:'), err instanceof Error ? err.message : err);
93
+ process.exitCode = 1;
94
+ }
95
+ });
96
+ }
@@ -0,0 +1,191 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import type { Command } from 'commander';
5
+ import { loadConfigWithMeta, CheckRunner } from '@i18nsmith/core';
6
+ import type { CheckSummary } from '@i18nsmith/core';
7
+ import { printLocaleDiffs } from '../utils/diff-utils.js';
8
+ import { getDiagnosisExitSignal } from '../utils/diagnostics-exit.js';
9
+ import { CHECK_EXIT_CODES } from '../utils/exit-codes.js';
10
+
11
+ interface CheckCommandOptions {
12
+ config?: string;
13
+ json?: boolean;
14
+ target?: string[];
15
+ report?: string;
16
+ listFiles?: boolean;
17
+ include?: string[];
18
+ exclude?: string[];
19
+ failOn?: 'none' | 'conflicts' | 'warnings';
20
+ assume?: string[];
21
+ assumeGlobs?: string[];
22
+ validateInterpolations?: boolean;
23
+ emptyValues?: boolean;
24
+ diff?: boolean;
25
+ invalidateCache?: boolean;
26
+ preferDiagnosticsExit?: boolean;
27
+ }
28
+
29
+ const collectAssumedKeys = (value: string, previous: string[]) => {
30
+ const tokens = value
31
+ .split(',')
32
+ .map((token) => token.trim())
33
+ .filter(Boolean);
34
+ return [...previous, ...tokens];
35
+ };
36
+
37
+ const collectTargetPatterns = (value: string | string[], previous: string[]) => {
38
+ const list = Array.isArray(value) ? value : [value];
39
+ const tokens = list
40
+ .flatMap((entry) => entry.split(','))
41
+ .map((token) => token.trim())
42
+ .filter(Boolean);
43
+ return [...previous, ...tokens];
44
+ };
45
+
46
+ function printCheckSummary(summary: CheckSummary) {
47
+ const report = summary.diagnostics;
48
+ console.log(chalk.green(`Locales directory: ${report.localesDir}`));
49
+ console.log(
50
+ chalk.gray(
51
+ `Detected locales: ${report.detectedLocales.length ? report.detectedLocales.join(', ') : 'none'}`
52
+ )
53
+ );
54
+ console.log(
55
+ chalk.gray(
56
+ `Runtime packages: ${report.runtimePackages.length ? report.runtimePackages.map((pkg) => pkg.name).join(', ') : 'none'}`
57
+ )
58
+ );
59
+ console.log(
60
+ chalk.gray(
61
+ `Translation references scanned: ${summary.sync.references.length} across ${summary.sync.filesScanned} file${summary.sync.filesScanned === 1 ? '' : 's'}`
62
+ )
63
+ );
64
+
65
+ if (summary.actionableItems.length) {
66
+ console.log(chalk.blue('\nActionable items'));
67
+ summary.actionableItems.slice(0, 25).forEach((item) => {
68
+ const label = formatSeverityLabel(item.severity);
69
+ console.log(` • [${label}] ${item.message}`);
70
+ });
71
+ if (summary.actionableItems.length > 25) {
72
+ console.log(chalk.gray(` ...and ${summary.actionableItems.length - 25} more.`));
73
+ }
74
+ } else {
75
+ console.log(chalk.green('\nNo actionable issues detected.'));
76
+ }
77
+
78
+ if (summary.suggestedCommands.length) {
79
+ console.log(chalk.blue('\nSuggested commands'));
80
+ summary.suggestedCommands.forEach((suggestion) => {
81
+ const label = formatSeverityLabel(suggestion.severity);
82
+ console.log(` • [${label}] ${suggestion.label}`);
83
+ console.log(` ${chalk.cyan(suggestion.command)}`);
84
+ console.log(chalk.gray(` ${suggestion.reason}`));
85
+ });
86
+ } else {
87
+ console.log(chalk.gray('\nNo automated suggestions—review actionable items above.'));
88
+ }
89
+ }
90
+
91
+ function formatSeverityLabel(severity: 'info' | 'warn' | 'error'): string {
92
+ if (severity === 'error') {
93
+ return chalk.red('ERROR');
94
+ }
95
+ if (severity === 'warn') {
96
+ return chalk.yellow('WARN');
97
+ }
98
+ return chalk.cyan('INFO');
99
+ }
100
+
101
+ export function registerCheck(program: Command) {
102
+ program
103
+ .command('check')
104
+ .description('Run diagnostics plus a sync dry-run for a consolidated health report')
105
+ .option('-c, --config <path>', 'Path to i18nsmith config file', 'i18n.config.json')
106
+ .option('--json', 'Print raw JSON results', false)
107
+ .option('--report <path>', 'Write JSON summary to a file (for CI or editors)')
108
+ .option('--fail-on <level>', 'Failure threshold: none | conflicts | warnings', 'conflicts')
109
+ .option('--assume <keys...>', 'List of runtime keys to assume (comma-separated)', collectAssumedKeys, [])
110
+ .option('--assume-globs <patterns...>', 'Glob patterns for dynamic key namespaces (e.g., errors.*, navigation.**)', collectTargetPatterns, [])
111
+ .option('--validate-interpolations', 'Validate interpolation placeholders across locales', false)
112
+ .option('--no-empty-values', 'Treat empty or placeholder locale values as failures')
113
+ .option('--diff', 'Include locale diff previews for missing/unused key fixes', false)
114
+ .option('--invalidate-cache', 'Ignore cached sync analysis and rescan all source files', false)
115
+ .option('--target <pattern...>', 'Limit translation reference scanning to specific files or patterns', collectTargetPatterns, [])
116
+ .option('--prefer-diagnostics-exit', 'Prefer diagnostics exit codes when --fail-on=conflicts and blocking conflicts exist', false)
117
+ .action(async (options: CheckCommandOptions) => {
118
+ console.log(chalk.blue('Running guided repository health check...'));
119
+ try {
120
+ const { config, projectRoot, configPath } = await loadConfigWithMeta(options.config);
121
+
122
+ // Inform user if config was found in a parent directory
123
+ const cwd = process.cwd();
124
+ if (projectRoot !== cwd) {
125
+ console.log(chalk.gray(`Config found at ${path.relative(cwd, configPath)}`));
126
+ console.log(chalk.gray(`Using project root: ${projectRoot}\n`));
127
+ }
128
+
129
+ // Merge --assume-globs with config
130
+ if (options.assumeGlobs?.length) {
131
+ config.sync = config.sync ?? {};
132
+ config.sync.dynamicKeyGlobs = [
133
+ ...(config.sync.dynamicKeyGlobs ?? []),
134
+ ...options.assumeGlobs,
135
+ ];
136
+ }
137
+ const runner = new CheckRunner(config, { workspaceRoot: projectRoot });
138
+ const summary = await runner.run({
139
+ assumedKeys: options.assume,
140
+ validateInterpolations: options.validateInterpolations,
141
+ emptyValuePolicy: options.emptyValues === false ? 'fail' : undefined,
142
+ diff: options.diff,
143
+ targets: options.target,
144
+ invalidateCache: options.invalidateCache,
145
+ });
146
+
147
+ if (options.report) {
148
+ const outputPath = path.resolve(process.cwd(), options.report);
149
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
150
+ await fs.writeFile(outputPath, JSON.stringify(summary, null, 2));
151
+ console.log(chalk.green(`Health report written to ${outputPath}`));
152
+ }
153
+
154
+ if (options.json) {
155
+ console.log(JSON.stringify(summary, null, 2));
156
+ } else {
157
+ printCheckSummary(summary);
158
+ if (options.diff) {
159
+ printLocaleDiffs(summary.sync.diffs);
160
+ }
161
+ }
162
+
163
+ // If diagnostics discovered blocking conflicts, prefer the diagnostics' exit
164
+ // signal so CI can branch on specific failure modes (missing source locale,
165
+ // invalid JSON, etc.). This mirrors `i18nsmith diagnose` behavior.
166
+ // Only when --prefer-diagnostics-exit is true and --fail-on=conflicts.
167
+ const diagExit = getDiagnosisExitSignal(summary.diagnostics);
168
+ if (diagExit && options.preferDiagnosticsExit && options.failOn === 'conflicts') {
169
+ console.error(chalk.red(`\nBlocking diagnostic conflict detected: ${diagExit.reason}`));
170
+ console.error(chalk.red(`Exit code ${diagExit.code}`));
171
+ process.exitCode = diagExit.code;
172
+ return;
173
+ }
174
+
175
+ const failMode = (options.failOn ?? 'conflicts').toLowerCase();
176
+ const hasErrors = summary.actionableItems.some((item) => item.severity === 'error');
177
+ const hasWarnings = summary.actionableItems.some((item) => item.severity === 'warn');
178
+
179
+ if (failMode === 'conflicts' && hasErrors) {
180
+ console.error(chalk.red('\nBlocking issues detected. Resolve the actionable errors above.'));
181
+ process.exitCode = CHECK_EXIT_CODES.CONFLICTS;
182
+ } else if (failMode === 'warnings' && (hasErrors || hasWarnings)) {
183
+ console.error(chalk.red('\nWarnings detected. Use --fail-on conflicts to limit failures to blocking issues.'));
184
+ process.exitCode = CHECK_EXIT_CODES.WARNINGS;
185
+ }
186
+ } catch (error) {
187
+ console.error(chalk.red('Check failed:'), (error as Error).message);
188
+ process.exitCode = 1;
189
+ }
190
+ });
191
+ }
@@ -0,0 +1,263 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import type { Command } from 'commander';
5
+ import { loadConfigWithMeta, DEFAULT_CONFIG_FILENAME } from '@i18nsmith/core';
6
+
7
+ interface ConfigCommandOptions {
8
+ config?: string;
9
+ json?: boolean;
10
+ }
11
+
12
+ interface ConfigSetOptions extends ConfigCommandOptions {
13
+ key: string;
14
+ value: string;
15
+ }
16
+
17
+ interface ConfigGetOptions extends ConfigCommandOptions {
18
+ key: string;
19
+ }
20
+
21
+ /**
22
+ * Parse a dot-notation key path into segments.
23
+ * e.g., 'translationAdapter.module' => ['translationAdapter', 'module']
24
+ */
25
+ function parseKeyPath(keyPath: string): string[] {
26
+ return keyPath.split('.').filter(Boolean);
27
+ }
28
+
29
+ /**
30
+ * Get a nested value from an object using a key path.
31
+ */
32
+ function getNestedValue(obj: Record<string, unknown>, keyPath: string[]): unknown {
33
+ let current: unknown = obj;
34
+ for (const key of keyPath) {
35
+ if (current === null || current === undefined || typeof current !== 'object') {
36
+ return undefined;
37
+ }
38
+ current = (current as Record<string, unknown>)[key];
39
+ }
40
+ return current;
41
+ }
42
+
43
+ /**
44
+ * Set a nested value in an object using a key path.
45
+ * Creates intermediate objects as needed.
46
+ */
47
+ function setNestedValue(obj: Record<string, unknown>, keyPath: string[], value: unknown): void {
48
+ let current = obj;
49
+ for (let i = 0; i < keyPath.length - 1; i++) {
50
+ const key = keyPath[i];
51
+ if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) {
52
+ current[key] = {};
53
+ }
54
+ current = current[key] as Record<string, unknown>;
55
+ }
56
+ const finalKey = keyPath[keyPath.length - 1];
57
+ current[finalKey] = value;
58
+ }
59
+
60
+ /**
61
+ * Parse a value string into an appropriate type.
62
+ * Handles booleans, numbers, arrays, and strings.
63
+ */
64
+ function parseValue(valueStr: string): unknown {
65
+ // Boolean
66
+ if (valueStr === 'true') return true;
67
+ if (valueStr === 'false') return false;
68
+
69
+ // Null
70
+ if (valueStr === 'null') return null;
71
+
72
+ // Number
73
+ if (/^-?\d+(\.\d+)?$/.test(valueStr)) {
74
+ return parseFloat(valueStr);
75
+ }
76
+
77
+ // JSON array or object
78
+ if ((valueStr.startsWith('[') && valueStr.endsWith(']')) ||
79
+ (valueStr.startsWith('{') && valueStr.endsWith('}'))) {
80
+ try {
81
+ return JSON.parse(valueStr);
82
+ } catch {
83
+ // Fall through to string
84
+ }
85
+ }
86
+
87
+ // Remove surrounding quotes if present
88
+ if ((valueStr.startsWith('"') && valueStr.endsWith('"')) ||
89
+ (valueStr.startsWith("'") && valueStr.endsWith("'"))) {
90
+ return valueStr.slice(1, -1);
91
+ }
92
+
93
+ return valueStr;
94
+ }
95
+
96
+ /**
97
+ * Read the raw config file content (unparsed JSON with comments preserved formatting).
98
+ */
99
+ async function readRawConfig(configPath: string): Promise<{ content: string; parsed: Record<string, unknown> }> {
100
+ const content = await fs.readFile(configPath, 'utf-8');
101
+ const parsed = JSON.parse(content);
102
+ return { content, parsed };
103
+ }
104
+
105
+ /**
106
+ * Write config back to file with nice formatting.
107
+ */
108
+ async function writeConfig(configPath: string, config: Record<string, unknown>): Promise<void> {
109
+ const content = JSON.stringify(config, null, 2);
110
+ await fs.writeFile(configPath, content, 'utf-8');
111
+ }
112
+
113
+ export function registerConfig(program: Command) {
114
+ const configCmd = program
115
+ .command('config')
116
+ .description('View or modify i18nsmith configuration');
117
+
118
+ // Subcommand: config get <key>
119
+ configCmd
120
+ .command('get <key>')
121
+ .description('Get a configuration value by key path (e.g., translationAdapter.module)')
122
+ .option('-c, --config <path>', 'Path to i18nsmith config file', DEFAULT_CONFIG_FILENAME)
123
+ .option('--json', 'Output as JSON', false)
124
+ .action(async (key: string, options: ConfigGetOptions) => {
125
+ try {
126
+ const { config, configPath } = await loadConfigWithMeta(options.config);
127
+ const keyPath = parseKeyPath(key);
128
+ const value = getNestedValue(config as unknown as Record<string, unknown>, keyPath);
129
+
130
+ if (value === undefined) {
131
+ console.log(chalk.yellow(`Key "${key}" not found in config`));
132
+ process.exitCode = 1;
133
+ return;
134
+ }
135
+
136
+ if (options.json) {
137
+ console.log(JSON.stringify({ key, value }, null, 2));
138
+ } else {
139
+ if (typeof value === 'object' && value !== null) {
140
+ console.log(JSON.stringify(value, null, 2));
141
+ } else {
142
+ console.log(String(value));
143
+ }
144
+ }
145
+ } catch (error) {
146
+ console.error(chalk.red('Failed to get config:'), (error as Error).message);
147
+ process.exitCode = 1;
148
+ }
149
+ });
150
+
151
+ // Subcommand: config set <key> <value>
152
+ configCmd
153
+ .command('set <key> <value>')
154
+ .description('Set a configuration value by key path (e.g., translationAdapter.module "src/i18n.ts")')
155
+ .option('-c, --config <path>', 'Path to i18nsmith config file', DEFAULT_CONFIG_FILENAME)
156
+ .option('--json', 'Output result as JSON', false)
157
+ .action(async (key: string, value: string, options: ConfigSetOptions) => {
158
+ try {
159
+ const { configPath } = await loadConfigWithMeta(options.config);
160
+ const { parsed } = await readRawConfig(configPath);
161
+
162
+ const keyPath = parseKeyPath(key);
163
+ const parsedValue = parseValue(value);
164
+
165
+ setNestedValue(parsed, keyPath, parsedValue);
166
+
167
+ await writeConfig(configPath, parsed);
168
+
169
+ if (options.json) {
170
+ console.log(JSON.stringify({ key, value: parsedValue, configPath }, null, 2));
171
+ } else {
172
+ console.log(chalk.green(`✓ Set ${key} = ${JSON.stringify(parsedValue)}`));
173
+ console.log(chalk.dim(` Updated: ${configPath}`));
174
+ }
175
+ } catch (error) {
176
+ console.error(chalk.red('Failed to set config:'), (error as Error).message);
177
+ process.exitCode = 1;
178
+ }
179
+ });
180
+
181
+ // Subcommand: config list
182
+ configCmd
183
+ .command('list')
184
+ .description('List all configuration values')
185
+ .option('-c, --config <path>', 'Path to i18nsmith config file', DEFAULT_CONFIG_FILENAME)
186
+ .option('--json', 'Output as JSON', false)
187
+ .action(async (options: ConfigCommandOptions) => {
188
+ try {
189
+ const { config, configPath } = await loadConfigWithMeta(options.config);
190
+
191
+ if (options.json) {
192
+ console.log(JSON.stringify(config, null, 2));
193
+ } else {
194
+ console.log(chalk.blue(`Configuration from: ${configPath}`));
195
+ console.log();
196
+ console.log(JSON.stringify(config, null, 2));
197
+ }
198
+ } catch (error) {
199
+ console.error(chalk.red('Failed to read config:'), (error as Error).message);
200
+ process.exitCode = 1;
201
+ }
202
+ });
203
+
204
+ // Subcommand: config path
205
+ configCmd
206
+ .command('path')
207
+ .description('Print the path to the active config file')
208
+ .option('-c, --config <path>', 'Path to i18nsmith config file', DEFAULT_CONFIG_FILENAME)
209
+ .action(async (options: ConfigCommandOptions) => {
210
+ try {
211
+ const { configPath } = await loadConfigWithMeta(options.config);
212
+ console.log(configPath);
213
+ } catch (error) {
214
+ console.error(chalk.red('Failed to find config:'), (error as Error).message);
215
+ process.exitCode = 1;
216
+ }
217
+ });
218
+
219
+ // Subcommand: config init-adapter <path>
220
+ configCmd
221
+ .command('init-adapter <adapterPath>')
222
+ .description('Configure a translation adapter module in the config file')
223
+ .option('-c, --config <path>', 'Path to i18nsmith config file', DEFAULT_CONFIG_FILENAME)
224
+ .option('--hook <name>', 'Name of the translation hook (default: useTranslation)', 'useTranslation')
225
+ .option('--json', 'Output result as JSON', false)
226
+ .action(async (adapterPath: string, options: { config?: string; hook: string; json?: boolean }) => {
227
+ try {
228
+ const { configPath } = await loadConfigWithMeta(options.config);
229
+ const { parsed } = await readRawConfig(configPath);
230
+
231
+ // Resolve relative path
232
+ const projectRoot = path.dirname(configPath);
233
+ const relativePath = path.isAbsolute(adapterPath)
234
+ ? path.relative(projectRoot, adapterPath)
235
+ : adapterPath;
236
+
237
+ // Update translationAdapter
238
+ if (!parsed.translationAdapter || typeof parsed.translationAdapter !== 'object') {
239
+ parsed.translationAdapter = {};
240
+ }
241
+ const adapter = parsed.translationAdapter as Record<string, unknown>;
242
+ adapter.module = relativePath;
243
+ adapter.hookName = options.hook;
244
+
245
+ await writeConfig(configPath, parsed);
246
+
247
+ if (options.json) {
248
+ console.log(JSON.stringify({
249
+ translationAdapter: parsed.translationAdapter,
250
+ configPath,
251
+ }, null, 2));
252
+ } else {
253
+ console.log(chalk.green('✓ Translation adapter configured:'));
254
+ console.log(chalk.dim(` module: ${relativePath}`));
255
+ console.log(chalk.dim(` hookName: ${options.hook}`));
256
+ console.log(chalk.dim(` Updated: ${configPath}`));
257
+ }
258
+ } catch (error) {
259
+ console.error(chalk.red('Failed to configure adapter:'), (error as Error).message);
260
+ process.exitCode = 1;
261
+ }
262
+ });
263
+ }
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { spawnSync } from 'child_process';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+ const CLI_PATH = path.resolve(__dirname, '../../dist/index.js');
11
+
12
+ function runCli(args: string[], options: { cwd?: string } = {}) {
13
+ const result = spawnSync('node', [CLI_PATH, ...args], {
14
+ cwd: options.cwd ?? process.cwd(),
15
+ encoding: 'utf8',
16
+ timeout: 30000,
17
+ env: {
18
+ ...process.env,
19
+ CI: 'true',
20
+ NO_COLOR: '1',
21
+ FORCE_COLOR: '0',
22
+ },
23
+ });
24
+
25
+ return {
26
+ stdout: result.stdout ?? '',
27
+ stderr: result.stderr ?? '',
28
+ output: (result.stdout ?? '') + (result.stderr ?? ''),
29
+ exitCode: result.status ?? 1,
30
+ };
31
+ }
32
+
33
+ describe('debug-patterns command', () => {
34
+ let tmpDir: string;
35
+
36
+ beforeEach(async () => {
37
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18nsmith-debug-patterns-'));
38
+
39
+ // Create a basic config
40
+ const config = {
41
+ sourceLanguage: 'en',
42
+ targetLanguages: ['fr'],
43
+ localesDir: 'locales',
44
+ include: ['src/**/*.tsx', 'src/**/*.ts'],
45
+ exclude: ['**/*.test.ts', '**/node_modules/**'],
46
+ };
47
+ await fs.writeFile(path.join(tmpDir, 'i18n.config.json'), JSON.stringify(config, null, 2));
48
+
49
+ // Create some source files
50
+ await fs.mkdir(path.join(tmpDir, 'src'));
51
+ await fs.writeFile(path.join(tmpDir, 'src', 'App.tsx'), 'export function App() { return <div>Hello</div>; }');
52
+ await fs.writeFile(path.join(tmpDir, 'src', 'utils.ts'), 'export const add = (a: number, b: number) => a + b;');
53
+ await fs.writeFile(path.join(tmpDir, 'src', 'utils.test.ts'), 'test("add", () => expect(add(1, 2)).toBe(3));');
54
+
55
+ // Create locales directory
56
+ await fs.mkdir(path.join(tmpDir, 'locales'));
57
+ await fs.writeFile(path.join(tmpDir, 'locales', 'en.json'), '{}');
58
+ });
59
+
60
+ afterEach(async () => {
61
+ await fs.rm(tmpDir, { recursive: true, force: true });
62
+ });
63
+
64
+ it('should show include patterns with file counts', () => {
65
+ const result = runCli(['debug-patterns'], { cwd: tmpDir });
66
+
67
+ expect(result.exitCode).toBe(0);
68
+ expect(result.output).toContain('Include Patterns');
69
+ expect(result.output).toContain('src/**/*.tsx');
70
+ expect(result.output).toContain('src/**/*.ts');
71
+ });
72
+
73
+ it('should show exclude patterns', () => {
74
+ const result = runCli(['debug-patterns'], { cwd: tmpDir });
75
+
76
+ expect(result.output).toContain('Exclude Patterns');
77
+ expect(result.output).toContain('**/*.test.ts');
78
+ });
79
+
80
+ it('should show summary with effective file count', () => {
81
+ const result = runCli(['debug-patterns'], { cwd: tmpDir });
82
+
83
+ expect(result.output).toContain('Summary');
84
+ expect(result.output).toContain('Effective files to scan');
85
+ });
86
+
87
+ it('should list files in verbose mode', () => {
88
+ const result = runCli(['debug-patterns', '--verbose'], { cwd: tmpDir });
89
+
90
+ expect(result.output).toContain('App.tsx');
91
+ expect(result.output).toContain('utils.ts');
92
+ });
93
+
94
+ it('should output JSON when --json flag is used', () => {
95
+ const result = runCli(['debug-patterns', '--json'], { cwd: tmpDir });
96
+
97
+ // Extract JSON from output
98
+ const jsonMatch = result.stdout.match(/\{[\s\S]*\}/);
99
+ expect(jsonMatch).not.toBeNull();
100
+
101
+ const parsed = JSON.parse(jsonMatch![0]);
102
+ expect(parsed).toHaveProperty('includePatterns');
103
+ expect(parsed).toHaveProperty('excludePatterns');
104
+ expect(parsed).toHaveProperty('effectiveFiles');
105
+ expect(Array.isArray(parsed.effectiveFiles)).toBe(true);
106
+ });
107
+
108
+ it('should detect unmatched patterns and suggest fixes', async () => {
109
+ // Update config with a pattern that won't match
110
+ const config = {
111
+ sourceLanguage: 'en',
112
+ targetLanguages: ['fr'],
113
+ localesDir: 'locales',
114
+ include: ['app/**/*.tsx', 'src/**/*.tsx'], // app/ doesn't exist
115
+ exclude: ['**/node_modules/**'],
116
+ };
117
+ await fs.writeFile(path.join(tmpDir, 'i18n.config.json'), JSON.stringify(config, null, 2));
118
+
119
+ const result = runCli(['debug-patterns'], { cwd: tmpDir });
120
+
121
+ // Should show the pattern that matched 0 files
122
+ expect(result.output).toContain('app/**/*.tsx');
123
+ expect(result.output).toContain('0 file(s)');
124
+ });
125
+
126
+ it('should handle missing config gracefully', async () => {
127
+ await fs.rm(path.join(tmpDir, 'i18n.config.json'));
128
+
129
+ const result = runCli(['debug-patterns'], { cwd: tmpDir });
130
+
131
+ expect(result.exitCode).toBe(1);
132
+ expect(result.output).toContain('failed');
133
+ });
134
+ });