i18nsmith 0.4.3 → 0.5.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18nsmith",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "CLI for i18nsmith",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -186,7 +186,11 @@ export function registerCheck(program: Command) {
186
186
 
187
187
  export async function runCheck(options: CheckCommandOptions): Promise<void> {
188
188
  const auditEnabled = Boolean(options.audit || options.auditStrict);
189
- console.log(chalk.blue('Running guided repository health check...'));
189
+ if (options.json) {
190
+ console.error(chalk.blue('Running guided repository health check...'));
191
+ } else {
192
+ console.log(chalk.blue('Running guided repository health check...'));
193
+ }
190
194
  try {
191
195
  const { config, projectRoot, configPath } = await loadConfigWithMeta(options.config);
192
196
 
@@ -282,4 +282,130 @@ export function registerConfig(program: Command) {
282
282
  }
283
283
  })
284
284
  );
285
+
286
+ // Subcommand: config migrate
287
+ configCmd
288
+ .command('migrate')
289
+ .description('Migrate configuration from v1 to v2 format and apply modern defaults')
290
+ .option('-c, --config <path>', 'Path to i18nsmith config file', DEFAULT_CONFIG_FILENAME)
291
+ .option('--dry-run', 'Show what would be changed without modifying files', false)
292
+ .option('--json', 'Output result as JSON', false)
293
+ .action(
294
+ withErrorHandling(async (options: { config?: string; dryRun?: boolean; json?: boolean }) => {
295
+ try {
296
+ const { configPath } = await loadConfigWithMeta(options.config);
297
+ const { parsed: rawConfig } = await readRawConfig(configPath);
298
+
299
+ // Check if migration is needed
300
+ const currentVersion = (rawConfig.configVersion ?? rawConfig.version ?? 1) as number;
301
+ if (currentVersion >= 2) {
302
+ if (options.json) {
303
+ console.log(JSON.stringify({ migrated: false, reason: 'Already at latest version' }, null, 2));
304
+ } else {
305
+ console.log(chalk.blue('Configuration is already up to date (v2 or later)'));
306
+ }
307
+ return;
308
+ }
309
+
310
+ // Apply migration transformations
311
+ const migratedConfig = { ...rawConfig };
312
+
313
+ // Set new version
314
+ migratedConfig.configVersion = 2;
315
+ delete migratedConfig.version;
316
+
317
+ // Migrate field names
318
+ if (rawConfig.sourceLocale) {
319
+ migratedConfig.sourceLanguage = rawConfig.sourceLocale;
320
+ delete migratedConfig.sourceLocale;
321
+ }
322
+ if (rawConfig.targetLocales) {
323
+ migratedConfig.targetLanguages = rawConfig.targetLocales;
324
+ delete migratedConfig.targetLocales;
325
+ }
326
+
327
+ // Add extraction preset with strict defaults for new configs
328
+ if (!migratedConfig.extraction) {
329
+ migratedConfig.extraction = {};
330
+ }
331
+ const extraction = migratedConfig.extraction as Record<string, unknown>;
332
+ if (!extraction.preset) {
333
+ extraction.preset = 'strict';
334
+ }
335
+
336
+ // Clean up deprecated fields
337
+ const deprecatedFields = ['projectName'];
338
+ for (const field of deprecatedFields) {
339
+ if (field in migratedConfig) {
340
+ delete migratedConfig[field];
341
+ }
342
+ }
343
+
344
+ if (options.dryRun) {
345
+ if (options.json) {
346
+ console.log(JSON.stringify({
347
+ migrated: true,
348
+ dryRun: true,
349
+ changes: {
350
+ added: { configVersion: 2, 'extraction.preset': 'strict' },
351
+ removed: deprecatedFields.filter(f => f in rawConfig),
352
+ renamed: Object.assign({},
353
+ rawConfig.sourceLocale ? { sourceLocale: 'sourceLanguage' } : {},
354
+ rawConfig.targetLocales ? { targetLocales: 'targetLanguages' } : {},
355
+ ),
356
+ },
357
+ result: migratedConfig,
358
+ }, null, 2));
359
+ } else {
360
+ console.log(chalk.blue('Migration preview (dry run):'));
361
+ console.log();
362
+ console.log(chalk.green('✓ Would set configVersion: 2'));
363
+ console.log(chalk.green('✓ Would add extraction.preset: strict'));
364
+ if (rawConfig.sourceLocale) {
365
+ console.log(chalk.green(`✓ Would rename sourceLocale → sourceLanguage`));
366
+ }
367
+ if (rawConfig.targetLocales) {
368
+ console.log(chalk.green(`✓ Would rename targetLocales → targetLanguages`));
369
+ }
370
+ const removed = deprecatedFields.filter(f => f in rawConfig);
371
+ if (removed.length > 0) {
372
+ console.log(chalk.green(`✓ Would remove deprecated fields: ${removed.join(', ')}`));
373
+ }
374
+ console.log();
375
+ console.log(chalk.dim('Run without --dry-run to apply changes'));
376
+ }
377
+ } else {
378
+ await writeConfig(configPath, migratedConfig);
379
+
380
+ if (options.json) {
381
+ console.log(JSON.stringify({
382
+ migrated: true,
383
+ configPath,
384
+ changes: {
385
+ added: { configVersion: 2, 'extraction.preset': 'strict' },
386
+ removed: deprecatedFields.filter(f => f in rawConfig),
387
+ renamed: Object.assign({},
388
+ rawConfig.sourceLocale ? { sourceLocale: 'sourceLanguage' } : {},
389
+ rawConfig.targetLocales ? { targetLocales: 'targetLanguages' } : {},
390
+ ),
391
+ },
392
+ }, null, 2));
393
+ } else {
394
+ console.log(chalk.green('✓ Configuration migrated to v2'));
395
+ console.log(chalk.dim(` Updated: ${configPath}`));
396
+ console.log();
397
+ console.log(chalk.blue('New features available:'));
398
+ console.log(chalk.dim(' • extraction.preset: strict (reduces false positives)'));
399
+ console.log(chalk.dim(' • Improved field names for clarity'));
400
+ }
401
+ }
402
+ } catch (error) {
403
+ if (error instanceof CliError) {
404
+ throw error;
405
+ }
406
+ const message = error instanceof Error ? error.message : String(error);
407
+ throw new CliError(`Failed to migrate config: ${message}`);
408
+ }
409
+ })
410
+ );
285
411
  }
