diff --git a/src/lib/app/flags.tsx b/src/lib/app/flags.tsx index 38942df8..781d691d 100644 --- a/src/lib/app/flags.tsx +++ b/src/lib/app/flags.tsx @@ -29,6 +29,10 @@ export const { } = makeFlagSet("installation", "i", { displayName: "app installation", normalize: normalizeAppInstallationId, + expectedShortIDFormat: { + pattern: /^a-.*/, + display: "a-XXXXXX", + }, }); export type AvailableFlagName = keyof AvailableFlags; diff --git a/src/lib/context_flags.ts b/src/lib/context_flags.ts index 04260141..99c040c4 100644 --- a/src/lib/context_flags.ts +++ b/src/lib/context_flags.ts @@ -8,6 +8,9 @@ import { import { MittwaldAPIV2Client } from "@mittwald/api-client"; import { AlphabetLowercase } from "@oclif/core/lib/interfaces/index.js"; import { Context, ContextKey, ContextNames } from "./context.js"; +import UnexpectedShortIDPassedError from "./error/UnexpectedShortIDPassedError.js"; +import { isUuid } from "../normalize_id.js"; +import { articleForWord } from "./language.js"; export type ContextFlags< N extends ContextNames, @@ -66,6 +69,10 @@ export type FlagSet = { export type FlagSetOptions = { normalize: NormalizeFn; displayName: string; + expectedShortIDFormat: { + pattern: RegExp; + display: string; + }; }; export type NormalizeFn = ( @@ -118,7 +125,7 @@ export function makeFlagSet( opts: Partial = {}, ): FlagSet { const { displayName = name, normalize = (_, id) => id } = opts; - const article = displayName.match(/^[aeiou]/i) ? "an" : "a"; + const article = articleForWord(displayName); const flagName: ContextKey = `${name}-id`; const flags = { @@ -152,6 +159,16 @@ export function makeFlagSet( return undefined; }; + let idInputSanityCheck: (id: string) => void = (): void => {}; + if (opts.expectedShortIDFormat != null) { + const format = opts.expectedShortIDFormat; + idInputSanityCheck = (id: string): void => { + if (!isUuid(id) && !format.pattern.test(id)) { + throw new UnexpectedShortIDPassedError(displayName, format.display); + } + }; + } + const withId = async ( apiClient: MittwaldAPIV2Client, commandType: CommandType | "flag" | "arg", @@ -161,6 +178,7 @@ export function makeFlagSet( ): Promise => { const idInput = idFromArgsOrFlag(flags, args); if (idInput) { + idInputSanityCheck(idInput); return normalize(apiClient, idInput); } diff --git a/src/lib/error/UnexpectedShortIDPassedError.ts b/src/lib/error/UnexpectedShortIDPassedError.ts new file mode 100644 index 00000000..6cad23b8 --- /dev/null +++ b/src/lib/error/UnexpectedShortIDPassedError.ts @@ -0,0 +1,15 @@ +import { articleForWord } from "../language.js"; + +export default class UnexpectedShortIDPassedError extends Error { + public readonly resourceName: string; + public readonly format: string; + + public constructor(name: string, format: string) { + super( + `This command expects ${articleForWord(name)} ${name}, which is typically formatted as ${format}. It looks like you passed a short ID for another type of resource, instead.`, + ); + + this.resourceName = name; + this.format = format; + } +} diff --git a/src/lib/language.ts b/src/lib/language.ts new file mode 100644 index 00000000..56fd1498 --- /dev/null +++ b/src/lib/language.ts @@ -0,0 +1,12 @@ +/** + * Helper function to determine the grammatically correct article for a word. + * + * This is an approximation and ignores common exceptions, like unsounded "h"s. + * However, for our purposes, it should be sufficient. + * + * @param word + * @returns Returns "an" if the word starts with a vowel, "a" otherwise + */ +export function articleForWord(word: string): "an" | "a" { + return /^[aeiou]/i.test(word) ? "an" : "a"; +} diff --git a/src/lib/project/flags.ts b/src/lib/project/flags.ts index cbdf3696..a339ae31 100644 --- a/src/lib/project/flags.ts +++ b/src/lib/project/flags.ts @@ -12,12 +12,19 @@ import { AlphabetLowercase } from "@oclif/core/lib/interfaces/index.js"; import { Args, Config, Flags } from "@oclif/core"; import { ArgOutput, FlagOutput } from "@oclif/core/lib/interfaces/parser.js"; import { MittwaldAPIV2Client } from "@mittwald/api-client"; +import { articleForWord } from "../language.js"; export const { flags: projectFlags, args: projectArgs, withId: withProjectId, -} = makeFlagSet("project", "p", { normalize: normalizeProjectId }); +} = makeFlagSet("project", "p", { + normalize: normalizeProjectId, + expectedShortIDFormat: { + pattern: /^p-.*/, + display: "p-XXXXXX", + }, +}); export type SubNormalizeFn = ( apiClient: MittwaldAPIV2Client, @@ -43,7 +50,7 @@ export function makeProjectFlagSet( displayName = name, supportsContext = false, } = opts; - const article = displayName.match(/^[aeiou]/i) ? "an" : "a"; + const article = articleForWord(displayName); const flagName: ContextKey = `${name}-id`; const flags = { diff --git a/src/rendering/react/RenderBaseCommand.tsx b/src/rendering/react/RenderBaseCommand.tsx index bad9dc84..fa01a449 100644 --- a/src/rendering/react/RenderBaseCommand.tsx +++ b/src/rendering/react/RenderBaseCommand.tsx @@ -11,6 +11,7 @@ import { CommandArgs, CommandFlags } from "../../types.js"; import { useIncreaseInkStdoutColumns } from "./hooks/useIncreaseInkStdoutColumns.js"; import { usePromise } from "@mittwald/react-use-promise"; import { CommandType } from "../../lib/context_flags.js"; +import ErrorBoundary from "./components/ErrorBoundary.js"; const renderFlags = { output: Flags.string({ @@ -53,7 +54,14 @@ export abstract class RenderBaseCommand< } public async run(): Promise { - render( + const onError = () => { + setImmediate(() => { + handle.unmount(); + process.exit(1); + }); + }; + + const handle = render( - { - useIncreaseInkStdoutColumns(); - return this.render(); - }} - /> + + { + useIncreaseInkStdoutColumns(); + return this.render(); + }} + /> + , diff --git a/src/rendering/react/components/Error/APIError.tsx b/src/rendering/react/components/Error/APIError.tsx new file mode 100644 index 00000000..e0a2f19a --- /dev/null +++ b/src/rendering/react/components/Error/APIError.tsx @@ -0,0 +1,118 @@ +import { + ApiClientError, + AxiosResponseHeaders, +} from "@mittwald/api-client-commons"; +import { Box, Text } from "ink"; +import { RawAxiosResponseHeaders } from "axios"; +import ErrorStack from "./ErrorStack.js"; +import ErrorText from "./ErrorText.js"; +import ErrorBox from "./ErrorBox.js"; + +function RequestHeaders({ headers }: { headers: string }) { + const lines = headers.trim().split("\r\n"); + const requestLine = lines.shift(); + const values = lines.map((line) => line.split(": ", 2)) as [string, string][]; + const maxKeyLength = Math.max(...values.map(([key]) => key.length)); + + return ( + + + {requestLine} + + {values.map(([key, value]) => ( + + {key.toLowerCase().padEnd(maxKeyLength, " ")} + {key === "x-access-token" ? "[redacted]" : value} + + ))} + + ); +} + +function Response({ + status, + statusText, + body, + headers, +}: { + status: number; + statusText: string; + body: unknown; + headers: RawAxiosResponseHeaders | AxiosResponseHeaders; +}) { + const keys = Object.keys(headers); + const maxKeyLength = Math.max(...keys.map((key) => key.length)); + return ( + + + {status} {statusText} + + {keys.map((key) => ( + + {key.toLowerCase().padEnd(maxKeyLength, " ")} + + {key === "x-access-token" ? "[redacted]" : headers[key]} + + + ))} + + {JSON.stringify(body, undefined, 2)} + + + ); +} + +function HttpMessages({ err }: { err: ApiClientError }) { + const response = err.response ? ( + + ) : ( + no response received + ); + + return ( + + + {response} + + ); +} + +interface APIErrorProps { + err: ApiClientError; + withStack: boolean; + withHTTPMessages: "no" | "body" | "full"; +} + +/** + * Render an API client error to the terminal. In the case of an API client + * error, the error message will be displayed, as well as (when enabled) the + * request and response headers and body. + */ +export default function APIError({ + err, + withStack, + withHTTPMessages, +}: APIErrorProps) { + return ( + <> + + + API CLIENT ERROR + + + An error occurred while communicating with the API: {err.message} + + + {JSON.stringify(err.response?.data, undefined, 2)} + + + {withHTTPMessages === "full" ? : undefined} + {withStack && "stack" in err ? : undefined} + + ); +} diff --git a/src/rendering/react/components/Error/ErrorBox.tsx b/src/rendering/react/components/Error/ErrorBox.tsx new file mode 100644 index 00000000..d39d89d8 --- /dev/null +++ b/src/rendering/react/components/Error/ErrorBox.tsx @@ -0,0 +1,20 @@ +import { Box, BoxProps } from "ink"; +import { PropsWithChildren } from "react"; + +const defaultErrorBoxProps: BoxProps = { + width: 80, + flexDirection: "column", + borderColor: "red", + borderStyle: "round", + paddingX: 1, + rowGap: 1, +}; + +/** A pre-styled box for displaying errors. */ +export default function ErrorBox(props: PropsWithChildren) { + return ( + + {props.children} + + ); +} diff --git a/src/rendering/react/components/Error/ErrorStack.tsx b/src/rendering/react/components/Error/ErrorStack.tsx new file mode 100644 index 00000000..f267ecca --- /dev/null +++ b/src/rendering/react/components/Error/ErrorStack.tsx @@ -0,0 +1,17 @@ +import { Box } from "ink"; +import ErrorText from "./ErrorText.js"; + +/** Render the stack trace of an error. */ +export default function ErrorStack({ err }: { err: Error }) { + return ( + + + ERROR STACK TRACE + + + Please provide this when opening a bug report. + + {err.stack} + + ); +} diff --git a/src/rendering/react/components/Error/ErrorText.tsx b/src/rendering/react/components/Error/ErrorText.tsx new file mode 100644 index 00000000..a383daf2 --- /dev/null +++ b/src/rendering/react/components/Error/ErrorText.tsx @@ -0,0 +1,10 @@ +import { Text, TextProps } from "ink"; + +/** A pre-styled text for displaying errors. */ +export default function ErrorText(props: TextProps) { + return ( + + {props.children} + + ); +} diff --git a/src/rendering/react/components/Error/GenericError.tsx b/src/rendering/react/components/Error/GenericError.tsx new file mode 100644 index 00000000..1124fc1c --- /dev/null +++ b/src/rendering/react/components/Error/GenericError.tsx @@ -0,0 +1,45 @@ +import { Box } from "ink"; +import ErrorStack from "./ErrorStack.js"; +import ErrorText from "./ErrorText.js"; +import ErrorBox from "./ErrorBox.js"; + +const issueURL = "https://github.com/mittwald/cli/issues/new"; + +interface GenericErrorProps { + err: Error; + withStack: boolean; + withIssue?: boolean; + title?: string; +} + +/** + * Render a generic error to the terminal. This is used for errors that don't + * have a specific rendering function. + */ +export default function GenericError({ + err, + withStack, + withIssue = true, + title = "Error", +}: GenericErrorProps) { + return ( + <> + + + {title.toUpperCase()} + + An error occurred while executing this command: + + {err.toString()} + + {withIssue ? ( + + If you believe this to be a bug, please open an issue at {issueURL}. + + ) : undefined} + + + {withStack && "stack" in err ? : undefined} + + ); +} diff --git a/src/rendering/react/components/Error/InvalidArgsError.tsx b/src/rendering/react/components/Error/InvalidArgsError.tsx new file mode 100644 index 00000000..88c121ac --- /dev/null +++ b/src/rendering/react/components/Error/InvalidArgsError.tsx @@ -0,0 +1,19 @@ +import { RequiredArgsError } from "@oclif/core/lib/parser/errors.js"; +import { Text } from "ink"; +import ErrorBox from "./ErrorBox.js"; + +/** Render an error for invalid command arguments. */ +export default function InvalidArgsError({ err }: { err: RequiredArgsError }) { + const color = "yellow"; + return ( + + + INVALID COMMAND ARGUMENTS + + + The arguments that you provided for this command were invalid.{" "} + {err.message} + + + ); +} diff --git a/src/rendering/react/components/Error/InvalidFlagsError.tsx b/src/rendering/react/components/Error/InvalidFlagsError.tsx new file mode 100644 index 00000000..bf915107 --- /dev/null +++ b/src/rendering/react/components/Error/InvalidFlagsError.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { FailedFlagValidationError } from "@oclif/core/lib/parser/errors.js"; +import { Text } from "ink"; +import ErrorBox from "./ErrorBox.js"; + +/** Render an error for invalid command flags. */ +export default function InvalidFlagsError({ + err, +}: { + err: FailedFlagValidationError; +}) { + const color = "yellow"; + return ( + + + INVALID COMMAND FLAGS + + + The flags that you provided for this command were invalid. {err.message} + + + ); +} diff --git a/src/rendering/react/components/Error/UnexpectedShortIDPassedErrorBox.tsx b/src/rendering/react/components/Error/UnexpectedShortIDPassedErrorBox.tsx new file mode 100644 index 00000000..d75c2fcc --- /dev/null +++ b/src/rendering/react/components/Error/UnexpectedShortIDPassedErrorBox.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { Text, Newline } from "ink"; +import ErrorBox from "./ErrorBox.js"; +import UnexpectedShortIDPassedError from "../../../../lib/error/UnexpectedShortIDPassedError.js"; + +export default function UnexpectedShortIDPassedErrorBox({ + err, +}: { + err: UnexpectedShortIDPassedError; +}) { + const color = "yellow"; + return ( + + + UNEXPECTED RESOURCE TYPE + + + Whoops! It looks like you passed an ID for an unexpected resource type. + + {err.message} + + + ); +} diff --git a/src/rendering/react/components/ErrorBoundary.tsx b/src/rendering/react/components/ErrorBoundary.tsx new file mode 100644 index 00000000..2219f22a --- /dev/null +++ b/src/rendering/react/components/ErrorBoundary.tsx @@ -0,0 +1,48 @@ +import { Component, PropsWithChildren } from "react"; +import { ErrorBox } from "./ErrorBox.js"; + +interface ErrorBoundaryProps { + onError?: (error: Error) => void; +} + +interface ErrorBoundaryState { + error?: Error; +} + +/** + * ErrorBoundary is a component that catches rendering errors in its children + * and displays an appropriately-styled error message. + * + * @see https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary + */ +export default class ErrorBoundary extends Component< + PropsWithChildren, + ErrorBoundaryState +> { + constructor(props: PropsWithChildren) { + super(props); + this.state = {}; + } + + static getDerivedStateFromError(error: unknown): ErrorBoundaryState { + if (error instanceof Error) { + return { error }; + } + + return {}; + } + + componentDidCatch(error: Error) { + if (this.props.onError) { + this.props.onError(error); + } + } + + render() { + if (this.state.error) { + return ; + } + + return this.props.children; + } +} diff --git a/src/rendering/react/components/ErrorBox.tsx b/src/rendering/react/components/ErrorBox.tsx index e3c3d417..850a949c 100644 --- a/src/rendering/react/components/ErrorBox.tsx +++ b/src/rendering/react/components/ErrorBox.tsx @@ -1,199 +1,20 @@ -import { Box, BoxProps, Text } from "ink"; -import { FC } from "react"; +import React, { FC } from "react"; import { FailedFlagValidationError, RequiredArgsError, } from "@oclif/core/lib/parser/errors.js"; -import { - ApiClientError, - AxiosResponseHeaders, -} from "@mittwald/api-client-commons"; -import { RawAxiosResponseHeaders } from "axios"; +import { ApiClientError } from "@mittwald/api-client-commons"; import InteractiveInputRequiredError from "../../../lib/error/InteractiveInputRequiredError.js"; - -const color = "red"; -const issueURL = "https://github.com/mittwald/cli/issues/new"; -const boxProps: BoxProps = { - width: 80, - flexDirection: "column", - borderColor: color, - borderStyle: "round", - paddingX: 1, - rowGap: 1, -}; - -const ErrorStack: FC<{ err: Error }> = ({ err }) => { - return ( - - - ERROR STACK TRACE - - - Please provide this when opening a bug report. - - - {err.stack} - - - ); -}; - -const GenericError: FC<{ - err: Error; - withStack: boolean; - withIssue?: boolean; - title?: string; -}> = ({ err, withStack, withIssue = true, title = "Error" }) => { - return ( - <> - - - {title.toUpperCase()} - - - An error occurred while executing this command: - - - {err.toString()} - - {withIssue ? ( - - If you believe this to be a bug, please open an issue at {issueURL}. - - ) : undefined} - - - {withStack && "stack" in err ? : undefined} - - ); -}; - -const InvalidFlagsError: FC<{ err: FailedFlagValidationError }> = ({ err }) => { - const color = "yellow"; - return ( - - - INVALID COMMAND FLAGS - - - The flags that you provided for this command were invalid. {err.message} - - - ); -}; - -const InvalidArgsError: FC<{ err: RequiredArgsError }> = ({ err }) => { - const color = "yellow"; - return ( - - - INVALID COMMAND ARGUMENTS - - - The arguments that you provided for this command were invalid.{" "} - {err.message} - - - ); -}; - -const RequestHeaders: FC<{ headers: string }> = ({ headers }) => { - const lines = headers.trim().split("\r\n"); - const requestLine = lines.shift(); - const values = lines.map((line) => line.split(": ", 2)) as [string, string][]; - const maxKeyLength = Math.max(...values.map(([key]) => key.length)); - - return ( - - - {requestLine} - - {values.map(([key, value]) => ( - - {key.toLowerCase().padEnd(maxKeyLength, " ")} - {key === "x-access-token" ? "[redacted]" : value} - - ))} - - ); -}; - -const Response: FC<{ - status: number; - statusText: string; - body: unknown; - headers: RawAxiosResponseHeaders | AxiosResponseHeaders; -}> = ({ status, statusText, body, headers }) => { - const keys = Object.keys(headers); - const maxKeyLength = Math.max(...keys.map((key) => key.length)); - return ( - - - {status} {statusText} - - {keys.map((key) => ( - - {key.toLowerCase().padEnd(maxKeyLength, " ")} - - {key === "x-access-token" ? "[redacted]" : headers[key]} - - - ))} - - {JSON.stringify(body, undefined, 2)} - - - ); -}; - -const HttpMessages: FC<{ err: ApiClientError }> = ({ err }) => { - const response = err.response ? ( - - ) : ( - no response received - ); - - return ( - - - {response} - - ); -}; - -const ApiError: FC<{ - err: ApiClientError; - withStack: boolean; - withHTTPMessages: "no" | "body" | "full"; -}> = ({ err, withStack, withHTTPMessages }) => { - return ( - <> - - - API CLIENT ERROR - - - An error occurred while communicating with the API: {err.message} - - - {JSON.stringify(err.response?.data, undefined, 2)} - - - {withHTTPMessages === "full" ? : undefined} - {withStack && "stack" in err ? : undefined} - - ); -}; +import UnexpectedShortIDPassedError from "../../../lib/error/UnexpectedShortIDPassedError.js"; +import GenericError from "./Error/GenericError.js"; +import InvalidFlagsError from "./Error/InvalidFlagsError.js"; +import InvalidArgsError from "./Error/InvalidArgsError.js"; +import APIError from "./Error/APIError.js"; +import UnexpectedShortIDPassedErrorBox from "./Error/UnexpectedShortIDPassedErrorBox.js"; /** * Render an error to the terminal. * - * @class * @param err The error to render. May be anything, although different errors * will be rendered differently. */ @@ -203,7 +24,7 @@ export const ErrorBox: FC<{ err: unknown }> = ({ err }) => { } else if (err instanceof RequiredArgsError) { return ; } else if (err instanceof ApiClientError) { - return ; + return ; } else if (err instanceof InteractiveInputRequiredError) { return ( = ({ err }) => { title="Input required" /> ); + } else if (err instanceof UnexpectedShortIDPassedError) { + return ; } else if (err instanceof Error) { return ; }