i18nsmith 0.2.1 → 0.3.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.
Files changed (53) hide show
  1. package/dist/commands/audit.d.ts.map +1 -1
  2. package/dist/commands/backup.d.ts.map +1 -1
  3. package/dist/commands/check.d.ts +25 -0
  4. package/dist/commands/check.d.ts.map +1 -1
  5. package/dist/commands/config.d.ts.map +1 -1
  6. package/dist/commands/debug-patterns.d.ts.map +1 -1
  7. package/dist/commands/diagnose.d.ts.map +1 -1
  8. package/dist/commands/init.d.ts.map +1 -1
  9. package/dist/commands/install-hooks.d.ts.map +1 -1
  10. package/dist/commands/preflight.d.ts.map +1 -1
  11. package/dist/commands/rename.d.ts.map +1 -1
  12. package/dist/commands/review.d.ts.map +1 -1
  13. package/dist/commands/scaffold-adapter.d.ts.map +1 -1
  14. package/dist/commands/scan.d.ts.map +1 -1
  15. package/dist/commands/sync.d.ts +1 -1
  16. package/dist/commands/sync.d.ts.map +1 -1
  17. package/dist/commands/transform.d.ts.map +1 -1
  18. package/dist/commands/translate/index.d.ts.map +1 -1
  19. package/dist/index.js +2536 -107783
  20. package/dist/rename-suspicious.test.d.ts +2 -0
  21. package/dist/rename-suspicious.test.d.ts.map +1 -0
  22. package/dist/utils/diff-utils.d.ts +5 -0
  23. package/dist/utils/diff-utils.d.ts.map +1 -1
  24. package/dist/utils/errors.d.ts +8 -0
  25. package/dist/utils/errors.d.ts.map +1 -0
  26. package/dist/utils/locale-audit.d.ts +39 -0
  27. package/dist/utils/locale-audit.d.ts.map +1 -0
  28. package/dist/utils/preview.d.ts.map +1 -1
  29. package/package.json +5 -5
  30. package/src/commands/audit.ts +18 -209
  31. package/src/commands/backup.ts +67 -63
  32. package/src/commands/check.ts +119 -68
  33. package/src/commands/config.ts +117 -95
  34. package/src/commands/debug-patterns.ts +25 -22
  35. package/src/commands/diagnose.ts +29 -26
  36. package/src/commands/init.ts +84 -79
  37. package/src/commands/install-hooks.ts +18 -15
  38. package/src/commands/preflight.ts +21 -13
  39. package/src/commands/rename.ts +86 -81
  40. package/src/commands/review.ts +81 -78
  41. package/src/commands/scaffold-adapter.ts +8 -4
  42. package/src/commands/scan.ts +61 -58
  43. package/src/commands/sync.ts +640 -203
  44. package/src/commands/transform.ts +46 -18
  45. package/src/commands/translate/index.ts +7 -4
  46. package/src/e2e.test.ts +34 -14
  47. package/src/integration.test.ts +86 -0
  48. package/src/rename-suspicious.test.ts +124 -0
  49. package/src/utils/diff-utils.ts +6 -0
  50. package/src/utils/errors.ts +34 -0
  51. package/src/utils/locale-audit.ts +219 -0
  52. package/src/utils/preview.test.ts +43 -0
  53. package/src/utils/preview.ts +2 -8
@@ -7,6 +7,7 @@ import { Transformer } from '@i18nsmith/transformer';
7
7
  import type { TransformProgress, TransformSummary } from '@i18nsmith/transformer';
8
8
  import { printLocaleDiffs, writeLocaleDiffPatches } from '../utils/diff-utils.js';
9
9
  import { applyPreviewFile, writePreviewFile } from '../utils/preview.js';
10
+ import { CliError, withErrorHandling } from '../utils/errors.js';
10
11
 
