i18nsmith 0.1.8 → 0.2.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 (105) hide show
  1. package/build.mjs +16 -10
  2. package/dist/commands/audit.d.ts +3 -0
  3. package/dist/commands/audit.d.ts.map +1 -0
  4. package/dist/commands/backup.d.ts +6 -0
  5. package/dist/commands/backup.d.ts.map +1 -0
  6. package/dist/commands/check.d.ts +3 -0
  7. package/dist/commands/check.d.ts.map +1 -0
  8. package/dist/commands/config.d.ts +3 -0
  9. package/dist/commands/config.d.ts.map +1 -0
  10. package/dist/commands/debug-patterns.d.ts +3 -0
  11. package/dist/commands/debug-patterns.d.ts.map +1 -0
  12. package/dist/commands/debug-patterns.test.d.ts +2 -0
  13. package/dist/commands/debug-patterns.test.d.ts.map +1 -0
  14. package/dist/commands/diagnose.d.ts +3 -0
  15. package/dist/commands/diagnose.d.ts.map +1 -0
  16. package/dist/commands/init.d.ts +8 -0
  17. package/dist/commands/init.d.ts.map +1 -0
  18. package/dist/commands/init.test.d.ts +2 -0
  19. package/dist/commands/init.test.d.ts.map +1 -0
  20. package/dist/commands/install-hooks.d.ts +3 -0
  21. package/dist/commands/install-hooks.d.ts.map +1 -0
  22. package/dist/commands/preflight.d.ts +7 -0
  23. package/dist/commands/preflight.d.ts.map +1 -0
  24. package/dist/commands/preflight.test.d.ts +5 -0
  25. package/dist/commands/preflight.test.d.ts.map +1 -0
  26. package/dist/commands/rename.d.ts +6 -0
  27. package/dist/commands/rename.d.ts.map +1 -0
  28. package/dist/commands/review.d.ts +4 -0
  29. package/dist/commands/review.d.ts.map +1 -0
  30. package/dist/commands/review.test.d.ts +2 -0
  31. package/dist/commands/review.test.d.ts.map +1 -0
  32. package/dist/commands/scaffold-adapter.d.ts +3 -0
  33. package/dist/commands/scaffold-adapter.d.ts.map +1 -0
  34. package/dist/commands/scaffold-adapter.test.d.ts +2 -0
  35. package/dist/commands/scaffold-adapter.test.d.ts.map +1 -0
  36. package/dist/commands/scan.d.ts +3 -0
  37. package/dist/commands/scan.d.ts.map +1 -0
  38. package/dist/commands/sync-seed.test.d.ts +2 -0
  39. package/dist/commands/sync-seed.test.d.ts.map +1 -0
  40. package/dist/commands/sync.d.ts +3 -0
  41. package/dist/commands/sync.d.ts.map +1 -0
  42. package/dist/commands/transform.d.ts +3 -0
  43. package/dist/commands/transform.d.ts.map +1 -0
  44. package/dist/commands/translate/csv-handler.d.ts +21 -0
  45. package/dist/commands/translate/csv-handler.d.ts.map +1 -0
  46. package/dist/commands/translate/executor.d.ts +31 -0
  47. package/dist/commands/translate/executor.d.ts.map +1 -0
  48. package/dist/commands/translate/index.d.ts +10 -0
  49. package/dist/commands/translate/index.d.ts.map +1 -0
  50. package/dist/commands/translate/reporter.d.ts +29 -0
  51. package/dist/commands/translate/reporter.d.ts.map +1 -0
  52. package/dist/commands/translate/types.d.ts +52 -0
  53. package/dist/commands/translate/types.d.ts.map +1 -0
  54. package/dist/commands/translate.d.ts +7 -0
  55. package/dist/commands/translate.d.ts.map +1 -0
  56. package/dist/commands/translate.test.d.ts +2 -0
  57. package/dist/commands/translate.test.d.ts.map +1 -0
  58. package/dist/e2e.test.d.ts +6 -0
  59. package/dist/e2e.test.d.ts.map +1 -0
  60. package/dist/index.d.ts +4 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +1121 -387
  63. package/dist/integration.test.d.ts +6 -0
  64. package/dist/integration.test.d.ts.map +1 -0
  65. package/dist/test-helpers/ensure-cli-built.d.ts +2 -0
  66. package/dist/test-helpers/ensure-cli-built.d.ts.map +1 -0
  67. package/dist/utils/diagnostics-exit.d.ts +12 -0
  68. package/dist/utils/diagnostics-exit.d.ts.map +1 -0
  69. package/dist/utils/diagnostics-exit.test.d.ts +2 -0
  70. package/dist/utils/diagnostics-exit.test.d.ts.map +1 -0
  71. package/dist/utils/diff-utils.d.ts +4 -0
  72. package/dist/utils/diff-utils.d.ts.map +1 -0
  73. package/dist/utils/diff-utils.test.d.ts +2 -0
  74. package/dist/utils/diff-utils.test.d.ts.map +1 -0
  75. package/dist/utils/exit-codes.d.ts +142 -0
  76. package/dist/utils/exit-codes.d.ts.map +1 -0
  77. package/dist/utils/package-manager.d.ts +4 -0
  78. package/dist/utils/package-manager.d.ts.map +1 -0
  79. package/dist/utils/pkg.d.ts +3 -0
  80. package/dist/utils/pkg.d.ts.map +1 -0
  81. package/dist/utils/preview.d.ts +13 -0
  82. package/dist/utils/preview.d.ts.map +1 -0
  83. package/dist/utils/provider-injector.d.ts +36 -0
  84. package/dist/utils/provider-injector.d.ts.map +1 -0
  85. package/dist/utils/provider-injector.test.d.ts +2 -0
  86. package/dist/utils/provider-injector.test.d.ts.map +1 -0
  87. package/dist/utils/scaffold.d.ts +20 -0
  88. package/dist/utils/scaffold.d.ts.map +1 -0
  89. package/package.json +3 -2
  90. package/src/commands/debug-patterns.test.ts +6 -1
  91. package/src/commands/rename.ts +35 -3
  92. package/src/commands/review.test.ts +12 -0
  93. package/src/commands/review.ts +228 -0
  94. package/src/commands/sync-seed.test.ts +9 -3
  95. package/src/commands/sync.ts +104 -10
  96. package/src/commands/transform.ts +31 -4
  97. package/src/commands/translate/index.ts +23 -3
  98. package/src/commands/translate/types.ts +2 -0
  99. package/src/commands/translate.test.ts +9 -3
  100. package/src/e2e.test.ts +3 -11
  101. package/src/index.ts +2 -0
  102. package/src/integration.test.ts +12 -1
  103. package/src/test-helpers/ensure-cli-built.ts +32 -0
  104. package/src/utils/preview.ts +126 -0
  105. package/dist/index.js.map +0 -7
