From 80aa692bee6f8f955dde002ce60de783c30b52c0 Mon Sep 17 00:00:00 2001 From: Cedric van Putten Date: Tue, 31 May 2022 14:45:22 +0200 Subject: [PATCH 1/2] [eas-cli] Add metadata root actions and error handling --- packages/eas-cli/src/metadata/download.ts | 67 ++++++++++++++++++++++ packages/eas-cli/src/metadata/errors.ts | 69 +++++++++++++++++++++++ packages/eas-cli/src/metadata/upload.ts | 60 ++++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 packages/eas-cli/src/metadata/download.ts create mode 100644 packages/eas-cli/src/metadata/errors.ts create mode 100644 packages/eas-cli/src/metadata/upload.ts diff --git a/packages/eas-cli/src/metadata/download.ts b/packages/eas-cli/src/metadata/download.ts new file mode 100644 index 0000000000..764c1c71e7 --- /dev/null +++ b/packages/eas-cli/src/metadata/download.ts @@ -0,0 +1,67 @@ +import fs from 'fs-extra'; +import path from 'path'; + +import { MetadataEvent } from '../analytics/events'; +import { confirmAsync } from '../prompts'; +import { AppleData } from './apple/data'; +import { createAppleTasks } from './apple/tasks'; +import { createAppleWriter } from './config'; +import { MetadataContext, ensureMetadataAppStoreAuthenticatedAsync } from './context'; +import { MetadataDownloadError, MetadataValidationError } from './errors'; +import { subscribeTelemetry } from './utils/telemetry'; + +/** + * Generate a local store configuration from the stores. + * Note, only App Store is supported at this time. + */ +export async function downloadMetadataAsync(metadataContext: MetadataContext): Promise { + const filePath = path.resolve(metadataContext.projectDir, metadataContext.metadataFile); + const fileExists = await fs.pathExists(filePath); + + if (fileExists && metadataContext.nonInteractive) { + throw new MetadataValidationError(`Store configuration already exists at "${filePath}"`); + } else if (fileExists) { + const overwrite = await confirmAsync({ + message: `Do you want to overwrite the existing store configuration "${metadataContext.metadataFile}"?`, + }); + if (!overwrite) { + throw new MetadataValidationError(`Store configuration already exists at "${filePath}"`); + } + } + + const { app, auth } = await ensureMetadataAppStoreAuthenticatedAsync(metadataContext); + const { unsubscribeTelemetry, executionId } = subscribeTelemetry( + MetadataEvent.APPLE_METADATA_DOWNLOAD, + { app, auth } + ); + + const errors: Error[] = []; + const config = createAppleWriter(); + const tasks = createAppleTasks(metadataContext); + const taskCtx = { app }; + + for (const task of tasks) { + try { + await task.prepareAsync({ context: taskCtx }); + } catch (error: any) { + errors.push(error); + } + } + + for (const task of tasks) { + try { + await task.downloadAsync({ config, context: taskCtx as AppleData }); + } catch (error: any) { + errors.push(error); + } + } + + await fs.writeJson(filePath, config.toSchema(), { spaces: 2 }); + unsubscribeTelemetry(); + + if (errors.length > 0) { + throw new MetadataDownloadError(errors, executionId); + } + + return filePath; +} diff --git a/packages/eas-cli/src/metadata/errors.ts b/packages/eas-cli/src/metadata/errors.ts new file mode 100644 index 0000000000..6142d96b2f --- /dev/null +++ b/packages/eas-cli/src/metadata/errors.ts @@ -0,0 +1,69 @@ +import type { ErrorObject } from 'ajv'; + +import Log, { link } from '../log'; + +/** + * Before syncing data to the ASC API, we need to validate the metadata config. + * This error represents unrecoverable issues before syncing that data, + * and should contain useful information for the user to solve before trying again. + */ +export class MetadataValidationError extends Error { + constructor(message?: string, public readonly errors?: ErrorObject[]) { + super(message ?? 'Store configuration validation failed'); + } +} + +/** + * If a single entity failed to update, we don't block the other entities from uploading. + * We still attempt to update the data in the stores as much as possible. + * Because of that, we keep track of any errors encountered and throw this generic error. + * It contains that list of encountered errors to present to the user. + */ +export class MetadataUploadError extends Error { + constructor(public readonly errors: Error[], public readonly executionId: string) { + super( + `Store configuration upload encountered ${ + errors.length === 1 ? 'an error' : `${errors.length} errors` + }.` + ); + } +} + +/** + * If a single entity failed to download, we don't block the other entities from downloading. + * We sill attempt to pull in the data from the stores as much as possible. + * Because of that, we keep track of any errors envountered and throw this generic error. + * It contains that list of encountered errors to present to the user. + */ +export class MetadataDownloadError extends Error { + constructor(public readonly errors: Error[], public readonly executionId: string) { + super( + `Store configuration download encountered ${ + errors.length === 1 ? 'an error' : `${errors.length} errors` + }.` + ); + } +} + +/** + * Handle a thrown metadata error by informing the user what went wrong. + * If a normal error is thrown, this method will re-throw that error to avoid consuming it. + */ +export function handleMetadataError(error: Error): void { + if (error instanceof MetadataValidationError) { + Log.error(error.message); + Log.log(error.errors?.map(err => ` - ${err.dataPath} ${err.message}`).join('\n')); + return; + } + + if (error instanceof MetadataDownloadError || error instanceof MetadataUploadError) { + Log.error(error.message); + Log.log('Please check the logs for any configuration issues.'); + Log.log('If this issue persists, please open a new issue at:'); + // TODO: add execution ID to the issue template link + Log.log(link('https://github.com/expo/eas-cli')); + return; + } + + throw error; +} diff --git a/packages/eas-cli/src/metadata/upload.ts b/packages/eas-cli/src/metadata/upload.ts new file mode 100644 index 0000000000..73a44000f5 --- /dev/null +++ b/packages/eas-cli/src/metadata/upload.ts @@ -0,0 +1,60 @@ +import fs from 'fs-extra'; +import path from 'path'; + +import { MetadataEvent } from '../analytics/events'; +import { AppleData } from './apple/data'; +import { createAppleTasks } from './apple/tasks'; +import { createAppleReader, validateConfig } from './config'; +import { MetadataContext, ensureMetadataAppStoreAuthenticatedAsync } from './context'; +import { MetadataUploadError, MetadataValidationError } from './errors'; +import { subscribeTelemetry } from './utils/telemetry'; + +/** + * Sync a local store configuration with the stores. + * Note, only App Store is supported at this time. + */ +export async function uploadMetadataAsync(metadataContext: MetadataContext): Promise { + const filePath = path.resolve(metadataContext.projectDir, metadataContext.metadataFile); + if (!(await fs.pathExists(filePath))) { + throw new MetadataValidationError(`Store configuration file not found "${filePath}"`); + } + + const { app, auth } = await ensureMetadataAppStoreAuthenticatedAsync(metadataContext); + const { unsubscribeTelemetry, executionId } = subscribeTelemetry( + MetadataEvent.APPLE_METADATA_UPLOAD, + { app, auth } + ); + + const fileData = await fs.readJson(filePath); + const { valid, errors: validationErrors } = validateConfig(fileData); + if (!valid) { + throw new MetadataValidationError(`Store configuration errors found`, validationErrors); + } + + const errors: Error[] = []; + const config = createAppleReader(fileData); + const tasks = createAppleTasks(metadataContext); + const taskCtx = { app }; + + for (const task of tasks) { + try { + await task.prepareAsync({ context: taskCtx }); + } catch (error: any) { + errors.push(error); + } + } + + for (const task of tasks) { + try { + await task.uploadAsync({ config, context: taskCtx as AppleData }); + } catch (error: any) { + errors.push(error); + } + } + + unsubscribeTelemetry(); + + if (errors.length > 0) { + throw new MetadataUploadError(errors, executionId); + } +} From f73ac8fa5db60b189050fbbe192c4ff562c16871 Mon Sep 17 00:00:00 2001 From: Cedric van Putten Date: Fri, 3 Jun 2022 12:30:21 +0200 Subject: [PATCH 2/2] [eas-cli] Update download and upload metadata methods to new ctx --- packages/eas-cli/src/metadata/download.ts | 14 ++++++-------- packages/eas-cli/src/metadata/upload.ts | 8 ++++---- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/eas-cli/src/metadata/download.ts b/packages/eas-cli/src/metadata/download.ts index 764c1c71e7..70e7441afe 100644 --- a/packages/eas-cli/src/metadata/download.ts +++ b/packages/eas-cli/src/metadata/download.ts @@ -14,22 +14,20 @@ import { subscribeTelemetry } from './utils/telemetry'; * Generate a local store configuration from the stores. * Note, only App Store is supported at this time. */ -export async function downloadMetadataAsync(metadataContext: MetadataContext): Promise { - const filePath = path.resolve(metadataContext.projectDir, metadataContext.metadataFile); +export async function downloadMetadataAsync(metadataCtx: MetadataContext): Promise { + const filePath = path.resolve(metadataCtx.projectDir, metadataCtx.metadataPath); const fileExists = await fs.pathExists(filePath); - if (fileExists && metadataContext.nonInteractive) { - throw new MetadataValidationError(`Store configuration already exists at "${filePath}"`); - } else if (fileExists) { + if (fileExists) { const overwrite = await confirmAsync({ - message: `Do you want to overwrite the existing store configuration "${metadataContext.metadataFile}"?`, + message: `Do you want to overwrite the existing store configuration "${metadataCtx.metadataPath}"?`, }); if (!overwrite) { throw new MetadataValidationError(`Store configuration already exists at "${filePath}"`); } } - const { app, auth } = await ensureMetadataAppStoreAuthenticatedAsync(metadataContext); + const { app, auth } = await ensureMetadataAppStoreAuthenticatedAsync(metadataCtx); const { unsubscribeTelemetry, executionId } = subscribeTelemetry( MetadataEvent.APPLE_METADATA_DOWNLOAD, { app, auth } @@ -37,7 +35,7 @@ export async function downloadMetadataAsync(metadataContext: MetadataContext): P const errors: Error[] = []; const config = createAppleWriter(); - const tasks = createAppleTasks(metadataContext); + const tasks = createAppleTasks(metadataCtx); const taskCtx = { app }; for (const task of tasks) { diff --git a/packages/eas-cli/src/metadata/upload.ts b/packages/eas-cli/src/metadata/upload.ts index 73a44000f5..68c25c91c1 100644 --- a/packages/eas-cli/src/metadata/upload.ts +++ b/packages/eas-cli/src/metadata/upload.ts @@ -13,13 +13,13 @@ import { subscribeTelemetry } from './utils/telemetry'; * Sync a local store configuration with the stores. * Note, only App Store is supported at this time. */ -export async function uploadMetadataAsync(metadataContext: MetadataContext): Promise { - const filePath = path.resolve(metadataContext.projectDir, metadataContext.metadataFile); +export async function uploadMetadataAsync(metadataCtx: MetadataContext): Promise { + const filePath = path.resolve(metadataCtx.projectDir, metadataCtx.metadataPath); if (!(await fs.pathExists(filePath))) { throw new MetadataValidationError(`Store configuration file not found "${filePath}"`); } - const { app, auth } = await ensureMetadataAppStoreAuthenticatedAsync(metadataContext); + const { app, auth } = await ensureMetadataAppStoreAuthenticatedAsync(metadataCtx); const { unsubscribeTelemetry, executionId } = subscribeTelemetry( MetadataEvent.APPLE_METADATA_UPLOAD, { app, auth } @@ -33,7 +33,7 @@ export async function uploadMetadataAsync(metadataContext: MetadataContext): Pro const errors: Error[] = []; const config = createAppleReader(fileData); - const tasks = createAppleTasks(metadataContext); + const tasks = createAppleTasks(metadataCtx); const taskCtx = { app }; for (const task of tasks) {