11
12
  interface TransformOptions {
12
13
  config?: string;
@@ -102,16 +103,26 @@ function createProgressLogger() {
102
103
  let lastPercent = -1;
103
104
  let lastLogTime = 0;
104
105
  let pendingCarriageReturn = false;
106
+ let lastPlainLog = 0;
107
+ let emittedZero = false;
108
+ const isTTY = Boolean(process.stdout.isTTY);
109
+ const minPercentStep = isTTY ? 5 : 15;
110
+ const minIntervalMs = isTTY ? 1000 : 4000;
105
111
 
106
112
  const writeLine = (line: string, final = false) => {
107
- if (process.stdout.isTTY) {
113
+ if (isTTY) {
108
114
  process.stdout.write(`\r${line}`);
109
115
  pendingCarriageReturn = !final;
110
116
  if (final) {
111
117
  process.stdout.write('\n');
112
118
  }
113
- } else {
119
+ return;
120
+ }
121
+
122
+ const now = Date.now();
123
+ if (final || now - lastPlainLog >= minIntervalMs) {
114
124
  console.log(line);
125
+ lastPlainLog = now;
115
126
  }
116
127
  };
117
128
 
@@ -122,25 +133,40 @@ function createProgressLogger() {
122
133
  }
123
134
 
124
135
  const now = Date.now();
125
- const shouldLog =
126
- progress.processed === progress.total ||
127
- progress.percent === 0 ||
128
- progress.percent >= lastPercent + 2 ||
129
- now - lastLogTime > 1500;
136
+ const computedPercent =
137
+ typeof progress.percent === 'number'
138
+ ? Math.max(0, Math.min(100, Math.round(progress.percent)))
139
+ : progress.total
140
+ ? Math.round((progress.processed / progress.total) * 100)
141
+ : 0;
142
+ const reachedEnd = progress.processed === progress.total;
143
+ const percentAdvanced =
144
+ (!emittedZero && computedPercent === 0) || computedPercent >= lastPercent + minPercentStep || reachedEnd;
145
+ const timedOut = now - lastLogTime >= minIntervalMs;
130
146
 
131
- if (!shouldLog) {
147
+ if (!percentAdvanced && !timedOut) {
132
148
  return;
133
149
  }
134
150
 
135
- lastPercent = progress.percent;
151
+ if (!emittedZero && computedPercent === 0) {
152
+ emittedZero = true;
153
+ }
154
+
155
+ lastPercent = Math.max(lastPercent, computedPercent);
136
156
  lastLogTime = now;
157
+
158
+ const remaining =
159
+ typeof progress.remaining === 'number'
160
+ ? progress.remaining
161
+ : Math.max(progress.total - progress.processed, 0);
162
+
137
163
  const line =
138
- `Applying transforms ${progress.processed}/${progress.total} (${progress.percent}%)` +
164
+ `Applying transforms ${progress.processed}/${progress.total} (${computedPercent}%)` +
139
165
  ` | applied: ${progress.applied ?? 0}` +
140
166
  ` | skipped: ${progress.skipped ?? 0}` +
141
167
  (progress.errors ? ` | errors: ${progress.errors}` : '') +
142
- ` | remaining: ${progress.remaining ?? progress.total - progress.processed}`;
143
- writeLine(line, progress.processed === progress.total);
168
+ ` | remaining: ${remaining}`;
169
+ writeLine(line, reachedEnd);
144
170
  },
145
171
  flush() {
146
172
  if (pendingCarriageReturn && process.stdout.isTTY) {
@@ -166,7 +192,8 @@ export function registerTransform(program: Command) {
166
192
  .option('--migrate-text-keys', 'Migrate existing t("Text") calls to structured keys')
167
193
  .option('--preview-output <path>', 'Write preview summary (JSON) to a file (implies dry-run)')
168
194
  .option('--apply-preview <path>', 'Apply a previously saved transform preview JSON file safely')
169
- .action(async (options: TransformOptions) => {
195
+ .action(
196
+ withErrorHandling(async (options: TransformOptions) => {
170
197
  if (options.applyPreview) {
171
198
  await applyPreviewFile('transform', options.applyPreview);
172
199
  return;
@@ -247,13 +274,14 @@ export function registerTransform(program: Command) {
247
274
  console.log(chalk.yellow('Run again with --write to apply these changes.'));
248
275
  }
249
276
  } catch (error) {
250
- const errorMessage = (error as Error).message;
277
+ const errorMessage = error instanceof Error ? error.message : String(error);
251
278
  if (options.json) {
252
279
  console.log(JSON.stringify({ ok: false, error: { message: errorMessage } }, null, 2));
253
- } else {
254
- console.error(chalk.red('Transform failed:'), errorMessage);
280
+ process.exitCode = 1;
281
+ return;
255
282
  }
256
- process.exitCode = 1;
283
+ throw new CliError(`Transform failed: ${errorMessage}`);
257
284
  }
258
- });
285
+ })
286
+ );
259
287
  }
@@ -21,6 +21,7 @@ import { emitTranslateOutput, maybePrintEstimate } from './reporter.js';
21
21
  import { handleCsvExport, handleCsvImport } from './csv-handler.js';
22
22
  import { executeTranslations } from './executor.js';
23
23
  import { applyPreviewFile, writePreviewFile } from '../../utils/preview.js';
24
+ import { CliError, withErrorHandling } from '../../utils/errors.js';
24
25
 
25
26
  // Re-export types for external use
26
27
  export * from './types.js';
@@ -102,7 +103,8 @@ export function registerTranslate(program: Command): void {
102
103
  .option('--import <path>', 'Import translations from a CSV file and merge into locale files')
103
104
  .option('--preview-output <path>', 'Write preview summary (JSON) to a file (implies dry-run)')
104
105
  .option('--apply-preview <path>', 'Apply a previously saved translate preview JSON file safely')
105
- .action(async (options: TranslateCommandOptions) => {
106
+ .action(
107
+ withErrorHandling(async (options: TranslateCommandOptions) => {
106
108
  if (options.applyPreview) {
107
109
  await applyPreviewFile('translate', options.applyPreview, ['--yes']);
108
110
  return;
@@ -233,8 +235,9 @@ export function registerTranslate(program: Command): void {
233
235
  ? error
234
236
  : new Error(typeof error === 'string' ? error : JSON.stringify(error));
235
237
 
236
- console.error(chalk.red('Translate failed:'), translatorError?.message ?? normalizedError.message);
237
- process.exitCode = 1;
238
+ const message = translatorError?.message ?? normalizedError.message;
239
+ throw new CliError(`Translate failed: ${message}`);
238
240
  }
239
- });
241
+ })
242
+ );
240
243
  }
package/src/e2e.test.ts CHANGED
@@ -85,6 +85,17 @@ function extractJson<T>(output: string): T {
85
85
  return JSON.parse(jsonMatch[0]);
86
86
  }
87
87
 
88
+ function parseRenameMap(content: string): Record<string, string> {
89
+ const mapping: Record<string, string> = {};
90
+ for (const line of content.split('\n')) {
91
+ const match = line.trim().match(/^"([^"]+)"\s*=\s*"([^"]+)"/);
92
+ if (match) {
93
+ mapping[match[1]] = match[2];
94
+ }
95
+ }
96
+ return mapping;
97
+ }
98
+
88
99
  beforeAll(async () => {
89
100
  await ensureCliBuilt(CLI_PATH);
90
101
  });
@@ -258,20 +269,29 @@ describe('E2E Fixture Tests', () => {
258
269
 
259
270
  const enLocalePath = path.join(fixtureDir, 'locales', 'en.json');
260
271
  const frLocalePath = path.join(fixtureDir, 'locales', 'fr.json');
261
- const enLocale = JSON.parse(await fs.readFile(enLocalePath, 'utf8'));
262
- const frLocale = JSON.parse(await fs.readFile(frLocalePath, 'utf8'));
263
-
264
- expect(enLocale).toHaveProperty('common.hello-world');
265
- expect(enLocale).not.toHaveProperty('Hello World');
266
- expect(frLocale).toHaveProperty('common.hello-world');
267
-
268
- const sourceFile = await fs.readFile(path.join(fixtureDir, 'src', 'BadKeys.tsx'), 'utf8');
269
- expect(sourceFile).toContain("t('common.hello-world')");
270
- expect(sourceFile).not.toContain("t('Hello World')");
271
-
272
- const mapPath = path.join(fixtureDir, 'auto-rename-map.txt');
273
- const mapContents = await fs.readFile(mapPath, 'utf8');
274
- expect(mapContents).toContain('"Hello World" = "common.hello-world"');
272
+ const enLocale = JSON.parse(await fs.readFile(enLocalePath, 'utf8'));
273
+ const frLocale = JSON.parse(await fs.readFile(frLocalePath, 'utf8'));
274
+
275
+ expect(enLocale).not.toHaveProperty('Hello World');
276
+ expect(frLocale).not.toHaveProperty('Hello World');
277
+
278
+ const mapPath = path.join(fixtureDir, 'auto-rename-map.txt');
279
+ const mapContents = await fs.readFile(mapPath, 'utf8');
280
+ expect(mapContents).toContain('"Hello World" = ');
281
+ const renameMap = parseRenameMap(mapContents);
282
+ const helloKey = renameMap['Hello World'];
283
+ expect(helloKey).toBeDefined();
284
+ const submitKey = renameMap['buttons.submit'];
285
+ expect(submitKey).toBeDefined();
286
+
287
+ expect(enLocale).toHaveProperty(helloKey!);
288
+ expect(frLocale).toHaveProperty(helloKey!);
289
+ expect(enLocale).toHaveProperty(submitKey!);
290
+ expect(frLocale).toHaveProperty(submitKey!);
291
+
292
+ const sourceFile = await fs.readFile(path.join(fixtureDir, 'src', 'BadKeys.tsx'), 'utf8');
293
+ expect(sourceFile).toContain(`t('${helloKey!}')`);
294
+ expect(sourceFile).not.toContain("t('Hello World')");
275
295
  });
276
296
  });
277
297
 
@@ -281,6 +281,45 @@ export function App() {
281
281
  expect(enData).not.toHaveProperty('existing.key');
282
282
  });
283
283
 
284
+ it('should delegate --check to the health check command', async () => {
285
+ await fs.writeFile(
286
+ path.join(tmpDir, 'src', 'App.tsx'),
287
+ `import { useTranslation } from 'react-i18next';
288
+ export function App() {
289
+ const { t } = useTranslation();
290
+ return <div>{t('existing.key')}</div>;
291
+ }`
292
+ );
293
+
294
+ const result = runCli(['sync', '--check'], { cwd: tmpDir });
295
+
296
+ expect(result.exitCode).toBe(0);
297
+ expect(result.output).toContain('guided repository health check');
298
+ });
299
+
300
+ it('should fail via delegated check when using --check --strict and audit issues exist', async () => {
301
+ const duplicateLocale = {
302
+ 'existing.key': 'Existing Value',
303
+ 'buttons.submit': 'Submit',
304
+ 'cta.submit': 'Submit',
305
+ };
306
+ await fs.writeFile(path.join(tmpDir, 'locales', 'en.json'), JSON.stringify(duplicateLocale, null, 2));
307
+ await fs.writeFile(path.join(tmpDir, 'locales', 'fr.json'), JSON.stringify(duplicateLocale, null, 2));
308
+ await fs.writeFile(
309
+ path.join(tmpDir, 'src', 'App.tsx'),
310
+ `import { useTranslation } from 'react-i18next';
311
+ export function App() {
312
+ const { t } = useTranslation();
313
+ return <div>{t('existing.key')}</div>;
314
+ }`
315
+ );
316
+
317
+ const result = runCli(['sync', '--check', '--strict'], { cwd: tmpDir });
318
+
319
+ expect(result.exitCode).toBe(10);
320
+ expect(result.output).toContain('Audit detected');
321
+ });
322
+
284
323
  it('should create backup when pruning', async () => {
285
324
  await fs.writeFile(
286
325
  path.join(tmpDir, 'src', 'App.tsx'),
@@ -445,5 +484,52 @@ export function App() {
445
484
  expect(parsed).toHaveProperty('diagnostics');
446
485
  expect(parsed).toHaveProperty('sync');
447
486
  });
487
+
488
+ it('should surface locale audit results and fail with --audit-strict', async () => {
489
+ const duplicateLocale = {
490
+ 'hello': 'Hello',
491
+ 'buttons.submit': 'Submit',
492
+ 'cta.submit': 'Submit',
493
+ };
494
+ await fs.writeFile(path.join(tmpDir, 'locales', 'en.json'), JSON.stringify(duplicateLocale, null, 2));
495
+ await fs.writeFile(path.join(tmpDir, 'locales', 'fr.json'), JSON.stringify(duplicateLocale, null, 2));
496
+ await fs.writeFile(
497
+ path.join(tmpDir, 'src', 'App.tsx'),
498
+ `import { useTranslation } from 'react-i18next';
499
+ export function App() {
500
+ const { t } = useTranslation();
501
+ return <div>{t('hello')}</div>;
502
+ }`
503
+ );
504
+
505
+ const result = runCli(['check', '--audit', '--audit-strict'], { cwd: tmpDir });
506
+
507
+ expect(result.exitCode).toBe(10);
508
+ expect(result.output).toContain('Locale quality audit');
509
+ });
510
+
511
+ it('should include audit payload in JSON output when --audit is set', async () => {
512
+ const duplicateLocale = {
513
+ 'hello': 'Hello',
514
+ 'buttons.submit': 'Submit',
515
+ 'cta.submit': 'Submit',
516
+ };
517
+ await fs.writeFile(path.join(tmpDir, 'locales', 'en.json'), JSON.stringify(duplicateLocale, null, 2));
518
+ await fs.writeFile(path.join(tmpDir, 'locales', 'fr.json'), JSON.stringify(duplicateLocale, null, 2));
519
+ await fs.writeFile(
520
+ path.join(tmpDir, 'src', 'App.tsx'),
521
+ `import { useTranslation } from 'react-i18next';
522
+ export function App() {
523
+ const { t } = useTranslation();
524
+ return <div>{t('hello')}</div>;
525
+ }`
526
+ );
527
+
528
+ const result = runCli(['check', '--audit', '--json'], { cwd: tmpDir });
529
+ const parsed = extractJson<{ audit?: { totalQualityIssues: number } }>(result.stdout);
530
+
531
+ expect(parsed).toHaveProperty('audit');
532
+ expect(parsed.audit?.totalQualityIssues ?? 0).toBeGreaterThan(0);
533
+ });
448
534
  });
449
535
  });
@@ -0,0 +1,124 @@
1
+
2
+ import { describe, it, expect, beforeAll, afterEach } from 'vitest';
3
+ import fs from 'fs/promises';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ import { spawnSync } from 'child_process';
7
+ import { fileURLToPath } from 'url';
8
+ import { ensureCliBuilt } from './test-helpers/ensure-cli-built';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+
13
+ const CLI_PATH = path.resolve(__dirname, '../dist/index.js');
14
+ const FIXTURES_DIR = path.resolve(__dirname, './fixtures');
15
+
16
+ function runCli(
17
+ args: string[],
18
+ options: { cwd?: string } = {}
19
+ ): { stdout: string; stderr: string; output: string; exitCode: number } {
20
+ const result = spawnSync('node', [CLI_PATH, ...args], {
21
+ cwd: options.cwd ?? process.cwd(),
22
+ encoding: 'utf8',
23
+ timeout: 30000,
24
+ env: {
25
+ ...process.env,
26
+ CI: 'true',
27
+ NO_COLOR: '1',
28
+ FORCE_COLOR: '0',
29
+ },
30
+ });
31
+
32
+ return {
33
+ stdout: result.stdout ?? '',
34
+ stderr: result.stderr ?? '',
35
+ output: (result.stdout ?? '') + (result.stderr ?? ''),
36
+ exitCode: result.status ?? 1,
37
+ };
38
+ }
39
+
40
+ async function setupFixture(fixtureName: string): Promise<string> {
41
+ const fixtureSource = path.join(FIXTURES_DIR, fixtureName);
42
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), `i18nsmith-e2e-${fixtureName}-`));
43
+ await fs.cp(fixtureSource, tmpDir, { recursive: true });
44
+ return tmpDir;
45
+ }
46
+
47
+ async function cleanupFixture(fixtureDir: string): Promise<void> {
48
+ await fs.rm(fixtureDir, { recursive: true, force: true });
49
+ }
50
+
51
+ describe('Rename Suspicious Keys E2E', () => {
52
+ let tmpDir: string;
53
+
54
+ beforeAll(async () => {
55
+ await ensureCliBuilt(CLI_PATH);
56
+ });
57
+
58
+ afterEach(async () => {
59
+ if (tmpDir) {
60
+ await cleanupFixture(tmpDir);
61
+ }
62
+ });
63
+
64
+ it('should generate rename diffs when running sync with auto-rename and preview output', async () => {
65
+ tmpDir = await setupFixture('suspicious-keys');
66
+ const previewPath = path.join(tmpDir, 'preview.json');
67
+
68
+ const { output, exitCode } = runCli(
69
+ ['sync', '--diff', '--auto-rename-suspicious', '--preview-output', previewPath],
70
+ { cwd: tmpDir }
71
+ );
72
+
73
+ expect(exitCode).toBe(0);
74
+ expect(output).toContain('Preview written to');
75
+
76
+ const previewContent = await fs.readFile(previewPath, 'utf8');
77
+ const preview = JSON.parse(previewContent);
78
+
79
+ expect(preview.summary).toBeDefined();
80
+ expect(preview.summary.suspiciousKeys.length).toBeGreaterThan(0);
81
+
82
+ // Check for renameDiffs
83
+ expect(preview.summary.renameDiffs).toBeDefined();
84
+ expect(preview.summary.renameDiffs.length).toBeGreaterThan(0);
85
+
86
+ // Check for localeDiffs
87
+ expect(preview.summary.localeDiffs).toBeDefined();
88
+ expect(preview.summary.localeDiffs.length).toBeGreaterThan(0);
89
+
90
+ // Verify specific rename proposal
91
+ const renameDiff = preview.summary.renameDiffs.find((d: any) => d.relativePath.includes('BadKeys.tsx'));
92
+ expect(renameDiff).toBeDefined();
93
+ // console.log('Actual diff:', renameDiff.diff);
94
+ expect(renameDiff.diff).toContain('- <h1>{t(\'Hello World\')}</h1>');
95
+ // Key generation includes hash and file slug
96
+ expect(renameDiff.diff).toMatch(/\+ <h1>{t\('common\.badkeys\.hello-world\.[a-f0-9]+'\)}<\/h1>/);
97
+ });
98
+
99
+ it('should handle existing keys that are suspicious (key-equals-value or contains-spaces)', async () => {
100
+ // This covers the user's specific scenario: key exists in locale but is suspicious
101
+ tmpDir = await setupFixture('suspicious-keys');
102
+ const previewPath = path.join(tmpDir, 'preview.json');
103
+
104
+ // "Hello World" exists in en.json and is suspicious
105
+ const { output, exitCode } = runCli(
106
+ ['sync', '--diff', '--auto-rename-suspicious', '--preview-output', previewPath],
107
+ { cwd: tmpDir }
108
+ );
109
+
110
+ expect(exitCode).toBe(0);
111
+
112
+ const preview = JSON.parse(await fs.readFile(previewPath, 'utf8'));
113
+
114
+ // Verify "Hello World" is in suspiciousKeys
115
+ const helloWorldSuspicious = preview.summary.suspiciousKeys.find((k: any) => k.key === 'Hello World');
116
+ expect(helloWorldSuspicious).toBeDefined();
117
+ // It might be 'contains-spaces' or 'key-equals-value' depending on priority
118
+ expect(['key-equals-value', 'contains-spaces']).toContain(helloWorldSuspicious.reason);
119
+
120
+ // Verify it has a rename diff
121
+ const renameDiff = preview.summary.renameDiffs.find((d: any) => d.relativePath.includes('BadKeys.tsx'));
122
+ expect(renameDiff).toBeDefined();
123
+ });
124
+ });
@@ -1,3 +1,9 @@
1
+ /**
2
+ * CLI presentation layer for diff utilities.
3
+ * This module handles printing and writing locale diffs for CLI commands.
4
+ * Core diff building logic is in packages/core/src/diff-utils.ts.
5
+ */
6
+
1
7
  import fs from 'fs/promises';
