diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f0a03c2a6..998c73daab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐ŸŽ‰ New features +- [build-tools] Auto-upload embedded bundle after build when `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE` is set. ([#3767](https://github.com/expo/eas-cli/pull/3767) by [@gwdp](https://github.com/gwdp)) +- [eas-cli] Add `eas update:embedded:view` command. ([#3721](https://github.com/expo/eas-cli/pull/3721) by [@gwdp](https://github.com/gwdp)) + ### ๐Ÿ› Bug fixes ### ๐Ÿงน Chores diff --git a/packages/build-tools/src/builders/__tests__/android.test.ts b/packages/build-tools/src/builders/__tests__/android.test.ts index 7722be8a4c..b6c706d601 100644 --- a/packages/build-tools/src/builders/__tests__/android.test.ts +++ b/packages/build-tools/src/builders/__tests__/android.test.ts @@ -6,6 +6,7 @@ import { createMockLogger } from '../../__tests__/utils/logger'; import { BuildContext } from '../../context'; import { Datadog } from '../../datadog'; import { restoreCredentials } from '../../android/credentials'; +import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded'; import androidBuilder from '../android'; import { runBuilderWithHooksAsync } from '../common'; import { @@ -57,6 +58,9 @@ jest.mock('../../utils/expoUpdates', () => ({ configureExpoUpdatesIfInstalledAsync: jest.fn(), resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null), })); +jest.mock('../../utils/expoUpdatesEmbedded', () => ({ + uploadEmbeddedBundleAsync: jest.fn(), +})); jest.mock('../../utils/hooks', () => ({ Hook: { POST_INSTALL: 'POST_INSTALL', @@ -269,4 +273,37 @@ describe(androidBuilder, () => { expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function)); }); + + it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => { + const ctx = new BuildContext(createTestAndroidJob(), { + workingdir: '/workingdir', + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + logger: createMockLogger(), + env: { + __API_SERVER_URL: 'http://api.expo.test', + EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1', + }, + uploadArtifact: jest.fn(), + }); + + await androidBuilder(ctx); + + expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx); + }); + + it('skips the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is not set', async () => { + const ctx = new BuildContext(createTestAndroidJob(), { + workingdir: '/workingdir', + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + logger: createMockLogger(), + env: { + __API_SERVER_URL: 'http://api.expo.test', + }, + uploadArtifact: jest.fn(), + }); + + await androidBuilder(ctx); + + expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled(); + }); }); diff --git a/packages/build-tools/src/builders/android.ts b/packages/build-tools/src/builders/android.ts index c48e80f631..6ccff6ca09 100644 --- a/packages/build-tools/src/builders/android.ts +++ b/packages/build-tools/src/builders/android.ts @@ -32,6 +32,7 @@ import { configureExpoUpdatesIfInstalledAsync, resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync, } from '../utils/expoUpdates'; +import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded'; import { Hook, runHookIfPresent } from '../utils/hooks'; import { prepareExecutableAsync } from '../utils/prepareBuildExecutable'; @@ -208,6 +209,12 @@ async function buildAsync(ctx: BuildContext): Promise { }); }); + if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) { + await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => { + await uploadEmbeddedBundleAsync(ctx); + }); + } + await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => { if (ctx.isLocal) { ctx.logger.info('Local builds do not support saving cache.'); diff --git a/packages/build-tools/src/builders/ios.ts b/packages/build-tools/src/builders/ios.ts index 5d0ad25069..2ad68487fc 100644 --- a/packages/build-tools/src/builders/ios.ts +++ b/packages/build-tools/src/builders/ios.ts @@ -25,6 +25,7 @@ import { configureExpoUpdatesIfInstalledAsync, resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync, } from '../utils/expoUpdates'; +import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded'; import { Hook, runHookIfPresent } from '../utils/hooks'; import { prepareExecutableAsync } from '../utils/prepareBuildExecutable'; import { getParentAndDescendantProcessPidsAsync } from '../utils/processes'; @@ -209,6 +210,12 @@ async function buildAsync(ctx: BuildContext): Promise { }); }); + if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) { + await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => { + await uploadEmbeddedBundleAsync(ctx); + }); + } + await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => { if (ctx.isLocal) { ctx.logger.info('Local builds do not support saving cache.'); diff --git a/packages/build-tools/src/utils/__tests__/expoUpdatesEmbedded.test.ts b/packages/build-tools/src/utils/__tests__/expoUpdatesEmbedded.test.ts new file mode 100644 index 0000000000..ca048cca7b --- /dev/null +++ b/packages/build-tools/src/utils/__tests__/expoUpdatesEmbedded.test.ts @@ -0,0 +1,273 @@ +import { Platform } from '@expo/eas-build-job'; + +import { BuildContext } from '../../context'; +import * as expoUpdates from '../expoUpdates'; +import { uploadEmbeddedBundleAsync } from '../expoUpdatesEmbedded'; +import * as easCli from '../easCli'; +import * as artifacts from '../artifacts'; + +jest.mock('../expoUpdates'); +jest.mock('../easCli'); +jest.mock('../artifacts'); + +const mockZipEntries = jest.fn(); +const mockZipExtract = jest.fn(); +const mockZipClose = jest.fn(); + +jest.mock('node-stream-zip', () => ({ + __esModule: true, + default: { + async: jest.fn(() => ({ + entries: mockZipEntries, + extract: mockZipExtract, + close: mockZipClose, + })), + }, +})); + +function zipEntryMap(entries: Record): Record { + return Object.fromEntries(Object.keys(entries).map(name => [name, { name }])); +} + +function makeCtx(overrides: { + platform: Platform; + simulator?: boolean; + channel?: string; + env?: Record; +}): BuildContext { + const job = + overrides.platform === Platform.IOS + ? { + platform: Platform.IOS, + simulator: overrides.simulator ?? false, + updates: overrides.channel ? { channel: overrides.channel } : undefined, + } + : { + platform: Platform.ANDROID, + updates: overrides.channel ? { channel: overrides.channel } : undefined, + }; + + return { + job, + env: overrides.env ?? {}, + appConfig: Promise.resolve({ + updates: { url: 'https://u.expo.dev/project-id' }, + }), + logger: { + info: jest.fn(), + warn: jest.fn(), + }, + markBuildPhaseSkipped: jest.fn(), + markBuildPhaseHasWarnings: jest.fn(), + getReactNativeProjectDirectory: () => '/project', + } as any; +} + +describe('uploadEmbeddedBundleAsync', () => { + beforeEach(() => { + jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(true); + jest.mocked(easCli.runEasCliCommand).mockResolvedValue({} as any); + jest.mocked(artifacts.findArtifacts).mockResolvedValue([]); + mockZipEntries.mockResolvedValue({}); + mockZipExtract.mockResolvedValue(undefined); + mockZipClose.mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('skips when EAS Update is not configured', async () => { + jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(false); + const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled(); + expect(artifacts.findArtifacts).not.toHaveBeenCalled(); + }); + + it('warns when no channel is configured and does not look for the archive', async () => { + const ctx = makeCtx({ platform: Platform.ANDROID }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(ctx.logger.warn).toHaveBeenCalledWith( + 'Skipping embedded bundle upload: no channel configured for this build profile.' + ); + expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled(); + expect(artifacts.findArtifacts).not.toHaveBeenCalled(); + }); + + it('throws for an unsupported platform', async () => { + const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' }); + (ctx.job as { platform: string }).platform = 'web'; + + await expect(uploadEmbeddedBundleAsync(ctx)).rejects.toThrow( + 'Uploading embedded updates is not supported for the web platform.' + ); + expect(artifacts.findArtifacts).not.toHaveBeenCalled(); + }); + + it('uploads from Android APK archives', async () => { + jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']); + mockZipEntries.mockResolvedValue( + zipEntryMap({ + 'assets/index.android.bundle': true, + 'assets/app.manifest': true, + }) + ); + const ctx = makeCtx({ + platform: Platform.ANDROID, + channel: 'production', + env: { EAS_BUILD_ID: 'build-123' }, + }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(mockZipExtract).toHaveBeenCalledWith( + 'assets/index.android.bundle', + expect.stringContaining('index.android.bundle') + ); + expect(easCli.runEasCliCommand).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.arrayContaining([ + 'update:embedded:upload', + '--platform', + Platform.ANDROID, + '--channel', + 'production', + '--build-id', + 'build-123', + ]), + }) + ); + }); + + it('uploads from Android AAB archives', async () => { + jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.aab']); + mockZipEntries.mockResolvedValue( + zipEntryMap({ + 'base/assets/index.android.bundle': true, + 'base/assets/app.manifest': true, + }) + ); + const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(mockZipExtract).toHaveBeenCalledWith( + 'base/assets/index.android.bundle', + expect.stringContaining('index.android.bundle') + ); + expect(easCli.runEasCliCommand).toHaveBeenCalled(); + }); + + it('uploads from iOS IPA archives', async () => { + jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/App.ipa']); + mockZipEntries.mockResolvedValue( + zipEntryMap({ + 'Payload/App.app/main.jsbundle': true, + 'Payload/App.app/EXUpdates.bundle/app.manifest': true, + }) + ); + const ctx = makeCtx({ platform: Platform.IOS, channel: 'production' }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(easCli.runEasCliCommand).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.arrayContaining(['--platform', Platform.IOS]), + }) + ); + }); + + it('skips simulator builds', async () => { + const ctx = makeCtx({ platform: Platform.IOS, simulator: true, channel: 'preview' }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled(); + expect(artifacts.findArtifacts).not.toHaveBeenCalled(); + }); + + it('warns when bundle or manifest is missing from the archive', async () => { + jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']); + mockZipEntries.mockResolvedValue( + zipEntryMap({ + 'assets/app.manifest': true, + }) + ); + const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(ctx.logger.warn).toHaveBeenCalledWith( + 'Skipping embedded bundle upload: bundle or manifest not found in archive.' + ); + expect(easCli.runEasCliCommand).not.toHaveBeenCalled(); + }); + + it('warns when build archive is not found', async () => { + jest.mocked(artifacts.findArtifacts).mockResolvedValue([]); + const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(ctx.logger.warn).toHaveBeenCalledWith( + 'Skipping embedded bundle upload: build archive not found.' + ); + expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled(); + expect(easCli.runEasCliCommand).not.toHaveBeenCalled(); + }); + + it('treats findArtifacts errors as no archive found', async () => { + jest.mocked(artifacts.findArtifacts).mockRejectedValue(new Error('glob failed')); + const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(ctx.logger.warn).toHaveBeenCalledWith( + 'Skipping embedded bundle upload: build archive not found.' + ); + expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled(); + expect(easCli.runEasCliCommand).not.toHaveBeenCalled(); + }); + + it('warns and continues when CLI upload throws', async () => { + jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']); + mockZipEntries.mockResolvedValue( + zipEntryMap({ + 'assets/index.android.bundle': true, + 'assets/app.manifest': true, + }) + ); + jest.mocked(easCli.runEasCliCommand).mockRejectedValue(new Error('upload failed')); + const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(ctx.logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ err: expect.any(Error) }), + 'Failed to upload embedded bundle.' + ); + expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled(); + }); + + it('swallows zip.close() failures so they do not mask the upload result', async () => { + jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']); + mockZipEntries.mockResolvedValue( + zipEntryMap({ + 'assets/index.android.bundle': true, + 'assets/app.manifest': true, + }) + ); + mockZipClose.mockRejectedValue(new Error('close failed')); + const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' }); + + await expect(uploadEmbeddedBundleAsync(ctx)).resolves.toBeUndefined(); + expect(easCli.runEasCliCommand).toHaveBeenCalled(); + expect(mockZipClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/build-tools/src/utils/expoUpdatesEmbedded.ts b/packages/build-tools/src/utils/expoUpdatesEmbedded.ts new file mode 100644 index 0000000000..63a4c5572d --- /dev/null +++ b/packages/build-tools/src/utils/expoUpdatesEmbedded.ts @@ -0,0 +1,118 @@ +import { Android, BuildJob, Ios, Platform } from '@expo/eas-build-job'; +import { PipeMode } from '@expo/logger'; +import { asyncResult } from '@expo/results'; +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; +import StreamZip from 'node-stream-zip'; + +import { findArtifacts } from './artifacts'; +import { runEasCliCommand } from './easCli'; +import { resolveArtifactPath } from '../ios/resolve'; +import { BuildContext } from '../context'; +import { isEASUpdateConfigured } from './expoUpdates'; + +export async function uploadEmbeddedBundleAsync(ctx: BuildContext): Promise { + if (!(await isEASUpdateConfigured(ctx))) { + ctx.markBuildPhaseSkipped(); + return; + } + + const { platform } = ctx.job; + if (platform === Platform.IOS && (ctx.job as Ios.Job).simulator) { + ctx.markBuildPhaseSkipped(); + return; + } + + const channel = ctx.job.updates?.channel; + if (!channel) { + ctx.logger.warn( + 'Skipping embedded bundle upload: no channel configured for this build profile.' + ); + ctx.markBuildPhaseHasWarnings(); + return; + } + + const projectDir = ctx.getReactNativeProjectDirectory(); + + let archivePattern: string; + if (platform === Platform.IOS) { + archivePattern = resolveArtifactPath(ctx as BuildContext); + } else if (platform === Platform.ANDROID) { + archivePattern = + (ctx as BuildContext).job.applicationArchivePath ?? + 'android/app/build/outputs/**/*.{apk,aab}'; + } else { + throw new Error(`Uploading embedded updates is not supported for the ${platform} platform.`); + } + + const [archivePath] = await findArtifacts({ + rootDir: projectDir, + patternOrPath: archivePattern, + logger: null, + }).catch(() => [] as string[]); + + if (!archivePath) { + ctx.logger.warn('Skipping embedded bundle upload: build archive not found.'); + ctx.markBuildPhaseHasWarnings(); + return; + } + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-embedded-bundle-')); + const bundleName = platform === Platform.IOS ? 'main.jsbundle' : 'index.android.bundle'; + const bundlePath = path.join(tmpDir, bundleName); + const manifestPath = path.join(tmpDir, 'app.manifest'); + const zip = new StreamZip.async({ file: archivePath }); + try { + const entries = Object.values(await zip.entries()); + const bundleEntry = entries.find(e => + platform === Platform.IOS + ? e.name.endsWith('/main.jsbundle') + : e.name.endsWith('assets/index.android.bundle') + ); + const manifestEntry = entries.find(e => + platform === Platform.IOS + ? e.name.includes('EXUpdates.bundle/app.manifest') + : e.name.endsWith('assets/app.manifest') + ); + + if (!bundleEntry || !manifestEntry) { + ctx.logger.warn('Skipping embedded bundle upload: bundle or manifest not found in archive.'); + ctx.markBuildPhaseHasWarnings(); + return; + } + + await zip.extract(bundleEntry.name, bundlePath); + await zip.extract(manifestEntry.name, manifestPath); + + const args = [ + 'update:embedded:upload', + '--platform', + platform, + '--bundle', + bundlePath, + '--manifest', + manifestPath, + '--channel', + channel, + '--non-interactive', + ]; + if (ctx.env.EAS_BUILD_ID) { + args.push('--build-id', ctx.env.EAS_BUILD_ID); + } + await runEasCliCommand({ + args, + options: { + cwd: projectDir, + env: ctx.env, + logger: ctx.logger, + mode: PipeMode.STDERR_ONLY_AS_STDOUT, + }, + }); + } catch (err: any) { + ctx.logger.warn({ err }, 'Failed to upload embedded bundle.'); + ctx.markBuildPhaseHasWarnings(); + } finally { + await asyncResult(zip.close()); + } +} diff --git a/packages/eas-build-job/src/logs.ts b/packages/eas-build-job/src/logs.ts index 38cdb8d832..8830f32e5f 100644 --- a/packages/eas-build-job/src/logs.ts +++ b/packages/eas-build-job/src/logs.ts @@ -27,6 +27,7 @@ export enum BuildPhase { */ UPLOAD_ARTIFACTS = 'UPLOAD_ARTIFACTS', UPLOAD_APPLICATION_ARCHIVE = 'UPLOAD_APPLICATION_ARCHIVE', + UPLOAD_EMBEDDED_BUNDLE = 'UPLOAD_EMBEDDED_BUNDLE', UPLOAD_BUILD_ARTIFACTS = 'UPLOAD_BUILD_ARTIFACTS', PREPARE_ARTIFACTS = 'PREPARE_ARTIFACTS', CLEAN_UP_CREDENTIALS = 'CLEAN_UP_CREDENTIALS', @@ -91,6 +92,7 @@ export const buildPhaseDisplayName: Record = { [BuildPhase.CACHE_STATS]: 'Cache stats', [BuildPhase.UPLOAD_ARTIFACTS]: 'Upload artifacts', [BuildPhase.UPLOAD_APPLICATION_ARCHIVE]: 'Upload application archive', + [BuildPhase.UPLOAD_EMBEDDED_BUNDLE]: 'Upload embedded bundle', [BuildPhase.UPLOAD_BUILD_ARTIFACTS]: 'Upload build artifacts', [BuildPhase.PREPARE_ARTIFACTS]: 'Prepare artifacts', [BuildPhase.CLEAN_UP_CREDENTIALS]: 'Clean up credentials', @@ -157,6 +159,7 @@ export const buildPhaseWebsiteId: Record = { [BuildPhase.CACHE_STATS]: 'cache-stats', [BuildPhase.UPLOAD_ARTIFACTS]: 'upload-artifacts', [BuildPhase.UPLOAD_APPLICATION_ARCHIVE]: 'upload-application-archive', + [BuildPhase.UPLOAD_EMBEDDED_BUNDLE]: 'upload-embedded-bundle', [BuildPhase.UPLOAD_BUILD_ARTIFACTS]: 'upload-build-artifacts', [BuildPhase.PREPARE_ARTIFACTS]: 'prepare-artifacts', [BuildPhase.CLEAN_UP_CREDENTIALS]: 'clean-up-credentials', diff --git a/packages/eas-cli/src/commands/update/embedded/view.ts b/packages/eas-cli/src/commands/update/embedded/view.ts new file mode 100644 index 0000000000..ff96e8d54d --- /dev/null +++ b/packages/eas-cli/src/commands/update/embedded/view.ts @@ -0,0 +1,82 @@ +import { Args, Errors } from '@oclif/core'; + +import EasCommand from '../../../commandUtils/EasCommand'; +import { EasJsonOnlyFlag } from '../../../commandUtils/flags'; +import { + EmbeddedUpdateFragment, + EmbeddedUpdateQuery, + isEmbeddedUpdateNotFoundError, +} from '../../../graphql/queries/EmbeddedUpdateQuery'; +import Log from '../../../log'; +import formatFields from '../../../utils/formatFields'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; + +export default class UpdateEmbeddedView extends EasCommand { + static override description = 'view details of an embedded update registered with EAS Update'; + + static override args = { + id: Args.string({ + required: true, + description: 'The ID of the embedded update (manifest UUID from app.manifest).', + }), + }; + + static override flags = { + ...EasJsonOnlyFlag, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectId, + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + const { + args: { id: embeddedUpdateId }, + flags: { json: jsonFlag }, + } = await this.parse(UpdateEmbeddedView); + + const { + projectId, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(UpdateEmbeddedView, { nonInteractive: true }); + + if (jsonFlag) { + enableJsonOutput(); + } + + let embeddedUpdate; + try { + embeddedUpdate = await EmbeddedUpdateQuery.viewByIdAsync(graphqlClient, { + embeddedUpdateId, + appId: projectId, + }); + } catch (e: unknown) { + if (isEmbeddedUpdateNotFoundError(e)) { + Errors.error( + `No embedded update found with id "${embeddedUpdateId}" for this project. ` + + `Verify the id is correct and belongs to this app.`, + { exit: 1 } + ); + } + throw e; + } + + if (jsonFlag) { + printJsonOnlyOutput(embeddedUpdate); + return; + } + + Log.log(formatEmbeddedUpdate(embeddedUpdate)); + } +} + +export function formatEmbeddedUpdate(embeddedUpdate: EmbeddedUpdateFragment): string { + return formatFields([ + { label: 'ID', value: embeddedUpdate.id }, + { label: 'Platform', value: embeddedUpdate.platform.toLowerCase() }, + { label: 'Runtime version', value: embeddedUpdate.runtimeVersion }, + { label: 'Channel', value: embeddedUpdate.channel }, + { label: 'Created at', value: new Date(embeddedUpdate.createdAt).toLocaleString() }, + ]); +} diff --git a/packages/eas-cli/src/graphql/queries/EmbeddedUpdateQuery.ts b/packages/eas-cli/src/graphql/queries/EmbeddedUpdateQuery.ts new file mode 100644 index 0000000000..f130ee9827 --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/EmbeddedUpdateQuery.ts @@ -0,0 +1,148 @@ +import { CombinedError } from '@urql/core'; +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { AppPlatform, EmbeddedUpdate } from '../generated'; +import { Connection } from '../../utils/relay'; +import { withErrorHandlingAsync } from '../client'; + +export function isEmbeddedUpdateNotFoundError(error: unknown): boolean { + return ( + error instanceof CombinedError && + error.graphQLErrors.some(e => e.extensions?.['errorCode'] === 'EMBEDDED_UPDATE_NOT_FOUND') + ); +} + +// Query result types are defined manually because the embeddedUpdates query fields +// are not yet included in the GraphQL codegen schema. +export type EmbeddedUpdateFragment = Pick< + EmbeddedUpdate, + 'id' | 'platform' | 'runtimeVersion' | 'channel' | 'createdAt' +>; + +type ViewEmbeddedUpdateByIdQueryResult = { + embeddedUpdates: { + byId: EmbeddedUpdateFragment; + }; +}; + +type ViewEmbeddedUpdateByIdQueryVariables = { + embeddedUpdateId: string; + appId: string; +}; + +type ViewEmbeddedUpdatesPaginatedQueryResult = { + app: { + byId: { + embeddedUpdatesPaginated: { + edges: { cursor: string; node: EmbeddedUpdateFragment }[]; + pageInfo: { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string | null; + endCursor: string | null; + }; + }; + }; + }; +}; + +type ViewEmbeddedUpdatesPaginatedQueryVariables = { + appId: string; + first: number; + after?: string; + filter?: EmbeddedUpdateFilter; +}; + +export type EmbeddedUpdateFilter = { + platform?: AppPlatform; + runtimeVersion?: string; + channel?: string; +}; + +export const EmbeddedUpdateQuery = { + async viewByIdAsync( + graphqlClient: ExpoGraphqlClient, + { embeddedUpdateId, appId }: { embeddedUpdateId: string; appId: string } + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query ViewEmbeddedUpdateById($embeddedUpdateId: ID!, $appId: ID!) { + embeddedUpdates { + byId(embeddedUpdateId: $embeddedUpdateId, appId: $appId) { + id + platform + runtimeVersion + channel + createdAt + } + } + } + `, + { embeddedUpdateId, appId }, + { additionalTypenames: ['EmbeddedUpdate'] } + ) + .toPromise() + ); + return data.embeddedUpdates.byId; + }, + + async viewPaginatedAsync( + graphqlClient: ExpoGraphqlClient, + { + appId, + filter, + first, + after, + }: { + appId: string; + filter?: EmbeddedUpdateFilter; + first: number; + after?: string; + } + ): Promise> { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query ViewEmbeddedUpdatesPaginated( + $appId: String! + $first: Int! + $after: String + $filter: EmbeddedUpdateFilterInput + ) { + app { + byId(appId: $appId) { + id + embeddedUpdatesPaginated(first: $first, after: $after, filter: $filter) { + edges { + cursor + node { + id + platform + runtimeVersion + channel + createdAt + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } + } + } + `, + { appId, first, after, filter }, + { additionalTypenames: ['EmbeddedUpdate'] } + ) + .toPromise() + ); + return data.app.byId.embeddedUpdatesPaginated; + }, +};