From 45cc93fcfdf930a6d245e7f6df4a28b264d30bbe Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Fri, 29 May 2026 16:24:27 -0700 Subject: [PATCH] [eas-cli] Add eas update:embedded:list command --- CHANGELOG.md | 1 + .../update/embedded/__tests__/list.test.ts | 145 ++++++++++++++++++ .../src/commands/update/embedded/list.ts | 120 +++++++++++++++ .../graphql/queries/EmbeddedUpdateQuery.ts | 145 ++++++++++++++++++ 4 files changed, 411 insertions(+) create mode 100644 packages/eas-cli/src/commands/update/embedded/__tests__/list.test.ts create mode 100644 packages/eas-cli/src/commands/update/embedded/list.ts create mode 100644 packages/eas-cli/src/graphql/queries/EmbeddedUpdateQuery.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 45ecc342f2..555ec1da2e 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:list` 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__/list.test.ts b/packages/eas-cli/src/commands/update/embedded/__tests__/list.test.ts new file mode 100644 index 0000000000..684816f635 --- /dev/null +++ b/packages/eas-cli/src/commands/update/embedded/__tests__/list.test.ts @@ -0,0 +1,145 @@ +import { getMockOclifConfig } from '../../../../__tests__/commands/utils'; +import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { AppPlatform } from '../../../../graphql/generated'; +import { + EmbeddedUpdateFragment, + EmbeddedUpdateQuery, +} from '../../../../graphql/queries/EmbeddedUpdateQuery'; +import Log from '../../../../log'; +import * as json from '../../../../utils/json'; +import UpdateEmbeddedList from '../list'; + +jest.mock('../../../../graphql/queries/EmbeddedUpdateQuery', () => ({ + EmbeddedUpdateQuery: { viewPaginatedAsync: jest.fn() }, +})); +jest.mock('../../../../log'); +jest.mock('../../../../utils/json'); + +const mockPaginated = jest.mocked(EmbeddedUpdateQuery.viewPaginatedAsync); +const mockLogLog = jest.mocked(Log.log); +const mockEnableJsonOutput = jest.mocked(json.enableJsonOutput); +const mockPrintJson = jest.mocked(json.printJsonOnlyOutput); + +const MOCK_CONTEXT = { + projectId: 'project-123', + loggedIn: { graphqlClient: {} as ExpoGraphqlClient }, +}; + +const ROW_A: EmbeddedUpdateFragment = { + id: 'aaaaaaaa-1111-4000-8000-000000000001', + platform: AppPlatform.Ios, + runtimeVersion: '1.0.0', + channel: 'production', + createdAt: '2026-05-29T00:00:00Z', +}; +const ROW_B: EmbeddedUpdateFragment = { + id: 'bbbbbbbb-2222-4000-8000-000000000002', + platform: AppPlatform.Android, + runtimeVersion: '1.0.1', + channel: 'preview', + createdAt: '2026-05-30T00:00:00Z', +}; + +function emptyConnection(): { + edges: { cursor: string; node: EmbeddedUpdateFragment }[]; + pageInfo: { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string | null; + endCursor: string | null; + }; +} { + return { + edges: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: null, endCursor: null }, + }; +} + +describe(UpdateEmbeddedList, () => { + const mockConfig = getMockOclifConfig(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + function createCommand(argv: string[]): UpdateEmbeddedList { + const command = new UpdateEmbeddedList(argv, mockConfig); + // @ts-expect-error getContextAsync is protected + jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT); + return command; + } + + it('prints each row when results exist', async () => { + mockPaginated.mockResolvedValue({ + edges: [ + { cursor: 'c1', node: ROW_A }, + { cursor: 'c2', node: ROW_B }, + ], + pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c2' }, + }); + await createCommand([]).run(); + + expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, { + appId: MOCK_CONTEXT.projectId, + filter: undefined, + first: 25, + after: undefined, + }); + // Two rows printed (formatted multi-line, one Log.log per row) + expect(mockLogLog.mock.calls.some(c => String(c[0]).includes(ROW_A.id))).toBe(true); + expect(mockLogLog.mock.calls.some(c => String(c[0]).includes(ROW_B.id))).toBe(true); + }); + + it('prints empty message when no results', async () => { + mockPaginated.mockResolvedValue(emptyConnection()); + await createCommand([]).run(); + expect(mockLogLog).toHaveBeenCalledWith('No embedded updates found.'); + }); + + it('--json prints connection payload and skips formatted output', async () => { + mockPaginated.mockResolvedValue({ + edges: [{ cursor: 'c1', node: ROW_A }], + pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' }, + }); + await createCommand(['--json', '--non-interactive']).run(); + + expect(mockEnableJsonOutput).toHaveBeenCalled(); + expect(mockPrintJson).toHaveBeenCalledWith({ + embeddedUpdates: [ROW_A], + pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' }, + }); + // No formatted Log.log of the row + expect(mockLogLog.mock.calls.every(c => !String(c[0]).includes(ROW_A.id))).toBe(true); + }); + + it('passes filter when flags supplied', async () => { + mockPaginated.mockResolvedValue(emptyConnection()); + await createCommand([ + '--platform', + 'ios', + '--runtime-version', + '1.2.0', + '--channel', + 'preview', + ]).run(); + + expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, { + appId: MOCK_CONTEXT.projectId, + filter: { platform: AppPlatform.Ios, runtimeVersion: '1.2.0', channel: 'preview' }, + first: 25, + after: undefined, + }); + }); + + it('shows next-page hint when hasNextPage', async () => { + mockPaginated.mockResolvedValue({ + edges: [{ cursor: 'c1', node: ROW_A }], + pageInfo: { hasNextPage: true, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' }, + }); + await createCommand([]).run(); + + expect( + mockLogLog.mock.calls.some(c => String(c[0]).includes('--after-cursor c1')) + ).toBe(true); + }); +}); diff --git a/packages/eas-cli/src/commands/update/embedded/list.ts b/packages/eas-cli/src/commands/update/embedded/list.ts new file mode 100644 index 0000000000..948b9c6b70 --- /dev/null +++ b/packages/eas-cli/src/commands/update/embedded/list.ts @@ -0,0 +1,120 @@ +import { Platform } from '@expo/eas-build-job'; +import { Flags } from '@oclif/core'; + +import EasCommand from '../../../commandUtils/EasCommand'; +import { + EasNonInteractiveAndJsonFlags, + resolveNonInteractiveAndJsonFlags, +} from '../../../commandUtils/flags'; +import { getLimitFlagWithCustomValues } from '../../../commandUtils/pagination'; +import { AppPlatform } from '../../../graphql/generated'; +import { + EmbeddedUpdateFragment, + EmbeddedUpdateQuery, +} from '../../../graphql/queries/EmbeddedUpdateQuery'; +import { toAppPlatform } from '../../../graphql/types/AppPlatform'; +import Log from '../../../log'; +import formatFields from '../../../utils/formatFields'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; + +const DEFAULT_LIMIT = 25; +const MAX_LIMIT = 50; + +export default class UpdateEmbeddedList extends EasCommand { + static override description = 'list embedded updates registered with EAS Update for this project'; + + static override flags = { + platform: Flags.option({ + char: 'p', + description: 'Filter by platform', + options: [Platform.IOS, Platform.ANDROID] as const, + })(), + 'runtime-version': Flags.string({ + description: 'Filter by runtime version', + }), + channel: Flags.string({ + description: 'Filter by channel name', + }), + limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_LIMIT, limit: MAX_LIMIT }), + 'after-cursor': Flags.string({ + description: 'Return items after this cursor (for pagination)', + }), + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectId, + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(UpdateEmbeddedList); + const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); + + const { + projectId, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(UpdateEmbeddedList, { nonInteractive }); + + if (jsonFlag) { + enableJsonOutput(); + } + + const platform: AppPlatform | undefined = flags.platform + ? toAppPlatform(flags.platform as Platform) + : undefined; + const filter = + (platform ?? flags['runtime-version'] ?? flags.channel) + ? { + platform, + runtimeVersion: flags['runtime-version'], + channel: flags.channel, + } + : undefined; + + const limit = flags.limit ?? DEFAULT_LIMIT; + const connection = await EmbeddedUpdateQuery.viewPaginatedAsync(graphqlClient, { + appId: projectId, + filter, + first: limit, + after: flags['after-cursor'], + }); + + const embeddedUpdates = connection.edges.map(e => e.node); + + if (jsonFlag) { + printJsonOnlyOutput({ + embeddedUpdates, + pageInfo: connection.pageInfo, + }); + return; + } + + if (embeddedUpdates.length === 0) { + Log.log('No embedded updates found.'); + return; + } + + for (const embeddedUpdate of embeddedUpdates) { + Log.log(formatEmbeddedUpdateRow(embeddedUpdate)); + Log.addNewLineIfNone(); + } + + if (connection.pageInfo.hasNextPage && connection.pageInfo.endCursor) { + Log.log( + `Showing ${embeddedUpdates.length} result${embeddedUpdates.length === 1 ? '' : 's'}. ` + + `For the next page, run with --after-cursor ${connection.pageInfo.endCursor}` + ); + } + } +} + +function formatEmbeddedUpdateRow(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..22ec27aacf --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/EmbeddedUpdateQuery.ts @@ -0,0 +1,145 @@ +import { CombinedError } from '@urql/core'; +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { Connection } from '../../utils/relay'; +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 + #27770 land and codegen runs. +export type EmbeddedUpdateFragment = { + id: string; + platform: AppPlatform; + runtimeVersion: string; + channel: string; + createdAt: string; +}; + +export type EmbeddedUpdateFilter = { + platform?: AppPlatform; + runtimeVersion?: string; + channel?: string; +}; + +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 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; + }, + + async viewPaginatedAsync( + graphqlClient: ExpoGraphqlClient, + { + appId, + filter, + first, + after, + }: { appId: string; filter?: EmbeddedUpdateFilter; first: number; after?: string } + ): Promise> { + const data = await withErrorHandlingAsync( + graphqlClient + .query< + ViewEmbeddedUpdatesPaginatedQueryResult, + ViewEmbeddedUpdatesPaginatedQueryVariables + >( + /* eslint-disable graphql/template-strings */ + 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 + } + } + } + } + } + `, + /* eslint-enable graphql/template-strings */ + { appId, first, after, filter }, + { additionalTypenames: ['EmbeddedUpdate'] } + ) + .toPromise() + ); + return data.app.byId.embeddedUpdatesPaginated; + }, +};