@@ -0,0 +1,228 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import inquirer from 'inquirer';
5
+ import type { Command } from 'commander';
6
+ import { loadConfigWithMeta, Scanner, type ScanCandidate, type ScanSummary } from '@i18nsmith/core';
7
+
8
+ interface ReviewCommandOptions {
9
+ config?: string;
10
+ json?: boolean;
11
+ limit?: number;
12
+ scanCalls?: boolean;
13
+ }
14
+
15
+ type ReviewAction = 'allow' | 'deny' | 'skip' | 'stop';
16
+
17
+ const DEFAULT_CONFIG_PATH = 'i18n.config.json';
18
+ const DEFAULT_LIMIT = 20;
19
+
20
+ export function literalToRegexPattern(value: string): string {
21
+ const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
22
+ return `^${escaped}$`;
23
+ }
24
+
25
+ function normalizeLimit(value?: number): number {
26
+ if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) {
27
+ return DEFAULT_LIMIT;
28
+ }
29
+ return Math.min(Math.max(1, Math.floor(value)), 200);
30
+ }
31
+
32
+ type BucketedScanSummary = ScanSummary & {
33
+ buckets?: {
34
+ needsReview?: ScanCandidate[];
35
+ skipped?: Array<{ reason: string }>;
36
+ };
37
+ };
38
+
39
+ function summarizeSkipReasons(skipped: Array<{ reason: string }>): string[] {
40
+ if (!skipped.length) {
41
+ return [];
42
+ }
43
+ const counts = new Map<string, number>();
44
+ for (const entry of skipped) {
45
+ counts.set(entry.reason, (counts.get(entry.reason) ?? 0) + 1);
46
+ }
47
+ return [...counts.entries()]
48
+ .sort((a, b) => b[1] - a[1])
49
+ .slice(0, 5)
50
+ .map(([reason, count]) => `${reason}: ${count}`);
51
+ }
52
+
53
+ async function writeExtractionOverrides(
54
+ configPath: string,
55
+ allowPatterns: string[],
56
+ denyPatterns: string[]
57
+ ): Promise<{ allowAdded: number; denyAdded: number; wrote: boolean }> {
58
+ if (!allowPatterns.length && !denyPatterns.length) {
59
+ return { allowAdded: 0, denyAdded: 0, wrote: false };
60
+ }
61
+
62
+ const raw = await fs.readFile(configPath, 'utf-8');
63
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
64
+ const extraction = (typeof parsed.extraction === 'object' && parsed.extraction !== null)
65
+ ? (parsed.extraction as Record<string, unknown>)
66
+ : (parsed.extraction = {});
67
+
68
+ const allowList = Array.isArray(extraction.allowPatterns)
69
+ ? [...(extraction.allowPatterns as string[])]
70
+ : [];
71
+ const denyList = Array.isArray(extraction.denyPatterns)
72
+ ? [...(extraction.denyPatterns as string[])]
73
+ : [];
74
+
75
+ const allowAdded = appendUnique(allowList, allowPatterns);
76
+ const denyAdded = appendUnique(denyList, denyPatterns);
77
+
78
+ if (allowAdded === 0 && denyAdded === 0) {
79
+ return { allowAdded, denyAdded, wrote: false };
80
+ }
81
+
82
+ extraction.allowPatterns = allowList;
83
+ extraction.denyPatterns = denyList;
84
+
85
+ await fs.writeFile(configPath, JSON.stringify(parsed, null, 2));
86
+ return { allowAdded, denyAdded, wrote: true };
87
+ }
88
+
89
+ function appendUnique(target: string[], additions: string[]): number {
90
+ const seen = new Set(target);
91
+ let added = 0;
92
+ for (const next of additions) {
93
+ if (seen.has(next)) continue;
94
+ target.push(next);
95
+ seen.add(next);
96
+ added++;
97
+ }
98
+ return added;
99
+ }
100
+
101
+ function printCandidate(candidate: ScanCandidate) {
102
+ const location = `${candidate.filePath}:${candidate.position.line}:${candidate.position.column}`;
103
+ console.log(chalk.cyan(`\n${candidate.text}`));
104
+ console.log(chalk.gray(` • Location: ${location}`));
105
+ console.log(chalk.gray(` • Kind: ${candidate.kind}`));
106
+ if (candidate.context) {
107
+ console.log(chalk.gray(` • Context: ${candidate.context}`));
108
+ }
109
+ }
110
+
111
+ async function promptAction(): Promise<ReviewAction> {
112
+ const { action } = await inquirer.prompt<{ action: ReviewAction }>([
113
+ {
114
+ type: 'list',
115
+ name: 'action',
116
+ message: 'Choose an action for this string',
117
+ choices: [
118
+ { name: 'Always translate (add to allowPatterns)', value: 'allow' },
119
+ { name: 'Always skip (add to denyPatterns)', value: 'deny' },
120
+ { name: 'Skip for now', value: 'skip' },
121
+ { name: 'Stop reviewing', value: 'stop' },
122
+ ],
123
+ },
124
+ ]);
125
+ return action;
126
+ }
127
+
128
+ export function registerReview(program: Command) {
129
+ program
130
+ .command('review')
131
+ .description('Review borderline candidates and persist allow/deny overrides')
132
+ .option('-c, --config <path>', 'Path to i18nsmith config file', DEFAULT_CONFIG_PATH)
133
+ .option('--json', 'Print raw bucket data as JSON', false)
134
+ .option('--limit <n>', 'Limit the number of items per session (default: 20)', (value) => parseInt(value, 10))
135
+ .option('--scan-calls', 'Include translation call arguments', false)
136
+ .action(async (options: ReviewCommandOptions) => {
137
+ try {
138
+ const { config, projectRoot, configPath } = await loadConfigWithMeta(options.config);
139
+ const scanner = new Scanner(config, { workspaceRoot: projectRoot });
140
+ const summary = scanner.scan({ scanCalls: options.scanCalls }) as BucketedScanSummary;
141
+ const buckets = summary.buckets ?? {};
142
+ const needsReview = buckets.needsReview ?? [];
143
+ const skipped = buckets.skipped ?? [];
144
+
145
+ if (options.json) {
146
+ console.log(JSON.stringify({ needsReview, skipped }, null, 2));
147
+ return;
148
+ }
149
+
150
+ if (!needsReview.length) {
151
+ console.log(chalk.green('No borderline candidates detected.'));
152
+ const reasons = summarizeSkipReasons(skipped);
153
+ if (reasons.length) {
154
+ console.log(chalk.gray('Most common skip reasons:'));
155
+ reasons.forEach((line) => console.log(chalk.gray(` • ${line}`)));
156
+ }
157
+ return;
158
+ }
159
+
160
+ if (!process.stdout.isTTY || process.env.CI === 'true') {
161
+ console.log(chalk.red('Interactive review requires a TTY. Use --json for non-interactive output.'));
162
+ process.exitCode = 1;
163
+ return;
164
+ }
165
+
166
+ const limit = normalizeLimit(options.limit);
167
+ const queue = needsReview.slice(0, limit);
168
+ console.log(
169
+ chalk.blue(
170
+ `Reviewing ${queue.length} of ${needsReview.length} candidate${needsReview.length === 1 ? '' : 's'} (limit=${limit}).`
171
+ )
172
+ );
173
+
174
+ const allowPatterns: string[] = [];
175
+ const denyPatterns: string[] = [];
176
+
177
+ for (const candidate of queue) {
178
+ printCandidate(candidate);
179
+ const action = await promptAction();
180
+ if (action === 'stop') {
181
+ break;
182
+ }
183
+ if (action === 'skip') {
184
+ continue;
185
+ }
186
+ const pattern = literalToRegexPattern(candidate.text);
187
+ if (action === 'allow') {
188
+ allowPatterns.push(pattern);
189
+ console.log(chalk.green(` → Queued ${pattern} for allowPatterns`));
190
+ } else if (action === 'deny') {
191
+ denyPatterns.push(pattern);
192
+ console.log(chalk.yellow(` → Queued ${pattern} for denyPatterns`));
193
+ }
194
+ }
195
+
196
+ if (!allowPatterns.length && !denyPatterns.length) {
197
+ console.log(chalk.gray('No config changes requested.'));
198
+ return;
199
+ }
200
+
201
+ const { allowAdded, denyAdded, wrote } = await writeExtractionOverrides(
202
+ configPath,
203
+ allowPatterns,
204
+ denyPatterns
205
+ );
206
+
207
+ if (!wrote) {
208
+ console.log(chalk.gray('Patterns already existed; config unchanged.'));
209
+ return;
210
+ }
211
+
212
+ console.log(
213
+ chalk.green(
214
+ `Updated ${relativize(configPath)} (${allowAdded} allow, ${denyAdded} deny pattern${
215
+ allowAdded + denyAdded === 1 ? '' : 's'
216
+ } added).`
217
+ )
218
+ );
219
+ } catch (error) {
220
+ console.error(chalk.red('Review failed:'), (error as Error).message);
221
+ process.exitCode = 1;
222
+ }
223
+ });
224
+ }
225
+
226
+ function relativize(filePath: string): string {
227
+ return path.relative(process.cwd(), filePath) || filePath;
228
+ }
@@ -1,12 +1,19 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest';
2
2
  import fs from 'fs/promises';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