@@ -160,6 +160,7 @@ vi.mock('inquirer', () => ({
160
160
  sourceLanguage: 'en',
161
161
  adapter: 'custom',
162
162
  localesDir: 'locales',
163
+ seedTargetLocales: false,
163
164
  }),
164
165
  },
165
166
  }));
@@ -256,6 +257,85 @@ describe('init command', () => {
256
257
  );
257
258
  });
258
259
  });
260
+
261
+ describe('interactive mode', () => {
262
+ it('writes seedTargetLocales into config when user enables it', async () => {
263
+ const program = new Command();
264
+ registerInit(program);
265
+ const command = program.commands.find((cmd) => cmd.name() === 'init')!;
266
+
267
+ // Mock process.cwd to return a test directory
268
+ const originalCwd = process.cwd;
269
+ process.cwd = vi.fn().mockReturnValue('/test/project');
270
+
271
+ // Make fs.access throw so init thinks config doesn't exist
272
+ vi.mocked(fs.access).mockRejectedValueOnce(new Error('File not found'));
273
+
274
+ // Override inquirer for this run to enable seedTargetLocales
275
+ const inquirer = await import('inquirer');
276
+ vi.mocked(inquirer.default.prompt).mockResolvedValueOnce({
277
+ setupMode: 'auto',
278
+ sourceLanguage: 'en',
279
+ localesDir: 'locales',
280
+ seedTargetLocales: true,
281
+ } as any);
282
+
283
+ try {
284
+ await (command as any).parseAsync([], { from: 'user' });
285
+ } finally {
286
+ process.cwd = originalCwd;
287
+ }
288
+
289
+ // Find the writeFile call that wrote i18n.config.json
290
+ const wroteConfig = vi.mocked(fs.writeFile).mock.calls.find((c) => String(c[0]).endsWith('i18n.config.json'));
291
+ expect(wroteConfig).toBeDefined();
292
+ expect(String(wroteConfig![1])).toContain('"seedTargetLocales": true');
293
+ });
294
+
295
+ it('continues when user chooses Overwrite for existing assets and records mergeStrategy', async () => {
296
+ const program = new Command();
297
+ registerInit(program);
298
+ const command = program.commands.find((cmd) => cmd.name() === 'init')!;
299
+
300
+ const originalCwd = process.cwd;
301
+ process.cwd = vi.fn().mockReturnValue('/test/project');
302
+
303
+ // Make fs.access throw so init thinks config doesn't exist
304
+ vi.mocked(fs.access).mockRejectedValueOnce(new Error('File not found'));
305
+
306
+ // Make diagnoseWorkspace report existing locale files so merge prompt appears
307
+ const core = await import('@i18nsmith/core');
308
+ vi.mocked(core.diagnoseWorkspace).mockResolvedValueOnce({
309
+ localesDir: 'locales',
310
+ localeFiles: [{ locale: 'en', missing: false, parseError: false }],
311
+ detectedLocales: [],
312
+ runtimePackages: [],
313
+ providerFiles: [],
314
+ adapterFiles: [],
315
+ translationUsage: { hookName: 'useTranslation', translationIdentifier: 't', filesExamined: 0, hookOccurrences: 0, identifierOccurrences: 0, hookExampleFiles: [], identifierExampleFiles: [] },
316
+ actionableItems: [],
317
+ conflicts: [],
318
+ recommendations: [],
319
+ } as any);
320
+
321
+ // Sequence of prompts: first the main answers, then the merge strategy choice
322
+ const inquirer = await import('inquirer');
323
+ vi.mocked(inquirer.default.prompt)
324
+ .mockResolvedValueOnce({ setupMode: 'auto', sourceLanguage: 'en', localesDir: 'locales', seedTargetLocales: false } as any)
325
+ .mockResolvedValueOnce({ strategy: 'overwrite' } as any);
326
+
327
+ try {
328
+ await (command as any).parseAsync([], { from: 'user' });
329
+ } finally {
330
+ process.cwd = originalCwd;
331
+ }
332
+
333
+ // Ensure config was written with mergeStrategy set to overwrite
334
+ const wroteConfig = vi.mocked(fs.writeFile).mock.calls.find((c) => String(c[0]).endsWith('i18n.config.json'));
335
+ expect(wroteConfig).toBeDefined();
336
+ expect(String(wroteConfig![1])).toContain('"mergeStrategy": "overwrite"');
337
+ });
338
+ });
259
339
  });
