diff --git a/integration-tests/tests/cli/__snapshots__/schema.spec.ts.snap b/integration-tests/tests/cli/__snapshots__/schema.spec.ts.snap index e04ac2cd6f..fc13c18df5 100644 --- a/integration-tests/tests/cli/__snapshots__/schema.spec.ts.snap +++ b/integration-tests/tests/cli/__snapshots__/schema.spec.ts.snap @@ -96,10 +96,13 @@ exports[`FEDERATION > publishing invalid schema SDL provides meaningful feedback exitCode------------------------------------------: 2 stderr--------------------------------------------: - Error: The SDL is not valid at line 1, column 1: - Syntax Error: Unexpected Name "iliketurtles". + › Error: The SDL is not valid at line 1, column 1: + › Syntax Error: Unexpected Name "iliketurtles". [301] + › > See https://__URL__ for + › a complete list of error codes and recommended fixes. + › To disable this message set HIVE_NO_ERROR_TIP=1 stdout--------------------------------------------: -✖ Failed to publish schema +__NONE__ `; exports[`FEDERATION > schema:check should notify user when registry is empty > schemaCheck 1`] = ` @@ -160,10 +163,13 @@ exports[`FEDERATION > schema:publish should see Invalid Token error when token i exitCode------------------------------------------: 2 stderr--------------------------------------------: - › Error: Invalid token provided - › Reference: __ID__ + › Error: A valid registry token is required to perform the action. The + › registry token used does not exist or has been revoked. [106] + › > See https://__URL__ for + › a complete list of error codes and recommended fixes. + › To disable this message set HIVE_NO_ERROR_TIP=1 stdout--------------------------------------------: -✖ Failed to publish schema +__NONE__ `; exports[`SINGLE > can publish a schema with breaking, warning and safe changes > schemaCheck 1`] = ` @@ -246,10 +252,13 @@ exports[`SINGLE > publishing invalid schema SDL provides meaningful feedback for exitCode------------------------------------------: 2 stderr--------------------------------------------: - Error: The SDL is not valid at line 1, column 1: - Syntax Error: Unexpected Name "iliketurtles". + › Error: The SDL is not valid at line 1, column 1: + › Syntax Error: Unexpected Name "iliketurtles". [301] + › > See https://__URL__ for + › a complete list of error codes and recommended fixes. + › To disable this message set HIVE_NO_ERROR_TIP=1 stdout--------------------------------------------: -✖ Failed to publish schema +__NONE__ `; exports[`SINGLE > schema:check should notify user when registry is empty > schemaCheck 1`] = ` @@ -310,10 +319,13 @@ exports[`SINGLE > schema:publish should see Invalid Token error when token is in exitCode------------------------------------------: 2 stderr--------------------------------------------: - › Error: Invalid token provided - › Reference: __ID__ + › Error: A valid registry token is required to perform the action. The + › registry token used does not exist or has been revoked. [106] + › > See https://__URL__ for + › a complete list of error codes and recommended fixes. + › To disable this message set HIVE_NO_ERROR_TIP=1 stdout--------------------------------------------: -✖ Failed to publish schema +__NONE__ `; exports[`STITCHING > can publish a schema with breaking, warning and safe changes > schemaCheck 1`] = ` @@ -412,10 +424,13 @@ exports[`STITCHING > publishing invalid schema SDL provides meaningful feedback exitCode------------------------------------------: 2 stderr--------------------------------------------: - Error: The SDL is not valid at line 1, column 1: - Syntax Error: Unexpected Name "iliketurtles". + › Error: The SDL is not valid at line 1, column 1: + › Syntax Error: Unexpected Name "iliketurtles". [301] + › > See https://__URL__ for + › a complete list of error codes and recommended fixes. + › To disable this message set HIVE_NO_ERROR_TIP=1 stdout--------------------------------------------: -✖ Failed to publish schema +__NONE__ `; exports[`STITCHING > schema:check should notify user when registry is empty > schemaCheck 1`] = ` @@ -478,8 +493,11 @@ exports[`STITCHING > schema:publish should see Invalid Token error when token is exitCode------------------------------------------: 2 stderr--------------------------------------------: - › Error: Invalid token provided - › Reference: __ID__ + › Error: A valid registry token is required to perform the action. The + › registry token used does not exist or has been revoked. [106] + › > See https://__URL__ for + › a complete list of error codes and recommended fixes. + › To disable this message set HIVE_NO_ERROR_TIP=1 stdout--------------------------------------------: -✖ Failed to publish schema +__NONE__ `; diff --git a/packages/libraries/cli/src/base-command.ts b/packages/libraries/cli/src/base-command.ts index 57db9bf6aa..cab6f5b535 100644 --- a/packages/libraries/cli/src/base-command.ts +++ b/packages/libraries/cli/src/base-command.ts @@ -1,9 +1,21 @@ -import { print, type GraphQLError } from 'graphql'; +import { existsSync, readFileSync } from 'node:fs'; +import { env } from 'node:process'; +import { print } from 'graphql'; import type { ExecutionResult } from 'graphql'; import { http } from '@graphql-hive/core'; import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; -import { Command, Errors, Flags, Interfaces } from '@oclif/core'; +import { Command, Flags, Interfaces } from '@oclif/core'; import { Config, GetConfigurationValueType, ValidConfigurationKeys } from './helpers/config'; +import { + APIError, + FileMissingError, + HTTPError, + InvalidFileContentsError, + InvalidRegistryTokenError, + isAggregateError, + MissingArgumentsError, + NetworkError, +} from './helpers/errors'; import { Texture } from './helpers/texture/texture'; export type Flags<T extends typeof Command> = Interfaces.InferredFlags< @@ -57,7 +69,7 @@ export default abstract class BaseCommand<T extends typeof Command> extends Comm } logFailure(...args: any[]) { - this.log(Texture.failure(...args)); + this.logToStderr(Texture.failure(...args)); } logInfo(...args: any[]) { @@ -98,7 +110,7 @@ export default abstract class BaseCommand<T extends typeof Command> extends Comm * @param key * @param args all arguments or flags * @param defaultValue default value - * @param message custom error message in case of no value + * @param description description of the flag in case of no value * @param env an env var name */ ensure< @@ -111,8 +123,8 @@ export default abstract class BaseCommand<T extends typeof Command> extends Comm args, legacyFlagName, defaultValue, - message, - env, + env: envName, + description, }: { args: TArgs; key: TKey; @@ -127,38 +139,34 @@ export default abstract class BaseCommand<T extends typeof Command> extends Comm }>; defaultValue?: TArgs[keyof TArgs] | null; - message?: string; + description: string; env?: string; }): NonNullable<GetConfigurationValueType<TKey>> | never { - if (args[key] != null) { - return args[key] as NonNullable<GetConfigurationValueType<TKey>>; - } - - if (legacyFlagName && (args as any)[legacyFlagName] != null) { - return args[legacyFlagName] as any as NonNullable<GetConfigurationValueType<TKey>>; - } - - // eslint-disable-next-line no-process-env - if (env && process.env[env]) { - // eslint-disable-next-line no-process-env - return process.env[env] as TArgs[keyof TArgs] as NonNullable<GetConfigurationValueType<TKey>>; - } - - const userConfigValue = this._userConfig!.get(key); - - if (userConfigValue != null) { - return userConfigValue; - } + let value: GetConfigurationValueType<TKey>; - if (defaultValue) { - return defaultValue; + if (args[key] != null) { + value = args[key]; + } else if (legacyFlagName && (args as any)[legacyFlagName] != null) { + value = args[legacyFlagName] as NonNullable<GetConfigurationValueType<TKey>>; + } else if (envName && env[envName] !== undefined) { + value = env[envName] as TArgs[keyof TArgs] as NonNullable<GetConfigurationValueType<TKey>>; + } else { + const configValue = this._userConfig!.get(key) as NonNullable< + GetConfigurationValueType<TKey> + >; + + if (configValue !== undefined) { + value = configValue; + } else if (defaultValue) { + value = defaultValue; + } } - if (message) { - throw new Errors.CLIError(message); + if (value?.length) { + return value; } - throw new Errors.CLIError(`Missing "${String(key)}"`); + throw new MissingArgumentsError([String(key), description]); } cleanRequestId(requestId?: string | null) { @@ -186,7 +194,7 @@ export default abstract class BaseCommand<T extends typeof Command> extends Comm const isDebug = this.flags.debug; return { - async request<TResult, TVariables>( + request: async <TResult, TVariables>( args: { operation: TypedDocumentNode<TResult, TVariables>; /** timeout in milliseconds */ @@ -198,43 +206,72 @@ export default abstract class BaseCommand<T extends typeof Command> extends Comm : { variables: TVariables; }), - ): Promise<TResult> { - const response = await http.post( - endpoint, - JSON.stringify({ - query: typeof args.operation === 'string' ? args.operation : print(args.operation), - variables: args.variables, - }), - { - logger: { - info: (...args) => { - if (isDebug) { - console.info(...args); - } - }, - error: (...args) => { - console.error(...args); + ): Promise<TResult> => { + let response: Response; + try { + response = await http.post( + endpoint, + JSON.stringify({ + query: typeof args.operation === 'string' ? args.operation : print(args.operation), + variables: args.variables, + }), + { + logger: { + info: (...args) => { + if (isDebug) { + this.logInfo(...args); + } + }, + error: (...args) => { + // Allow retrying requests without noise + if (isDebug) { + this.logWarning(...args); + } + }, }, + headers: requestHeaders, + timeout: args.timeout, }, - headers: requestHeaders, - timeout: args.timeout, - }, - ); + ); + } catch (e: any) { + const sourceError = e?.cause ?? e; + if (isAggregateError(sourceError)) { + throw new NetworkError(sourceError.errors[0]?.message); + } else { + throw new NetworkError(sourceError); + } + } if (!response.ok) { - throw new Error(`Invalid status code for HTTP call: ${response.status}`); + throw new HTTPError( + endpoint, + response.status, + response.statusText ?? 'Invalid status code for HTTP call', + ); + } + + let jsonData; + try { + jsonData = (await response.json()) as ExecutionResult<TResult>; + } catch (err) { + const contentType = response?.headers?.get('content-type'); + throw new APIError( + `Response from graphql was not valid JSON.${contentType ? ` Received "content-type": "${contentType}".` : ''}`, + this.cleanRequestId(response?.headers?.get('x-request-id')), + ); } - const jsonData = (await response.json()) as ExecutionResult<TResult>; if (jsonData.errors && jsonData.errors.length > 0) { - throw new ClientError( - `Failed to execute GraphQL operation: ${jsonData.errors - .map(e => e.message) - .join('\n')}`, - { - errors: jsonData.errors, - headers: response.headers, - }, + if (jsonData.errors[0].message === 'Invalid token provided') { + throw new InvalidRegistryTokenError(); + } + + if (isDebug) { + this.logFailure(jsonData.errors); + } + throw new APIError( + jsonData.errors.map(e => e.message).join('\n'), + this.cleanRequestId(response?.headers?.get('x-request-id')), ); } @@ -243,32 +280,6 @@ export default abstract class BaseCommand<T extends typeof Command> extends Comm }; } - handleFetchError(error: unknown): never { - if (typeof error === 'string') { - return this.error(error); - } - - if (error instanceof Error) { - if (isClientError(error)) { - const errors = error.response?.errors; - - if (Array.isArray(errors) && errors.length > 0) { - return this.error(errors[0].message, { - ref: this.cleanRequestId(error.response?.headers?.get('x-request-id')), - }); - } - - return this.error(error.message, { - ref: this.cleanRequestId(error.response?.headers?.get('x-request-id')), - }); - } - - return this.error(error); - } - - return this.error(JSON.stringify(error)); - } - async require< TFlags extends { require: string[]; @@ -281,20 +292,25 @@ export default abstract class BaseCommand<T extends typeof Command> extends Comm ); } } -} -class ClientError extends Error { - constructor( - message: string, - public response: { - errors?: readonly GraphQLError[]; - headers: Headers; - }, - ) { - super(message); - } -} + readJSON(file: string): string { + // If we can't parse it, we can try to load it from FS + const exists = existsSync(file); -function isClientError(error: Error): error is ClientError { - return error instanceof ClientError; + if (!exists) { + throw new FileMissingError( + file, + 'Please specify a path to an existing file, or a string with valid JSON', + ); + } + + try { + const fileContent = readFileSync(file, 'utf-8'); + JSON.parse(fileContent); + + return fileContent; + } catch (e) { + throw new InvalidFileContentsError(file, 'JSON'); + } + } } diff --git a/packages/libraries/cli/src/commands/app/create.ts b/packages/libraries/cli/src/commands/app/create.ts index 36956b8273..ef7e8a10bb 100644 --- a/packages/libraries/cli/src/commands/app/create.ts +++ b/packages/libraries/cli/src/commands/app/create.ts @@ -4,7 +4,12 @@ import Command from '../../base-command'; import { graphql } from '../../gql'; import { AppDeploymentStatus } from '../../gql/graphql'; import { graphqlEndpoint } from '../../helpers/config'; -import { ACCESS_TOKEN_MISSING } from '../../helpers/errors'; +import { + APIError, + MissingEndpointError, + MissingRegistryTokenError, + PersistedOperationsMalformedError, +} from '../../helpers/errors'; export default class AppCreate extends Command<typeof AppCreate> { static description = 'create an app deployment'; @@ -37,28 +42,37 @@ export default class AppCreate extends Command<typeof AppCreate> { async run() { const { flags, args } = await this.parse(AppCreate); - const endpoint = this.ensure({ - key: 'registry.endpoint', - args: flags, - defaultValue: graphqlEndpoint, - env: 'HIVE_REGISTRY', - }); - const accessToken = this.ensure({ - key: 'registry.accessToken', - args: flags, - env: 'HIVE_TOKEN', - message: ACCESS_TOKEN_MISSING, - }); + let endpoint: string, accessToken: string; + try { + endpoint = this.ensure({ + key: 'registry.endpoint', + args: flags, + defaultValue: graphqlEndpoint, + env: 'HIVE_REGISTRY', + description: AppCreate.flags['registry.endpoint'].description!, + }); + } catch (e) { + throw new MissingEndpointError(); + } + + try { + accessToken = this.ensure({ + key: 'registry.accessToken', + args: flags, + env: 'HIVE_TOKEN', + description: AppCreate.flags['registry.accessToken'].description!, + }); + } catch (e) { + throw new MissingRegistryTokenError(); + } const file: string = args.file; - const fs = await import('fs/promises'); - const contents = await fs.readFile(file, 'utf-8'); + const contents = this.readJSON(file); const operations: unknown = JSON.parse(contents); const validationResult = ManifestModel.safeParse(operations); if (validationResult.success === false) { - // TODO: better error message :) - throw new Error('Invalid manifest'); + throw new PersistedOperationsMalformedError(file); } const result = await this.registryApi(endpoint, accessToken).request({ @@ -72,12 +86,11 @@ export default class AppCreate extends Command<typeof AppCreate> { }); if (result.createAppDeployment.error) { - // TODO: better error message formatting :) - throw new Error(result.createAppDeployment.error.message); + throw new APIError(result.createAppDeployment.error.message); } if (!result.createAppDeployment.ok) { - throw new Error('Unknown error'); + throw new APIError(`Create App failed without providing a reason.`); } if (result.createAppDeployment.ok.createdAppDeployment.status !== AppDeploymentStatus.Pending) { @@ -123,7 +136,7 @@ export default class AppCreate extends Command<typeof AppCreate> { ); } } - this.error(result.addDocumentsToAppDeployment.error.message); + throw new APIError(result.addDocumentsToAppDeployment.error.message); } buffer = []; } diff --git a/packages/libraries/cli/src/commands/app/publish.ts b/packages/libraries/cli/src/commands/app/publish.ts index 45a990be9a..663c7a92e0 100644 --- a/packages/libraries/cli/src/commands/app/publish.ts +++ b/packages/libraries/cli/src/commands/app/publish.ts @@ -2,7 +2,7 @@ import { Flags } from '@oclif/core'; import Command from '../../base-command'; import { graphql } from '../../gql'; import { graphqlEndpoint } from '../../helpers/config'; -import { ACCESS_TOKEN_MISSING } from '../../helpers/errors'; +import { APIError, MissingEndpointError, MissingRegistryTokenError } from '../../helpers/errors'; export default class AppPublish extends Command<typeof AppPublish> { static description = 'publish an app deployment'; @@ -26,18 +26,29 @@ export default class AppPublish extends Command<typeof AppPublish> { async run() { const { flags } = await this.parse(AppPublish); - const endpoint = this.ensure({ - key: 'registry.endpoint', - args: flags, - defaultValue: graphqlEndpoint, - env: 'HIVE_REGISTRY', - }); - const accessToken = this.ensure({ - key: 'registry.accessToken', - args: flags, - env: 'HIVE_TOKEN', - message: ACCESS_TOKEN_MISSING, - }); + let endpoint: string, accessToken: string; + try { + endpoint = this.ensure({ + key: 'registry.endpoint', + args: flags, + defaultValue: graphqlEndpoint, + env: 'HIVE_REGISTRY', + description: AppPublish.flags['registry.endpoint'].description!, + }); + } catch (e) { + throw new MissingEndpointError(); + } + + try { + accessToken = this.ensure({ + key: 'registry.accessToken', + args: flags, + env: 'HIVE_TOKEN', + description: AppPublish.flags['registry.accessToken'].description!, + }); + } catch (e) { + throw new MissingRegistryTokenError(); + } const result = await this.registryApi(endpoint, accessToken).request({ operation: ActivateAppDeploymentMutation, @@ -50,7 +61,7 @@ export default class AppPublish extends Command<typeof AppPublish> { }); if (result.activateAppDeployment.error) { - throw new Error(result.activateAppDeployment.error.message); + throw new APIError(result.activateAppDeployment.error.message); } if (result.activateAppDeployment.ok) { diff --git a/packages/libraries/cli/src/commands/artifact/fetch.ts b/packages/libraries/cli/src/commands/artifact/fetch.ts index 3677b35710..5fac9d31f9 100644 --- a/packages/libraries/cli/src/commands/artifact/fetch.ts +++ b/packages/libraries/cli/src/commands/artifact/fetch.ts @@ -1,6 +1,14 @@ import { http, URL } from '@graphql-hive/core'; import { Flags } from '@oclif/core'; import Command from '../../base-command'; +import { + HTTPError, + isAggregateError, + MissingCdnEndpointError, + MissingCdnKeyError, + NetworkError, + UnexpectedError, +} from '../../helpers/errors'; export default class ArtifactsFetch extends Command<typeof ArtifactsFetch> { static description = 'fetch artifacts from the CDN'; @@ -24,55 +32,86 @@ export default class ArtifactsFetch extends Command<typeof ArtifactsFetch> { async run() { const { flags } = await this.parse(ArtifactsFetch); - const cdnEndpoint = this.ensure({ - key: 'cdn.endpoint', - args: flags, - env: 'HIVE_CDN_ENDPOINT', - }); + let cdnEndpoint: string, token: string; + try { + cdnEndpoint = this.ensure({ + key: 'cdn.endpoint', + args: flags, + env: 'HIVE_CDN_ENDPOINT', + description: ArtifactsFetch.flags['cdn.endpoint'].description!, + }); + } catch (e) { + throw new MissingCdnEndpointError(); + } - const token = this.ensure({ - key: 'cdn.accessToken', - args: flags, - env: 'HIVE_CDN_ACCESS_TOKEN', - }); + try { + token = this.ensure({ + key: 'cdn.accessToken', + args: flags, + env: 'HIVE_CDN_ACCESS_TOKEN', + description: ArtifactsFetch.flags['cdn.accessToken'].description!, + }); + } catch (e) { + throw new MissingCdnKeyError(); + } const artifactType = flags.artifact; const url = new URL(`${cdnEndpoint}/${artifactType}`); - const response = await http.get(url.toString(), { - headers: { - 'x-hive-cdn-key': token, - 'User-Agent': `hive-cli/${this.config.version}`, - }, - retry: { - retries: 3, - }, - logger: { - info: (...args) => { - if (this.flags.debug) { - console.info(...args); - } + let response; + try { + response = await http.get(url.toString(), { + headers: { + 'x-hive-cdn-key': token, + 'User-Agent': `hive-cli/${this.config.version}`, + }, + retry: { + retries: 3, }, - error: (...args) => { - console.error(...args); + logger: { + info: (...args) => { + if (this.flags.debug) { + console.info(...args); + } + }, + error: (...args) => { + if (this.flags.debug) { + console.error(...args); + } + }, }, - }, - }); + }); + } catch (e: any) { + const sourceError = e?.cause ?? e; + if (isAggregateError(sourceError)) { + throw new NetworkError(sourceError.errors[0]?.message); + } else { + throw new NetworkError(sourceError); + } + } - if (response.status >= 300) { + if (!response.ok) { const responseBody = await response.text(); - throw new Error(responseBody); + throw new HTTPError( + url.toString(), + response.status, + responseBody ?? response.statusText ?? 'Invalid status code for HTTP call', + ); } - if (flags.outputFile) { - const fs = await import('fs/promises'); - const contents = Buffer.from(await response.arrayBuffer()); - await fs.writeFile(flags.outputFile, contents); - this.log(`Wrote ${contents.length} bytes to ${flags.outputFile}`); - return; - } + try { + if (flags.outputFile) { + const fs = await import('fs/promises'); + const contents = Buffer.from(await response.arrayBuffer()); + await fs.writeFile(flags.outputFile, contents); + this.log(`Wrote ${contents.length} bytes to ${flags.outputFile}`); + return; + } - this.log(await response.text()); + this.log(await response.text()); + } catch (e) { + throw new UnexpectedError(e); + } } } diff --git a/packages/libraries/cli/src/commands/dev.ts b/packages/libraries/cli/src/commands/dev.ts index 17aee31150..77c93977dd 100644 --- a/packages/libraries/cli/src/commands/dev.ts +++ b/packages/libraries/cli/src/commands/dev.ts @@ -11,8 +11,19 @@ import { import Command from '../base-command'; import { graphql } from '../gql'; import { graphqlEndpoint } from '../helpers/config'; -import { ACCESS_TOKEN_MISSING } from '../helpers/errors'; -import { loadSchema, renderErrors } from '../helpers/schema'; +import { + APIError, + HiveCLIError, + IntrospectionError, + InvalidCompositionResultError, + LocalCompositionError, + MissingEndpointError, + MissingRegistryTokenError, + RemoteCompositionError, + ServiceAndUrlLengthMismatch, + UnexpectedError, +} from '../helpers/errors'; +import { loadSchema } from '../helpers/schema'; import { invariant } from '../helpers/validation'; const CLI_SchemaComposeMutation = graphql(/* GraphQL */ ` @@ -173,9 +184,7 @@ export default class Dev extends Command<typeof Dev> { const { unstable__forceLatest } = flags; if (flags.service.length !== flags.url.length) { - this.error('Not every services has a matching url', { - exit: 1, - }); + throw new ServiceAndUrlLengthMismatch(flags.service, flags.url); } const isRemote = flags.remote === true; @@ -193,20 +202,30 @@ export default class Dev extends Command<typeof Dev> { if (flags.watch === true) { if (isRemote) { - const registry = this.ensure({ - key: 'registry.endpoint', - legacyFlagName: 'registry', - args: flags, - defaultValue: graphqlEndpoint, - env: 'HIVE_REGISTRY', - }); - const token = this.ensure({ - key: 'registry.accessToken', - legacyFlagName: 'token', - args: flags, - env: 'HIVE_TOKEN', - message: ACCESS_TOKEN_MISSING, - }); + let registry: string, token: string; + try { + registry = this.ensure({ + key: 'registry.endpoint', + legacyFlagName: 'registry', + args: flags, + defaultValue: graphqlEndpoint, + env: 'HIVE_REGISTRY', + description: Dev.flags['registry.endpoint'].description!, + }); + } catch (e) { + throw new MissingEndpointError(); + } + try { + token = this.ensure({ + key: 'registry.accessToken', + legacyFlagName: 'token', + args: flags, + env: 'HIVE_TOKEN', + description: Dev.flags['registry.accessToken'].description!, + }); + } catch (e) { + throw new MissingRegistryTokenError(); + } void this.watch(flags.watchInterval, serviceInputs, services => this.compose({ @@ -215,8 +234,9 @@ export default class Dev extends Command<typeof Dev> { token, write: flags.write, unstable__forceLatest, - onError: message => { - this.logFailure(message); + onError: error => { + // watch mode should not exit. Log instead. + this.logFailure(error.message); }, }), ); @@ -228,8 +248,9 @@ export default class Dev extends Command<typeof Dev> { this.composeLocally({ services, write: flags.write, - onError: message => { - this.logFailure(message); + onError: error => { + // watch mode should not exit. Log instead. + this.logFailure(error.message); }, }), ); @@ -239,20 +260,30 @@ export default class Dev extends Command<typeof Dev> { const services = await this.resolveServices(serviceInputs); if (isRemote) { - const registry = this.ensure({ - key: 'registry.endpoint', - legacyFlagName: 'registry', - args: flags, - defaultValue: graphqlEndpoint, - env: 'HIVE_REGISTRY', - }); - const token = this.ensure({ - key: 'registry.accessToken', - legacyFlagName: 'token', - args: flags, - env: 'HIVE_TOKEN', - message: ACCESS_TOKEN_MISSING, - }); + let registry: string, token: string; + try { + registry = this.ensure({ + key: 'registry.endpoint', + legacyFlagName: 'registry', + args: flags, + defaultValue: graphqlEndpoint, + env: 'HIVE_REGISTRY', + description: Dev.flags['registry.endpoint'].description!, + }); + } catch (e) { + throw new MissingEndpointError(); + } + try { + token = this.ensure({ + key: 'registry.accessToken', + legacyFlagName: 'token', + args: flags, + env: 'HIVE_TOKEN', + description: Dev.flags['registry.accessToken'].description!, + }); + } catch (e) { + throw new MissingRegistryTokenError(); + } return this.compose({ services, @@ -260,10 +291,8 @@ export default class Dev extends Command<typeof Dev> { token, write: flags.write, unstable__forceLatest, - onError: message => { - this.error(message, { - exit: 1, - }); + onError: error => { + throw error; }, }); } @@ -271,10 +300,8 @@ export default class Dev extends Command<typeof Dev> { return this.composeLocally({ services, write: flags.write, - onError: message => { - this.error(message, { - exit: 1, - }); + onError: error => { + throw error; }, }); } @@ -286,7 +313,7 @@ export default class Dev extends Command<typeof Dev> { sdl: string; }>; write: string; - onError: (message: string) => void | never; + onError: (error: HiveCLIError) => void | never; }) { const compositionResult = await new Promise<CompositionResult>((resolve, reject) => { try { @@ -300,32 +327,15 @@ export default class Dev extends Command<typeof Dev> { ), ); } catch (error) { + // @note: composeServices should not throw. + // This reject is for the offchance that something happens under the hood that was not expected. + // Without it, if something happened then the promise would hang. reject(error); } - }).catch(error => { - this.handleFetchError(error); }); if (compositionHasErrors(compositionResult)) { - if (compositionResult.errors) { - this.log( - renderErrors({ - total: compositionResult.errors.length, - nodes: compositionResult.errors.map(error => ({ - message: error.message, - })), - }), - ); - } - - input.onError('Composition failed'); - return; - } - - if (typeof compositionResult.supergraphSdl !== 'string') { - input.onError( - 'Composition successful but failed to get supergraph schema. Please try again later or contact support', - ); + input.onError(new LocalCompositionError(compositionResult)); return; } @@ -344,52 +354,56 @@ export default class Dev extends Command<typeof Dev> { token: string; write: string; unstable__forceLatest: boolean; - onError: (message: string) => void | never; + onError: (error: HiveCLIError) => void | never; }) { - const result = await this.registryApi(input.registry, input.token) - .request({ - operation: CLI_SchemaComposeMutation, - variables: { - input: { - useLatestComposableVersion: !input.unstable__forceLatest, - services: input.services.map(service => ({ - name: service.name, - url: service.url, - sdl: service.sdl, - })), - }, + const result = await this.registryApi(input.registry, input.token).request({ + operation: CLI_SchemaComposeMutation, + variables: { + input: { + useLatestComposableVersion: !input.unstable__forceLatest, + services: input.services.map(service => ({ + name: service.name, + url: service.url, + sdl: service.sdl, + })), }, - }) - .catch(error => { - this.handleFetchError(error); - }); + }, + }); if (result.schemaCompose.__typename === 'SchemaComposeError') { - input.onError(result.schemaCompose.message); + input.onError(new APIError(result.schemaCompose.message)); return; } const { valid, compositionResult } = result.schemaCompose; if (!valid) { + // @note: Can this actually be invalid without any errors? if (compositionResult.errors) { - this.log(renderErrors(compositionResult.errors)); + input.onError(new RemoteCompositionError(compositionResult.errors)); + return; } - input.onError('Composition failed'); + input.onError(new InvalidCompositionResultError(compositionResult.supergraphSdl)); return; } if (typeof compositionResult.supergraphSdl !== 'string') { - input.onError( - 'Composition successful but failed to get supergraph schema. Please try again later or contact support', - ); + input.onError(new InvalidCompositionResultError(compositionResult.supergraphSdl)); return; } this.logSuccess('Composition successful'); this.log(`Saving supergraph schema to ${input.write}`); - await writeFile(resolve(process.cwd(), input.write), compositionResult.supergraphSdl, 'utf-8'); + try { + await writeFile( + resolve(process.cwd(), input.write), + compositionResult.supergraphSdl, + 'utf-8', + ); + } catch (e) { + input.onError(new UnexpectedError(e)); + } } private async watch( @@ -399,8 +413,13 @@ export default class Dev extends Command<typeof Dev> { ) { this.logInfo('Watch mode enabled'); - let services = await this.resolveServices(serviceInputs); - await compose(services); + let services: ServiceWithSource[]; + try { + services = await this.resolveServices(serviceInputs); + await compose(services); + } catch (e) { + throw new UnexpectedError(e); + } this.logInfo('Watching for changes'); @@ -424,7 +443,7 @@ export default class Dev extends Command<typeof Dev> { services = newServices; } } catch (error) { - this.logFailure(String(error)); + this.logFailure(new UnexpectedError(error)); } timeoutId = setTimeout(watch, watchInterval); @@ -483,16 +502,12 @@ export default class Dev extends Command<typeof Dev> { } private async resolveSdlFromUrl(url: string) { - const result = await this.graphql(url) - .request({ operation: ServiceIntrospectionQuery }) - .catch(error => { - this.handleFetchError(error); - }); + const result = await this.graphql(url).request({ operation: ServiceIntrospectionQuery }); const sdl = result._service.sdl; if (!sdl) { - throw new Error('Failed to introspect service'); + throw new IntrospectionError(); } return sdl; diff --git a/packages/libraries/cli/src/commands/introspect.ts b/packages/libraries/cli/src/commands/introspect.ts index 3682562c91..acb586a46d 100644 --- a/packages/libraries/cli/src/commands/introspect.ts +++ b/packages/libraries/cli/src/commands/introspect.ts @@ -1,8 +1,9 @@ import { writeFileSync } from 'node:fs'; import { extname, resolve } from 'node:path'; -import { buildSchema, GraphQLError, introspectionFromSchema } from 'graphql'; +import { buildSchema, introspectionFromSchema } from 'graphql'; import { Args, Flags } from '@oclif/core'; import Command from '../base-command'; +import { APIError, UnexpectedError, UnsupportedFileExtensionError } from '../helpers/errors'; import { loadSchema } from '../helpers/schema'; export default class Introspect extends Command<typeof Introspect> { @@ -46,19 +47,11 @@ export default class Introspect extends Command<typeof Introspect> { headers, method: 'POST', }).catch(err => { - if (err instanceof GraphQLError) { - this.logFailure(err.message); - this.exit(1); - } - - this.error(err, { - exit: 1, - }); + throw new APIError(err); }); if (!schema) { - this.logFailure('Unable to load schema'); - this.exit(1); + throw new UnexpectedError('Unable to load schema'); } if (!flags.write) { @@ -89,8 +82,7 @@ export default class Introspect extends Command<typeof Introspect> { break; } default: - this.logFailure(`Unsupported file extension ${extname(flags.write)}`); - this.exit(1); + throw new UnsupportedFileExtensionError(flags.write); } this.logSuccess(`Saved to ${filepath}`); diff --git a/packages/libraries/cli/src/commands/operations/check.ts b/packages/libraries/cli/src/commands/operations/check.ts index 7b3266d274..150c8a323e 100644 --- a/packages/libraries/cli/src/commands/operations/check.ts +++ b/packages/libraries/cli/src/commands/operations/check.ts @@ -1,10 +1,16 @@ -import { buildSchema, GraphQLError, Source } from 'graphql'; -import { InvalidDocument, validate } from '@graphql-inspector/core'; +import { buildSchema, Source } from 'graphql'; +import { validate } from '@graphql-inspector/core'; import { Args, Errors, Flags } from '@oclif/core'; import Command from '../../base-command'; import { graphql } from '../../gql'; import { graphqlEndpoint } from '../../helpers/config'; -import { ACCESS_TOKEN_MISSING } from '../../helpers/errors'; +import { + InvalidDocumentsError, + MissingEndpointError, + MissingRegistryTokenError, + SchemaNotFoundError, + UnexpectedError, +} from '../../helpers/errors'; import { loadOperations } from '../../helpers/operations'; import { Texture } from '../../helpers/texture/texture'; @@ -84,21 +90,33 @@ export default class OperationsCheck extends Command<typeof OperationsCheck> { const { flags, args } = await this.parse(OperationsCheck); await this.require(flags); + let accessToken: string, endpoint: string; + + try { + endpoint = this.ensure({ + key: 'registry.endpoint', + args: flags, + legacyFlagName: 'registry', + defaultValue: graphqlEndpoint, + env: 'HIVE_REGISTRY', + description: OperationsCheck.flags['registry.endpoint'].description!, + }); + } catch (e) { + throw new MissingEndpointError(); + } + + try { + accessToken = this.ensure({ + key: 'registry.accessToken', + args: flags, + legacyFlagName: 'token', + env: 'HIVE_TOKEN', + description: OperationsCheck.flags['registry.accessToken'].description!, + }); + } catch (e) { + throw new MissingRegistryTokenError(); + } - const endpoint = this.ensure({ - key: 'registry.endpoint', - args: flags, - legacyFlagName: 'registry', - defaultValue: graphqlEndpoint, - env: 'HIVE_REGISTRY', - }); - const accessToken = this.ensure({ - key: 'registry.accessToken', - args: flags, - legacyFlagName: 'token', - env: 'HIVE_TOKEN', - message: ACCESS_TOKEN_MISSING, - }); const graphqlTag = flags.graphqlTag; const globalGraphqlTag = flags.globalGraphqlTag; @@ -129,7 +147,7 @@ export default class OperationsCheck extends Command<typeof OperationsCheck> { const sdl = result.latestValidVersion?.sdl; if (!sdl) { - this.error('Could not find a published schema. Please publish a valid schema first.'); + throw new SchemaNotFoundError(); } const schema = buildSchema(sdl, { @@ -178,29 +196,14 @@ export default class OperationsCheck extends Command<typeof OperationsCheck> { this.log(Texture.header('Details')); - this.printInvalidDocuments(operationsWithErrors); - this.exit(1); + throw new InvalidDocumentsError(operationsWithErrors); } catch (error) { - if (error instanceof Errors.ExitError) { + if (error instanceof Errors.CLIError) { throw error; } else { this.logFailure('Failed to validate operations'); - this.handleFetchError(error); + throw new UnexpectedError(error); } } } - - private printInvalidDocuments(invalidDocuments: InvalidDocument[]): void { - invalidDocuments.forEach(doc => { - this.renderErrors(doc.source.name, doc.errors); - }); - } - - private renderErrors(sourceName: string, errors: GraphQLError[]) { - this.logFailure(sourceName); - errors.forEach(e => { - this.log(` - ${Texture.boldQuotedWords(e.message)}`); - }); - this.log(''); - } } diff --git a/packages/libraries/cli/src/commands/schema/check.ts b/packages/libraries/cli/src/commands/schema/check.ts index 8a77901912..be9ac3e0af 100644 --- a/packages/libraries/cli/src/commands/schema/check.ts +++ b/packages/libraries/cli/src/commands/schema/check.ts @@ -3,7 +3,16 @@ import { Args, Errors, Flags } from '@oclif/core'; import Command from '../../base-command'; import { graphql } from '../../gql'; import { graphqlEndpoint } from '../../helpers/config'; -import { ACCESS_TOKEN_MISSING } from '../../helpers/errors'; +import { + APIError, + GithubCommitRequiredError, + GithubRepositoryRequiredError, + MissingEndpointError, + MissingRegistryTokenError, + SchemaFileEmptyError, + SchemaFileNotFoundError, + UnexpectedError, +} from '../../helpers/errors'; import { gitInfo } from '../../helpers/git'; import { loadSchema, @@ -163,22 +172,35 @@ export default class SchemaCheck extends Command<typeof SchemaCheck> { const service = flags.service; const forceSafe = flags.forceSafe; const usesGitHubApp = flags.github === true; - const endpoint = this.ensure({ - key: 'registry.endpoint', - args: flags, - legacyFlagName: 'registry', - defaultValue: graphqlEndpoint, - env: 'HIVE_REGISTRY', - }); + let endpoint: string, accessToken: string; + try { + endpoint = this.ensure({ + key: 'registry.endpoint', + args: flags, + legacyFlagName: 'registry', + defaultValue: graphqlEndpoint, + env: 'HIVE_REGISTRY', + description: SchemaCheck.flags['registry.endpoint'].description!, + }); + } catch (e) { + throw new MissingEndpointError(); + } const file = args.file; - const accessToken = this.ensure({ - key: 'registry.accessToken', - args: flags, - legacyFlagName: 'token', - env: 'HIVE_TOKEN', - message: ACCESS_TOKEN_MISSING, + try { + accessToken = this.ensure({ + key: 'registry.accessToken', + args: flags, + legacyFlagName: 'token', + env: 'HIVE_TOKEN', + description: SchemaCheck.flags['registry.accessToken'].description!, + }); + } catch (e) { + throw new MissingRegistryTokenError(); + } + + const sdl = await loadSchema(file).catch(e => { + throw new SchemaFileNotFoundError(file, e); }); - const sdl = await loadSchema(file); const git = await gitInfo(() => { // noop }); @@ -187,7 +209,7 @@ export default class SchemaCheck extends Command<typeof SchemaCheck> { const author = flags.author || git?.author; if (typeof sdl !== 'string' || sdl.length === 0) { - throw new Errors.CLIError('Schema seems empty'); + throw new SchemaFileEmptyError(file); } let github: null | { @@ -198,12 +220,10 @@ export default class SchemaCheck extends Command<typeof SchemaCheck> { if (usesGitHubApp) { if (!commit) { - throw new Errors.CLIError(`Couldn't resolve commit sha required for GitHub Application`); + throw new GithubCommitRequiredError(); } if (!git.repository) { - throw new Errors.CLIError( - `Couldn't resolve git repository required for GitHub Application`, - ); + throw new GithubRepositoryRequiredError(); } if (!git.pullRequestNumber) { this.warn( @@ -289,14 +309,14 @@ export default class SchemaCheck extends Command<typeof SchemaCheck> { } else if (result.schemaCheck.__typename === 'GitHubSchemaCheckSuccess') { this.logSuccess(result.schemaCheck.message); } else { - this.error(result.schemaCheck.message); + throw new APIError(result.schemaCheck.message); } } catch (error) { - if (error instanceof Errors.ExitError) { + if (error instanceof Errors.CLIError) { throw error; } else { this.logFailure('Failed to check schema'); - this.handleFetchError(error); + throw new UnexpectedError(error); } } } diff --git a/packages/libraries/cli/src/commands/schema/delete.ts b/packages/libraries/cli/src/commands/schema/delete.ts index 7a3e729199..29bc4e3c16 100644 --- a/packages/libraries/cli/src/commands/schema/delete.ts +++ b/packages/libraries/cli/src/commands/schema/delete.ts @@ -2,7 +2,12 @@ import { Args, Errors, Flags, ux } from '@oclif/core'; import Command from '../../base-command'; import { graphql } from '../../gql'; import { graphqlEndpoint } from '../../helpers/config'; -import { ACCESS_TOKEN_MISSING } from '../../helpers/errors'; +import { + APIError, + MissingEndpointError, + MissingRegistryTokenError, + UnexpectedError, +} from '../../helpers/errors'; import { renderErrors } from '../../helpers/schema'; const schemaDeleteMutation = graphql(/* GraphQL */ ` @@ -99,20 +104,30 @@ export default class SchemaDelete extends Command<typeof SchemaDelete> { } } - const endpoint = this.ensure({ - key: 'registry.endpoint', - args: flags, - legacyFlagName: 'registry', - defaultValue: graphqlEndpoint, - env: 'HIVE_REGISTRY', - }); - const accessToken = this.ensure({ - key: 'registry.accessToken', - args: flags, - legacyFlagName: 'token', - env: 'HIVE_TOKEN', - message: ACCESS_TOKEN_MISSING, - }); + let accessToken: string, endpoint: string; + try { + endpoint = this.ensure({ + key: 'registry.endpoint', + args: flags, + legacyFlagName: 'registry', + defaultValue: graphqlEndpoint, + env: 'HIVE_REGISTRY', + description: SchemaDelete.flags['registry.endpoint'].description!, + }); + } catch (e) { + throw new MissingEndpointError(); + } + try { + accessToken = this.ensure({ + key: 'registry.accessToken', + args: flags, + legacyFlagName: 'token', + env: 'HIVE_TOKEN', + description: SchemaDelete.flags['registry.accessToken'].description!, + }); + } catch (e) { + throw new MissingRegistryTokenError(); + } const result = await this.registryApi(endpoint, accessToken).request({ operation: schemaDeleteMutation, @@ -134,15 +149,14 @@ export default class SchemaDelete extends Command<typeof SchemaDelete> { const errors = result.schemaDelete.errors; if (errors) { - this.log(renderErrors(errors)); - this.exit(1); + throw new APIError(renderErrors(errors)); } } catch (error) { - if (error instanceof Errors.ExitError) { + if (error instanceof Errors.CLIError) { throw error; } else { this.logFailure(`Failed to complete`); - this.handleFetchError(error); + throw new UnexpectedError(error); } } } diff --git a/packages/libraries/cli/src/commands/schema/fetch.ts b/packages/libraries/cli/src/commands/schema/fetch.ts index 2d2737e73e..ed4f076478 100644 --- a/packages/libraries/cli/src/commands/schema/fetch.ts +++ b/packages/libraries/cli/src/commands/schema/fetch.ts @@ -4,7 +4,13 @@ import { Args, Flags } from '@oclif/core'; import Command from '../../base-command'; import { graphql } from '../../gql'; import { graphqlEndpoint } from '../../helpers/config'; -import { ACCESS_TOKEN_MISSING } from '../../helpers/errors'; +import { + InvalidSchemaError, + MissingEndpointError, + MissingRegistryTokenError, + SchemaNotFoundError, + UnsupportedFileExtensionError, +} from '../../helpers/errors'; import { Texture } from '../../helpers/texture/texture'; const SchemaVersionForActionIdQuery = graphql(/* GraphQL */ ` @@ -119,21 +125,30 @@ export default class SchemaFetch extends Command<typeof SchemaFetch> { async run() { const { flags, args } = await this.parse(SchemaFetch); - const endpoint = this.ensure({ - key: 'registry.endpoint', - args: flags, - env: 'HIVE_REGISTRY', - legacyFlagName: 'registry', - defaultValue: graphqlEndpoint, - }); - - const accessToken = this.ensure({ - key: 'registry.accessToken', - args: flags, - legacyFlagName: 'token', - env: 'HIVE_TOKEN', - message: ACCESS_TOKEN_MISSING, - }); + let endpoint: string, accessToken: string; + try { + endpoint = this.ensure({ + key: 'registry.endpoint', + args: flags, + env: 'HIVE_REGISTRY', + legacyFlagName: 'registry', + defaultValue: graphqlEndpoint, + description: SchemaFetch.flags['registry.endpoint'].description!, + }); + } catch (e) { + throw new MissingEndpointError(); + } + try { + accessToken = this.ensure({ + key: 'registry.accessToken', + args: flags, + legacyFlagName: 'token', + env: 'HIVE_TOKEN', + description: SchemaFetch.flags['registry.accessToken'].description!, + }); + } catch (e) { + throw new MissingRegistryTokenError(); + } const { actionId } = args; @@ -172,11 +187,11 @@ export default class SchemaFetch extends Command<typeof SchemaFetch> { } if (schemaVersion == null) { - return this.error(`No schema found for action id ${actionId}`); + throw new SchemaNotFoundError(actionId); } if (schemaVersion.valid === false) { - return this.error(`Schema is invalid for action id ${actionId}`); + throw new InvalidSchemaError(actionId); } if (schemaVersion.schemas) { @@ -202,7 +217,7 @@ export default class SchemaFetch extends Command<typeof SchemaFetch> { const schema = schemaVersion.sdl ?? schemaVersion.supergraph; if (schema == null) { - return this.error(`No ${sdlType} found for action id ${actionId}`); + throw new SchemaNotFoundError(actionId); } if (flags.write) { @@ -215,8 +230,7 @@ export default class SchemaFetch extends Command<typeof SchemaFetch> { await writeFile(filepath, schema, 'utf8'); break; default: - this.logFailure(`Unsupported file extension ${extname(flags.write)}`); - this.exit(1); + throw new UnsupportedFileExtensionError(flags.write); } return; } diff --git a/packages/libraries/cli/src/commands/schema/publish.ts b/packages/libraries/cli/src/commands/schema/publish.ts index 4fbcc335ac..d3951a573a 100644 --- a/packages/libraries/cli/src/commands/schema/publish.ts +++ b/packages/libraries/cli/src/commands/schema/publish.ts @@ -1,11 +1,22 @@ -import { existsSync, readFileSync } from 'fs'; import { GraphQLError, print } from 'graphql'; import { transformCommentsToDescriptions } from '@graphql-tools/utils'; import { Args, Errors, Flags } from '@oclif/core'; import Command from '../../base-command'; import { DocumentType, graphql } from '../../gql'; import { graphqlEndpoint } from '../../helpers/config'; -import { ACCESS_TOKEN_MISSING } from '../../helpers/errors'; +import { + APIError, + GithubAuthorRequiredError, + GithubCommitRequiredError, + InvalidSDLError, + MissingEndpointError, + MissingEnvironmentError, + MissingRegistryTokenError, + SchemaPublishFailedError, + SchemaPublishMissingServiceError, + SchemaPublishMissingUrlError, + UnexpectedError, +} from '../../helpers/errors'; import { gitInfo } from '../../helpers/git'; import { loadSchema, minifySchema, renderChanges, renderErrors } from '../../helpers/schema'; import { invariant } from '../../helpers/validation'; @@ -143,7 +154,7 @@ export default class SchemaPublish extends Command<typeof SchemaPublish> { }), }; - resolveMetadata(metadata: string | undefined): string | undefined { + resolveMetadata = (metadata: string | undefined): string | undefined => { if (!metadata) { return; } @@ -155,26 +166,9 @@ export default class SchemaPublish extends Command<typeof SchemaPublish> { return metadata; } catch (e) { // If we can't parse it, we can try to load it from FS - const exists = existsSync(metadata); - - if (!exists) { - throw new Error( - `Failed to load metadata from "${metadata}": Please specify a path to an existing file, or a string with valid JSON.`, - ); - } - - try { - const fileContent = readFileSync(metadata, 'utf-8'); - JSON.parse(fileContent); - - return fileContent; - } catch (e) { - throw new Error( - `Failed to load metadata from file "${metadata}": Please make sure the file is readable and contains a valid JSON`, - ); - } + return this.readJSON(metadata); } - } + }; async run() { try { @@ -182,20 +176,30 @@ export default class SchemaPublish extends Command<typeof SchemaPublish> { await this.require(flags); - const endpoint = this.ensure({ - key: 'registry.endpoint', - args: flags, - legacyFlagName: 'registry', - defaultValue: graphqlEndpoint, - env: 'HIVE_REGISTRY', - }); - const accessToken = this.ensure({ - key: 'registry.accessToken', - args: flags, - legacyFlagName: 'token', - env: 'HIVE_TOKEN', - message: ACCESS_TOKEN_MISSING, - }); + let endpoint: string, accessToken: string; + try { + endpoint = this.ensure({ + key: 'registry.endpoint', + args: flags, + legacyFlagName: 'registry', + defaultValue: graphqlEndpoint, + env: 'HIVE_REGISTRY', + description: SchemaPublish.flags['registry.endpoint'].description!, + }); + } catch (e) { + throw new MissingEndpointError(); + } + try { + accessToken = this.ensure({ + key: 'registry.accessToken', + args: flags, + legacyFlagName: 'token', + env: 'HIVE_TOKEN', + description: SchemaPublish.flags['registry.accessToken'].description!, + }); + } catch (e) { + throw new MissingRegistryTokenError(); + } const service = flags.service; const url = flags.url; const file = args.file; @@ -235,18 +239,21 @@ export default class SchemaPublish extends Command<typeof SchemaPublish> { } if (!author) { - throw new Errors.CLIError(`Missing "author"`); + throw new GithubAuthorRequiredError(); } if (!commit) { - throw new Errors.CLIError(`Missing "commit"`); + throw new GithubCommitRequiredError(); } if (usesGitHubApp) { // eslint-disable-next-line no-process-env const repository = process.env['GITHUB_REPOSITORY'] ?? null; if (!repository) { - throw new Errors.CLIError(`Missing "GITHUB_REPOSITORY" environment variable.`); + throw new MissingEnvironmentError([ + 'GITHUB_REPOSITORY', + 'Github repository full name, e.g. graphql-hive/console', + ]); } gitHub = { repository, @@ -262,11 +269,7 @@ export default class SchemaPublish extends Command<typeof SchemaPublish> { sdl = minifySchema(transformedSDL); } catch (err) { if (err instanceof GraphQLError) { - const location = err.locations?.[0]; - const locationString = location - ? ` at line ${location.line}, column ${location.column}` - : ''; - throw new Error(`The SDL is not valid${locationString}:\n ${err.message}`); + throw new InvalidSDLError(err); } throw err; } @@ -319,15 +322,9 @@ export default class SchemaPublish extends Command<typeof SchemaPublish> { this.log('Waiting for other schema publishes to complete...'); result = null; } else if (result.schemaPublish.__typename === 'SchemaPublishMissingServiceError') { - this.logFailure( - `${result.schemaPublish.missingServiceError} Please use the '--service <name>' parameter.`, - ); - this.exit(1); + throw new SchemaPublishMissingServiceError(result.schemaPublish.missingServiceError); } else if (result.schemaPublish.__typename === 'SchemaPublishMissingUrlError') { - this.logFailure( - `${result.schemaPublish.missingUrlError} Please use the '--url <url>' parameter.`, - ); - this.exit(1); + throw new SchemaPublishMissingUrlError(result.schemaPublish.missingUrlError); } else if (result.schemaPublish.__typename === 'SchemaPublishError') { const changes = result.schemaPublish.changes; const errors = result.schemaPublish.errors; @@ -340,8 +337,7 @@ export default class SchemaPublish extends Command<typeof SchemaPublish> { this.log(''); if (!force) { - this.logFailure('Failed to publish schema'); - this.exit(1); + throw new SchemaPublishFailedError(); } else { this.logSuccess('Schema published (forced)'); } @@ -352,17 +348,19 @@ export default class SchemaPublish extends Command<typeof SchemaPublish> { } else if (result.schemaPublish.__typename === 'GitHubSchemaPublishSuccess') { this.logSuccess(result.schemaPublish.message); } else { - this.error( - 'message' in result.schemaPublish ? result.schemaPublish.message : 'Unknown error', + throw new APIError( + 'message' in result.schemaPublish + ? result.schemaPublish.message + : `Received unhandled type "${(result.schemaPublish as any)?.__typename}" in response.`, ); } } while (result === null); } catch (error) { - if (error instanceof Errors.ExitError) { + if (error instanceof Errors.CLIError) { throw error; } else { this.logFailure('Failed to publish schema'); - this.handleFetchError(error); + throw new UnexpectedError(error instanceof Error ? error.message : JSON.stringify(error)); } } } diff --git a/packages/libraries/cli/src/commands/whoami.ts b/packages/libraries/cli/src/commands/whoami.ts index 05f820bd7a..ac13e44b93 100644 --- a/packages/libraries/cli/src/commands/whoami.ts +++ b/packages/libraries/cli/src/commands/whoami.ts @@ -2,7 +2,12 @@ import { Flags } from '@oclif/core'; import Command from '../base-command'; import { graphql } from '../gql'; import { graphqlEndpoint } from '../helpers/config'; -import { ACCESS_TOKEN_MISSING } from '../helpers/errors'; +import { + InvalidRegistryTokenError, + MissingEndpointError, + MissingRegistryTokenError, + UnexpectedError, +} from '../helpers/errors'; import { Texture } from '../helpers/texture/texture'; const myTokenInfoQuery = graphql(/* GraphQL */ ` @@ -62,29 +67,35 @@ export default class WhoAmI extends Command<typeof WhoAmI> { async run() { const { flags } = await this.parse(WhoAmI); + let registry: string, token: string; + try { + registry = this.ensure({ + key: 'registry.endpoint', + legacyFlagName: 'registry', + args: flags, + defaultValue: graphqlEndpoint, + env: 'HIVE_REGISTRY', + description: WhoAmI.flags['registry.endpoint'].description!, + }); + } catch (e) { + throw new MissingEndpointError(); + } - const registry = this.ensure({ - key: 'registry.endpoint', - legacyFlagName: 'registry', - args: flags, - defaultValue: graphqlEndpoint, - env: 'HIVE_REGISTRY', - }); - const token = this.ensure({ - key: 'registry.accessToken', - legacyFlagName: 'token', - args: flags, - env: 'HIVE_TOKEN', - message: ACCESS_TOKEN_MISSING, - }); - - const result = await this.registryApi(registry, token) - .request({ - operation: myTokenInfoQuery, - }) - .catch(error => { - this.handleFetchError(error); + try { + token = this.ensure({ + key: 'registry.accessToken', + legacyFlagName: 'token', + args: flags, + env: 'HIVE_TOKEN', + description: WhoAmI.flags['registry.accessToken'].description!, }); + } catch (e) { + throw new MissingRegistryTokenError(); + } + + const result = await this.registryApi(registry, token).request({ + operation: myTokenInfoQuery, + }); if (result.tokenInfo.__typename === 'TokenInfo') { const { tokenInfo } = result; @@ -115,10 +126,12 @@ export default class WhoAmI extends Command<typeof WhoAmI> { this.log(print()); } else if (result.tokenInfo.__typename === 'TokenNotFoundError') { - this.error(`Token not found. Reason: ${result.tokenInfo.message}`, { - exit: 0, - suggestions: [`How to create a token? https://docs.graphql-hive.com/features/tokens`], - }); + this.debug(result.tokenInfo.message); + throw new InvalidRegistryTokenError(); + } else { + throw new UnexpectedError( + `Token response got an unsupported type: ${(result.tokenInfo as any).__typename}`, + ); } } } diff --git a/packages/libraries/cli/src/helpers/errors.ts b/packages/libraries/cli/src/helpers/errors.ts index 964839fe3a..a525c56ea4 100644 --- a/packages/libraries/cli/src/helpers/errors.ts +++ b/packages/libraries/cli/src/helpers/errors.ts @@ -1 +1,412 @@ -export const ACCESS_TOKEN_MISSING = `--registry.accessToken is required. For help generating an access token, see https://the-guild.dev/graphql/hive/docs/management/targets#registry-access-tokens`; +import { extname } from 'node:path'; +import { env } from 'node:process'; +import { GraphQLError } from 'graphql'; +import { InvalidDocument } from '@graphql-inspector/core'; +import { CLIError } from '@oclif/core/lib/errors'; +import { CompositionFailure } from '@theguild/federation-composition'; +import { SchemaErrorConnection } from '../gql/graphql'; +import { renderErrors } from './schema'; +import { Texture } from './texture/texture'; + +export const ACCESS_TOKEN_MISSING = '@TODO FIX'; + +export enum ExitCode { + // The command execution succeeded. + SUCCESS = 0, + + // The command execution failed with a completion code that signals an error. + ERROR = 1, + + // The CLI was able to handle the command but it took too long and timed out. + TIMED_OUT = 2, + + // Initialization of the CLI failed. E.g. malformed input + BAD_INIT = 3, +} + +export class HiveCLIError extends CLIError { + constructor( + public readonly exitCode: ExitCode, + code: number, + message: string, + ) { + const tip = `> See https://the-guild.dev/graphql/hive/docs/api-reference/cli#errors for a complete list of error codes and recommended fixes. +To disable this message set HIVE_NO_ERROR_TIP=1`; + super(`${message} [${code}]${env.HIVE_NO_ERROR_TIP === '1' ? '' : `\n${tip}`}`); + } +} + +/** Categorized by command */ +enum ErrorCategory { + GENERIC = 1_00, + SCHEMA_CHECK = 2_00, + SCHEMA_PUBLISH = 3_00, + APP_CREATE = 4_00, + ARTIFACT_FETCH = 5_00, + DEV = 6_00, +} + +const errorCode = (category: ErrorCategory, id: number): number => { + return category + id; +}; + +export class InvalidConfigError extends HiveCLIError { + constructor(configName = 'hive.json') { + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.GENERIC, 0), + `The provided "${configName}" is invalid.`, + ); + } +} + +export class InvalidCommandError extends HiveCLIError { + constructor(command: string) { + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.GENERIC, 1), + `The command, "${command}", does not exist.`, + ); + } +} + +export class MissingArgumentsError extends HiveCLIError { + constructor(...requiredArgs: Array<[string, string]>) { + const argsStr = requiredArgs.map(a => `${a[0]} \t${a[1]}`).join('\n'); + const message = `Missing ${requiredArgs.length} required argument${requiredArgs.length > 1 ? 's' : ''}:\n${argsStr}`; + super(ExitCode.BAD_INIT, errorCode(ErrorCategory.GENERIC, 2), message); + } +} + +export class MissingRegistryTokenError extends HiveCLIError { + constructor() { + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.GENERIC, 3), + `A registry token is required to perform the action. For help generating an access token, see https://the-guild.dev/graphql/hive/docs/management/targets#registry-access-tokens`, + ); + } +} + +export class MissingCdnKeyError extends HiveCLIError { + constructor() { + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.GENERIC, 4), + `A CDN key is required to perform the action. For help generating a CDN key, see https://the-guild.dev/graphql/hive/docs/management/targets#cdn-access-tokens`, + ); + } +} + +export class MissingEndpointError extends HiveCLIError { + constructor() { + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.GENERIC, 5), + `A registry endpoint is required to perform the action.`, + ); + } +} + +export class InvalidRegistryTokenError extends HiveCLIError { + constructor() { + super( + ExitCode.ERROR, + errorCode(ErrorCategory.GENERIC, 6), + `A valid registry token is required to perform the action. The registry token used does not exist or has been revoked.`, + ); + } +} + +export class InvalidCdnKeyError extends HiveCLIError { + constructor() { + super( + ExitCode.ERROR, + errorCode(ErrorCategory.GENERIC, 7), + `A valid CDN key is required to perform the action. The CDN key used does not exist or has been revoked.`, + ); + } +} + +export class MissingCdnEndpointError extends HiveCLIError { + constructor() { + super( + ExitCode.ERROR, + errorCode(ErrorCategory.GENERIC, 8), + `A CDN endpoint is required to perform the action.`, + ); + } +} + +export class MissingEnvironmentError extends HiveCLIError { + constructor(...requiredVars: Array<[string, string]>) { + const varsStr = requiredVars.map(a => `\t${a[0]} \t${a[1]}`).join('\n'); + const message = `Missing required environment variable${requiredVars.length > 1 ? 's' : ''}:\n${varsStr}`; + super(ExitCode.BAD_INIT, errorCode(ErrorCategory.GENERIC, 9), message); + } +} + +export class SchemaFileNotFoundError extends HiveCLIError { + constructor(fileName: string, reason?: string | Error) { + const message = reason instanceof Error ? reason.message : reason; + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.SCHEMA_CHECK, 0), + `Error reading the schema file "${fileName}"${message ? `: ${message}` : '.'}`, + ); + } +} + +export class SchemaFileEmptyError extends HiveCLIError { + constructor(fileName: string) { + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.SCHEMA_CHECK, 1), + `The schema file "${fileName}" is empty.`, + ); + } +} + +export class GithubCommitRequiredError extends HiveCLIError { + constructor() { + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.GENERIC, 10), + `Couldn't resolve commit sha required for GitHub Application.`, + ); + } +} + +export class GithubRepositoryRequiredError extends HiveCLIError { + constructor() { + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.GENERIC, 11), + `Couldn't resolve git repository required for GitHub Application.`, + ); + } +} + +export class GithubAuthorRequiredError extends HiveCLIError { + constructor() { + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.GENERIC, 12), + `Couldn't resolve commit sha required for GitHub Application.`, + ); + } +} + +export class SchemaPublishFailedError extends HiveCLIError { + constructor() { + super(ExitCode.ERROR, errorCode(ErrorCategory.SCHEMA_PUBLISH, 0), `Schema publish failed.`); + } +} + +export class HTTPError extends HiveCLIError { + constructor(endpoint: string, status: number, message: string) { + const is400 = status >= 400 && status < 500; + super( + ExitCode.ERROR, + errorCode(ErrorCategory.GENERIC, 13), + `A ${is400 ? 'client' : 'server'} error occurred while performing the action. A call to "${endpoint}" failed with Status: ${status}, Text: ${message}`, + ); + } +} + +export class NetworkError extends HiveCLIError { + constructor(cause: Error | string) { + super( + ExitCode.ERROR, + errorCode(ErrorCategory.GENERIC, 14), + `A network error occurred while performing the action: "${cause instanceof Error ? `${cause.name}: ${cause.message}` : cause}"`, + ); + } +} + +/** GraphQL Errors returned from an operation. Note that some GraphQL Errors that require specific steps to correct are handled through other error types. */ +export class APIError extends HiveCLIError { + public ref?: string; + constructor(cause: Error | string, requestId?: string) { + super( + ExitCode.ERROR, + errorCode(ErrorCategory.GENERIC, 15), + (cause instanceof Error ? `${cause.name}: ${cause.message}` : cause) + + (requestId ? ` (Request ID: "${requestId}")` : ''), + ); + this.ref = requestId; + } +} + +export class IntrospectionError extends HiveCLIError { + constructor() { + super( + ExitCode.ERROR, + errorCode(ErrorCategory.GENERIC, 16), + 'Could not get introspection result from the service. Make sure introspection is enabled by the server.', + ); + } +} + +export class InvalidSDLError extends HiveCLIError { + constructor(err: GraphQLError) { + const location = err.locations?.[0]; + const locationString = location ? ` at line ${location.line}, column ${location.column}` : ''; + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.SCHEMA_PUBLISH, 1), + `The SDL is not valid${locationString}:\n ${err.message}`, + ); + } +} + +export class SchemaPublishMissingServiceError extends HiveCLIError { + constructor(message: string) { + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.SCHEMA_PUBLISH, 2), + `${message} Please use the '--service <name>' parameter.`, + ); + } +} + +export class SchemaPublishMissingUrlError extends HiveCLIError { + constructor(message: string) { + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.SCHEMA_PUBLISH, 3), + `${message} Please use the '--url <url>' parameter.`, + ); + } +} + +export class InvalidDocumentsError extends HiveCLIError { + constructor(invalidDocuments: InvalidDocument[]) { + const message = invalidDocuments + .map(doc => { + return `${Texture.failure(doc.source)}\n${doc.errors.map(e => ` - ${Texture.boldQuotedWords(e.message)}`).join('\n')}`; + }) + .join('\n'); + super(ExitCode.ERROR, errorCode(ErrorCategory.SCHEMA_CHECK, 2), message); + } +} + +export class ServiceAndUrlLengthMismatch extends HiveCLIError { + constructor(services: string[], urls: string[]) { + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.DEV, 0), + `Not every services has a matching url. Got ${services.length} services and ${urls.length} urls.`, + ); + } +} + +export class LocalCompositionError extends HiveCLIError { + constructor(compositionResult: CompositionFailure) { + const message = renderErrors({ + total: compositionResult.errors.length, + nodes: compositionResult.errors.map(error => ({ + message: error.message, + })), + }); + super(ExitCode.ERROR, errorCode(ErrorCategory.DEV, 1), `Local composition failed:\n${message}`); + } +} + +export class RemoteCompositionError extends HiveCLIError { + constructor(errors: SchemaErrorConnection) { + const message = renderErrors(errors); + super(ExitCode.ERROR, errorCode(ErrorCategory.DEV, 2), message); + } +} + +export class InvalidCompositionResultError extends HiveCLIError { + /** Compose API spits out the error message */ + constructor(supergraph?: string | undefined | null) { + super( + ExitCode.ERROR, + errorCode(ErrorCategory.DEV, 3), + `Composition resulted in an invalid supergraph: ${supergraph}`, + ); + } +} + +export class PersistedOperationsMalformedError extends HiveCLIError { + constructor(file: string) { + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.APP_CREATE, 0), + `Persisted Operations file "${file}" is malformed.`, + ); + } +} + +export class UnsupportedFileExtensionError extends HiveCLIError { + constructor(filename: string) { + super(ExitCode.BAD_INIT, errorCode(ErrorCategory.GENERIC, 17), `${extname(filename)}`); + } +} + +export class FileMissingError extends HiveCLIError { + constructor(fileName: string, additionalContext?: string) { + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.GENERIC, 18), + `Failed to load file "${fileName}"${additionalContext ? `: ${additionalContext}` : '.'}`, + ); + } +} + +export class InvalidFileContentsError extends HiveCLIError { + constructor(fileName: string, expectedFormat: string) { + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.GENERIC, 19), + `File "${fileName}" could not be parsed. Please make sure the file is readable and contains a valid ${expectedFormat}.`, + ); + } +} + +export class SchemaNotFoundError extends HiveCLIError { + constructor(actionId?: string) { + super( + ExitCode.ERROR, + errorCode(ErrorCategory.ARTIFACT_FETCH, 0), + `No schema found${actionId ? ` for action id ${actionId}.` : '.'}`, + ); + } +} + +export class InvalidSchemaError extends HiveCLIError { + constructor(actionId?: string) { + super( + ExitCode.ERROR, + errorCode(ErrorCategory.ARTIFACT_FETCH, 1), + `Schema is invalid${actionId ? ` for action id ${actionId}.` : '.'}`, + ); + } +} + +export class UnexpectedError extends HiveCLIError { + constructor(cause: unknown) { + const message = + cause instanceof Error + ? cause.message + : typeof cause === 'string' + ? cause + : JSON.stringify(cause); + super( + ExitCode.ERROR, + errorCode(ErrorCategory.GENERIC, 99), + `An unexpected error occurred: ${message}\n> Enable DEBUG=* for more details.`, + ); + } +} + +export interface AggregateError extends Error { + errors: Error[]; +} + +export function isAggregateError(error: unknown): error is AggregateError { + return !!error && typeof error === 'object' && 'errors' in error && Array.isArray(error.errors); +}