5
  import { execSync } from 'child_process';
6
+ import { ensureCliBuilt } from '../test-helpers/ensure-cli-built';
7
+
8
+ const CLI_PATH = path.resolve(__dirname, '..', '..', 'dist', 'index.js');
6
9
 
7
10
  describe('sync --seed-target-locales', () => {
8
11
  let tempDir: string;
9
12
 
13
+ beforeAll(async () => {
14
+ await ensureCliBuilt(CLI_PATH);
15
+ });
16
+
10
17
  beforeEach(async () => {
11
18
  tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18nsmith-seed-test-'));
12
19
 
@@ -55,9 +62,8 @@ export function App() {
55
62
  });
56
63
 
57
64
  const runCli = (args: string): string => {
58
- const cliPath = path.resolve(__dirname, '..', '..', 'dist', 'index.js');
59
65
  try {
60
- return execSync(`node ${cliPath} ${args}`, {
66
+ return execSync(`node ${CLI_PATH} ${args}`, {
61
67
  cwd: tempDir,
62
68
  encoding: 'utf8',
63
69
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -13,11 +13,13 @@ import {
13
13
  createRenameMappingFile,
14
14
  type SyncSummary,
15
15
  type KeyRenameBatchSummary,
16
+ type SyncSelection,
16
17
  } from '@i18nsmith/core';
17
18
  import {
18
19
  printLocaleDiffs,
19
20
  writeLocaleDiffPatches,
20
21
  } from '../utils/diff-utils.js';
22
+ import { applyPreviewFile, writePreviewFile } from '../utils/preview.js';
21
23
  import { SYNC_EXIT_CODES } from '../utils/exit-codes.js';
22
24
 
23
25
  interface SyncCommandOptions {
@@ -49,6 +51,9 @@ interface SyncCommandOptions {
49
51
  shapeDelimiter?: string;
50
52
  seedTargetLocales?: boolean;
51
53
  seedValue?: string;
54
+ previewOutput?: string;
55
+ selectionFile?: string;
56
+ applyPreview?: string;
52
57
  }
53
58
 
54
59
  function collectAssumedKeys(value: string, previous: string[] = []) {
@@ -90,26 +95,66 @@ export function registerSync(program: Command) {
90
95
  .option('--shape-delimiter <char>', 'Delimiter for key nesting (default: ".")', '.')
91
96
  .option('--seed-target-locales', 'Add missing keys to target locale files with empty or placeholder values', false)
92
97
  .option('--seed-value <value>', 'Value to use when seeding target locales (default: empty string)', '')
98
+ .option('--preview-output <path>', 'Write preview summary (JSON) to a file (implies dry-run)')
99
+ .option('--selection-file <path>', 'Path to JSON file with selected missing/unused keys to write (used with --write)')
100
+ .option('--apply-preview <path>', 'Apply a previously saved sync preview JSON file safely')
93
101
  .action(async (options: SyncCommandOptions) => {
102
+ if (options.applyPreview) {
103
+ const extraArgs: string[] = [];
104
+ if (options.selectionFile) {
105
+ extraArgs.push('--selection-file', options.selectionFile);
106
+ }
107
+ if (options.prune) {
108
+ extraArgs.push('--prune');
109
+ }
110
+ await applyPreviewFile('sync', options.applyPreview, extraArgs);
111
+ return;
112
+ }
113
+
94
114
  const interactive = Boolean(options.interactive);
95
115
  const diffEnabled = Boolean(options.diff || options.patchDir);
96
116
  const invalidateCache = Boolean(options.invalidateCache);
97
- const diffRequested = diffEnabled || Boolean(options.json);
117
+ const previewMode = Boolean(options.previewOutput);
118
+ const diffRequested = diffEnabled || Boolean(options.json) || previewMode;
119
+
98
120
  if (interactive && options.json) {
99
121
  console.error(chalk.red('--interactive cannot be combined with --json output.'));
100
122
  process.exitCode = 1;
101
123
  return;
102
124
  }
103
125
 
104
- console.log(
105
- chalk.blue(
106
- interactive
107
- ? 'Interactive sync (dry-run first)...'
108
- : options.write
109
- ? 'Syncing locale files...'
110
- : 'Checking locale drift...'
111
- )
112
- );
126
+ if (previewMode && interactive) {
127
+ console.error(chalk.red('--preview-output cannot be combined with --interactive.'));
128
+ process.exitCode = 1;
129
+ return;
130
+ }
131
+
132
+ const writeEnabled = Boolean(options.write) && !previewMode;
133
+ if (previewMode && options.write) {
134
+ console.log(
135
+ chalk.yellow('Preview requested; ignoring --write and running in dry-run mode.')
136
+ );
137
+ }
138
+ options.write = writeEnabled;
139
+
140
+ if (options.selectionFile && !options.write) {
141
+ console.error(chalk.red('--selection-file requires --write (or --apply-preview) to take effect.'));
142
+ process.exitCode = 1;
143
+ return;
144
+ }
145
+
146
+ const selectionFromFile = options.selectionFile
147
+ ? await loadSelectionFile(options.selectionFile)
148
+ : undefined;
149
+
150
+ const banner = previewMode
151
+ ? 'Generating sync preview...'
152
+ : interactive
153
+ ? 'Interactive sync (dry-run first)...'
154
+ : writeEnabled
155
+ ? 'Syncing locale files...'
156
+ : 'Checking locale drift...';
157
+ console.log(chalk.blue(banner));
113
158
 
114
159
  try {
115
160
  const { config, projectRoot, configPath } = await loadConfigWithMeta(options.config);
@@ -202,11 +247,17 @@ export function registerSync(program: Command) {
202
247
  validateInterpolations: options.validateInterpolations,
203
248
  emptyValuePolicy: options.emptyValues === false ? 'fail' : undefined,
204
249
  assumedKeys: options.assume,
250
+ selection: selectionFromFile,
205
251
  diff: diffRequested,
206
252
  invalidateCache,
207
253
  targets: options.target,
208
254
  });
209
255
 
256
+ if (previewMode && options.previewOutput) {
257
+ const savedPath = await writePreviewFile('sync', summary, options.previewOutput);
258
+ console.log(chalk.green(`Preview written to ${path.relative(process.cwd(), savedPath)}`));
259
+ }
260
+
210
261
  // Show backup info if created
211
262
  if (summary.backup) {
212
263
  console.log(chalk.blue(`\n📦 ${summary.backup.summary}`));
@@ -685,6 +736,49 @@ async function runInteractiveSync(syncer: Syncer, options: SyncCommandOptions) {
685
736
  }
686
737
  }
687
738
 
739
+ async function loadSelectionFile(filePath: string): Promise<SyncSelection> {
740
+ const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
741
+ let raw: string;
742
+ try {
743
+ raw = await fs.readFile(resolvedPath, 'utf8');
744
+ } catch (error) {
745
+ const message = error instanceof Error ? error.message : String(error);
746
+ throw new Error(`Unable to read selection file at ${resolvedPath}: ${message}`);
747
+ }
748
+
749
+ let parsed: unknown;
750
+ try {
751
+ parsed = JSON.parse(raw);
752
+ } catch (error) {
753
+ const message = error instanceof Error ? error.message : String(error);
754
+ throw new Error(`Selection file ${resolvedPath} contains invalid JSON: ${message}`);
755
+ }
756
+
757
+ const selection: SyncSelection = {};
758
+ const missing = (parsed as { missing?: unknown }).missing;
759
+ const unused = (parsed as { unused?: unknown }).unused;
760
+
761
+ if (Array.isArray(missing)) {
762
+ const normalized = missing.map((key) => String(key).trim()).filter(Boolean);
763
+ if (normalized.length) {
764
+ selection.missing = normalized;
765
+ }
766
+ }
767
+
768
+ if (Array.isArray(unused)) {
769
+ const normalized = unused.map((key) => String(key).trim()).filter(Boolean);
770
+ if (normalized.length) {
771
+ selection.unused = normalized;
772
+ }
773
+ }
774
+
775
+ if (!selection.missing?.length && !selection.unused?.length) {
776
+ throw new Error(`Selection file ${resolvedPath} must include at least one "missing" or "unused" entry.`);
777
+ }
778
+
779
+ return selection;
780
+ }
781
+
688
782
  function printRenameBatchSummary(summary: KeyRenameBatchSummary) {
689
783
  console.log(
690
784
  chalk.green(
@@ -6,6 +6,7 @@ import { loadConfigWithMeta } from '@i18nsmith/core';
6
6
  import { Transformer } from '@i18nsmith/transformer';
7
7
  import type { TransformSummary } from '@i18nsmith/transformer';
8
8
  import { printLocaleDiffs, writeLocaleDiffPatches } from '../utils/diff-utils.js';
9
+ import { applyPreviewFile, writePreviewFile } from '../utils/preview.js';
9
10
 
10
11
  interface TransformOptions {
11
12
  config?: string;
@@ -17,6 +18,8 @@ interface TransformOptions {
17
18
  diff?: boolean;
18
19
  patchDir?: string;
19
20
  migrateTextKeys?: boolean;
21
+ previewOutput?: string;
22
+ applyPreview?: string;
20
23
  }
21
24
 
22
25
  const collectTargetPatterns = (value: string | string[], previous: string[]) => {
@@ -83,11 +86,30 @@ export function registerTransform(program: Command) {
83
86
  .option('--patch-dir <path>', 'Write locale diffs to .patch files in the specified directory')
84
87
  .option('--target <pattern...>', 'Limit scanning to specific files or glob patterns', collectTargetPatterns, [])
85
88
  .option('--migrate-text-keys', 'Migrate existing t("Text") calls to structured keys')
89
+ .option('--preview-output <path>', 'Write preview summary (JSON) to a file (implies dry-run)')
90
+ .option('--apply-preview <path>', 'Apply a previously saved transform preview JSON file safely')
86
91
  .action(async (options: TransformOptions) => {
92
+ if (options.applyPreview) {
93
+ await applyPreviewFile('transform', options.applyPreview);
94
+ return;
95
+ }
96
+
87
97
  const diffEnabled = Boolean(options.diff || options.patchDir);
88
- console.log(
89
- chalk.blue(options.write ? 'Running transform (write mode)...' : 'Planning transform (dry-run)...')
90
- );
98
+ const previewMode = Boolean(options.previewOutput);
99
+ const diffRequested = diffEnabled || previewMode;
100
+ const writeEnabled = Boolean(options.write) && !previewMode;
101
+
102
+ if (previewMode && options.write) {
103
+ console.log(chalk.yellow('Preview requested; ignoring --write and running in dry-run mode.'));
104
+ }
105
+ options.write = writeEnabled;
106
+
107
+ const banner = previewMode
108
+ ? 'Generating transform preview...'
109
+ : writeEnabled
110
+ ? 'Running transform (write mode)...'
111
+ : 'Planning transform (dry-run)...';
112
+ console.log(chalk.blue(banner));
91
113
 
92
114
  try {
93
115
  const { config, projectRoot, configPath } = await loadConfigWithMeta(options.config);
@@ -103,10 +125,15 @@ export function registerTransform(program: Command) {
103
125
  const summary = await transformer.run({
104
126
  write: options.write,
105
127
  targets: options.target,
106
- diff: diffEnabled,
128
+ diff: diffRequested,
107
129
  migrateTextKeys: options.migrateTextKeys,
108
130
  });
109
131
 
132
+ if (previewMode && options.previewOutput) {
133
+ const savedPath = await writePreviewFile('transform', summary, options.previewOutput);
134
+ console.log(chalk.green(`Preview written to ${path.relative(process.cwd(), savedPath)}`));
135
+ }
136
+
110
137
  if (options.report) {
111
138
  const outputPath = path.resolve(process.cwd(), options.report);
112
139
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
@@ -4,6 +4,7 @@
4
4
 
5
5
  import chalk from 'chalk';
6
6
  import inquirer from 'inquirer';
7
+ import path from 'path';
7
8
  import type { Command } from 'commander';
8
9
  import {
9
10
  DEFAULT_PLACEHOLDER_FORMATS,
@@ -19,6 +20,7 @@ import type { TranslateCommandOptions, TranslateSummary, ProviderSettings } from
19
20
  import { emitTranslateOutput, maybePrintEstimate } from './reporter.js';
20
21
  import { handleCsvExport, handleCsvImport } from './csv-handler.js';
21
22
  import { executeTranslations } from './executor.js';
23
+ import { applyPreviewFile, writePreviewFile } from '../../utils/preview.js';
22
24
 
23
25
  // Re-export types for external use
24
26
  export * from './types.js';
@@ -98,7 +100,14 @@ export function registerTranslate(program: Command): void {
98
100
  .option('--strict-placeholders', 'Fail if translated output has placeholder mismatches (for CI)', false)
99
101
  .option('--export <path>', 'Export missing translations to a CSV file for external translation')
100
102
  .option('--import <path>', 'Import translations from a CSV file and merge into locale files')
103
+ .option('--preview-output <path>', 'Write preview summary (JSON) to a file (implies dry-run)')
104
+ .option('--apply-preview <path>', 'Apply a previously saved translate preview JSON file safely')
101
105
  .action(async (options: TranslateCommandOptions) => {
106
+ if (options.applyPreview) {
107
+ await applyPreviewFile('translate', options.applyPreview, ['--yes']);
108
+ return;
109
+ }
110
+
102
111
  // Handle CSV export mode
103
112
  if (options.export) {
104
113
  await handleCsvExport(options);
@@ -111,9 +120,14 @@ export function registerTranslate(program: Command): void {
111
120
  return;
112
121
  }
113
122
 
114
- console.log(
115
- chalk.blue(options.write ? 'Translating locale files...' : 'Planning translations (dry-run)...')
116
- );
123
+ const previewMode = Boolean(options.previewOutput);
124
+ const writeEnabled = Boolean(options.write) && !previewMode;
125
+ if (previewMode && options.write) {
126
+ console.log(chalk.yellow('Preview requested; ignoring --write and running in dry-run mode.'));
127
+ }
128
+ options.write = writeEnabled;
129
+
130
+ console.log(chalk.blue(writeEnabled ? 'Translating locale files...' : 'Planning translations (dry-run)...'));
117
131
 
118
132
  try {
119
133
  const config = await loadConfig(options.config);
@@ -142,6 +156,12 @@ export function registerTranslate(program: Command): void {
142
156
  };
143
157
 
144
158
  if (!options.write) {
159
+ if (previewMode && options.previewOutput) {
160
+ const savedPath = await writePreviewFile('translate', summary, options.previewOutput);
161
+ console.log(chalk.green(`Preview written to ${path.relative(process.cwd(), savedPath)}`));
162
+ return;
163
+ }
164
+
145
165
  if (options.estimate && providerSettings.name !== 'manual') {
146
166
  await maybePrintEstimate(plan, providerSettings);
147
167
  }
@@ -19,6 +19,8 @@ export interface TranslateCommandOptions {
19
19
  strictPlaceholders?: boolean;
20
20
  export?: string;
21
21
  import?: string;
22
+ previewOutput?: string;
23
+ applyPreview?: string;
22
24
  }
23
25
 
24
26
  export interface TranslateLocaleResult extends TranslationWriteSummary {
@@ -1,12 +1,19 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest';
2
2
  import fs from 'fs/promises';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
5
  import { execSync } from 'child_process';
6
+ import { ensureCliBuilt } from '../test-helpers/ensure-cli-built';
7
+
8
+ const CLI_PATH = path.resolve(__dirname, '..', '..', 'dist', 'index.js');
6
9
 
7
10
  describe('translate command', () => {
8
11
  let tempDir: string;
9
12
 
13
+ beforeAll(async () => {
14
+ await ensureCliBuilt(CLI_PATH);
15
+ });
16
+
10
17
  beforeEach(async () => {
11
18
  tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18nsmith-translate-test-'));
12
19
 
@@ -55,9 +62,8 @@ export function App() {
55
62
  });
56
63
 
57
64
  const runCli = (args: string): string => {
58
- const cliPath = path.resolve(__dirname, '..', '..', 'dist', 'index.js');
59
65
  try {
60
- return execSync(`node ${cliPath} ${args}`, {
66
+ return execSync(`node ${CLI_PATH} ${args}`, {
61
67
  cwd: tempDir,
62
68
  encoding: 'utf8',
63
69
  stdio: ['pipe', 'pipe', 'pipe'],
package/src/e2e.test.ts CHANGED
@@ -9,6 +9,7 @@ import path from 'path';
9
9
  import os from 'os';
10
10
  import { spawnSync } from 'child_process';
11
11
  import { fileURLToPath } from 'url';
12
+ import { ensureCliBuilt } from './test-helpers/ensure-cli-built';
12
13
 
13
14
  const __filename = fileURLToPath(import.meta.url);
14
15
  const __dirname = path.dirname(__filename);
@@ -84,17 +85,8 @@ function extractJson<T>(output: string): T {
84
85
  return JSON.parse(jsonMatch[0]);
85
86
  }
86
87
 
87
- beforeAll(() => {
88
- const cliRoot = path.resolve(__dirname, '..');
89
- const result = spawnSync('pnpm', ['build'], {
90
- cwd: cliRoot,
91
- stdio: 'inherit',
92
- env: { ...process.env },
93
- });
94
-
95
- if (result.status !== 0) {
96
- throw new Error('Failed to build CLI before running E2E tests');
97
- }
88
+ beforeAll(async () => {
89
+ await ensureCliBuilt(CLI_PATH);
98
90
  });
99
91
 
100
92
  describe('E2E Fixture Tests', () => {
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ import { registerBackup } from './commands/backup.js';
15
15
  import { registerRename } from './commands/rename.js';
16
16
  import { registerInstallHooks } from './commands/install-hooks.js';
17
17
  import { registerConfig } from './commands/config.js';
18
+ import { registerReview } from './commands/review.js';
18
19
 
19
20
  export const program = new Command();
20
21
 
@@ -38,6 +39,7 @@ registerBackup(program);
38
39
  registerRename(program);
39
40
  registerInstallHooks(program);
40
41
  registerConfig(program);
42
+ registerReview(program);
41
43
 
42
44
 
43
45
  program.parse();
@@ -3,17 +3,19 @@
3
3
  * These tests run the actual CLI commands against real file systems
4
4
  */
5
5
 
6
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
+ import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest';
7
7
  import fs from 'fs/promises';
8
8
  import path from 'path';
9
9
  import os from 'os';
10
10
  import { spawnSync } from 'child_process';
11
11
  import { fileURLToPath } from 'url';
12
+ import { ensureCliBuilt } from './test-helpers/ensure-cli-built';
12
13
 
13
14
  const __filename = fileURLToPath(import.meta.url);
14
15
  const __dirname = path.dirname(__filename);
15
16
 
16
17
  // Get the correct CLI path regardless of where tests are run from
18
+ // During tests, __dirname points to src/, so we go up one level to find dist/
17
19
  const CLI_PATH = path.resolve(__dirname, '../dist/index.js');
18
20
 
19
21
  // Helper to run CLI commands
@@ -33,6 +35,11 @@ function runCli(
33
35
  },
34
36
  });
35
37
 
38
+ // Log errors for debugging
39
+ if (result.error) {
40
+ console.error('CLI execution error:', result.error);
41
+ }
42
+
36
43
  const stdout = result.stdout ?? '';
37
44
  const stderr = result.stderr ?? '';
38
45
 
@@ -56,6 +63,10 @@ function extractJson<T>(output: string): T {
56
63
  describe('CLI Integration Tests', () => {
57
64
  let tmpDir: string;
58
65
 
66
+ beforeAll(async () => {
67
+ await ensureCliBuilt(CLI_PATH);
68
+ });
69
+
59
70
  beforeEach(async () => {
60
71
  tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18nsmith-cli-integration-'));
61
72
  });