From f34a3ecaf66f4cde3c3a182c256cc4bbd517afd1 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 17 Jan 2025 22:50:18 -0800 Subject: [PATCH 1/9] Standardize CLI errors with codes --- packages/libraries/cli/src/base-command.ts | 184 +++++------- .../libraries/cli/src/commands/app/create.ts | 47 +-- .../libraries/cli/src/commands/app/publish.ts | 39 ++- .../cli/src/commands/artifact/fetch.ts | 102 ++++--- packages/libraries/cli/src/commands/dev.ts | 176 +++++------ .../libraries/cli/src/commands/introspect.ts | 18 +- .../cli/src/commands/operations/check.ts | 69 +++-- .../cli/src/commands/schema/check.ts | 55 ++-- .../cli/src/commands/schema/delete.ts | 45 +-- .../cli/src/commands/schema/fetch.ts | 50 ++-- .../cli/src/commands/schema/publish.ts | 71 +++-- packages/libraries/cli/src/commands/whoami.ts | 54 ++-- packages/libraries/cli/src/helpers/errors.ts | 280 +++++++++++++++++- 13 files changed, 758 insertions(+), 432 deletions(-) diff --git a/packages/libraries/cli/src/base-command.ts b/packages/libraries/cli/src/base-command.ts index 57db9bf6aa..00639929bf 100644 --- a/packages/libraries/cli/src/base-command.ts +++ b/packages/libraries/cli/src/base-command.ts @@ -1,10 +1,12 @@ -import { print, type GraphQLError } from 'graphql'; +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 { Texture } from './helpers/texture/texture'; +import { env } from 'node:process' +import { APIError, HTTPError, MissingArgumentsError, NetworkError, isAggregateError } from './helpers/errors'; export type Flags = Interfaces.InferredFlags< (typeof BaseCommand)['baseFlags'] & T['flags'] @@ -57,7 +59,7 @@ export default abstract class BaseCommand extends Comm } logFailure(...args: any[]) { - this.log(Texture.failure(...args)); + this.logToStderr(Texture.failure(...args)); } logInfo(...args: any[]) { @@ -98,7 +100,7 @@ export default abstract class BaseCommand 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 +113,8 @@ export default abstract class BaseCommand extends Comm args, legacyFlagName, defaultValue, - message, - env, + env: envName, + description, }: { args: TArgs; key: TKey; @@ -127,38 +129,32 @@ export default abstract class BaseCommand extends Comm }>; defaultValue?: TArgs[keyof TArgs] | null; - message?: string; + description: string; env?: string; }): NonNullable> | never { - if (args[key] != null) { - return args[key] as NonNullable>; - } - - if (legacyFlagName && (args as any)[legacyFlagName] != null) { - return args[legacyFlagName] as any as NonNullable>; - } - - // 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>; - } - - const userConfigValue = this._userConfig!.get(key); + let value: GetConfigurationValueType; - if (userConfigValue != null) { - return userConfigValue; - } - - if (defaultValue) { - return defaultValue; + if (args[key] != null) { + value = args[key]; + } else if (legacyFlagName && (args as any)[legacyFlagName] != null) { + value = args[legacyFlagName] as NonNullable>; + } else if (envName && env[envName] !== undefined) { + value = env[envName] as TArgs[keyof TArgs] as NonNullable>; + } else { + const configValue = this._userConfig!.get(key) as NonNullable>; + + 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 +182,7 @@ export default abstract class BaseCommand extends Comm const isDebug = this.flags.debug; return { - async request( + request: async ( args: { operation: TypedDocumentNode; /** timeout in milliseconds */ @@ -198,77 +194,71 @@ export default abstract class BaseCommand extends Comm : { variables: TVariables; }), - ): Promise { - 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 => { + 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') } - const jsonData = (await response.json()) as ExecutionResult; - 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, - }, + let jsonData; + try { + jsonData = (await response.json()) as ExecutionResult; + } 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')), ); } + if (jsonData.errors && jsonData.errors.length > 0) { + 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'))) + } + return jsonData.data!; }, }; } - 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[]; @@ -282,19 +272,3 @@ export default abstract class BaseCommand extends Comm } } } - -class ClientError extends Error { - constructor( - message: string, - public response: { - errors?: readonly GraphQLError[]; - headers: Headers; - }, - ) { - super(message); - } -} - -function isClientError(error: Error): error is ClientError { - return error instanceof ClientError; -} diff --git a/packages/libraries/cli/src/commands/app/create.ts b/packages/libraries/cli/src/commands/app/create.ts index 36956b8273..504a9feaf3 100644 --- a/packages/libraries/cli/src/commands/app/create.ts +++ b/packages/libraries/cli/src/commands/app/create.ts @@ -4,7 +4,7 @@ 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 { static description = 'create an app deployment'; @@ -37,18 +37,29 @@ export default class AppCreate extends Command { 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'); @@ -57,8 +68,7 @@ export default class AppCreate extends Command { 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 +82,11 @@ export default class AppCreate extends Command { }); 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 +132,7 @@ export default class AppCreate extends Command { ); } } - 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 { static description = 'publish an app deployment'; @@ -26,18 +26,29 @@ export default class AppPublish extends Command { 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 { }); 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..8247a97f4a 100644 --- a/packages/libraries/cli/src/commands/artifact/fetch.ts +++ b/packages/libraries/cli/src/commands/artifact/fetch.ts @@ -1,6 +1,7 @@ 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 { static description = 'fetch artifacts from the CDN'; @@ -24,55 +25,82 @@ export default class ArtifactsFetch extends Command { 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()); + } catch(e) { + throw new UnexpectedError(e); } - - this.log(await response.text()); } } diff --git a/packages/libraries/cli/src/commands/dev.ts b/packages/libraries/cli/src/commands/dev.ts index 17aee31150..f0c3ede523 100644 --- a/packages/libraries/cli/src/commands/dev.ts +++ b/packages/libraries/cli/src/commands/dev.ts @@ -11,8 +11,8 @@ 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 +173,7 @@ export default class Dev extends Command { 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 +191,30 @@ export default class Dev extends Command { 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 +223,9 @@ export default class Dev extends Command { 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 +237,9 @@ export default class Dev extends Command { 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 +249,30 @@ export default class Dev extends Command { 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 +280,8 @@ export default class Dev extends Command { token, write: flags.write, unstable__forceLatest, - onError: message => { - this.error(message, { - exit: 1, - }); + onError: error => { + throw error; }, }); } @@ -271,10 +289,8 @@ export default class Dev extends Command { return this.composeLocally({ services, write: flags.write, - onError: message => { - this.error(message, { - exit: 1, - }); + onError: error => { + throw error; }, }); } @@ -286,7 +302,7 @@ export default class Dev extends Command { sdl: string; }>; write: string; - onError: (message: string) => void | never; + onError: (error: HiveCLIError) => void | never; }) { const compositionResult = await new Promise((resolve, reject) => { try { @@ -300,32 +316,15 @@ export default class Dev extends Command { ), ); } 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,7 +343,7 @@ export default class Dev extends Command { 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({ @@ -359,37 +358,39 @@ export default class Dev extends Command { })), }, }, - }) - .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 +400,14 @@ export default class Dev extends Command { ) { 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 +431,7 @@ export default class Dev extends Command { services = newServices; } } catch (error) { - this.logFailure(String(error)); + this.logFailure(new UnexpectedError(error)); } timeoutId = setTimeout(watch, watchInterval); @@ -484,15 +491,12 @@ export default class Dev extends Command { private async resolveSdlFromUrl(url: string) { const result = await this.graphql(url) - .request({ operation: ServiceIntrospectionQuery }) - .catch(error => { - this.handleFetchError(error); - }); + .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..5614378c99 100644 --- a/packages/libraries/cli/src/commands/introspect.ts +++ b/packages/libraries/cli/src/commands/introspect.ts @@ -1,9 +1,10 @@ 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 { loadSchema } from '../helpers/schema'; +import { APIError, UnexpectedError, UnsupportedFileExtensionError } from 'src/helpers/errors'; export default class Introspect extends Command { static description = 'introspects a GraphQL Schema'; @@ -46,19 +47,11 @@ export default class Introspect extends Command { 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 { 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..b0b9ff0c93 100644 --- a/packages/libraries/cli/src/commands/operations/check.ts +++ b/packages/libraries/cli/src/commands/operations/check.ts @@ -1,10 +1,10 @@ -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 { HiveCLIError, InvalidDocumentsError, MissingEndpointError, MissingRegistryTokenError, SchemaNotFoundError, UnexpectedError } from '../../helpers/errors'; import { loadOperations } from '../../helpers/operations'; import { Texture } from '../../helpers/texture/texture'; @@ -84,21 +84,33 @@ export default class OperationsCheck extends Command { const { flags, args } = await this.parse(OperationsCheck); 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 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 graphqlTag = flags.graphqlTag; const globalGraphqlTag = flags.globalGraphqlTag; @@ -129,7 +141,7 @@ export default class OperationsCheck extends Command { 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 +190,14 @@ export default class OperationsCheck extends Command { this.log(Texture.header('Details')); - this.printInvalidDocuments(operationsWithErrors); - this.exit(1); + throw new InvalidDocumentsError(operationsWithErrors); } catch (error) { if (error instanceof Errors.ExitError) { 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..5e2a1b29d1 100644 --- a/packages/libraries/cli/src/commands/schema/check.ts +++ b/packages/libraries/cli/src/commands/schema/check.ts @@ -3,7 +3,7 @@ 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 +163,35 @@ export default class SchemaCheck extends Command { 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 +200,7 @@ export default class SchemaCheck extends Command { 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 +211,10 @@ export default class SchemaCheck extends Command { 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 +300,14 @@ export default class SchemaCheck extends Command { } 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) { 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..c33e26ea7e 100644 --- a/packages/libraries/cli/src/commands/schema/delete.ts +++ b/packages/libraries/cli/src/commands/schema/delete.ts @@ -2,7 +2,7 @@ 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 +99,30 @@ export default class SchemaDelete extends Command { } } - 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 +144,14 @@ export default class SchemaDelete extends Command { 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) { 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..55c422f040 100644 --- a/packages/libraries/cli/src/commands/schema/fetch.ts +++ b/packages/libraries/cli/src/commands/schema/fetch.ts @@ -4,7 +4,7 @@ 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 +119,30 @@ export default class SchemaFetch extends Command { 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 +181,11 @@ export default class SchemaFetch extends Command { } 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 +211,7 @@ export default class SchemaFetch extends Command { 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 +224,7 @@ export default class SchemaFetch extends Command { 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..08a74304df 100644 --- a/packages/libraries/cli/src/commands/schema/publish.ts +++ b/packages/libraries/cli/src/commands/schema/publish.ts @@ -5,7 +5,7 @@ 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, HiveCLIError, InvalidSDLError, MissingArgumentsError, 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'; @@ -182,20 +182,30 @@ export default class SchemaPublish extends Command { 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 +245,18 @@ export default class SchemaPublish extends Command { } 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 +272,7 @@ export default class SchemaPublish extends Command { 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 +325,9 @@ export default class SchemaPublish extends Command { 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 ' 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 ' 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 +340,7 @@ export default class SchemaPublish extends Command { this.log(''); if (!force) { - this.logFailure('Failed to publish schema'); - this.exit(1); + throw new SchemaPublishFailedError() } else { this.logSuccess('Schema published (forced)'); } @@ -352,9 +351,7 @@ export default class SchemaPublish extends Command { } 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) { @@ -362,7 +359,7 @@ export default class SchemaPublish extends Command { 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..d38fd07683 100644 --- a/packages/libraries/cli/src/commands/whoami.ts +++ b/packages/libraries/cli/src/commands/whoami.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 { InvalidRegistryTokenError, MissingEndpointError, MissingRegistryTokenError, UnexpectedError } from '../helpers/errors'; import { Texture } from '../helpers/texture/texture'; const myTokenInfoQuery = graphql(/* GraphQL */ ` @@ -62,29 +62,37 @@ export default class WhoAmI extends Command { async run() { const { flags } = await this.parse(WhoAmI); - - 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: WhoAmI.flags['registry.endpoint'].description!, + }); + } catch (e) { + throw new MissingEndpointError(); + } + + 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, }) - .catch(error => { - this.handleFetchError(error); - }); if (result.tokenInfo.__typename === 'TokenInfo') { const { tokenInfo } = result; @@ -115,10 +123,10 @@ export default class WhoAmI extends Command { 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..872ffabd2d 100644 --- a/packages/libraries/cli/src/helpers/errors.ts +++ b/packages/libraries/cli/src/helpers/errors.ts @@ -1 +1,279 @@ -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 { InvalidDocument } from '@graphql-inspector/core'; +import { CLIError } from '@oclif/core/lib/errors'; +import { env } from 'node:process' +import { Texture } from './texture/texture'; +import { CompositionFailure } from '@theguild/federation-composition'; +import { renderErrors } from './schema'; +import { SchemaErrorConnection } from '../gql/graphql'; +import { extname } from 'node:path'; +import { GraphQLError } from 'graphql'; + +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 ' parameter.`); + } +} + +export class SchemaPublishMissingUrlError extends HiveCLIError { + constructor(message: string) { + super(ExitCode.BAD_INIT, errorCode(ErrorCategory.SCHEMA_PUBLISH, 3), `${message} Please use the '--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 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); +} \ No newline at end of file From cc39f837eff4ec57d3d335bd40944f4dd963529a Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 17 Jan 2025 23:15:12 -0800 Subject: [PATCH 2/9] Add example to docs --- .../web/docs/src/pages/docs/api-reference/cli.mdx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/web/docs/src/pages/docs/api-reference/cli.mdx b/packages/web/docs/src/pages/docs/api-reference/cli.mdx index dec32b0914..bdde48edc2 100644 --- a/packages/web/docs/src/pages/docs/api-reference/cli.mdx +++ b/packages/web/docs/src/pages/docs/api-reference/cli.mdx @@ -453,3 +453,15 @@ hive schema:check schema.graphql --github List of all available CLI commands and their options can be found [here](https://github.com/graphql-hive/platform/blob/main/packages/libraries/cli/README.md#commands) + +## Errors + +### HC110 "Github Commit Required" + +Example: `hive schema:check FILE --github` + +> "Couldn't resolve commit sha required for GitHub Application." + +#### Suggested Fix + +To use the Github integration, there must be at a commit in the history to reference. The commit sha is set as the schema version in the registry and is used for change approvals and other features. See https://the-guild.dev/graphql/hive/docs/management/organizations#github for more details about this integration. From bb721824ff1d9a36cffe9ed7fcd7e51c528f8c4b Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Sat, 18 Jan 2025 11:11:44 -0800 Subject: [PATCH 3/9] Fix imports --- packages/libraries/cli/src/commands/introspect.ts | 2 +- packages/libraries/cli/src/commands/operations/check.ts | 2 +- packages/libraries/cli/src/commands/schema/publish.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/libraries/cli/src/commands/introspect.ts b/packages/libraries/cli/src/commands/introspect.ts index 5614378c99..ce1d5b8efa 100644 --- a/packages/libraries/cli/src/commands/introspect.ts +++ b/packages/libraries/cli/src/commands/introspect.ts @@ -4,7 +4,7 @@ import { buildSchema, introspectionFromSchema } from 'graphql'; import { Args, Flags } from '@oclif/core'; import Command from '../base-command'; import { loadSchema } from '../helpers/schema'; -import { APIError, UnexpectedError, UnsupportedFileExtensionError } from 'src/helpers/errors'; +import { APIError, UnexpectedError, UnsupportedFileExtensionError } from '../helpers/errors'; export default class Introspect extends Command { static description = 'introspects a GraphQL Schema'; diff --git a/packages/libraries/cli/src/commands/operations/check.ts b/packages/libraries/cli/src/commands/operations/check.ts index b0b9ff0c93..83ad397dc5 100644 --- a/packages/libraries/cli/src/commands/operations/check.ts +++ b/packages/libraries/cli/src/commands/operations/check.ts @@ -4,7 +4,7 @@ import { Args, Errors, Flags } from '@oclif/core'; import Command from '../../base-command'; import { graphql } from '../../gql'; import { graphqlEndpoint } from '../../helpers/config'; -import { HiveCLIError, InvalidDocumentsError, MissingEndpointError, MissingRegistryTokenError, SchemaNotFoundError, UnexpectedError } from '../../helpers/errors'; +import { InvalidDocumentsError, MissingEndpointError, MissingRegistryTokenError, SchemaNotFoundError, UnexpectedError } from '../../helpers/errors'; import { loadOperations } from '../../helpers/operations'; import { Texture } from '../../helpers/texture/texture'; diff --git a/packages/libraries/cli/src/commands/schema/publish.ts b/packages/libraries/cli/src/commands/schema/publish.ts index 08a74304df..4c3f219966 100644 --- a/packages/libraries/cli/src/commands/schema/publish.ts +++ b/packages/libraries/cli/src/commands/schema/publish.ts @@ -5,7 +5,7 @@ import { Args, Errors, Flags } from '@oclif/core'; import Command from '../../base-command'; import { DocumentType, graphql } from '../../gql'; import { graphqlEndpoint } from '../../helpers/config'; -import { APIError, GithubAuthorRequiredError, GithubCommitRequiredError, HiveCLIError, InvalidSDLError, MissingArgumentsError, MissingEndpointError, MissingEnvironmentError, MissingRegistryTokenError, SchemaPublishFailedError, SchemaPublishMissingServiceError, SchemaPublishMissingUrlError, UnexpectedError } 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'; From 3ea07ecdccaa2d84193ec4f5aeaded3fb63fd8e2 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Sat, 18 Jan 2025 11:19:20 -0800 Subject: [PATCH 4/9] Prettier write --- packages/libraries/cli/src/base-command.ts | 31 ++- .../libraries/cli/src/commands/app/create.ts | 9 +- .../cli/src/commands/artifact/fetch.ts | 25 +- packages/libraries/cli/src/commands/dev.ts | 53 ++-- .../libraries/cli/src/commands/introspect.ts | 2 +- .../cli/src/commands/operations/check.ts | 12 +- .../cli/src/commands/schema/check.ts | 17 +- .../cli/src/commands/schema/delete.ts | 11 +- .../cli/src/commands/schema/fetch.ts | 8 +- .../cli/src/commands/schema/publish.ts | 35 ++- packages/libraries/cli/src/commands/whoami.ts | 21 +- packages/libraries/cli/src/helpers/errors.ts | 239 +++++++++++++----- .../docs/src/pages/docs/api-reference/cli.mdx | 5 +- 13 files changed, 337 insertions(+), 131 deletions(-) diff --git a/packages/libraries/cli/src/base-command.ts b/packages/libraries/cli/src/base-command.ts index 00639929bf..0fe4574aa8 100644 --- a/packages/libraries/cli/src/base-command.ts +++ b/packages/libraries/cli/src/base-command.ts @@ -1,12 +1,18 @@ +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, Flags, Interfaces } from '@oclif/core'; import { Config, GetConfigurationValueType, ValidConfigurationKeys } from './helpers/config'; +import { + APIError, + HTTPError, + isAggregateError, + MissingArgumentsError, + NetworkError, +} from './helpers/errors'; import { Texture } from './helpers/texture/texture'; -import { env } from 'node:process' -import { APIError, HTTPError, MissingArgumentsError, NetworkError, isAggregateError } from './helpers/errors'; export type Flags = Interfaces.InferredFlags< (typeof BaseCommand)['baseFlags'] & T['flags'] @@ -141,7 +147,9 @@ export default abstract class BaseCommand extends Comm } else if (envName && env[envName] !== undefined) { value = env[envName] as TArgs[keyof TArgs] as NonNullable>; } else { - const configValue = this._userConfig!.get(key) as NonNullable>; + const configValue = this._userConfig!.get(key) as NonNullable< + GetConfigurationValueType + >; if (configValue !== undefined) { value = configValue; @@ -231,7 +239,11 @@ export default abstract class BaseCommand extends Comm } if (!response.ok) { - throw new HTTPError(endpoint, response.status, response.statusText ?? 'Invalid status code for HTTP call') + throw new HTTPError( + endpoint, + response.status, + response.statusText ?? 'Invalid status code for HTTP call', + ); } let jsonData; @@ -240,18 +252,19 @@ export default abstract class BaseCommand extends Comm } catch (err) { const contentType = response?.headers?.get('content-type'); throw new APIError( - `Response from graphql was not valid JSON.${contentType ? ` Received "content-type": "${contentType}".` : ''}`, + `Response from graphql was not valid JSON.${contentType ? ` Received "content-type": "${contentType}".` : ''}`, this.cleanRequestId(response?.headers?.get('x-request-id')), ); } if (jsonData.errors && jsonData.errors.length > 0) { if (isDebug) { - this.logFailure(jsonData.errors) + this.logFailure(jsonData.errors); } - throw new APIError(jsonData.errors - .map(e => e.message) - .join('\n'), this.cleanRequestId(response?.headers?.get('x-request-id'))) + throw new APIError( + jsonData.errors.map(e => e.message).join('\n'), + this.cleanRequestId(response?.headers?.get('x-request-id')), + ); } return jsonData.data!; diff --git a/packages/libraries/cli/src/commands/app/create.ts b/packages/libraries/cli/src/commands/app/create.ts index 504a9feaf3..18e176d15e 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 { APIError, MissingEndpointError, MissingRegistryTokenError, PersistedOperationsMalformedError } from '../../helpers/errors'; +import { + APIError, + MissingEndpointError, + MissingRegistryTokenError, + PersistedOperationsMalformedError, +} from '../../helpers/errors'; export default class AppCreate extends Command { static description = 'create an app deployment'; @@ -86,7 +91,7 @@ export default class AppCreate extends Command { } if (!result.createAppDeployment.ok) { - throw new APIError(`Create App failed without providing a reason.`) + throw new APIError(`Create App failed without providing a reason.`); } if (result.createAppDeployment.ok.createdAppDeployment.status !== AppDeploymentStatus.Pending) { diff --git a/packages/libraries/cli/src/commands/artifact/fetch.ts b/packages/libraries/cli/src/commands/artifact/fetch.ts index 8247a97f4a..5fac9d31f9 100644 --- a/packages/libraries/cli/src/commands/artifact/fetch.ts +++ b/packages/libraries/cli/src/commands/artifact/fetch.ts @@ -1,7 +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'; +import { + HTTPError, + isAggregateError, + MissingCdnEndpointError, + MissingCdnKeyError, + NetworkError, + UnexpectedError, +} from '../../helpers/errors'; export default class ArtifactsFetch extends Command { static description = 'fetch artifacts from the CDN'; @@ -33,8 +40,8 @@ export default class ArtifactsFetch extends Command { env: 'HIVE_CDN_ENDPOINT', description: ArtifactsFetch.flags['cdn.endpoint'].description!, }); - } catch(e) { - throw new MissingCdnEndpointError() + } catch (e) { + throw new MissingCdnEndpointError(); } try { @@ -75,7 +82,7 @@ export default class ArtifactsFetch extends Command { }, }, }); - } catch(e: any) { + } catch (e: any) { const sourceError = e?.cause ?? e; if (isAggregateError(sourceError)) { throw new NetworkError(sourceError.errors[0]?.message); @@ -86,7 +93,11 @@ export default class ArtifactsFetch extends Command { if (!response.ok) { const responseBody = await response.text(); - throw new HTTPError(url.toString(), response.status, responseBody ?? response.statusText ?? 'Invalid status code for HTTP call'); + throw new HTTPError( + url.toString(), + response.status, + responseBody ?? response.statusText ?? 'Invalid status code for HTTP call', + ); } try { @@ -97,9 +108,9 @@ export default class ArtifactsFetch extends Command { this.log(`Wrote ${contents.length} bytes to ${flags.outputFile}`); return; } - + this.log(await response.text()); - } catch(e) { + } 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 f0c3ede523..77c93977dd 100644 --- a/packages/libraries/cli/src/commands/dev.ts +++ b/packages/libraries/cli/src/commands/dev.ts @@ -11,7 +11,18 @@ import { import Command from '../base-command'; import { graphql } from '../gql'; import { graphqlEndpoint } from '../helpers/config'; -import { APIError, HiveCLIError, IntrospectionError, InvalidCompositionResultError, LocalCompositionError, MissingEndpointError, MissingRegistryTokenError, RemoteCompositionError, ServiceAndUrlLengthMismatch, UnexpectedError } from '../helpers/errors'; +import { + APIError, + HiveCLIError, + IntrospectionError, + InvalidCompositionResultError, + LocalCompositionError, + MissingEndpointError, + MissingRegistryTokenError, + RemoteCompositionError, + ServiceAndUrlLengthMismatch, + UnexpectedError, +} from '../helpers/errors'; import { loadSchema } from '../helpers/schema'; import { invariant } from '../helpers/validation'; @@ -343,25 +354,24 @@ export default class Dev extends Command { token: string; write: string; unstable__forceLatest: boolean; - onError: (error: HiveCLIError) => 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, + })), }, - }); + }, + }); if (result.schemaCompose.__typename === 'SchemaComposeError') { - input.onError(new APIError(result.schemaCompose.message), ); + input.onError(new APIError(result.schemaCompose.message)); return; } @@ -386,11 +396,14 @@ export default class Dev extends Command { this.logSuccess('Composition successful'); this.log(`Saving supergraph schema to ${input.write}`); try { - await writeFile(resolve(process.cwd(), input.write), compositionResult.supergraphSdl, 'utf-8'); + await writeFile( + resolve(process.cwd(), input.write), + compositionResult.supergraphSdl, + 'utf-8', + ); } catch (e) { input.onError(new UnexpectedError(e)); } - } private async watch( @@ -407,7 +420,6 @@ export default class Dev extends Command { } catch (e) { throw new UnexpectedError(e); } - this.logInfo('Watching for changes'); @@ -490,8 +502,7 @@ export default class Dev extends Command { } private async resolveSdlFromUrl(url: string) { - const result = await this.graphql(url) - .request({ operation: ServiceIntrospectionQuery }); + const result = await this.graphql(url).request({ operation: ServiceIntrospectionQuery }); const sdl = result._service.sdl; diff --git a/packages/libraries/cli/src/commands/introspect.ts b/packages/libraries/cli/src/commands/introspect.ts index ce1d5b8efa..acb586a46d 100644 --- a/packages/libraries/cli/src/commands/introspect.ts +++ b/packages/libraries/cli/src/commands/introspect.ts @@ -3,8 +3,8 @@ import { extname, resolve } from 'node:path'; import { buildSchema, introspectionFromSchema } from 'graphql'; import { Args, Flags } from '@oclif/core'; import Command from '../base-command'; -import { loadSchema } from '../helpers/schema'; import { APIError, UnexpectedError, UnsupportedFileExtensionError } from '../helpers/errors'; +import { loadSchema } from '../helpers/schema'; export default class Introspect extends Command { static description = 'introspects a GraphQL Schema'; diff --git a/packages/libraries/cli/src/commands/operations/check.ts b/packages/libraries/cli/src/commands/operations/check.ts index 83ad397dc5..aebc775b23 100644 --- a/packages/libraries/cli/src/commands/operations/check.ts +++ b/packages/libraries/cli/src/commands/operations/check.ts @@ -4,7 +4,13 @@ import { Args, Errors, Flags } from '@oclif/core'; import Command from '../../base-command'; import { graphql } from '../../gql'; import { graphqlEndpoint } from '../../helpers/config'; -import { InvalidDocumentsError, MissingEndpointError, MissingRegistryTokenError, SchemaNotFoundError, UnexpectedError } from '../../helpers/errors'; +import { + InvalidDocumentsError, + MissingEndpointError, + MissingRegistryTokenError, + SchemaNotFoundError, + UnexpectedError, +} from '../../helpers/errors'; import { loadOperations } from '../../helpers/operations'; import { Texture } from '../../helpers/texture/texture'; @@ -98,7 +104,7 @@ export default class OperationsCheck extends Command { } catch (e) { throw new MissingEndpointError(); } - + try { accessToken = this.ensure({ key: 'registry.accessToken', @@ -110,7 +116,7 @@ export default class OperationsCheck extends Command { } catch (e) { throw new MissingRegistryTokenError(); } - + const graphqlTag = flags.graphqlTag; const globalGraphqlTag = flags.globalGraphqlTag; diff --git a/packages/libraries/cli/src/commands/schema/check.ts b/packages/libraries/cli/src/commands/schema/check.ts index 5e2a1b29d1..6bdf197f0b 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 { APIError, GithubCommitRequiredError, GithubRepositoryRequiredError, MissingEndpointError, MissingRegistryTokenError, SchemaFileEmptyError, SchemaFileNotFoundError, UnexpectedError } from '../../helpers/errors'; +import { + APIError, + GithubCommitRequiredError, + GithubRepositoryRequiredError, + MissingEndpointError, + MissingRegistryTokenError, + SchemaFileEmptyError, + SchemaFileNotFoundError, + UnexpectedError, +} from '../../helpers/errors'; import { gitInfo } from '../../helpers/git'; import { loadSchema, @@ -173,8 +182,8 @@ export default class SchemaCheck extends Command { env: 'HIVE_REGISTRY', description: SchemaCheck.flags['registry.endpoint'].description!, }); - } catch(e) { - throw new MissingEndpointError() + } catch (e) { + throw new MissingEndpointError(); } const file = args.file; try { @@ -185,7 +194,7 @@ export default class SchemaCheck extends Command { env: 'HIVE_TOKEN', description: SchemaCheck.flags['registry.accessToken'].description!, }); - } catch(e) { + } catch (e) { throw new MissingRegistryTokenError(); } diff --git a/packages/libraries/cli/src/commands/schema/delete.ts b/packages/libraries/cli/src/commands/schema/delete.ts index c33e26ea7e..43c7994dfc 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 { APIError, MissingEndpointError, MissingRegistryTokenError, UnexpectedError } from '../../helpers/errors'; +import { + APIError, + MissingEndpointError, + MissingRegistryTokenError, + UnexpectedError, +} from '../../helpers/errors'; import { renderErrors } from '../../helpers/schema'; const schemaDeleteMutation = graphql(/* GraphQL */ ` @@ -107,7 +112,7 @@ export default class SchemaDelete extends Command { legacyFlagName: 'registry', defaultValue: graphqlEndpoint, env: 'HIVE_REGISTRY', - description: SchemaDelete.flags["registry.endpoint"].description!, + description: SchemaDelete.flags['registry.endpoint'].description!, }); } catch (e) { throw new MissingEndpointError(); @@ -118,7 +123,7 @@ export default class SchemaDelete extends Command { args: flags, legacyFlagName: 'token', env: 'HIVE_TOKEN', - description: SchemaDelete.flags["registry.accessToken"].description!, + description: SchemaDelete.flags['registry.accessToken'].description!, }); } catch (e) { throw new MissingRegistryTokenError(); diff --git a/packages/libraries/cli/src/commands/schema/fetch.ts b/packages/libraries/cli/src/commands/schema/fetch.ts index 55c422f040..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 { InvalidSchemaError, MissingEndpointError, MissingRegistryTokenError, SchemaNotFoundError, UnsupportedFileExtensionError } from '../../helpers/errors'; +import { + InvalidSchemaError, + MissingEndpointError, + MissingRegistryTokenError, + SchemaNotFoundError, + UnsupportedFileExtensionError, +} from '../../helpers/errors'; import { Texture } from '../../helpers/texture/texture'; const SchemaVersionForActionIdQuery = graphql(/* GraphQL */ ` diff --git a/packages/libraries/cli/src/commands/schema/publish.ts b/packages/libraries/cli/src/commands/schema/publish.ts index 4c3f219966..fec4750f14 100644 --- a/packages/libraries/cli/src/commands/schema/publish.ts +++ b/packages/libraries/cli/src/commands/schema/publish.ts @@ -5,7 +5,19 @@ import { Args, Errors, Flags } from '@oclif/core'; import Command from '../../base-command'; import { DocumentType, graphql } from '../../gql'; import { graphqlEndpoint } from '../../helpers/config'; -import { APIError, GithubAuthorRequiredError, GithubCommitRequiredError, InvalidSDLError, MissingEndpointError, MissingEnvironmentError, MissingRegistryTokenError, SchemaPublishFailedError, SchemaPublishMissingServiceError, SchemaPublishMissingUrlError, UnexpectedError } 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'; @@ -192,7 +204,7 @@ export default class SchemaPublish extends Command { env: 'HIVE_REGISTRY', description: SchemaPublish.flags['registry.endpoint'].description!, }); - } catch(e) { + } catch (e) { throw new MissingEndpointError(); } try { @@ -204,7 +216,7 @@ export default class SchemaPublish extends Command { description: SchemaPublish.flags['registry.accessToken'].description!, }); } catch (e) { - throw new MissingRegistryTokenError() + throw new MissingRegistryTokenError(); } const service = flags.service; const url = flags.url; @@ -256,7 +268,10 @@ export default class SchemaPublish extends Command { // eslint-disable-next-line no-process-env const repository = process.env['GITHUB_REPOSITORY'] ?? null; if (!repository) { - throw new MissingEnvironmentError(['GITHUB_REPOSITORY', 'Github repository full name, e.g. graphql-hive/console']) + throw new MissingEnvironmentError([ + 'GITHUB_REPOSITORY', + 'Github repository full name, e.g. graphql-hive/console', + ]); } gitHub = { repository, @@ -272,7 +287,7 @@ export default class SchemaPublish extends Command { sdl = minifySchema(transformedSDL); } catch (err) { if (err instanceof GraphQLError) { - throw new InvalidSDLError(err) + throw new InvalidSDLError(err); } throw err; } @@ -340,7 +355,7 @@ export default class SchemaPublish extends Command { this.log(''); if (!force) { - throw new SchemaPublishFailedError() + throw new SchemaPublishFailedError(); } else { this.logSuccess('Schema published (forced)'); } @@ -351,7 +366,11 @@ export default class SchemaPublish extends Command { } else if (result.schemaPublish.__typename === 'GitHubSchemaPublishSuccess') { this.logSuccess(result.schemaPublish.message); } else { - throw new APIError('message' in result.schemaPublish ? result.schemaPublish.message : `Received unhandled type "${(result.schemaPublish as any)?.__typename}" in response.`); + 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) { @@ -359,7 +378,7 @@ export default class SchemaPublish extends Command { throw error; } else { this.logFailure('Failed to publish schema'); - throw new UnexpectedError(error instanceof Error ? error.message : JSON.stringify(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 d38fd07683..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 { InvalidRegistryTokenError, MissingEndpointError, MissingRegistryTokenError, UnexpectedError } from '../helpers/errors'; +import { + InvalidRegistryTokenError, + MissingEndpointError, + MissingRegistryTokenError, + UnexpectedError, +} from '../helpers/errors'; import { Texture } from '../helpers/texture/texture'; const myTokenInfoQuery = graphql(/* GraphQL */ ` @@ -75,7 +80,7 @@ export default class WhoAmI extends Command { } catch (e) { throw new MissingEndpointError(); } - + try { token = this.ensure({ key: 'registry.accessToken', @@ -87,12 +92,10 @@ export default class WhoAmI extends Command { } catch (e) { throw new MissingRegistryTokenError(); } - - const result = await this.registryApi(registry, token) - .request({ - operation: myTokenInfoQuery, - }) + const result = await this.registryApi(registry, token).request({ + operation: myTokenInfoQuery, + }); if (result.tokenInfo.__typename === 'TokenInfo') { const { tokenInfo } = result; @@ -126,7 +129,9 @@ export default class WhoAmI extends Command { this.debug(result.tokenInfo.message); throw new InvalidRegistryTokenError(); } else { - throw new UnexpectedError(`Token response got an unsupported type: ${(result.tokenInfo as any).__typename}`) + 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 872ffabd2d..b04138d706 100644 --- a/packages/libraries/cli/src/helpers/errors.ts +++ b/packages/libraries/cli/src/helpers/errors.ts @@ -1,14 +1,14 @@ +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 { env } from 'node:process' -import { Texture } from './texture/texture'; import { CompositionFailure } from '@theguild/federation-composition'; -import { renderErrors } from './schema'; import { SchemaErrorConnection } from '../gql/graphql'; -import { extname } from 'node:path'; -import { GraphQLError } from 'graphql'; +import { renderErrors } from './schema'; +import { Texture } from './texture/texture'; -export const ACCESS_TOKEN_MISSING = '@TODO FIX' +export const ACCESS_TOKEN_MISSING = '@TODO FIX'; export enum ExitCode { // The command execution succeeded. @@ -25,10 +25,14 @@ export enum ExitCode { } export class HiveCLIError extends CLIError { - constructor(public readonly exitCode: ExitCode, code: number, message: string) { + 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}`}`); + super(`${message} [${code}]${env.HIVE_NO_ERROR_TIP === '1' ? '' : `\n${tip}`}`); } } @@ -40,7 +44,6 @@ enum ErrorCategory { APP_CREATE = 4_00, ARTIFACT_FETCH = 5_00, DEV = 6_00, - } const errorCode = (category: ErrorCategory, id: number): number => { @@ -48,16 +51,24 @@ const errorCode = (category: ErrorCategory, id: number): number => { }; export class InvalidConfigError extends HiveCLIError { - constructor(configName = "hive.json") { - super(ExitCode.BAD_INIT, errorCode(ErrorCategory.GENERIC, 0), `The provided "${configName}" is invalid.`); + 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.`); + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.GENERIC, 1), + `The command, "${command}", does not exist.`, + ); } -}; +} export class MissingArgumentsError extends HiveCLIError { constructor(...requiredArgs: Array<[string, string]>) { @@ -65,41 +76,65 @@ export class MissingArgumentsError extends HiveCLIError { 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`); + 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`); + 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.`); + 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.`); + 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.`); + 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.`); + super( + ExitCode.ERROR, + errorCode(ErrorCategory.GENERIC, 8), + `A CDN endpoint is required to perform the action.`, + ); } } @@ -114,102 +149,155 @@ export class MissingEnvironmentError extends HiveCLIError { 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}` : '.'}`); + 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.`); + 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.`); + 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.`); + 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.`); + 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.`) + super(ExitCode.ERROR, errorCode(ErrorCategory.SCHEMA_PUBLISH, 0), `Schema publish failed.`); } } export class HTTPError extends HiveCLIError { - constructor(endpoint:string, status: number, message: string) { + 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}`); + 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)}"`)); + 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}")` : '')); + 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.') + 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}`); + 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 ' parameter.`); + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.SCHEMA_PUBLISH, 2), + `${message} Please use the '--service ' parameter.`, + ); } } export class SchemaPublishMissingUrlError extends HiveCLIError { constructor(message: string) { - super(ExitCode.BAD_INIT, errorCode(ErrorCategory.SCHEMA_PUBLISH, 3), `${message} Please use the '--url ' parameter.`); + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.SCHEMA_PUBLISH, 3), + `${message} Please use the '--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'); + 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.`); + super( + ExitCode.BAD_INIT, + errorCode(ErrorCategory.DEV, 0), + `Not every services has a matching url. Got ${services.length} services and ${urls.length} urls.`, + ); } } @@ -235,38 +323,63 @@ export class RemoteCompositionError extends HiveCLIError { 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}`); + 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.`); + 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)}`) + super(ExitCode.BAD_INIT, errorCode(ErrorCategory.GENERIC, 17), `${extname(filename)}`); } } export class SchemaNotFoundError extends HiveCLIError { constructor(actionId?: string) { - super(ExitCode.ERROR, errorCode(ErrorCategory.ARTIFACT_FETCH, 0), `No schema found${actionId ? ` for action id ${actionId}.` : '.'}`) + 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}.` : '.'}`) + 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.`); + 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.`, + ); } } @@ -276,4 +389,4 @@ export interface AggregateError extends Error { export function isAggregateError(error: unknown): error is AggregateError { return !!error && typeof error === 'object' && 'errors' in error && Array.isArray(error.errors); -} \ No newline at end of file +} diff --git a/packages/web/docs/src/pages/docs/api-reference/cli.mdx b/packages/web/docs/src/pages/docs/api-reference/cli.mdx index bdde48edc2..876c111df3 100644 --- a/packages/web/docs/src/pages/docs/api-reference/cli.mdx +++ b/packages/web/docs/src/pages/docs/api-reference/cli.mdx @@ -464,4 +464,7 @@ Example: `hive schema:check FILE --github` #### Suggested Fix -To use the Github integration, there must be at a commit in the history to reference. The commit sha is set as the schema version in the registry and is used for change approvals and other features. See https://the-guild.dev/graphql/hive/docs/management/organizations#github for more details about this integration. +To use the Github integration, there must be at a commit in the history to reference. The commit sha +is set as the schema version in the registry and is used for change approvals and other features. +See https://the-guild.dev/graphql/hive/docs/management/organizations#github for more details about +this integration. From d76923684fb8dece3742419abc68e601a514daa2 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Sat, 18 Jan 2025 21:18:33 -0800 Subject: [PATCH 5/9] Improve token and file reading errors --- packages/libraries/cli/src/base-command.ts | 26 +++++++++++++++++++ .../libraries/cli/src/commands/app/create.ts | 3 +-- .../cli/src/commands/schema/publish.ts | 22 ++-------------- packages/libraries/cli/src/helpers/errors.ts | 12 +++++++++ 4 files changed, 41 insertions(+), 22 deletions(-) diff --git a/packages/libraries/cli/src/base-command.ts b/packages/libraries/cli/src/base-command.ts index 0fe4574aa8..0696038ea6 100644 --- a/packages/libraries/cli/src/base-command.ts +++ b/packages/libraries/cli/src/base-command.ts @@ -7,12 +7,16 @@ 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'; +import { existsSync, readFileSync } from 'node:fs'; export type Flags = Interfaces.InferredFlags< (typeof BaseCommand)['baseFlags'] & T['flags'] @@ -258,6 +262,10 @@ export default abstract class BaseCommand extends Comm } if (jsonData.errors && jsonData.errors.length > 0) { + if(jsonData.errors[0].message === "Invalid token provided") { + throw new InvalidRegistryTokenError() + } + if (isDebug) { this.logFailure(jsonData.errors); } @@ -284,4 +292,22 @@ export default abstract class BaseCommand extends Comm ); } } + + readJSON(file: string): string { + // If we can't parse it, we can try to load it from FS + const exists = existsSync(file); + + 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 18e176d15e..ef7e8a10bb 100644 --- a/packages/libraries/cli/src/commands/app/create.ts +++ b/packages/libraries/cli/src/commands/app/create.ts @@ -67,8 +67,7 @@ export default class AppCreate extends Command { } 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); diff --git a/packages/libraries/cli/src/commands/schema/publish.ts b/packages/libraries/cli/src/commands/schema/publish.ts index fec4750f14..65b3fd812f 100644 --- a/packages/libraries/cli/src/commands/schema/publish.ts +++ b/packages/libraries/cli/src/commands/schema/publish.ts @@ -1,4 +1,3 @@ -import { existsSync, readFileSync } from 'fs'; import { GraphQLError, print } from 'graphql'; import { transformCommentsToDescriptions } from '@graphql-tools/utils'; import { Args, Errors, Flags } from '@oclif/core'; @@ -155,7 +154,7 @@ export default class SchemaPublish extends Command { }), }; - resolveMetadata(metadata: string | undefined): string | undefined { + resolveMetadata = (metadata: string | undefined): string | undefined => { if (!metadata) { return; } @@ -167,24 +166,7 @@ export default class SchemaPublish extends Command { 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); } } diff --git a/packages/libraries/cli/src/helpers/errors.ts b/packages/libraries/cli/src/helpers/errors.ts index b04138d706..382d7b3dec 100644 --- a/packages/libraries/cli/src/helpers/errors.ts +++ b/packages/libraries/cli/src/helpers/errors.ts @@ -347,6 +347,18 @@ export class UnsupportedFileExtensionError extends HiveCLIError { } } +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( From 8d7ef3de450258f608c195f1bede73aaf9eac714 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Sat, 18 Jan 2025 21:23:00 -0800 Subject: [PATCH 6/9] Prettier --- packages/libraries/cli/src/base-command.ts | 13 ++++++++----- .../libraries/cli/src/commands/schema/publish.ts | 2 +- packages/libraries/cli/src/helpers/errors.ts | 12 ++++++++++-- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/libraries/cli/src/base-command.ts b/packages/libraries/cli/src/base-command.ts index 0696038ea6..cab6f5b535 100644 --- a/packages/libraries/cli/src/base-command.ts +++ b/packages/libraries/cli/src/base-command.ts @@ -1,3 +1,4 @@ +import { existsSync, readFileSync } from 'node:fs'; import { env } from 'node:process'; import { print } from 'graphql'; import type { ExecutionResult } from 'graphql'; @@ -16,7 +17,6 @@ import { NetworkError, } from './helpers/errors'; import { Texture } from './helpers/texture/texture'; -import { existsSync, readFileSync } from 'node:fs'; export type Flags = Interfaces.InferredFlags< (typeof BaseCommand)['baseFlags'] & T['flags'] @@ -262,8 +262,8 @@ export default abstract class BaseCommand extends Comm } if (jsonData.errors && jsonData.errors.length > 0) { - if(jsonData.errors[0].message === "Invalid token provided") { - throw new InvalidRegistryTokenError() + if (jsonData.errors[0].message === 'Invalid token provided') { + throw new InvalidRegistryTokenError(); } if (isDebug) { @@ -298,7 +298,10 @@ export default abstract class BaseCommand extends Comm const exists = existsSync(file); if (!exists) { - throw new FileMissingError(file, 'Please specify a path to an existing file, or a string with valid JSON'); + throw new FileMissingError( + file, + 'Please specify a path to an existing file, or a string with valid JSON', + ); } try { @@ -307,7 +310,7 @@ export default abstract class BaseCommand extends Comm return fileContent; } catch (e) { - throw new InvalidFileContentsError(file, 'JSON') + throw new InvalidFileContentsError(file, 'JSON'); } } } diff --git a/packages/libraries/cli/src/commands/schema/publish.ts b/packages/libraries/cli/src/commands/schema/publish.ts index 65b3fd812f..1e8758f104 100644 --- a/packages/libraries/cli/src/commands/schema/publish.ts +++ b/packages/libraries/cli/src/commands/schema/publish.ts @@ -168,7 +168,7 @@ export default class SchemaPublish extends Command { // If we can't parse it, we can try to load it from FS return this.readJSON(metadata); } - } + }; async run() { try { diff --git a/packages/libraries/cli/src/helpers/errors.ts b/packages/libraries/cli/src/helpers/errors.ts index 382d7b3dec..a525c56ea4 100644 --- a/packages/libraries/cli/src/helpers/errors.ts +++ b/packages/libraries/cli/src/helpers/errors.ts @@ -349,13 +349,21 @@ export class UnsupportedFileExtensionError extends HiveCLIError { 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}` : '.'}`); + 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}.`); + 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}.`, + ); } } From 06e158cc8b3d8f1de3675fbc578293d4c8b98e92 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 20 Jan 2025 20:27:14 -0800 Subject: [PATCH 7/9] Match on clierror instead of exiterror for rethrow --- packages/libraries/cli/src/commands/operations/check.ts | 2 +- packages/libraries/cli/src/commands/schema/check.ts | 2 +- packages/libraries/cli/src/commands/schema/delete.ts | 2 +- packages/libraries/cli/src/commands/schema/publish.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/libraries/cli/src/commands/operations/check.ts b/packages/libraries/cli/src/commands/operations/check.ts index aebc775b23..150c8a323e 100644 --- a/packages/libraries/cli/src/commands/operations/check.ts +++ b/packages/libraries/cli/src/commands/operations/check.ts @@ -198,7 +198,7 @@ export default class OperationsCheck extends Command { 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'); diff --git a/packages/libraries/cli/src/commands/schema/check.ts b/packages/libraries/cli/src/commands/schema/check.ts index 6bdf197f0b..be9ac3e0af 100644 --- a/packages/libraries/cli/src/commands/schema/check.ts +++ b/packages/libraries/cli/src/commands/schema/check.ts @@ -312,7 +312,7 @@ export default class SchemaCheck extends Command { 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'); diff --git a/packages/libraries/cli/src/commands/schema/delete.ts b/packages/libraries/cli/src/commands/schema/delete.ts index 43c7994dfc..29bc4e3c16 100644 --- a/packages/libraries/cli/src/commands/schema/delete.ts +++ b/packages/libraries/cli/src/commands/schema/delete.ts @@ -152,7 +152,7 @@ export default class SchemaDelete extends Command { 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`); diff --git a/packages/libraries/cli/src/commands/schema/publish.ts b/packages/libraries/cli/src/commands/schema/publish.ts index 1e8758f104..d3951a573a 100644 --- a/packages/libraries/cli/src/commands/schema/publish.ts +++ b/packages/libraries/cli/src/commands/schema/publish.ts @@ -356,7 +356,7 @@ export default class SchemaPublish extends Command { } } while (result === null); } catch (error) { - if (error instanceof Errors.ExitError) { + if (error instanceof Errors.CLIError) { throw error; } else { this.logFailure('Failed to publish schema'); From 8fdcb6d09bc613718f3c2a335f586b202db21a6e Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 20 Jan 2025 21:01:38 -0800 Subject: [PATCH 8/9] Update snapshots --- .../cli/__snapshots__/schema.spec.ts.snap | 54 ++++++++++++------- 1 file changed, 36 insertions(+), 18 deletions(-) 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__ `; From e97cde649b1bb408b9725e1571a064b5e19bf54d Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 21 Jan 2025 06:09:36 -0800 Subject: [PATCH 9/9] Move error code docs to a separate change --- .../web/docs/src/pages/docs/api-reference/cli.mdx | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/packages/web/docs/src/pages/docs/api-reference/cli.mdx b/packages/web/docs/src/pages/docs/api-reference/cli.mdx index 876c111df3..dec32b0914 100644 --- a/packages/web/docs/src/pages/docs/api-reference/cli.mdx +++ b/packages/web/docs/src/pages/docs/api-reference/cli.mdx @@ -453,18 +453,3 @@ hive schema:check schema.graphql --github List of all available CLI commands and their options can be found [here](https://github.com/graphql-hive/platform/blob/main/packages/libraries/cli/README.md#commands) - -## Errors - -### HC110 "Github Commit Required" - -Example: `hive schema:check FILE --github` - -> "Couldn't resolve commit sha required for GitHub Application." - -#### Suggested Fix - -To use the Github integration, there must be at a commit in the history to reference. The commit sha -is set as the schema version in the registry and is used for change approvals and other features. -See https://the-guild.dev/graphql/hive/docs/management/organizations#github for more details about -this integration.