2
8
  import path from 'path';
3
9
  import chalk from 'chalk';
@@ -0,0 +1,34 @@
1
+ import chalk from 'chalk';
2
+
3
+ export class CliError extends Error {
4
+ constructor(message: string, public exitCode = 1) {
5
+ super(message);
6
+ this.name = 'CliError';
7
+ }
8
+ }
9
+
10
+ type MaybePromise<T> = T | Promise<T>;
11
+
12
+ export function withErrorHandling<A extends unknown[], R>(
13
+ action: (...args: A) => MaybePromise<R>
14
+ ): (...args: A) => Promise<R | undefined> {
15
+ return async (...args: A): Promise<R | undefined> => {
16
+ try {
17
+ return (await action(...args)) as R;
18
+ } catch (error) {
19
+ if (error instanceof CliError) {
20
+ console.error(chalk.red(error.message));
21
+ process.exitCode = error.exitCode;
22
+ return undefined;
23
+ }
24
+
25
+ const message = error instanceof Error ? error.message : String(error);
26
+ console.error(chalk.red(`Unexpected error: ${message}`));
27
+ if (error instanceof Error && error.stack) {
28
+ console.error(chalk.gray(error.stack));
29
+ }
30
+ process.exitCode = 1;
31
+ return undefined;
32
+ }
33
+ };
34
+ }