Skip to content

Commit 62c464d

Browse files
feat: standardize error handling in CLI (#261)
* feat: standardize error handling in CLI * fix: changset file to ensure all CI/CD checks pass --------- Co-authored-by: Replexica <[email protected]>
1 parent 02d69be commit 62c464d

File tree

9 files changed

+126
-21
lines changed

9 files changed

+126
-21
lines changed

.changeset/tidy-pillows-greet.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@replexica/cli": patch
3+
"replexica": patch
4+
---
5+
6+
This pr introduces a custom error handling base class for the CLI

packages/cli/src/cli/i18n.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { createAuthenticator } from './../workers/auth';
1010
import { ReplexicaEngine } from '@replexica/sdk';
1111
import { expandPlaceholderedGlob, createBucketLoader } from '../workers/bucket';
1212
import { ensureLockfileExists } from './lockfile';
13+
import { ReplexicaCLIError } from '../utils/errors';
1314

1415
export default new Command()
1516
.command('i18n')
@@ -34,13 +35,25 @@ export default new Command()
3435
const settings = await loadSettings(flags.apiKey);
3536

3637
if (!i18nConfig) {
37-
throw new Error('i18n.json not found. Please run `replexica init` to initialize the project.');
38+
throw new ReplexicaCLIError({
39+
message: 'i18n.json not found. Please run `replexica init` to initialize the project.',
40+
docUrl: "i18nNotFound"
41+
});
3842
} else if (!i18nConfig.buckets || !Object.keys(i18nConfig.buckets).length) {
39-
throw new Error('No buckets found in i18n.json. Please add at least one bucket containing i18n content.');
43+
throw new ReplexicaCLIError({
44+
message: "No buckets found in i18n.json. Please add at least one bucket containing i18n content.",
45+
docUrl: "bucketNotFound"
46+
});
4047
} else if (flags.locale && !i18nConfig.locale.targets.includes(flags.locale)) {
41-
throw new Error(`Source locale ${i18nConfig.locale.source} does not exist in i18n.json locale.targets. Please add it to the list and try again.`);
48+
throw new ReplexicaCLIError({
49+
message: `Source locale ${i18nConfig.locale.source} does not exist in i18n.json locale.targets. Please add it to the list and try again.`,
50+
docUrl: "localeTargetNotFound"
51+
});
4252
} else if (flags.bucket && !i18nConfig.buckets[flags.bucket]) {
43-
throw new Error(`Bucket ${flags.bucket} does not exist in i18n.json. Please add it to the list and try again.`);
53+
throw new ReplexicaCLIError({
54+
message: `Bucket ${flags.bucket} does not exist in i18n.json. Please add it to the list and try again.`,
55+
docUrl: "bucketNotFound"
56+
});
4457
} else {
4558
ora.succeed('Replexica configuration loaded');
4659
}
@@ -50,7 +63,10 @@ export default new Command()
5063
try {
5164
lockfileResult = await ensureLockfileExists();
5265
} catch (error: any) {
53-
throw new Error(`Failed to ensure lockfile exists: ${error.message}`);
66+
throw new ReplexicaCLIError({
67+
message: `Failed to ensure lockfile exists: ${error.message}`,
68+
docUrl: "lockFiletNotFound"
69+
});
5470
}
5571
if (lockfileResult === 'exists') {
5672
ora.succeed(`Lockfile exists`);
@@ -65,7 +81,10 @@ export default new Command()
6581
});
6682
const auth = await authenticator.whoami();
6783
if (!auth) {
68-
throw new Error('Not authenticated');
84+
throw new ReplexicaCLIError({
85+
message: 'Not authenticated',
86+
docUrl: "authError"
87+
});
6988
}
7089

7190
ora.start('Connecting to Replexica AI engine');
@@ -76,7 +95,10 @@ export default new Command()
7695
apiUrl: settings.auth.apiUrl,
7796
});
7897
} catch (error: any) {
79-
throw new Error(`Failed to initialize ReplexicaEngine: ${error.message}`);
98+
throw new ReplexicaCLIError({
99+
message: `Failed to initialize ReplexicaEngine: ${error.message}`,
100+
docUrl: "failedReplexicaEngine"
101+
});
80102
}
81103
ora.succeed('Replexica AI engine connected');
82104

@@ -102,7 +124,10 @@ export default new Command()
102124
}
103125
}
104126
} catch (error: any) {
105-
throw new Error(`Failed to expand placeholdered globs: ${error.message}`);
127+
throw new ReplexicaCLIError({
128+
message: `Failed to expand placeholdered globs: ${error.message}`,
129+
docUrl: "placeHolderFailed"
130+
});
106131
}
107132

108133
const lockfileProcessor = createLockfileProcessor();
@@ -164,7 +189,10 @@ export default new Command()
164189
};
165190

