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,438 @@
1
+ /**
2
+ * Integration tests for CLI commands
3
+ * These tests run the actual CLI commands against real file systems
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7
+ import fs from 'fs/promises';
8
+ import path from 'path';
9
+ import os from 'os';
10
+ import { spawnSync } from 'child_process';
11
+ import { fileURLToPath } from 'url';
12
+
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = path.dirname(__filename);
15
+
16
+ // Get the correct CLI path regardless of where tests are run from
17
+ const CLI_PATH = path.resolve(__dirname, '../dist/index.js');
18
+
19
+ // Helper to run CLI commands
20
+ function runCli(
21
+ args: string[],
22
+ options: { cwd?: string } = {}
23
+ ): { stdout: string; stderr: string; output: string; exitCode: number } {
24
+ const result = spawnSync('node', [CLI_PATH, ...args], {
25
+ cwd: options.cwd ?? process.cwd(),
26
+ encoding: 'utf8',
27
+ timeout: 30000,
28
+ env: {
29
+ ...process.env,
30
+ CI: 'true',
31
+ NO_COLOR: '1',
32
+ FORCE_COLOR: '0',
33
+ },
34
+ });
35
+
36
+ const stdout = result.stdout ?? '';
37
+ const stderr = result.stderr ?? '';
38
+
39
+ return {
40
+ stdout,
41
+ stderr,
42
+ output: stdout + stderr,
43
+ exitCode: result.status ?? 1,
44
+ };
45
+ }
46
+
47
+ // Helper to extract JSON from CLI output (may contain log messages before JSON)
48
+ function extractJson<T>(output: string): T {
49
+ const jsonMatch = output.match(/\{[\s\S]*\}/);
50
+ if (!jsonMatch) {
51
+ throw new Error(`No JSON found in output: ${output.slice(0, 200)}...`);
52
+ }
53
+ return JSON.parse(jsonMatch[0]);
54
+ }
55
+
56
+ describe('CLI Integration Tests', () => {
57
+ let tmpDir: string;
58
+
59
+ beforeEach(async () => {
60
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18nsmith-cli-integration-'));
61
+ });
62
+
63
+ afterEach(async () => {
64
+ await fs.rm(tmpDir, { recursive: true, force: true });
65
+ });
66
+
67
+ describe('preflight command', () => {
68
+ it('should fail when no config file exists', async () => {
69
+ const result = runCli(['preflight'], { cwd: tmpDir });
70
+
71
+ expect(result.exitCode).toBe(1);
72
+ expect(result.output).toContain('Config File');
73
+ });
74
+
75
+ it('should pass with valid config and locales', async () => {
76
+ const config = {
77
+ sourceLanguage: 'en',
78
+ targetLanguages: ['fr'],
79
+ localesDir: 'locales',
80
+ include: ['src/**/*.tsx'],
81
+ };
82
+ await fs.writeFile(path.join(tmpDir, 'i18n.config.json'), JSON.stringify(config, null, 2));
83
+ await fs.mkdir(path.join(tmpDir, 'locales'));
84
+ await fs.writeFile(path.join(tmpDir, 'locales', 'en.json'), '{}');
85
+ await fs.mkdir(path.join(tmpDir, 'src'));
86
+ await fs.writeFile(path.join(tmpDir, 'src', 'App.tsx'), 'export function App() { return <div>Hello</div>; }');
87
+
88
+ const result = runCli(['preflight'], { cwd: tmpDir });
89
+
90
+ expect(result.output).toContain('Config File');
91
+ expect(result.output).toContain('pass');
92
+ });
93
+
94
+ it('should output JSON when --json flag is used', async () => {
95
+ const config = {
96
+ sourceLanguage: 'en',
97
+ targetLanguages: ['fr'],
98
+ localesDir: 'locales',
99
+ include: ['src/**/*.tsx'],
100
+ };
101
+ await fs.writeFile(path.join(tmpDir, 'i18n.config.json'), JSON.stringify(config, null, 2));
102
+ await fs.mkdir(path.join(tmpDir, 'locales'));
103
+ await fs.writeFile(path.join(tmpDir, 'locales', 'en.json'), '{}');
104
+ await fs.mkdir(path.join(tmpDir, 'src'));
105
+ await fs.writeFile(path.join(tmpDir, 'src', 'App.tsx'), 'export function App() {}');
106
+
107
+ const result = runCli(['preflight', '--json'], { cwd: tmpDir });
108
+
109
+ const parsed = JSON.parse(result.stdout);
110
+ expect(parsed).toHaveProperty('passed');
111
+ expect(parsed).toHaveProperty('checks');
112
+ expect(Array.isArray(parsed.checks)).toBe(true);
113
+ });
114
+
115
+ it('should create missing directories with --fix', async () => {
116
+ const config = {
117
+ sourceLanguage: 'en',
118
+ targetLanguages: ['fr'],
119
+ localesDir: 'i18n/locales',
120
+ include: ['src/**/*.tsx'],
121
+ };
122
+ await fs.writeFile(path.join(tmpDir, 'i18n.config.json'), JSON.stringify(config, null, 2));
123
+ await fs.mkdir(path.join(tmpDir, 'src'));
124
+ await fs.writeFile(path.join(tmpDir, 'src', 'App.tsx'), 'export function App() {}');
125
+
126
+ runCli(['preflight', '--fix'], { cwd: tmpDir });
127
+
128
+ const localesDirExists = await fs.access(path.join(tmpDir, 'i18n', 'locales'))
129
+ .then(() => true)
130
+ .catch(() => false);
131
+
132
+ expect(localesDirExists).toBe(true);
133
+ });
134
+ });
135
+
136
+ describe('scan command', () => {
137
+ beforeEach(async () => {
138
+ const config = {
139
+ sourceLanguage: 'en',
140
+ targetLanguages: ['fr', 'de'],
141
+ localesDir: 'locales',
142
+ include: ['src/**/*.tsx'],
143
+ };
144
+ await fs.writeFile(path.join(tmpDir, 'i18n.config.json'), JSON.stringify(config, null, 2));
145
+ await fs.mkdir(path.join(tmpDir, 'locales'));
146
+ await fs.writeFile(path.join(tmpDir, 'locales', 'en.json'), '{}');
147
+ await fs.mkdir(path.join(tmpDir, 'src'), { recursive: true });
148
+ });
149
+
150
+ it('should scan and find translatable strings', async () => {
151
+ await fs.writeFile(
152
+ path.join(tmpDir, 'src', 'App.tsx'),
153
+ `
154
+ import React from 'react';
155
+
156
+ export function App() {
157
+ return (
158
+ <div>
159
+ <h1>Welcome to our app</h1>
160
+ <p>This is a description</p>
161
+ </div>
162
+ );
163
+ }
164
+ `
165
+ );
166
+
167
+ const result = runCli(['scan'], { cwd: tmpDir });
168
+
169
+ expect(result.exitCode).toBe(0);
170
+ expect(result.output).toContain('Scanned');
171
+ });
172
+
173
+ it('should output JSON when --json flag is used', async () => {
174
+ await fs.writeFile(
175
+ path.join(tmpDir, 'src', 'App.tsx'),
176
+ `export function App() { return <div>Hello</div>; }`
177
+ );
178
+
179
+ const result = runCli(['scan', '--json'], { cwd: tmpDir });
180
+ const parsed = extractJson<{ filesScanned: number; candidates: unknown[] }>(result.stdout);
181
+
182
+ expect(parsed).toHaveProperty('filesScanned');
183
+ expect(parsed).toHaveProperty('candidates');
184
+ });
185
+ });
186
+
187
+ describe('sync command', () => {
188
+ beforeEach(async () => {
189
+ const config = {
190
+ sourceLanguage: 'en',
191
+ targetLanguages: ['fr'],
192
+ localesDir: 'locales',
193
+ include: ['src/**/*.tsx'],
194
+ };
195
+ await fs.writeFile(path.join(tmpDir, 'i18n.config.json'), JSON.stringify(config, null, 2));
196
+ await fs.mkdir(path.join(tmpDir, 'locales'));
197
+ await fs.writeFile(
198
+ path.join(tmpDir, 'locales', 'en.json'),
199
+ JSON.stringify({ 'existing.key': 'Existing Value' }, null, 2)
200
+ );
201
+ await fs.writeFile(
202
+ path.join(tmpDir, 'locales', 'fr.json'),
203
+ JSON.stringify({ 'existing.key': 'Valeur existante' }, null, 2)
204
+ );
205
+ await fs.mkdir(path.join(tmpDir, 'src'), { recursive: true });
206
+ });
207
+
208
+ it('should run dry-run by default', async () => {
209
+ await fs.writeFile(
210
+ path.join(tmpDir, 'src', 'App.tsx'),
211
+ `
212
+ import { useTranslation } from 'react-i18next';
213
+
214
+ export function App() {
215
+ const { t } = useTranslation();
216
+ return <div>{t('new.key')}</div>;
217
+ }
218
+ `
219
+ );
220
+
221
+ const result = runCli(['sync'], { cwd: tmpDir });
222
+
223
+ expect(result.output).toContain('DRY RUN');
224
+ });
225
+
226
+ it('should add missing keys with --write', async () => {
227
+ await fs.writeFile(
228
+ path.join(tmpDir, 'src', 'App.tsx'),
229
+ `
230
+ import { useTranslation } from 'react-i18next';
231
+
232
+ export function App() {
233
+ const { t } = useTranslation();
234
+ return <div>{t('new.key')}</div>;
235
+ }
236
+ `
237
+ );
238
+
239
+ const result = runCli(['sync', '--write', '-y'], { cwd: tmpDir });
240
+ expect(result.exitCode).toBe(0);
241
+
242
+ const enContent = await fs.readFile(path.join(tmpDir, 'locales', 'en.json'), 'utf8');
243
+ const enData = JSON.parse(enContent);
244
+ expect(enData).toHaveProperty('new.key');
245
+ });
246
+
247
+ it('should not prune by default even with --write', async () => {
248
+ await fs.writeFile(
249
+ path.join(tmpDir, 'src', 'App.tsx'),
250
+ `export function App() { return <div>Hello</div>; }`
251
+ );
252
+
253
+ runCli(['sync', '--write', '-y'], { cwd: tmpDir });
254
+
255
+ const enContent = await fs.readFile(path.join(tmpDir, 'locales', 'en.json'), 'utf8');
256
+ const enData = JSON.parse(enContent);
257
+ expect(enData).toHaveProperty('existing.key');
258
+ });
259
+
260
+ it('should prune unused keys with --write --prune', async () => {
261
+ await fs.writeFile(
262
+ path.join(tmpDir, 'src', 'App.tsx'),
263
+ `export function App() { return <div>Hello</div>; }`
264
+ );
265
+
266
+ runCli(['sync', '--write', '--prune', '-y'], { cwd: tmpDir });
267
+
268
+ const enContent = await fs.readFile(path.join(tmpDir, 'locales', 'en.json'), 'utf8');
269
+ const enData = JSON.parse(enContent);
270
+ expect(enData).not.toHaveProperty('existing.key');
271
+ });
272
+
273
+ it('should create backup when pruning', async () => {
274
+ await fs.writeFile(
275
+ path.join(tmpDir, 'src', 'App.tsx'),
276
+ `export function App() { return <div>Hello</div>; }`
277
+ );
278
+
279
+ runCli(['sync', '--write', '--prune', '-y'], { cwd: tmpDir });
280
+
281
+ const backupDir = path.join(tmpDir, '.i18nsmith-backup');
282
+ const backupExists = await fs.access(backupDir).then(() => true).catch(() => false);
283
+ expect(backupExists).toBe(true);
284
+ });
285
+
286
+ it('should skip backup with --no-backup', async () => {
287
+ await fs.writeFile(
288
+ path.join(tmpDir, 'src', 'App.tsx'),
289
+ `export function App() { return <div>Hello</div>; }`
290
+ );
291
+
292
+ runCli(['sync', '--write', '--prune', '-y', '--no-backup'], { cwd: tmpDir });
293
+
294
+ const backupDir = path.join(tmpDir, '.i18nsmith-backup');
295
+ const backupExists = await fs.access(backupDir).then(() => true).catch(() => false);
296
+ expect(backupExists).toBe(false);
297
+ });
298
+ });
299
+
300
+ describe('backup commands', () => {
301
+ beforeEach(async () => {
302
+ const config = {
303
+ sourceLanguage: 'en',
304
+ targetLanguages: ['fr'],
305
+ localesDir: 'locales',
306
+ include: ['src/**/*.tsx'],
307
+ };
308
+ await fs.writeFile(path.join(tmpDir, 'i18n.config.json'), JSON.stringify(config, null, 2));
309
+ await fs.mkdir(path.join(tmpDir, 'locales'));
310
+ await fs.writeFile(
311
+ path.join(tmpDir, 'locales', 'en.json'),
312
+ JSON.stringify({ 'original.key': 'Original' }, null, 2)
313
+ );
314
+ await fs.mkdir(path.join(tmpDir, 'src'));
315
+ await fs.writeFile(
316
+ path.join(tmpDir, 'src', 'App.tsx'),
317
+ `export function App() { return <div>Hello</div>; }`
318
+ );
319
+ });
320
+
321
+ it('should list no backups when none exist', async () => {
322
+ const result = runCli(['backup-list'], { cwd: tmpDir });
323
+
324
+ expect(result.exitCode).toBe(0);
325
+ expect(result.output).toContain('No backups found');
326
+ });
327
+
328
+ it('should list backups after sync creates one', async () => {
329
+ runCli(['sync', '--write', '--prune', '-y'], { cwd: tmpDir });
330
+
331
+ const result = runCli(['backup-list'], { cwd: tmpDir });
332
+
333
+ expect(result.exitCode).toBe(0);
334
+ expect(result.output).toContain('Found');
335
+ expect(result.output).toContain('backup');
336
+ });
337
+ });
338
+
339
+ describe('transform command', () => {
340
+ beforeEach(async () => {
341
+ const config = {
342
+ sourceLanguage: 'en',
343
+ targetLanguages: ['fr'],
344
+ localesDir: 'locales',
345
+ include: ['src/**/*.tsx'],
346
+ translationAdapter: {
347
+ module: 'react-i18next',
348
+ hookName: 'useTranslation',
349
+ translationIdentifier: 't',
350
+ },
351
+ };
352
+ await fs.writeFile(path.join(tmpDir, 'i18n.config.json'), JSON.stringify(config, null, 2));
353
+ await fs.mkdir(path.join(tmpDir, 'locales'));
354
+ await fs.writeFile(path.join(tmpDir, 'locales', 'en.json'), '{}');
355
+ await fs.mkdir(path.join(tmpDir, 'src'));
356
+ });
357
+
358
+ it('should run dry-run by default', async () => {
359
+ await fs.writeFile(
360
+ path.join(tmpDir, 'src', 'App.tsx'),
361
+ `export function App() { return <div>Hello World</div>; }`
362
+ );
363
+
364
+ const result = runCli(['transform'], { cwd: tmpDir });
365
+
366
+ expect(result.output).toContain('DRY RUN');
367
+ });
368
+
369
+ it('should output JSON with --json flag', async () => {
370
+ await fs.writeFile(
371
+ path.join(tmpDir, 'src', 'App.tsx'),
372
+ `export function App() { return <div>Hello</div>; }`
373
+ );
374
+
375
+ const result = runCli(['transform', '--json'], { cwd: tmpDir });
376
+ const parsed = extractJson<{ filesScanned: number; candidates: unknown[] }>(result.stdout);
377
+
378
+ expect(parsed).toHaveProperty('filesScanned');
379
+ expect(parsed).toHaveProperty('candidates');
380
+ expect(Array.isArray(parsed.candidates)).toBe(true);
381
+ });
382
+ });
383
+
384
+ describe('check command', () => {
385
+ beforeEach(async () => {
386
+ const config = {
387
+ sourceLanguage: 'en',
388
+ targetLanguages: ['fr'],
389
+ localesDir: 'locales',
390
+ include: ['src/**/*.tsx'],
391
+ };
392
+ await fs.writeFile(path.join(tmpDir, 'i18n.config.json'), JSON.stringify(config, null, 2));
393
+ await fs.mkdir(path.join(tmpDir, 'locales'));
394
+ await fs.writeFile(path.join(tmpDir, 'locales', 'en.json'), JSON.stringify({ 'hello': 'Hello' }, null, 2));
395
+ await fs.writeFile(path.join(tmpDir, 'locales', 'fr.json'), JSON.stringify({ 'hello': 'Bonjour' }, null, 2));
396
+ await fs.mkdir(path.join(tmpDir, 'src'));
397
+ });
398
+
399
+ it('should run health check successfully', async () => {
400
+ await fs.writeFile(
401
+ path.join(tmpDir, 'src', 'App.tsx'),
402
+ `
403
+ import { useTranslation } from 'react-i18next';
404
+
405
+ export function App() {
406
+ const { t } = useTranslation();
407
+ return <div>{t('hello')}</div>;
408
+ }
409
+ `
410
+ );
411
+
412
+ const result = runCli(['check'], { cwd: tmpDir });
413
+
414
+ expect(result.exitCode).toBe(0);
415
+ expect(result.output).toContain('Locales directory');
416
+ });
417
+
418
+ it('should output JSON with --json flag', async () => {
419
+ await fs.writeFile(
420
+ path.join(tmpDir, 'src', 'App.tsx'),
421
+ `
422
+ import { useTranslation } from 'react-i18next';
423
+
424
+ export function App() {
425
+ const { t } = useTranslation();
426
+ return <div>{t('hello')}</div>;
427
+ }
428
+ `
429
+ );
430
+
431
+ const result = runCli(['check', '--json'], { cwd: tmpDir });
432
+ const parsed = extractJson<{ diagnostics: unknown; sync: unknown }>(result.stdout);
433
+
434
+ expect(parsed).toHaveProperty('diagnostics');
435
+ expect(parsed).toHaveProperty('sync');
436
+ });
437
+ });
438
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { DiagnoseConflict } from '@i18nsmith/core';
3
+ import { selectExitSignal } from './diagnostics-exit';
4
+ import { DIAGNOSTICS_EXIT_CODES, DIAGNOSTICS_EXIT_DESCRIPTIONS } from './exit-codes';
5
+
6
+ describe('selectExitSignal', () => {
7
+ it('returns null when there are no conflicts', () => {
8
+ expect(selectExitSignal([])).toBeNull();
9
+ });
10
+
11
+ it('returns the mapped exit code for missing source locale conflicts', () => {
12
+ const conflicts: DiagnoseConflict[] = [
13
+ { kind: 'missing-source-locale', message: 'Source locale missing' },
14
+ ];
15
+
16
+ const result = selectExitSignal(conflicts);
17
+ expect(result).toEqual({
18
+ code: DIAGNOSTICS_EXIT_CODES.MISSING_SOURCE_LOCALE,
19
+ reason: DIAGNOSTICS_EXIT_DESCRIPTIONS[DIAGNOSTICS_EXIT_CODES.MISSING_SOURCE_LOCALE],
20
+ });
21
+ });
22
+
23
+ it('prefers higher severity codes when multiple conflicts exist', () => {
24
+ const conflicts: DiagnoseConflict[] = [
25
+ { kind: 'missing-source-locale', message: 'Source missing' },
26
+ { kind: 'invalid-locale-json', message: 'Invalid JSON detected' },
27
+ ];
28
+
29
+ const result = selectExitSignal(conflicts);
30
+ expect(result).toEqual({
31
+ code: DIAGNOSTICS_EXIT_CODES.INVALID_LOCALE_JSON,
32
+ reason: DIAGNOSTICS_EXIT_DESCRIPTIONS[DIAGNOSTICS_EXIT_CODES.INVALID_LOCALE_JSON],
33
+ });
34
+ });
35
+
36
+ it('falls back to a general conflict exit code for unknown kinds', () => {
37
+ const conflicts: DiagnoseConflict[] = [
38
+ { kind: 'custom-conflict', message: 'Something unexpected happened' },
39
+ ];
40
+
41
+ const result = selectExitSignal(conflicts);
42
+ expect(result).toEqual({
43
+ code: DIAGNOSTICS_EXIT_CODES.GENERAL_CONFLICT,
44
+ reason: 'Something unexpected happened',
45
+ });
46
+ });
47
+ });
@@ -0,0 +1,63 @@
1
+ import type { DiagnoseConflict, DiagnosisReport } from '@i18nsmith/core';
2
+ import { DIAGNOSTICS_EXIT_CODES, DIAGNOSTICS_EXIT_DESCRIPTIONS } from './exit-codes.js';
3
+
4
+ export interface DiagnosisExitSignal {
5
+ code: number;
6
+ reason: string;
7
+ }
8
+
9
+ const CONFLICT_EXIT_SIGNALS: Record<string, DiagnosisExitSignal> = {
10
+ 'missing-source-locale': {
11
+ code: DIAGNOSTICS_EXIT_CODES.MISSING_SOURCE_LOCALE,
12
+ reason: DIAGNOSTICS_EXIT_DESCRIPTIONS[DIAGNOSTICS_EXIT_CODES.MISSING_SOURCE_LOCALE],
13
+ },
14
+ 'invalid-locale-json': {
15
+ code: DIAGNOSTICS_EXIT_CODES.INVALID_LOCALE_JSON,
16
+ reason: DIAGNOSTICS_EXIT_DESCRIPTIONS[DIAGNOSTICS_EXIT_CODES.INVALID_LOCALE_JSON],
17
+ },
18
+ 'unsafe-provider-clash': {
19
+ code: DIAGNOSTICS_EXIT_CODES.UNSAFE_PROVIDER_CLASH,
20
+ reason: DIAGNOSTICS_EXIT_DESCRIPTIONS[DIAGNOSTICS_EXIT_CODES.UNSAFE_PROVIDER_CLASH],
21
+ },
22
+ };
23
+
24
+ export function getDiagnosisExitSignal(report: Pick<DiagnosisReport, 'conflicts'>): DiagnosisExitSignal | null {
25
+ return selectExitSignal(report.conflicts);
26
+ }
27
+
28
+ export function selectExitSignal(conflicts: DiagnoseConflict[]): DiagnosisExitSignal | null {
29
+ if (!conflicts.length) {
30
+ return null;
31
+ }
32
+
33
+ let chosen: DiagnosisExitSignal | null = null;
34
+
35
+ for (const conflict of conflicts) {
36
+ const knownSignal = CONFLICT_EXIT_SIGNALS[conflict.kind];
37
+ if (knownSignal) {
38
+ if (!chosen || knownSignal.code > chosen.code) {
39
+ chosen = knownSignal;
40
+ }
41
+ continue;
42
+ }
43
+
44
+ const fallbackSignal: DiagnosisExitSignal = {
45
+ code: DIAGNOSTICS_EXIT_CODES.GENERAL_CONFLICT,
46
+ reason: conflict.message,
47
+ };
48
+
49
+ if (!chosen || fallbackSignal.code > chosen.code) {
50
+ chosen = fallbackSignal;
51
+ }
52
+ }
53
+
54
+ return chosen;
55
+ }
56
+
57
+ export function describeDiagnosisExitCodes(): Array<{ code: number; reason: string }> {
58
+ const entries = Object.values(CONFLICT_EXIT_SIGNALS).sort((a, b) => a.code - b.code);
59
+ return entries.concat({
60
+ code: DIAGNOSTICS_EXIT_CODES.GENERAL_CONFLICT,
61
+ reason: DIAGNOSTICS_EXIT_DESCRIPTIONS[DIAGNOSTICS_EXIT_CODES.GENERAL_CONFLICT],
62
+ });
63
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import fs from 'fs/promises';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { writeLocaleDiffPatches, printLocaleDiffs } from './diff-utils';
6
+
7
+ describe('diff-utils', () => {
8
+ it('writes patch files for provided diffs', async () => {
9
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'i18nsmith-test-'));
10
+ const diffs = [
11
+ { locale: 'en', path: '/locales/en.json', diff: '--- a\n+++ b\n+"hello": "Hello"', added: ['hello'], updated: [], removed: [] },
12
+ { locale: 'fr', path: '/locales/fr.json', diff: '--- a\n+++ b\n+"hello": "Bonjour"', added: ['hello'], updated: [], removed: [] },
13
+ ];
14
+
15
+ await writeLocaleDiffPatches(diffs as any, tmp);
16
+
17
+ const files = await fs.readdir(tmp);
18
+ expect(files.length).toBe(2);
19
+
20
+ const enContent = await fs.readFile(path.join(tmp, 'en.patch'), 'utf8');
21
+ expect(enContent).toContain('Hello');
22
+
23
+ // cleanup
24
+ await fs.rm(tmp, { recursive: true, force: true });
25
+ });
26
+
27
+ it('prints diffs without throwing', () => {
28
+ const diffs = [
29
+ { locale: 'en', path: '/locales/en.json', diff: '--- a\n+++ b\n+"hello": "Hello"', added: [], updated: [], removed: [] },
30
+ ];
31
+
32
+ const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
33
+ expect(() => printLocaleDiffs(diffs as any)).not.toThrow();
34
+ spy.mockRestore();
35
+ });
36
+ });
@@ -0,0 +1,42 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import type { SyncSummary } from '@i18nsmith/core';
5
+
6
+ export function printLocaleDiffs(diffs: SyncSummary['diffs']) {
7
+ if (!diffs.length) {
8
+ console.log(chalk.gray('No locale diffs to display.'));
9
+ return;
10
+ }
11
+
12
+ console.log(chalk.blue('\nUnified locale diffs:'));
13
+ diffs.forEach((entry) => {
14
+ console.log(chalk.yellow(`\n--- ${entry.locale} (${entry.path})`));
15
+ console.log(entry.diff.trimEnd());
16
+ });
17
+ }
18
+
19
+ export async function writeLocaleDiffPatches(diffs: SyncSummary['diffs'], directory: string) {
20
+ if (!diffs.length) {
21
+ console.log(chalk.gray('No locale diffs to write.'));
22
+ return;
23
+ }
24
+
25
+ const targetDir = path.isAbsolute(directory) ? directory : path.resolve(process.cwd(), directory);
26
+ await fs.mkdir(targetDir, { recursive: true });
27
+
28
+ await Promise.all(
29
+ diffs.map((entry) => {
30
+ const safeLocale = entry.locale.replace(/[^a-z0-9_-]/gi, '-');
31
+ const fileName = `${safeLocale || 'locale'}.patch`;
32
+ const filePath = path.join(targetDir, fileName);
33
+ return fs.writeFile(filePath, `${entry.diff.trimEnd()}\n`, 'utf8');
34
+ })
35
+ );
36
+
37
+ console.log(
38
+ chalk.green(
39
+ `Wrote ${diffs.length} locale patch file${diffs.length === 1 ? '' : 's'} to ${targetDir}.`
40
+ )
41
+ );
42
+ }