260
340
 
261
341
  describe('parseGlobList', () => {
@@ -112,7 +112,7 @@ async function runNonInteractiveInit(commandOptions: InitCommandOptions): Promis
112
112
  let suggestedConfig: SuggestedConfig;
113
113
 
114
114
  if (intelligence) {
115
- const { framework, locales, filePatterns, confidence } = intelligence;
115
+ const { framework, locales, filePatterns } = intelligence;
116
116
 
117
117
  // Report detection results
118
118
  if (framework.type !== 'unknown') {
@@ -510,6 +510,13 @@ export function registerInit(program: Command) {
510
510
  return !isNaN(num) && num > 0 ? true : 'Please enter a positive number';
511
511
  },
512
512
  },
513
+ {
514
+ type: 'confirm',
515
+ name: 'seedTargetLocales',
516
+ message: 'Seed target locale files with placeholders for missing keys?',
517
+ when: () => true,
518
+ default: false,
519
+ },
513
520
  ]);
514
521
 
515
522
 
@@ -645,6 +652,12 @@ export function registerInit(program: Command) {
645
652
  };
646
653
  }
647
654
 
655
+ // Honor explicit interactive answer for seeding target locales regardless
656
+ // of which setup mode (auto/template/manual) produced the initial config.
657
+ if (typeof (answers as InitAnswers).seedTargetLocales === 'boolean' && config) {
658
+ config.seedTargetLocales = (answers as InitAnswers).seedTargetLocales;
659
+ }
660
+
648
661
  const mergeDecision = await maybePromptMergeStrategy(config, workspaceRoot, Boolean(commandOptions.merge));
649
662
  if (mergeDecision?.aborted) {
650
663
  console.log(chalk.yellow('Aborting init to avoid overwriting existing i18n assets. Re-run with --merge to bypass.'));
@@ -787,35 +800,33 @@ async function maybePromptMergeStrategy(
787
800
  }
788
801
  }
789
802
 