166191
if (flags.frozen && (payloadStats.processable > 0 || payloadStats.deleted > 0)) {
167-
throw new Error(`Translations are not up to date. Run the command without the --frozen flag to update the translations, then try again.`);
192+
throw new ReplexicaCLIError({
193+
message: `Translations are not up to date. Run the command without the --frozen flag to update the translations, then try again.`,
194+
docUrl: "translationFailed"
195+
});
168196
}
169197

170198
let processedPayload: Record<string, string> = {};

packages/cli/src/cli/show/files.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Z from 'zod';
55
import { loadConfig } from "../../workers/config";
66
import { bucketTypeSchema } from "@replexica/spec";
77
import { expandPlaceholderedGlob } from "../../workers/bucket";
8+
import { ReplexicaCLIError } from "../../utils/errors";
89

910
export default new Command()
1011
.command("files")
@@ -16,7 +17,10 @@ export default new Command()
1617
const i18nConfig = await loadConfig();
1718

1819
if (!i18nConfig) {
19-
throw new Error('i18n.json not found. Please run `replexica init` to initialize the project.');
20+
throw new ReplexicaCLIError({
21+
message: 'i18n.json not found. Please run `replexica init` to initialize the project.',
22+
docUrl: "i18nNotFound"
23+
});
2024
}
2125

2226
// Expand the placeholdered globs into actual (placeholdered) paths
@@ -38,7 +42,10 @@ export default new Command()
3842
}
3943
}
4044
} catch (error: any) {
41-
throw new Error(`Failed to expand placeholdered globs: ${error.message}`);
45+
throw new ReplexicaCLIError({
46+
message: `Failed to expand placeholdered globs: ${error.message}`,
47+
docUrl: "placeHolderFailed"
48+
});
4249
}
4350

4451
const files: string[] = [];

packages/cli/src/cli/show/locale.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import _ from "lodash";
33
import Z from 'zod';
44
import Ora from 'ora';
55
import { localeCodes } from "@replexica/spec";
6+
import { ReplexicaCLIError } from "../../utils/errors";
67

