diff --git a/CHANGELOG.md b/CHANGELOG.md index 45ecc342f2..610dc3138c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features +- [eas-cli] Add `eas update:embedded:view` command. ([@gwdp](https://github.com/gwdp)) - [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)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/src/commands/update/embedded/__tests__/view.test.ts b/packages/eas-cli/src/commands/update/embedded/__tests__/view.test.ts new file mode 100644 index 0000000000..5735748c74 --- /dev/null +++ b/packages/eas-cli/src/commands/update/embedded/__tests__/view.test.ts @@ -0,0 +1,94 @@ +import { CombinedError } from '@urql/core'; + +import { getMockOclifConfig } from '../../../../__tests__/commands/utils'; +import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { AppPlatform } from '../../../../graphql/generated'; +import { + EmbeddedUpdateFragment, + EmbeddedUpdateQuery, + isEmbeddedUpdateNotFoundError, +} from '../../../../graphql/queries/EmbeddedUpdateQuery'; +import Log from '../../../../log'; +import * as json from '../../../../utils/json'; +import UpdateEmbeddedView from '../view'; + +jest.mock('../../../../graphql/queries/EmbeddedUpdateQuery', () => ({ + EmbeddedUpdateQuery: { viewByIdAsync: jest.fn() }, + isEmbeddedUpdateNotFoundError: jest.fn(), +})); +jest.mock('../../../../log'); +jest.mock('../../../../utils/json'); + +const mockView = jest.mocked(EmbeddedUpdateQuery.viewByIdAsync); +const mockIsNotFound = jest.mocked(isEmbeddedUpdateNotFoundError); +const mockLogLog = jest.mocked(Log.log); +const mockEnableJsonOutput = jest.mocked(json.enableJsonOutput); +const mockPrintJson = jest.mocked(json.printJsonOnlyOutput); + +const VALID_UUID = 'a1b2c3d4-1234-4000-8000-000000000000'; + +const MOCK_CONTEXT = { + projectId: 'project-123', + loggedIn: { graphqlClient: {} as ExpoGraphqlClient }, +}; + +const MOCK_EMBEDDED_UPDATE: EmbeddedUpdateFragment = { + id: VALID_UUID, + platform: AppPlatform.Ios, + runtimeVersion: '1.0.0', + channel: 'production', + createdAt: '2026-05-29T00:00:00Z', +}; + +describe(UpdateEmbeddedView, () => { + const mockConfig = getMockOclifConfig(); + + beforeEach(() => { + jest.clearAllMocks(); + mockView.mockResolvedValue(MOCK_EMBEDDED_UPDATE); + mockIsNotFound.mockReturnValue(false); + }); + + function createCommand(argv: string[]): UpdateEmbeddedView { + const command = new UpdateEmbeddedView(argv, mockConfig); + // @ts-expect-error getContextAsync is protected + jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT); + return command; + } + + it('prints formatted details on success', async () => { + await createCommand([VALID_UUID]).run(); + + expect(mockView).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, { + embeddedUpdateId: VALID_UUID, + appId: MOCK_CONTEXT.projectId, + }); + expect(mockLogLog).toHaveBeenCalledTimes(1); + expect(mockLogLog.mock.calls[0][0]).toContain(VALID_UUID); + }); + + it('--json prints the raw embedded update and skips formatted output', async () => { + await createCommand([VALID_UUID, '--json']).run(); + + expect(mockEnableJsonOutput).toHaveBeenCalled(); + expect(mockPrintJson).toHaveBeenCalledWith(MOCK_EMBEDDED_UPDATE); + expect(mockLogLog).not.toHaveBeenCalled(); + }); + + it('exits with a friendly message when the server returns NOT_FOUND', async () => { + const notFound = new CombinedError({ graphQLErrors: [] }); + mockView.mockRejectedValue(notFound); + mockIsNotFound.mockReturnValue(true); + + await expect(createCommand([VALID_UUID]).run()).rejects.toThrow(); + expect(mockIsNotFound).toHaveBeenCalledWith(notFound); + }); + + it('rethrows unexpected errors', async () => { + const boom = new Error('boom'); + mockView.mockRejectedValue(boom); + mockIsNotFound.mockReturnValue(false); + + await expect(createCommand([VALID_UUID]).run()).rejects.toThrow('boom'); + }); +}); 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..1131a82979 --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/EmbeddedUpdateQuery.ts @@ -0,0 +1,63 @@ +import { CombinedError } from '@urql/core'; +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { AppPlatform } from '../generated'; +import { withErrorHandlingAsync } from '../client'; + +export function isEmbeddedUpdateNotFoundError(error: unknown): boolean { + return ( + error instanceof CombinedError && + error.graphQLErrors.some(e => e.extensions?.['errorCode'] === 'NOT_FOUND_ERROR') + ); +} + +// TODO: replace with generated types once expo/universe#27769 lands and codegen runs. +export type EmbeddedUpdateFragment = { + id: string; + platform: AppPlatform; + runtimeVersion: string; + channel: string; + createdAt: string; +}; + +type ViewEmbeddedUpdateByIdQueryResult = { + embeddedUpdates: { byId: EmbeddedUpdateFragment }; +}; + +type ViewEmbeddedUpdateByIdQueryVariables = { + embeddedUpdateId: string; + appId: string; +}; + +export const EmbeddedUpdateQuery = { + async viewByIdAsync( + graphqlClient: ExpoGraphqlClient, + { embeddedUpdateId, appId }: { embeddedUpdateId: string; appId: string } + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + /* eslint-disable graphql/template-strings */ + gql` + query ViewEmbeddedUpdateById($embeddedUpdateId: ID!, $appId: ID!) { + embeddedUpdates { + byId(embeddedUpdateId: $embeddedUpdateId, appId: $appId) { + id + platform + runtimeVersion + channel + createdAt + } + } + } + `, + /* eslint-enable graphql/template-strings */ + { embeddedUpdateId, appId }, + { additionalTypenames: ['EmbeddedUpdate'] } + ) + .toPromise() + ); + return data.embeddedUpdates.byId; + }, +};