diff --git a/CHANGELOG.md b/CHANGELOG.md index 89368d3bfd..d61756163a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features - [eas-cli] `eas go` now prompts to select an Expo SDK version interactively when `--sdk-version` is not provided. ([#3768](https://github.com/expo/eas-cli/pull/3768) by [@gwdp](https://github.com/gwdp)) +- [eas-cli] Add `eas update:embedded:upload` command. ([#3720](https://github.com/expo/eas-cli/pull/3720) by [@gwdp](https://github.com/gwdp)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/src/commands/update/embedded/__tests__/upload.test.ts b/packages/eas-cli/src/commands/update/embedded/__tests__/upload.test.ts new file mode 100644 index 0000000000..c5382c3395 --- /dev/null +++ b/packages/eas-cli/src/commands/update/embedded/__tests__/upload.test.ts @@ -0,0 +1,337 @@ +import { Platform } from '@expo/eas-build-job'; +import { Updates } from '@expo/config-plugins'; +import { vol } from 'memfs'; + +import { getMockOclifConfig } from '../../../../__tests__/commands/utils'; +import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { EmbeddedUpdateAssetMutation } from '../../../../graphql/mutations/EmbeddedUpdateAssetMutation'; +import { + EmbeddedUpdateMutation, + isEmbeddedUpdateAlreadyExistsError, + isEmbeddedUpdateAssetNotAvailableError, +} from '../../../../graphql/mutations/EmbeddedUpdateMutation'; +import { AppPlatform } from '../../../../graphql/generated'; +import Log from '../../../../log'; +import { ora } from '../../../../ora'; +import * as uploads from '../../../../uploads'; +import * as json from '../../../../utils/json'; +import * as promise from '../../../../utils/promise'; +import UpdateEmbeddedUpload from '../upload'; + +jest.mock('fs', () => jest.requireActual('memfs').fs); +jest.mock('../../../../ora'); +jest.mock('@expo/config-plugins', () => ({ + Updates: { getRuntimeVersionNullableAsync: jest.fn() }, +})); +jest.mock('../../../../graphql/mutations/EmbeddedUpdateAssetMutation', () => ({ + EmbeddedUpdateAssetMutation: { getSignedUploadSpecAsync: jest.fn() }, +})); +jest.mock('../../../../graphql/mutations/EmbeddedUpdateMutation', () => ({ + EmbeddedUpdateMutation: { uploadEmbeddedUpdateAsync: jest.fn() }, + isEmbeddedUpdateAssetNotAvailableError: jest.fn(), + isEmbeddedUpdateAlreadyExistsError: jest.fn(), +})); +jest.mock('../../../../uploads'); +jest.mock('../../../../log'); +jest.mock('../../../../utils/json'); +jest.mock('../../../../utils/promise', () => ({ + sleepAsync: jest.fn().mockResolvedValue(undefined), +})); + +const mockGetRuntimeVersion = jest.mocked(Updates.getRuntimeVersionNullableAsync); +const mockGetSignedUploadSpec = jest.mocked(EmbeddedUpdateAssetMutation.getSignedUploadSpecAsync); +const mockUpload = jest.mocked(uploads.uploadWithPresignedPostWithRetryAsync); +const mockUploadEmbeddedUpdate = jest.mocked(EmbeddedUpdateMutation.uploadEmbeddedUpdateAsync); +const mockOra = jest.mocked(ora); +const mockUploadSpinnerSucceed = jest.fn(); +const mockUploadSpinnerFail = jest.fn(); +const mockRegisterSpinnerSucceed = jest.fn(); +const mockRegisterSpinnerFail = jest.fn(); +const mockSleepAsync = jest.mocked(promise.sleepAsync); +const mockIsEmbeddedUpdateAssetNotAvailableError = jest.mocked( + isEmbeddedUpdateAssetNotAvailableError +); +const mockIsEmbeddedUpdateAlreadyExistsError = jest.mocked(isEmbeddedUpdateAlreadyExistsError); +const mockLogLog = jest.mocked(Log.log); + +const BUNDLE_PATH = '/project/app.bundle'; +const MANIFEST_PATH = '/project/app.manifest'; +const VALID_UUID = 'a1b2c3d4-1234-4000-8000-000000000000'; +const VALID_MANIFEST = JSON.stringify({ id: VALID_UUID }); + +const BASE_ARGV = [ + '--platform', + Platform.IOS, + '--bundle', + BUNDLE_PATH, + '--manifest', + MANIFEST_PATH, + '--channel', + 'production', +]; + +const MOCK_CONTEXT = { + loggedIn: { graphqlClient: {} as ExpoGraphqlClient }, + privateProjectConfig: { + projectId: 'project-123', + exp: { name: 'test', slug: 'test' }, + projectDir: '/project', + }, +}; + +const MOCK_EMBEDDED_UPDATE = { + id: 'embedded-update-id-abc', + platform: AppPlatform.Ios, + runtimeVersion: '1.0.0', + channel: 'production', + createdAt: '2024-01-01T00:00:00Z', +}; + +describe(UpdateEmbeddedUpload, () => { + const mockConfig = getMockOclifConfig(); + + beforeEach(() => { + jest.clearAllMocks(); + mockOra.mockImplementation( + (text?: string | object) => + ({ + start: () => + text === 'Uploading bundle...' + ? { succeed: mockUploadSpinnerSucceed, fail: mockUploadSpinnerFail } + : { succeed: mockRegisterSpinnerSucceed, fail: mockRegisterSpinnerFail }, + }) as any + ); + vol.reset(); + vol.fromJSON({ + [BUNDLE_PATH]: 'bundle-bytes', + [MANIFEST_PATH]: VALID_MANIFEST, + }); + mockGetRuntimeVersion.mockResolvedValue('1.0.0'); + mockGetSignedUploadSpec.mockResolvedValue({ + storageKey: 'storage-key-abc', + presignedUrl: 'https://storage.googleapis.com/upload-bucket', + fields: { key: 'obj-key', policy: 'abc123' }, + }); + mockUpload.mockImplementation(async (_path, _spec, onProgress) => { + // Invoke the progress callback so the no-op arrow passed by the command is executed. + (onProgress as () => void)(); + return undefined as any; + }); + mockUploadEmbeddedUpdate.mockResolvedValue(MOCK_EMBEDDED_UPDATE); + mockIsEmbeddedUpdateAssetNotAvailableError.mockReturnValue(false); + mockIsEmbeddedUpdateAlreadyExistsError.mockReturnValue(false); + }); + + function createCommand(argv: string[]): UpdateEmbeddedUpload { + const command = new UpdateEmbeddedUpload(argv, mockConfig); + // @ts-expect-error getContextAsync is protected + jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT); + return command; + } + + describe('file existence checks', () => { + it('runs successfully when both files exist', async () => { + const command = createCommand(BASE_ARGV); + await command.runAsync(); + expect(mockUploadSpinnerSucceed).toHaveBeenCalledWith('Uploaded bundle'); + expect(mockRegisterSpinnerSucceed).toHaveBeenCalledWith( + expect.stringContaining('production') + ); + }); + + it('errors with message when bundle file does not exist', async () => { + vol.reset(); + vol.fromJSON({ [MANIFEST_PATH]: VALID_MANIFEST }); + const command = createCommand(BASE_ARGV); + await expect(command.runAsync()).rejects.toThrow(/Bundle file not found/); + }); + + it('errors with message when manifest file does not exist', async () => { + vol.reset(); + vol.fromJSON({ [BUNDLE_PATH]: 'bundle-bytes' }); + const command = createCommand(BASE_ARGV); + await expect(command.runAsync()).rejects.toThrow(/Could not read or parse manifest/); + }); + + it('includes build-id in log when provided', async () => { + const command = createCommand([...BASE_ARGV, '--build-id', 'build-uuid-123']); + await command.runAsync(); + expect(mockRegisterSpinnerSucceed).toHaveBeenCalledWith( + expect.stringContaining('production') + ); + }); + + it('accepts android platform', async () => { + const argv = [...BASE_ARGV]; + argv[1] = Platform.ANDROID; + const command = createCommand(argv); + await command.runAsync(); + expect(mockRegisterSpinnerSucceed).toHaveBeenCalledWith( + expect.stringContaining(Platform.ANDROID) + ); + }); + }); + + describe('manifest parsing', () => { + it('reads embeddedUpdateId from manifest and passes platform directly to getRuntimeVersion', async () => { + const command = createCommand(BASE_ARGV); + await command.runAsync(); + expect(mockGetRuntimeVersion).toHaveBeenCalledWith( + MOCK_CONTEXT.privateProjectConfig.projectDir, + MOCK_CONTEXT.privateProjectConfig.exp, + Platform.IOS + ); + }); + + it('errors when manifest contains invalid JSON', async () => { + vol.fromJSON({ [MANIFEST_PATH]: 'not-json', [BUNDLE_PATH]: 'bytes' }); + const command = createCommand(BASE_ARGV); + await expect(command.runAsync()).rejects.toThrow(/Could not read or parse manifest/); + }); + + it('errors when manifest id is missing', async () => { + vol.fromJSON({ [MANIFEST_PATH]: JSON.stringify({ noId: true }), [BUNDLE_PATH]: 'bytes' }); + const command = createCommand(BASE_ARGV); + await expect(command.runAsync()).rejects.toThrow(/"id" field/); + }); + + it('errors when manifest id is not a valid UUID', async () => { + vol.fromJSON({ + [MANIFEST_PATH]: JSON.stringify({ id: 'not-a-uuid' }), + [BUNDLE_PATH]: 'bytes', + }); + const command = createCommand(BASE_ARGV); + await expect(command.runAsync()).rejects.toThrow(/is not a UUID/); + }); + + it('errors when runtimeVersion cannot be resolved', async () => { + mockGetRuntimeVersion.mockResolvedValue(null); + const command = createCommand(BASE_ARGV); + await expect(command.runAsync()).rejects.toThrow(/runtimeVersion/); + }); + }); + + describe('bundle upload', () => { + it('requests a presigned URL with embeddedUpdateId and uploads the bundle', async () => { + const command = createCommand(BASE_ARGV); + await command.runAsync(); + expect(mockGetSignedUploadSpec).toHaveBeenCalledWith( + MOCK_CONTEXT.loggedIn.graphqlClient, + expect.objectContaining({ + appId: MOCK_CONTEXT.privateProjectConfig.projectId, + embeddedUpdateId: VALID_UUID, + contentType: 'application/javascript', + }) + ); + expect(mockUpload).toHaveBeenCalledWith( + BUNDLE_PATH, + { + url: 'https://storage.googleapis.com/upload-bucket', + fields: { key: 'obj-key', policy: 'abc123' }, + }, + expect.any(Function) + ); + }); + + it('propagates upload errors', async () => { + mockUpload.mockRejectedValue(new Error('upload failed')); + const command = createCommand(BASE_ARGV); + await expect(command.runAsync()).rejects.toThrow('upload failed'); + }); + }); + + describe('mutation registration', () => { + it('calls uploadEmbeddedUpdateAsync with channel name and AppPlatform enum', async () => { + const command = createCommand(BASE_ARGV); + await command.runAsync(); + expect(mockUploadEmbeddedUpdate).toHaveBeenCalledWith( + MOCK_CONTEXT.loggedIn.graphqlClient, + expect.objectContaining({ + appId: MOCK_CONTEXT.privateProjectConfig.projectId, + platform: AppPlatform.Ios, + runtimeVersion: '1.0.0', + channel: 'production', + embeddedUpdateId: VALID_UUID, + }) + ); + }); + + it('logs the embedded update id on success', async () => { + const command = createCommand(BASE_ARGV); + await command.runAsync(); + expect(mockUploadSpinnerSucceed).toHaveBeenCalledWith('Uploaded bundle'); + expect(mockLogLog).toHaveBeenCalledWith(expect.stringContaining(MOCK_EMBEDDED_UPDATE.id)); + }); + + it('passes build-id as turtleBuildId when provided', async () => { + const command = createCommand([...BASE_ARGV, '--build-id', 'turtle-build-xyz']); + await command.runAsync(); + expect(mockUploadEmbeddedUpdate).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ turtleBuildId: 'turtle-build-xyz' }) + ); + }); + + it('retries on ASSET_NOT_AVAILABLE: succeeds on second attempt, sleeps once with first delay', async () => { + const assetNotAvailableError = new Error('asset not available'); + mockIsEmbeddedUpdateAssetNotAvailableError.mockImplementation( + e => e === assetNotAvailableError + ); + mockUploadEmbeddedUpdate + .mockRejectedValueOnce(assetNotAvailableError) + .mockResolvedValueOnce(MOCK_EMBEDDED_UPDATE); + + const command = createCommand(BASE_ARGV); + await command.runAsync(); + + expect(mockUploadEmbeddedUpdate).toHaveBeenCalledTimes(2); + expect(mockSleepAsync).toHaveBeenCalledTimes(1); + expect(mockSleepAsync).toHaveBeenCalledWith(3_000); + expect(mockUploadSpinnerSucceed).toHaveBeenCalledWith('Uploaded bundle'); + expect(mockLogLog).toHaveBeenCalledWith(expect.stringContaining(MOCK_EMBEDDED_UPDATE.id)); + }); + + it('exhausts all 10 attempts on ASSET_NOT_AVAILABLE, sleeping 9 times then throwing', async () => { + const assetNotAvailableError = new Error('asset not available'); + mockIsEmbeddedUpdateAssetNotAvailableError.mockImplementation( + e => e === assetNotAvailableError + ); + mockUploadEmbeddedUpdate.mockRejectedValue(assetNotAvailableError); + + const command = createCommand(BASE_ARGV); + await expect(command.runAsync()).rejects.toThrow(); + expect(mockUploadEmbeddedUpdate).toHaveBeenCalledTimes(10); + expect(mockSleepAsync).toHaveBeenCalledTimes(9); + }); + + it('propagates unexpected mutation errors immediately without retrying', async () => { + const unexpectedError = new Error('network timeout'); + mockUploadEmbeddedUpdate.mockRejectedValue(unexpectedError); + + const command = createCommand(BASE_ARGV); + await expect(command.runAsync()).rejects.toThrow('network timeout'); + expect(mockUploadEmbeddedUpdate).toHaveBeenCalledTimes(1); + expect(mockRegisterSpinnerFail).toHaveBeenCalledWith('Failed to register embedded update'); + }); + + it('fails with a clean message when the update is already registered', async () => { + const alreadyExistsError = new Error('already exists'); + mockIsEmbeddedUpdateAlreadyExistsError.mockImplementation(e => e === alreadyExistsError); + mockUploadEmbeddedUpdate.mockRejectedValue(alreadyExistsError); + + const command = createCommand(BASE_ARGV); + await expect(command.runAsync()).rejects.toThrow(/already registered/); + expect(mockUploadEmbeddedUpdate).toHaveBeenCalledTimes(1); + expect(mockRegisterSpinnerFail).toHaveBeenCalledWith('Failed to register embedded update'); + }); + }); + + describe('--json flag', () => { + it('enables JSON output and prints the embedded update as JSON', async () => { + const command = createCommand([...BASE_ARGV, '--json', '--non-interactive']); + await command.runAsync(); + expect(jest.mocked(json.enableJsonOutput)).toHaveBeenCalled(); + expect(jest.mocked(json.printJsonOnlyOutput)).toHaveBeenCalledWith(MOCK_EMBEDDED_UPDATE); + }); + }); +}); diff --git a/packages/eas-cli/src/commands/update/embedded/upload.ts b/packages/eas-cli/src/commands/update/embedded/upload.ts new file mode 100644 index 0000000000..faccde3189 --- /dev/null +++ b/packages/eas-cli/src/commands/update/embedded/upload.ts @@ -0,0 +1,177 @@ +import { Platform } from '@expo/eas-build-job'; +import { Updates } from '@expo/config-plugins'; +import { Errors, Flags } from '@oclif/core'; +import fs from 'fs-extra'; + +import EasCommand from '../../../commandUtils/EasCommand'; +import { + EasNonInteractiveAndJsonFlags, + resolveNonInteractiveAndJsonFlags, +} from '../../../commandUtils/flags'; +import { EmbeddedUpdateAssetMutation } from '../../../graphql/mutations/EmbeddedUpdateAssetMutation'; +import { + EmbeddedUpdateMutation, + EmbeddedUpdateResult, + isEmbeddedUpdateAlreadyExistsError, + isEmbeddedUpdateAssetNotAvailableError, +} from '../../../graphql/mutations/EmbeddedUpdateMutation'; +import { toAppPlatform } from '../../../graphql/types/AppPlatform'; +import Log from '../../../log'; +import { ora } from '../../../ora'; +import { readEmbeddedManifestAsync } from '../../../update/embeddedManifest'; +import { uploadWithPresignedPostWithRetryAsync } from '../../../uploads'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; +import { sleepAsync } from '../../../utils/promise'; + +const MAX_ATTEMPTS = 10; +const RETRY_BASE_DELAY_MS = 3_000; +const RETRY_MAX_DELAY_MS = 10_000; + +export default class UpdateEmbeddedUpload extends EasCommand { + static override description = + 'upload the JS bundle embedded in a native build so EAS Update can generate bsdiff patches against it'; + + static override examples = [ + '$ eas update:embedded:upload --platform ios --bundle ios/build/App.app/main.jsbundle --manifest ios/build/App.app/app.manifest --channel production', + '$ eas update:embedded:upload --platform android --bundle android/app/src/main/assets/index.android.bundle --manifest android/app/src/main/assets/app.manifest --channel production --build-id ', + ]; + + static override flags = { + platform: Flags.option({ + char: 'p', + description: 'Platform of the embedded bundle', + options: [Platform.IOS, Platform.ANDROID] as const, + required: true, + })(), + bundle: Flags.string({ + description: 'Path to the embedded JS bundle file', + required: true, + }), + manifest: Flags.string({ + description: 'Path to the app.manifest file embedded in the build', + required: true, + }), + channel: Flags.string({ + description: 'Channel name the embedded update should be associated with', + required: true, + }), + 'build-id': Flags.string({ + description: 'EAS Build ID that produced this binary (required when invoked from EAS Build)', + required: false, + }), + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectConfig, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(UpdateEmbeddedUpload); + const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); + const platform = flags.platform; + const bundlePath = flags.bundle; + const manifestPath = flags.manifest; + const channelName = flags.channel; + const buildId = flags['build-id']; + + const { + loggedIn: { graphqlClient }, + privateProjectConfig: { projectId, exp, projectDir }, + } = await this.getContextAsync(UpdateEmbeddedUpload, { + nonInteractive, + withServerSideEnvironment: null, + }); + + if (jsonFlag) { + enableJsonOutput(); + } + + if (!(await fs.pathExists(bundlePath))) { + Errors.error( + `Bundle file not found at "${bundlePath}". Check that the path is correct and points to the JS bundle in your native build output.`, + { exit: 1 } + ); + } + + const { id: embeddedUpdateId } = await readEmbeddedManifestAsync(manifestPath); + + const runtimeVersion = await Updates.getRuntimeVersionNullableAsync(projectDir, exp, platform); + if (runtimeVersion === null) { + Errors.error( + `Could not resolve runtimeVersion for platform "${platform}". ` + + `Ensure runtimeVersion is set in your app.json under the expo key.`, + { exit: 1 } + ); + } + + const appPlatform = toAppPlatform(platform); + + const uploadSpinner = ora('Uploading bundle...').start(); + + const contentType = 'application/javascript'; + const uploadSpec = await EmbeddedUpdateAssetMutation.getSignedUploadSpecAsync(graphqlClient, { + appId: projectId, + embeddedUpdateId, + contentType, + }); + await uploadWithPresignedPostWithRetryAsync( + bundlePath, + { url: uploadSpec.presignedUrl, fields: uploadSpec.fields }, + () => {} + ); + + uploadSpinner.succeed('Uploaded bundle'); + + const registerSpinner = ora('Registering embedded update...').start(); + + let embeddedUpdate: EmbeddedUpdateResult | undefined; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + embeddedUpdate = await EmbeddedUpdateMutation.uploadEmbeddedUpdateAsync(graphqlClient, { + appId: projectId, + platform: appPlatform, + runtimeVersion, + channel: channelName, + embeddedUpdateId, + turtleBuildId: buildId, + }); + break; + } catch (e: unknown) { + if (isEmbeddedUpdateAssetNotAvailableError(e)) { + if (attempt < MAX_ATTEMPTS) { + await sleepAsync( + Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS) + ); + } + continue; + } + + registerSpinner.fail('Failed to register embedded update'); + if (isEmbeddedUpdateAlreadyExistsError(e)) { + Errors.error( + `An embedded update with id "${embeddedUpdateId}" is already registered for this app. Delete it before re-uploading.`, + { exit: 1 } + ); + } + throw e; + } + } + + if (embeddedUpdate === undefined) { + registerSpinner.fail('Failed to register embedded update'); + throw new Error( + 'Embedded bundle could not be processed in time. Try re-running the command in a moment.' + ); + } + + registerSpinner.succeed( + `Registered ${platform} embedded update (runtimeVersion: ${runtimeVersion}, channel: "${channelName}")` + ); + Log.log(`Embedded update ID: ${embeddedUpdate.id}`); + + if (jsonFlag) { + printJsonOnlyOutput(embeddedUpdate); + } + } +} diff --git a/packages/eas-cli/src/graphql/__tests__/client-test.ts b/packages/eas-cli/src/graphql/__tests__/client-test.ts index ab7d2ab7c1..35ae50cf0a 100644 --- a/packages/eas-cli/src/graphql/__tests__/client-test.ts +++ b/packages/eas-cli/src/graphql/__tests__/client-test.ts @@ -1,16 +1,46 @@ import { CombinedError } from '@urql/core'; +import Log from '../../log'; import { EAS_CLI_UPGRADE_REQUIRED_ERROR_CODE, + withErrorHandlingAsync, withUpgradeRequiredErrorHandlingAsync, } from '../client'; +jest.mock('../../log'); + function makeError(message: string, extensions?: Record): CombinedError { return new CombinedError({ graphQLErrors: [{ message, extensions } as any], }); } +const mockLogError = jest.mocked(Log.error); + +describe(withErrorHandlingAsync, () => { + beforeEach(() => jest.clearAllMocks()); + + it('logs the transient error message for generic transient errors', async () => { + const error = makeError('Transient failure', { + isTransient: true, + errorCode: 'SOME_TRANSIENT', + }); + await expect(withErrorHandlingAsync(Promise.resolve({ error } as any))).rejects.toBe(error); + expect(mockLogError).toHaveBeenCalledWith( + expect.stringContaining("We've encountered a transient error") + ); + }); + + it('suppresses the transient error message for EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE', async () => { + const error = makeError('Asset not yet available', { + isTransient: true, + errorCode: 'EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE', + }); + await expect(withErrorHandlingAsync(Promise.resolve({ error } as any))).rejects.toBe(error); + expect(mockLogError).not.toHaveBeenCalled(); + }); +}); + describe(withUpgradeRequiredErrorHandlingAsync, () => { it('returns data when the promise resolves successfully', async () => { const result = await withUpgradeRequiredErrorHandlingAsync( diff --git a/packages/eas-cli/src/graphql/client.ts b/packages/eas-cli/src/graphql/client.ts index 72dc2d1c7a..d7e9f776d4 100644 --- a/packages/eas-cli/src/graphql/client.ts +++ b/packages/eas-cli/src/graphql/client.ts @@ -16,6 +16,7 @@ export async function withErrorHandlingAsync(promise: Promise; + + +export type GetSignedEmbeddedUpdateAssetUploadSpecMutation = { __typename?: 'RootMutation', embeddedUpdateAsset: { __typename?: 'EmbeddedUpdateAssetMutation', getSignedEmbeddedUpdateAssetUploadSpecifications: { __typename?: 'EmbeddedUpdateAssetUploadSpec', storageKey: string, presignedUrl: string, fields: any } } }; + +export type UploadEmbeddedUpdateMutationVariables = Exact<{ + input: UploadEmbeddedUpdateInput; +}>; + + +export type UploadEmbeddedUpdateMutation = { __typename?: 'RootMutation', embeddedUpdate: { __typename?: 'EmbeddedUpdateMutation', uploadEmbeddedUpdate: { __typename?: 'EmbeddedUpdate', id: string, platform: AppPlatform, runtimeVersion: string, channel: string, createdAt: any } } }; + export type CreateEnvironmentSecretForAccountMutationVariables = Exact<{ input: CreateEnvironmentSecretInput; accountId: Scalars['String']['input']; diff --git a/packages/eas-cli/src/graphql/mutations/EmbeddedUpdateAssetMutation.ts b/packages/eas-cli/src/graphql/mutations/EmbeddedUpdateAssetMutation.ts new file mode 100644 index 0000000000..ca7434ac7d --- /dev/null +++ b/packages/eas-cli/src/graphql/mutations/EmbeddedUpdateAssetMutation.ts @@ -0,0 +1,56 @@ +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { + GetSignedEmbeddedUpdateAssetUploadSpecMutation, + GetSignedEmbeddedUpdateAssetUploadSpecMutationVariables, +} from '../generated'; +import { withErrorHandlingAsync } from '../client'; + +export type EmbeddedUpdateAssetUploadSpec = { + storageKey: string; + presignedUrl: string; + fields: Record; +}; + +export const EmbeddedUpdateAssetMutation = { + async getSignedUploadSpecAsync( + graphqlClient: ExpoGraphqlClient, + { + appId, + embeddedUpdateId, + contentType, + }: { appId: string; embeddedUpdateId: string; contentType: string } + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation< + GetSignedEmbeddedUpdateAssetUploadSpecMutation, + GetSignedEmbeddedUpdateAssetUploadSpecMutationVariables + >( + gql` + mutation GetSignedEmbeddedUpdateAssetUploadSpec( + $appId: ID! + $embeddedUpdateId: ID! + $contentType: String! + ) { + embeddedUpdateAsset { + getSignedEmbeddedUpdateAssetUploadSpecifications( + appId: $appId + embeddedUpdateId: $embeddedUpdateId + contentType: $contentType + ) { + storageKey + presignedUrl + fields + } + } + } + `, + { appId, embeddedUpdateId, contentType } + ) + .toPromise() + ); + return data.embeddedUpdateAsset.getSignedEmbeddedUpdateAssetUploadSpecifications; + }, +}; diff --git a/packages/eas-cli/src/graphql/mutations/EmbeddedUpdateMutation.ts b/packages/eas-cli/src/graphql/mutations/EmbeddedUpdateMutation.ts new file mode 100644 index 0000000000..7eab4ee8bf --- /dev/null +++ b/packages/eas-cli/src/graphql/mutations/EmbeddedUpdateMutation.ts @@ -0,0 +1,64 @@ +import { CombinedError } from '@urql/core'; +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { + AppPlatform, + UploadEmbeddedUpdateInput, + UploadEmbeddedUpdateMutation, + UploadEmbeddedUpdateMutationVariables, +} from '../generated'; +import { withErrorHandlingAsync } from '../client'; + +export type EmbeddedUpdateResult = { + id: string; + platform: AppPlatform; + runtimeVersion: string; + channel: string; + createdAt: string; +}; + +export function isEmbeddedUpdateAssetNotAvailableError(error: unknown): boolean { + return ( + error instanceof CombinedError && + error.graphQLErrors.some( + e => e.extensions?.['errorCode'] === 'EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE' + ) + ); +} + +export function isEmbeddedUpdateAlreadyExistsError(error: unknown): boolean { + return ( + error instanceof CombinedError && + error.graphQLErrors.some(e => e.extensions?.['errorCode'] === 'EMBEDDED_UPDATE_ALREADY_EXISTS') + ); +} + +export const EmbeddedUpdateMutation = { + async uploadEmbeddedUpdateAsync( + graphqlClient: ExpoGraphqlClient, + input: UploadEmbeddedUpdateInput + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation( + gql` + mutation UploadEmbeddedUpdate($input: UploadEmbeddedUpdateInput!) { + embeddedUpdate { + uploadEmbeddedUpdate(input: $input) { + id + platform + runtimeVersion + channel + createdAt + } + } + } + `, + { input } + ) + .toPromise() + ); + return data.embeddedUpdate.uploadEmbeddedUpdate; + }, +}; diff --git a/packages/eas-cli/src/graphql/mutations/__tests__/EmbeddedUpdateAssetMutation-test.ts b/packages/eas-cli/src/graphql/mutations/__tests__/EmbeddedUpdateAssetMutation-test.ts new file mode 100644 index 0000000000..49b4451041 --- /dev/null +++ b/packages/eas-cli/src/graphql/mutations/__tests__/EmbeddedUpdateAssetMutation-test.ts @@ -0,0 +1,33 @@ +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { EmbeddedUpdateAssetMutation } from '../EmbeddedUpdateAssetMutation'; + +function makeGraphqlClient(data: unknown): ExpoGraphqlClient { + return { + mutation: jest.fn().mockReturnValue({ + toPromise: jest.fn().mockResolvedValue({ data }), + }), + } as unknown as ExpoGraphqlClient; +} + +describe('EmbeddedUpdateAssetMutation.getSignedUploadSpecAsync', () => { + it('returns the upload spec from the GraphQL response', async () => { + const expected = { + storageKey: 'app-id/update-id', + presignedUrl: 'https://storage.example.com/upload', + fields: { key: 'value' }, + }; + const client = makeGraphqlClient({ + embeddedUpdateAsset: { + getSignedEmbeddedUpdateAssetUploadSpecifications: expected, + }, + }); + + const result = await EmbeddedUpdateAssetMutation.getSignedUploadSpecAsync(client, { + appId: 'app-id', + embeddedUpdateId: 'update-id', + contentType: 'application/javascript', + }); + + expect(result).toEqual(expected); + }); +}); diff --git a/packages/eas-cli/src/graphql/mutations/__tests__/EmbeddedUpdateMutation-test.ts b/packages/eas-cli/src/graphql/mutations/__tests__/EmbeddedUpdateMutation-test.ts new file mode 100644 index 0000000000..2a5bed64d0 --- /dev/null +++ b/packages/eas-cli/src/graphql/mutations/__tests__/EmbeddedUpdateMutation-test.ts @@ -0,0 +1,99 @@ +import { CombinedError } from '@urql/core'; + +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { AppPlatform } from '../../generated'; +import { + EmbeddedUpdateMutation, + isEmbeddedUpdateAlreadyExistsError, + isEmbeddedUpdateAssetNotAvailableError, +} from '../EmbeddedUpdateMutation'; + +function makeGraphqlClient(data: unknown): ExpoGraphqlClient { + return { + mutation: jest.fn().mockReturnValue({ + toPromise: jest.fn().mockResolvedValue({ data }), + }), + } as unknown as ExpoGraphqlClient; +} + +function makeCombinedError(errorCode: string): CombinedError { + return new CombinedError({ + graphQLErrors: [{ message: 'error', extensions: { errorCode } }], + }); +} + +describe('isEmbeddedUpdateAssetNotAvailableError', () => { + it('returns true for CombinedError with EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE errorCode', () => { + expect( + isEmbeddedUpdateAssetNotAvailableError( + makeCombinedError('EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE') + ) + ).toBe(true); + }); + + it('returns false for CombinedError with a different errorCode', () => { + expect(isEmbeddedUpdateAssetNotAvailableError(makeCombinedError('SOME_OTHER_ERROR'))).toBe( + false + ); + }); + + it('returns false for CombinedError with no extensions', () => { + expect( + isEmbeddedUpdateAssetNotAvailableError( + new CombinedError({ graphQLErrors: [{ message: 'oops' }] }) + ) + ).toBe(false); + }); + + it('returns false for a plain Error', () => { + expect(isEmbeddedUpdateAssetNotAvailableError(new Error('plain error'))).toBe(false); + }); + + it('returns false for null and non-error values', () => { + expect(isEmbeddedUpdateAssetNotAvailableError(null)).toBe(false); + expect(isEmbeddedUpdateAssetNotAvailableError('string')).toBe(false); + expect(isEmbeddedUpdateAssetNotAvailableError(42)).toBe(false); + }); +}); + +describe('isEmbeddedUpdateAlreadyExistsError', () => { + it('returns true for CombinedError with EMBEDDED_UPDATE_ALREADY_EXISTS errorCode', () => { + expect( + isEmbeddedUpdateAlreadyExistsError(makeCombinedError('EMBEDDED_UPDATE_ALREADY_EXISTS')) + ).toBe(true); + }); + + it('returns false for CombinedError with a different errorCode', () => { + expect(isEmbeddedUpdateAlreadyExistsError(makeCombinedError('SOME_OTHER_ERROR'))).toBe(false); + }); + + it('returns false for a plain Error', () => { + expect(isEmbeddedUpdateAlreadyExistsError(new Error('plain error'))).toBe(false); + }); +}); + +describe('EmbeddedUpdateMutation.uploadEmbeddedUpdateAsync', () => { + it('returns the embedded update from the GraphQL response', async () => { + const expected = { + id: 'update-1', + platform: AppPlatform.Android, + runtimeVersion: '1.0.0', + channel: 'production', + createdAt: '2024-01-01T00:00:00Z', + }; + const client = makeGraphqlClient({ + embeddedUpdate: { uploadEmbeddedUpdate: expected }, + }); + + const result = await EmbeddedUpdateMutation.uploadEmbeddedUpdateAsync(client, { + appId: 'app-1', + platform: AppPlatform.Android, + runtimeVersion: '1.0.0', + channel: 'production', + embeddedUpdateId: 'update-1', + turtleBuildId: null, + }); + + expect(result).toEqual(expected); + }); +}); diff --git a/packages/eas-cli/src/update/embeddedManifest.ts b/packages/eas-cli/src/update/embeddedManifest.ts new file mode 100644 index 0000000000..9026274487 --- /dev/null +++ b/packages/eas-cli/src/update/embeddedManifest.ts @@ -0,0 +1,35 @@ +import fs from 'fs-extra'; +import * as uuid from 'uuid'; + +export async function readEmbeddedManifestAsync(manifestPath: string): Promise<{ id: string }> { + let parsed: unknown; + try { + parsed = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + } catch { + throw new Error( + `Could not read or parse manifest at "${manifestPath}". ` + + `Check the file exists, is readable, and contains valid JSON.` + ); + } + + if ( + typeof parsed !== 'object' || + parsed === null || + typeof (parsed as Record).id !== 'string' + ) { + throw new Error( + `Manifest at "${manifestPath}" is missing the required "id" field. ` + + `Make sure you're pointing at the app.manifest generated by expo-updates during your native build, not a different JSON file.` + ); + } + + const id = (parsed as Record).id; + if (!uuid.validate(id)) { + throw new Error( + `Manifest at "${manifestPath}" has an invalid "id" field ("${id}" is not a UUID). ` + + `Make sure you're pointing at the app.manifest generated by expo-updates during your native build.` + ); + } + + return { id }; +}