790
- if (!mergeRequested) {
791
- const { proceed } = await inquirer.prompt<{ proceed: boolean }>([
792
- {
793
- type: 'confirm',
794
- name: 'proceed',
795
- message: 'Merge with the existing setup instead of overwriting?',
796
- default: true,
797
- },
798
- ]);
799
- if (!proceed) {
800
- return { strategy: null, aborted: true };
801
- }
802
- }
803
+ // Single consolidated prompt for both --merge and interactive flows. When
804
+ // `--merge` is provided we omit the 'Abort' choice and default to
805
+ // 'keep-source'; otherwise include an explicit 'Abort' option (last).
806
+ const baseChoices = [
807
+ { name: 'Keep source values (append new keys only)', value: 'keep-source' },
808
+ { name: 'Overwrite with placeholders (backup first)', value: 'overwrite' },
809
+ { name: 'Prompt during sync to review changes interactively', value: 'interactive' },
810
+ ];
811
+
812
+ const choices = mergeRequested ? baseChoices : [...baseChoices, { name: 'Abort (do not touch existing files)', value: 'abort' }];
803
813
 
804
- const { strategy } = await inquirer.prompt<{ strategy: MergeStrategy }>([
814
+ const { strategy } = await inquirer.prompt<{
815
+ strategy: 'abort' | MergeStrategy;
816
+ }>([
805
817
  {
806
818
  type: 'list',
807
819
  name: 'strategy',
808
- message: 'Choose a merge strategy for existing locale keys',
809
- choices: [
810
- { name: 'Keep source values (append new keys only)', value: 'keep-source' },
811
- { name: 'Overwrite with placeholders (backup first)', value: 'overwrite' },
812
- { name: 'Interactive review during sync', value: 'interactive' },
813
- ],
820
+ message: 'Existing i18n assets detected choose how to proceed',
821
+ choices,
814
822
  default: 'keep-source',
815
823
  },
816
824
  ]);
817
825
 
818
- return { strategy, aborted: false };
826
+ if (strategy === 'abort') {
827
+ return { strategy: null, aborted: true };
828
+ }
829
+ return { strategy: strategy as MergeStrategy, aborted: false };
819
830
  } catch (error) {
820
831
  console.warn(chalk.gray(`Skipping merge diagnostics: ${(error as Error).message}`));
821
832
  return { strategy: null, aborted: false };
@@ -57,7 +57,14 @@ export function registerScan(program: Command) {
57
57
  .option('--exclude <patterns...>', 'Override exclude globs from config (comma or space separated)', collectTargetPatterns, [])
58
58
  .action(
59
59
  withErrorHandling(async (options: ScanOptions) => {
60
- console.log(chalk.blue('Starting scan...'));
60
+ // When JSON output is requested, avoid human-readable preamble on stdout
61
+ // so callers can reliably parse the JSON summary. Send banner to stderr
62
+ // when --json is used.
63
+ if (options.json) {
64
+ console.error(chalk.blue('Starting scan...'));
65
+ } else {
66
+ console.log(chalk.blue('Starting scan...'));
67
+ }
61
68
 
62
69
  try {
63
70
  const { config, projectRoot, configPath } = await loadConfigWithMeta(options.config);
@@ -48,7 +48,7 @@ interface SyncCommandOptions {
48
48
  invalidateCache?: boolean;
49
49
  autoRenameSuspicious?: boolean;
50
50
  renameMapFile?: string;
51
- namingConvention?: "kebab-case" | "camelCase" | "snake_case";
51
+ namingConvention?: "kebab-case" | "camelCase" | "snake_case" | "auto";
52
52
  rewriteShape?: "flat" | "nested";
53
53
  shapeDelimiter?: string;
54
54
  seedTargetLocales?: boolean;
@@ -193,7 +193,7 @@ export function registerSync(program: Command) {
193
193
  )
194
194
  .option(
195
195
  "--naming-convention <convention>",
196
- "Naming convention for auto-rename (kebab-case, camelCase, snake_case)",
196
+ "Naming convention for auto-rename (kebab-case, camelCase, snake_case, auto)",
197
197
  "kebab-case"
198
198
  )
199
199
  .option(
@@ -328,7 +328,14 @@ export function registerSync(program: Command) {
328
328
  : writeEnabled
329
329
  ? "Syncing locale files..."
330
330
  : "Checking locale drift...";
331
- console.log(chalk.blue(banner));
331
+ // When JSON output is requested, avoid human-readable preamble on stdout
332
+ // so callers can reliably parse the JSON summary. Send banner to stderr
333
+ // when --json is used.
334
+ if (options.json) {
335
+ console.error(chalk.blue(banner));
336
+ } else {
337
+ console.log(chalk.blue(banner));
338
+ }
332
339
 
333
340
  try {
334
341
  const { config, projectRoot, configPath } = await loadConfigWithMeta(
@@ -480,6 +487,8 @@ export function registerSync(program: Command) {
480
487
  namingConvention,
481
488
  workspaceRoot: projectRoot,
482
489
  allowExistingConflicts: true,
490
+ allExistingKeys: Object.keys(sourceData), // Pass all existing keys for convention detection
491
+ preserveExistingConvention: true, // Respect existing project conventions
483
492
  });
484
493
 
485
494
  if (report.safeProposals.length > 0) {
@@ -769,6 +778,18 @@ function printSyncSummary(summary: SyncSummary) {
769
778
  console.log(chalk.green("No unused locale keys detected."));
770
779
  }
771
780
 
781
+ if (summary.untranslatedKeys.length) {
782
+ console.log(chalk.blue("Untranslated keys (protected from pruning):"));
783
+ summary.untranslatedKeys.slice(0, 50).forEach((item) => {
784
+ console.log(` • ${item.key} (${item.locales.join(", ")})`);
785
+ });
786
+ if (summary.untranslatedKeys.length > 50) {
787
+ console.log(
788
+ chalk.gray(` ...and ${summary.untranslatedKeys.length - 50} more.`)
789
+ );
790
+ }
791
+ }
792
+
772
793
  if (summary.validation.interpolations) {
773
794
  if (summary.placeholderIssues.length) {
774
795
  console.log(chalk.yellow("Placeholder mismatches:"));
@@ -880,11 +901,29 @@ async function handleAutoRenameSuspicious(
880
901
  const sourceData = await localeStore.get(sourceLocale);
881
902
  const existingKeys = new Set(Object.keys(sourceData));
882
903
 
883
- // Generate rename proposals
904
+ // For auto-detection, collect all existing keys from all locales
905
+ let allExistingKeys: string[] | undefined;
884
906
  const namingConvention = options.namingConvention ?? "kebab-case";
907
+ if (namingConvention === "auto") {
908
+ try {
909
+ const allLocales = await localeStore.getStoredLocales();
910
+ const allKeys = new Set<string>();
911
+ for (const locale of allLocales) {
912
+ const localeData = await localeStore.get(locale);
913
+ Object.keys(localeData).forEach(key => allKeys.add(key));
914
+ }
915
+ allExistingKeys = Array.from(allKeys);
916
+ console.log(` Auto-detected naming convention from ${allKeys.size} existing keys`);
917
+ } catch (error) {
918
+ console.warn(chalk.yellow(" Warning: Could not load all locales for convention detection, falling back to kebab-case"));
919
+ }
920
+ }
921
+
922
+ // Generate rename proposals
885
923
  const report = generateRenameProposals(summary.suspiciousKeys, {
886
924
  existingKeys,
887
925
  namingConvention,
926
+ allExistingKeys,
888
927
  allowExistingConflicts: true,
889
928
  });
890
929
 
@@ -227,7 +227,14 @@ export function registerTransform(program: Command) {
227
227
  : writeEnabled
228
228
  ? 'Running transform (write mode)...'
229
229
  : 'Planning transform (dry-run)...';
230
- console.log(chalk.blue(banner));
230
+ // When JSON output is requested, avoid human-readable preamble on stdout
231
+ // so callers can reliably parse the JSON summary. Send banner to stderr
232
+ // when --json is used.
233
+ if (options.json) {
234
+ console.error(chalk.blue(banner));
235
+ } else {
236
+ console.log(chalk.blue(banner));
237
+ }
231
238
 
232
239
  try {
233
240
  const { config, projectRoot, configPath } = await loadConfigWithMeta(options.config);
package/src/index.ts CHANGED
@@ -24,7 +24,7 @@ export const program = new Command();
24
24
  program
25
25
  .name('i18nsmith')
26
26
  .description('Universal Automated i18n Library')
27
- .version('0.4.3');
27
+ .version('0.5.1');
28
28
 
29
29
  registerInit(program);
30
30
  registerScaffoldAdapter(program);