diff --git a/.changeset/quiet-plums-develop.md b/.changeset/quiet-plums-develop.md new file mode 100644 index 00000000..cc5d511a --- /dev/null +++ b/.changeset/quiet-plums-develop.md @@ -0,0 +1,5 @@ +--- +"composable-cli": patch +--- + +login command improved error handling and explanation diff --git a/packages/composable-cli/src/commands/login/epcc-authenticate.ts b/packages/composable-cli/src/commands/login/epcc-authenticate.ts index 20df01f7..ab8590c6 100644 --- a/packages/composable-cli/src/commands/login/epcc-authenticate.ts +++ b/packages/composable-cli/src/commands/login/epcc-authenticate.ts @@ -1,10 +1,12 @@ import { encodeObjectToQueryString } from "../../util/encode-object-to-query-str" import { EpccRequester } from "../../util/command" +import { resolveHostFromRegion } from "../../util/resolve-region" +import { Region } from "../../lib/stores/region-schema" export async function authenticateGrantTypePassword( - requester: EpccRequester, username: string, password: string, + region: Region, ): Promise { const body = { grant_type: "password", @@ -12,7 +14,9 @@ export async function authenticateGrantTypePassword( password, } - const response = await requester(`/oauth/access_token`, { + const url = new URL(`/oauth/access_token`, resolveHostFromRegion(region)) + + const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", diff --git a/packages/composable-cli/src/commands/login/login-command.ts b/packages/composable-cli/src/commands/login/login-command.ts index f7a35eec..894cce1f 100644 --- a/packages/composable-cli/src/commands/login/login-command.ts +++ b/packages/composable-cli/src/commands/login/login-command.ts @@ -25,17 +25,19 @@ import { } from "../../util/conf-store/store-credentials" import { isAuthenticated } from "../../util/check-authenticated" import { trackCommandHandler } from "../../util/track-command-handler" -import { EpccRequester } from "../../util/command" +import { createFixedRequester } from "../../util/command" import { Credentials, credentialsResponseSchema, } from "../../lib/authentication/credentials-schema" import { welcomeNote } from "../ui/alert" import { outputContent, outputToken } from "../output" -import { renderError, renderWarning } from "../ui" +import { renderError, renderInfo, renderWarning } from "../ui" import { Listr } from "listr2" import { UserProfile } from "../../lib/epcc-user-profile-schema" import { processUnknownError } from "../../util/process-unknown-error" +import { CLITaskError } from "../../lib/error/cli-task-error" +import { Region } from "../../lib/stores/region-schema" /** * Region prompts @@ -43,7 +45,7 @@ import { processUnknownError } from "../../util/process-unknown-error" const regionPrompts = { type: "list", name: "region", - message: "What region do you want to authenticated with?", + message: "What region is your store hosted in?", choices: [ { name: "us-east (free trial users)", value: "us-east" }, { name: "eu-west", value: "eu-west" }, @@ -109,11 +111,14 @@ export function createAuthenticationMiddleware( const isInsightsCommand = args._.some((arg) => arg === "insights") + const isConfigCommand = args._.some((arg) => arg === "config") + if ( isAuthenticated(store) || !args.interactive || isAuthCommand || - isInsightsCommand + isInsightsCommand || + isConfigCommand ) { return } @@ -139,7 +144,6 @@ export function createLoginCommandHandler( LoginCommandArguments > { const { store } = ctx - const requester = ctx.requester return async function loginCommandHandler(args) { const alreadyLoggedIn = isAuthenticated(store) @@ -167,16 +171,27 @@ export function createLoginCommandHandler( } } + renderInfo({ + body: outputContent`Elastic Path stores are hosted across multiple regions, please select the region where your store is hosted.\n\nYou can find out your stores region by looking at the version of Commerce Manager you're using. e.g. ${outputToken.green( + "region", + )}.cm.elasticpath.com where ${outputToken.green( + "region", + )} is either useast or euwest`.value, + }) + const regionAnswers = await inquirer.prompt(regionPrompts, { ...(args.region ? { region: args.region } : {}), }) + renderInfo({ + body: outputContent`Authenticate with your Elastic Path account credentials.` + .value, + }) const { username, password } = await promptUsernamePasswordLogin(args) const loginTask = createLoginTask({ username, password, - requester, store, region: regionAnswers.region, }) @@ -239,14 +254,12 @@ export function createLoginCommandHandler( function createLoginTask({ username, password, - requester, region, store, }: { username: string password: string - region: "eu-west" | "us-east" - requester: CommandContext["requester"] + region: Region store: Conf }) { return new Listr( @@ -259,9 +272,9 @@ function createLoginTask({ title: "Authenticating", task: async (innerCtx) => { const result = await authenticateUserPassword( - requester, username, password, + region, ) if (!result.success) { @@ -269,7 +282,12 @@ function createLoginTask({ headline: "Failed to authenticate", body: "There was a problem logging you in. Make sure that your username and password are correct.", }) - throw new Error("Failed to authenticate") + throw new CLITaskError({ + message: result.message, + description: result.name, + taskName: "Authenticating", + code: "request_failure", + }) } innerCtx.credentials = result.data @@ -280,13 +298,19 @@ function createLoginTask({ skip: (innerCtx) => !innerCtx.credentials, task: async (innerCtx, innerTask) => { if (!innerCtx.credentials) { - throw new Error( - "No credentials found can't complete Fetching your profile task.", - ) + throw new CLITaskError({ + message: + "No credentials found can't complete Fetching your profile task.", + taskName: innerTask.title, + code: "missing_expected_context_data", + }) } const userProfileResponse = await epccUserProfile( - requester, + createFixedRequester( + region, + innerCtx.credentials.access_token, + ), innerCtx.credentials.access_token, ) @@ -296,9 +320,12 @@ function createLoginTask({ "Successfully authenticated but failed to load user profile", body: `Their was a problem loading your user profile ${userProfileResponse.error.code} - ${userProfileResponse.error.message}.`, }) - throw new Error( - "Successfully authenticated but failed to load user profile", - ) + throw new CLITaskError({ + message: userProfileResponse.error.message, + description: userProfileResponse.error.code, + taskName: innerTask.title, + code: "request_failure", + }) } innerCtx.profile = userProfileResponse.data.data innerTask.output = `Successfully authenticated as ${userProfileResponse.data.data.email}` @@ -307,17 +334,23 @@ function createLoginTask({ { title: "Storing credentials", skip: (innerCtx) => !innerCtx.credentials, - task: async (innerCtx) => { + task: async (innerCtx, innerTask) => { if (!innerCtx.profile) { - throw new Error( - "No profile found can't complete Storing credentials task.", - ) + throw new CLITaskError({ + message: + "Expected to find profile on context but it was not defined can't complete Storing credentials task.", + taskName: innerTask.title, + code: "missing_expected_context_data", + }) } if (!innerCtx.credentials) { - throw new Error( - "No credentials found can't complete Storing credentials task.", - ) + throw new CLITaskError({ + message: + "Expected to find credentials on context but it was not defined can't complete Storing credentials task.", + taskName: innerTask.title, + code: "missing_expected_context_data", + }) } handleClearCredentials(store) @@ -341,9 +374,9 @@ function createLoginTask({ } async function authenticateUserPassword( - requester: EpccRequester, username: string, password: string, + region: Region, ): Promise< | { success: true; data: Credentials } | { @@ -355,9 +388,9 @@ async function authenticateUserPassword( > { try { const credentialsResp = await authenticateGrantTypePassword( - requester, username, password, + region, ) const parsedCredentialsResp = @@ -409,7 +442,7 @@ async function promptUsernamePasswordLogin( [ { type: "string", - message: "Enter your username", + message: "Enter your email address", name: "username", }, { diff --git a/packages/composable-cli/src/commands/ui/error/error.tsx b/packages/composable-cli/src/commands/ui/error/error.tsx deleted file mode 100644 index 3a4deb30..00000000 --- a/packages/composable-cli/src/commands/ui/error/error.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from "react" -import { Box, Newline, Text } from "ink" -import Table from "ink-table" - -export function ErrorTable({ data }: { data: Record }) { - const errors = Object.keys(data).map((key) => { - return { key, value: data[key] } - }) - - return ( - - - - There was an issue! - - - - To get support on this issue, report it on our slack community. - - Join us at - - {" "} - https://elasticpathcommunity.slack.com/join/shared_invite/zt-1upzq3nlc-O3sy1bT0UJYcOWEQQCtnqw - - - - - ) -} diff --git a/packages/composable-cli/src/lib/error/cli-task-error.ts b/packages/composable-cli/src/lib/error/cli-task-error.ts new file mode 100644 index 00000000..3fa41715 --- /dev/null +++ b/packages/composable-cli/src/lib/error/cli-task-error.ts @@ -0,0 +1,33 @@ +export type CLITaskErrorOptions = { + message: string + code: CLITaskErrorCode + taskName: string + description?: string +} + +export type CLITaskErrorCode = + | "missing_expected_context_data" + | "request_failure" + +export class CLITaskError extends Error { + /** + * Unique error code for the error. + */ + public code: CLITaskErrorCode + /** + * The name of the task that failed. + */ + public taskName: string + /** + * A description of the error that is more details than the message you want to show to the user. + */ + public description: string | undefined + + constructor({ message, code, taskName, description }: CLITaskErrorOptions) { + super(message) + this.name = "CLITaskError" + this.code = code + this.taskName = taskName + this.description = description + } +} diff --git a/packages/composable-cli/src/types/command.ts b/packages/composable-cli/src/types/command.ts index 53258e52..be7a6ced 100644 --- a/packages/composable-cli/src/types/command.ts +++ b/packages/composable-cli/src/types/command.ts @@ -22,7 +22,14 @@ export type CommandResult = export type CommandContext = { store: Conf + /** + * Requester makes fetch requests with authentication headers and host values preset + * e.g. requester("/v2/user") will make a request to the configured host with the authenticated users access token + */ requester: typeof fetch + /** + * rawRequester makes fetch requests without any authentication headers or host values preset + */ rawRequester: typeof fetch posthog?: { client: PostHog diff --git a/packages/composable-cli/src/util/command.ts b/packages/composable-cli/src/util/command.ts index 1ffc57ff..6202e610 100644 --- a/packages/composable-cli/src/util/command.ts +++ b/packages/composable-cli/src/util/command.ts @@ -4,14 +4,12 @@ import { CommandContext } from "../types/command" import fetch, { RequestInfo, RequestInit, Response } from "node-fetch" import { getToken } from "../lib/authentication/get-token" import { getRegion, resolveHostFromRegion } from "./resolve-region" -import path from "path" import ws from "ws" import { createConsoleLogger, ProcessOutput } from "@angular-devkit/core/node" import * as ansiColors from "ansi-colors" import { makeErrorWrapper } from "./error-handler" -import { renderInk } from "../lib/ink/render-ink" -import React from "react" -import { ErrorTable } from "../commands/ui/error/error" +import { renderError } from "../commands/ui" +import { Region } from "../lib/stores/region-schema" // polyfill fetch & websocket const globalAny = global as any @@ -100,9 +98,16 @@ export function createCommandContext({ console.error(`Error Stack: ${result.error.stack}`) console.error(`Error Cause: ${result.error.cause}`) } else if (isRecordStringAny(result.error)) { - await renderInk( - React.createElement(ErrorTable, { data: result.error }), - ) + const errorObject = result.error + const errors = Object.keys(errorObject).map((key) => { + return { key, value: errorObject[key] } + }) + + renderError({ + body: `${errors + .map((error) => `${error.key}: ${error.value}`) + .join("\n")}`, + }) } else { console.warn( "Error was not an known error type! Could not parse a useful error message.", @@ -119,6 +124,25 @@ function isRecordStringAny(obj: unknown): obj is Record { return typeof obj === "object" && obj !== null } +export function createFixedRequester(region: Region, accessToken: string) { + return async function regionScopedRequester( + url: RequestInfo, + init?: RequestInit, + ) { + const apiUrl = resolveHostFromRegion(region) + const completeUrl = new URL(url.toString(), apiUrl) + const authHeader = `Bearer ${accessToken}` + + return fetch(completeUrl, { + ...(init || {}), + headers: { + ...(init?.headers || {}), + Authorization: authHeader, + }, + }) + } as typeof fetch +} + function createRequester(store: Conf): EpccRequester { return async function requester( url: RequestInfo, @@ -135,9 +159,7 @@ function createRequester(store: Conf): EpccRequester { const apiUrl = resolveHostFromRegion(regionResult.data) const authHeader = await resolveAuthorizationHeader(store, apiUrl) - - // TODO: handle the fact `url` is a RequestInfo not just a plain string - const completeUrl = path.join(apiUrl, url.toString()) + const completeUrl = new URL(url.toString(), apiUrl) return fetch(completeUrl, { ...(init || {}), diff --git a/packages/composable-cli/src/util/process-unknown-error.ts b/packages/composable-cli/src/util/process-unknown-error.ts index bfdda13d..0c18d91a 100644 --- a/packages/composable-cli/src/util/process-unknown-error.ts +++ b/packages/composable-cli/src/util/process-unknown-error.ts @@ -1,5 +1,21 @@ +import { CLITaskError } from "../lib/error/cli-task-error" + export function processUnknownError(error: unknown): string { - let errorMessage = "An unknown error occurred" + let errorMessage = "Error" + + if (error instanceof CLITaskError) { + if (error.code) { + errorMessage += `: ${error.code}` + } + + if (error.description) { + errorMessage += `: ${error.description}` + } + + if (error.taskName) { + errorMessage += `: ${error.taskName}` + } + } if (error instanceof Error) { if (error.message) {