78
export default new Command()
89
.command("locale")
@@ -14,7 +15,10 @@ export default new Command()
1415
const ora = Ora();
1516
try {
1617
switch (type) {
17-
default: throw new Error(`Invalid type: ${type}`);
18+
default: throw new ReplexicaCLIError({
19+
message: `Invalid type: ${type}`,
20+
docUrl: 'invalidType'
21+
});
1822
case 'sources':
1923
localeCodes.forEach((locale) => console.log(locale));
2024
break;

packages/cli/src/utils/errors.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export const docLinks = {
2+
i18nNotFound: "https://docs.replexica.com/quickstart#initialization",
3+
bucketNotFound: "https://docs.replexica.com/config#buckets",
4+
authError: "https://docs.replexica.com/auth",
5+
localeTargetNotFound: "https://docs.replexica.com/config#locale",
6+
lockFiletNotFound: "https://docs.replexica.com/config#i18n-lock",
7+
failedReplexicaEngine: "https://docs.replexica.com/",
8+
placeHolderFailed: "https://docs.replexica.com/formats/json",
9+
translationFailed: "https://docs.replexica.com/setup/cli#core-command-i18n",
10+
connectionFailed: "https://docs.replexica.com/",
11+
invalidType: "https://docs.replexica.com/setup/cli#configuration-commands",
12+
invalidPathPattern: "https://docs.replexica.com/config#buckets",
13+
androidResouceError: "https://docs.replexica.com/formats/android",
14+
invalidBucketType: "https://docs.replexica.com/config#buckets",
15+
invalidStringDict: "https://docs.replexica.com/formats/xcode-stringsdict",
16+
};
17+
18+
type DocLinkKeys = keyof typeof docLinks;
19+
20+
export class ReplexicaCLIError extends Error {
21+
public readonly docUrl: string;
22+
23+
constructor({ message, docUrl }: { message: string; docUrl: DocLinkKeys }) {
24+
super(message);
25+
this.docUrl = docLinks[docUrl];
26+
this.message = `${this.message}\n visit: ${this.docUrl}`;
27+
}
28+
}

packages/cli/src/workers/auth.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { ReplexicaCLIError } from "../utils/errors";
2+
13
export type AuthenticatorParams = {
24
apiUrl: string;
35
apiKey: string;
@@ -32,7 +34,10 @@ export function createAuthenticator(params: AuthenticatorParams) {
3234
} catch (error) {
3335
const isNetworkError = error instanceof TypeError && error.message === "fetch failed";
3436
if (isNetworkError) {
35-
throw new Error(`Failed to connect to the API at ${params.apiUrl}. Please check your connection and try again.`);
37+
throw new ReplexicaCLIError({
38+
message: `Failed to connect to the API at ${params.apiUrl}. Please check your connection and try again.`,
39+
docUrl: "connectionFailed"
40+
});
3641
} else {
3742
throw error;
3843
}

packages/cli/src/workers/bucket/android.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { parseStringPromise, Builder } from 'xml2js';
22
import { BucketLoader } from './_base';
3+
import { ReplexicaCLIError } from '../../utils/errors';
34

45
interface AndroidResource {
56
$: {
@@ -66,7 +67,10 @@ export const androidLoader = (): BucketLoader<string, Record<string, any>> => ({
6667
return result;
6768
} catch (error) {
6869
console.error('Error parsing Android resource file:', error);
69-
throw new Error('Failed to parse Android resource file');
70+
throw new ReplexicaCLIError({
71+
message: 'Failed to parse Android resource file',
72+
docUrl: "androidResouceError"
73+
});
7074
}
7175
},
7276

packages/cli/src/workers/bucket/index.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,37 @@ import { propertiesLoader } from './properties';
1616
import { xcodeStringsLoader } from './xcode-strings';
1717
import { xcodeStringsdictLoader } from './xcode-stringsdict';
1818
import { flutterLoader } from './flutter';
19+
import { ReplexicaCLIError } from '../../utils/errors';
1920

2021
// Path expansion
2122
export function expandPlaceholderedGlob(pathPattern: string, sourceLocale: string): string[] {
2223
// Throw if pathPattern is an absolute path
2324
if (path.isAbsolute(pathPattern)) {
24-
throw new Error(`Invalid path pattern: ${pathPattern}. Path pattern must be relative.`);
25+
throw new ReplexicaCLIError({
26+
message: `Invalid path pattern: ${pathPattern}. Path pattern must be relative.`,
27+
docUrl: 'invalidPathPattern'
28+
});
2529
}
2630
// Throw if pathPattern points outside the current working directory
2731
if (path.relative(process.cwd(), pathPattern).startsWith('..')) {
28-
throw new Error(`Invalid path pattern: ${pathPattern}. Path pattern must be within the current working directory.`);
32+
throw new ReplexicaCLIError({
33+
message: `Invalid path pattern: ${pathPattern}. Path pattern must be within the current working directory.`,
34+
docUrl: "invalidPathPattern"
35+
});
2936
}
3037
// Throw error if pathPattern contains "**" – we don't support recursive path patterns
3138
if (pathPattern.includes('**')) {
32-
throw new Error(`Invalid path pattern: ${pathPattern}. Recursive path patterns are not supported.`);
39+
throw new ReplexicaCLIError({
40+
message: `Invalid path pattern: ${pathPattern}. Recursive path patterns are not supported.`,
41+
docUrl: 'invalidPathPattern'
42+
});
3343
}
3444
// Throw error if pathPattern contains "[locale]" several times
3545
if (pathPattern.split('[locale]').length > 2) {
36-
throw new Error(`Invalid path pattern: ${pathPattern}. Path pattern must contain at most one "[locale]" placeholder.`);
46+
throw new ReplexicaCLIError({
47+
message: `Invalid path pattern: ${pathPattern}. Path pattern must contain at most one "[locale]" placeholder.`,
48+
docUrl: "invalidPathPattern"
49+
});
3750
}
3851
// Break down path pattern into parts
3952
const pathPatternChunks = pathPattern.split(path.sep);
@@ -77,7 +90,10 @@ export function createBucketLoader(params: CreateBucketLoaderParams) {
7790
const filepath = params.placeholderedPath.replace(/\[locale\]/g, params.locale);
7891
switch (params.bucketType) {
7992
default:
80-
throw new Error(`Unsupported bucket type: ${params.bucketType}`);
93+
throw new ReplexicaCLIError({
94+
message: `Unsupported bucket type: ${params.bucketType}`,
95+
docUrl: 'invalidBucketType'
96+
});
8197
case 'markdown':
8298
return composeLoaders<string, Record<string, string>>(
8399
textLoader(filepath),

packages/cli/src/workers/bucket/xcode-stringsdict.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
import plist from 'plist';
22
import { BucketLoader } from './_base';
3+
import { ReplexicaCLIError } from '../../utils/errors';
34

45
export const xcodeStringsdictLoader = (): BucketLoader<string, Record<string, any>> => ({
56
async load(text: string) {
67
try {
78
const parsed = plist.parse(text);
89
if (typeof parsed !== 'object' || parsed === null) {
9-
throw new Error('Invalid .stringsdict format');
10+
throw new ReplexicaCLIError({
11+
message: 'Invalid .stringsdict format',
12+
docUrl: "invalidStringDict"
13+
});
1014
}
1115
return parsed as Record<string, any>;
1216
} catch (error: any) {
13-
throw new Error(`Invalid .stringsdict format: ${error.message}`);
17+
throw new ReplexicaCLIError({
18+
message: `Invalid .stringsdict format: ${error.message}`,
19+
docUrl: "invalidStringDict"
20+
});
1421
}
1522
},
1623

0 commit comments

Comments
 (0)