diff --git a/lib/src/index.ts b/lib/src/index.ts index 5e7c508..e934cc7 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -10,6 +10,7 @@ import initAPIToken from "./services/apiToken/initAPIToken"; import { initProjectConfig } from "./services/projectConfig"; import appContext from "./utils/appContext"; import type commander from "commander"; +import { ErrorType, isDittoError, isDittoErrorType } from "./utils/DittoError"; type Command = "pull"; @@ -87,18 +88,33 @@ const executeCommand = async ( } } } catch (error) { - const eventId = Sentry.captureException(error); - const eventStr = `\n\nError ID: ${logger.info(eventId)}`; - if (process.env.DEBUG === "true") { console.error(logger.info("Development stack trace:\n"), error); } - return await quit( - logger.errorText( - "Something went wrong. Please contact support or try again later." - ) + eventStr - ); + let sentryOptions = undefined; + let exitCode = undefined; + let errorText = + "Something went wrong. Please contact support or try again later."; + + if (isDittoError(error)) { + exitCode = error.exitCode; + + if (isDittoErrorType(error, ErrorType.ConfigYamlLoadError)) { + errorText = error.message; + } else if (isDittoErrorType(error, ErrorType.ConfigParseError)) { + errorText = `${error.data.messagePrefix}\n\n${error.data.formattedError}`; + } + + sentryOptions = { + extra: { message: errorText, ...(error.data || {}) }, + }; + } + + const eventId = Sentry.captureException(error, sentryOptions); + const eventStr = `\n\nError ID: ${logger.info(eventId)}`; + + return await quit(logger.errorText(errorText) + eventStr, exitCode); } }; diff --git a/lib/src/outputs/json.ts b/lib/src/outputs/json.ts index 21edefd..ab544ab 100644 --- a/lib/src/outputs/json.ts +++ b/lib/src/outputs/json.ts @@ -4,12 +4,12 @@ import { ZBaseOutputFilters } from "./shared"; const ZBaseJSONOutput = ZBaseOutputFilters.extend({ format: z.literal("json"), framework: z.undefined(), -}); +}).strict(); const Zi18NextJSONOutput = ZBaseJSONOutput.extend({ framework: z.literal("i18next"), type: z.literal("module").or(z.literal("commonjs")).optional(), -}); +}).strict(); export const ZJSONOutput = z.discriminatedUnion("framework", [ ZBaseJSONOutput, diff --git a/lib/src/services/projectConfig.ts b/lib/src/services/projectConfig.ts index 713b35b..d18fe9d 100644 --- a/lib/src/services/projectConfig.ts +++ b/lib/src/services/projectConfig.ts @@ -5,6 +5,7 @@ import appContext from "../utils/appContext"; import yaml from "js-yaml"; import { ZBaseOutputFilters } from "../outputs/shared"; import { ZOutput } from "../outputs"; +import DittoError, { ErrorType } from "../utils/DittoError"; const ZProjectConfigYAML = ZBaseOutputFilters.extend({ outputs: z.array(ZOutput), @@ -38,12 +39,30 @@ function readProjectConfigData( file = appContext.projectConfigFile, defaultData: ProjectConfigYAML = DEFAULT_PROJECT_CONFIG_JSON ): ProjectConfigYAML { - createFileIfMissingSync(file, yaml.dump(defaultData)); - const fileContents = fs.readFileSync(file, "utf8"); - const yamlData = yaml.load(fileContents); + let yamlData: unknown = defaultData; + + try { + createFileIfMissingSync(file, yaml.dump(defaultData)); + const fileContents = fs.readFileSync(file, "utf8"); + yamlData = yaml.load(fileContents); + } catch (err) { + throw new DittoError({ + type: ErrorType.ConfigYamlLoadError, + data: { rawErrorMessage: (err as any).message }, + message: + "Could not load the project config file. Please check the file path and that it is a valid YAML file.", + }); + } + const parsedYAML = ZProjectConfigYAML.safeParse(yamlData); if (!parsedYAML.success) { - throw new Error("Failed to parse project config file"); + throw new DittoError({ + type: ErrorType.ConfigParseError, + data: { + formattedError: JSON.stringify(parsedYAML.error.flatten(), null, 2), + messagePrefix: "There is an error in your project config file.", + }, + }); } return parsedYAML.data; } diff --git a/lib/src/utils/DittoError.ts b/lib/src/utils/DittoError.ts new file mode 100644 index 0000000..31efb44 --- /dev/null +++ b/lib/src/utils/DittoError.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; + +/** + * An Error extension designed to be thrown from anywhere in the CLI. + * The custom properties provide a reliable way to include additional data + * (what the error was and context around it) that can be leveraged + * to pass along data to the user, to Sentry, or to other services. + */ +export default class DittoError extends Error { + exitCode: number | undefined; + type: ErrorType; + // Note: if you see the type error "Type 'T' cannot be used to index type 'ErrorDataMap'", + // a value is missing from the ErrorDataMap defined below + data: ErrorDataMap[T]; + + /** + * Creates a new custom error with the following properties: + * @param type The type of error, from the ErrorType enum + * @param message Optional: error message to display to the user + * @param exitCode Optional: exit code to return to the shell. + * @param data Optional: additional data to pass along with the error + */ + constructor({ + type, + message, + exitCode, + data, + }: { + type: T; + message?: string; + exitCode?: number; + data: ErrorDataMap[T]; + }) { + const errorMessage = + message || + "Something went wrong. Please contact support or try again later."; + + super(errorMessage); + + this.exitCode = exitCode; + this.type = type; + this.data = data; + } +} + +/** + * Exhaustive list of DittoError types + * When adding to this list, you must also add a Data type to ErrorDataMap + */ +export enum ErrorType { + ConfigYamlLoadError = "ConfigYamlLoadError", + ConfigParseError = "ConfigParseError", +} + +/** + * Map of DittoError types to the data that is required for that type + * The keys of this must exhaustively match the keys of the ErrorType enum + */ +type ErrorDataMap = { + [ErrorType.ConfigYamlLoadError]: ConfigYamlLoadErrorData; + [ErrorType.ConfigParseError]: ConfigParseErrorData; +}; + +type ConfigYamlLoadErrorData = { + rawErrorMessage: string; +}; + +type ConfigParseErrorData = { + formattedError: string; + messagePrefix: string; +}; + +export function isDittoError(error: unknown): error is DittoError { + return error instanceof DittoError; +} +export function isDittoErrorType( + error: DittoError, + type: T +): error is DittoError { + return error.type === type; +}