Skip to content

Commit 45cc93f

Browse files
committed
[eas-cli] Add eas update:embedded:list command
1 parent 2c1837f commit 45cc93f

4 files changed

Lines changed: 411 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ This is the log of notable changes to EAS CLI and related packages.
88

99
### 🎉 New features
1010

11+
- [eas-cli] Add `eas update:embedded:list` command. ([@gwdp](https://github.com/gwdp))
1112
- [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))
1213

1314
### 🐛 Bug fixes
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
2+
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
3+
import { AppPlatform } from '../../../../graphql/generated';
4+
import {
5+
EmbeddedUpdateFragment,
6+
EmbeddedUpdateQuery,
7+
} from '../../../../graphql/queries/EmbeddedUpdateQuery';
8+
import Log from '../../../../log';
9+
import * as json from '../../../../utils/json';
10+
import UpdateEmbeddedList from '../list';
11+
12+
jest.mock('../../../../graphql/queries/EmbeddedUpdateQuery', () => ({
13+
EmbeddedUpdateQuery: { viewPaginatedAsync: jest.fn() },
14+
}));
15+
jest.mock('../../../../log');
16+
jest.mock('../../../../utils/json');
17+
18+
const mockPaginated = jest.mocked(EmbeddedUpdateQuery.viewPaginatedAsync);
19+
const mockLogLog = jest.mocked(Log.log);
20+
const mockEnableJsonOutput = jest.mocked(json.enableJsonOutput);
21+
const mockPrintJson = jest.mocked(json.printJsonOnlyOutput);
22+
23+
const MOCK_CONTEXT = {
24+
projectId: 'project-123',
25+
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
26+
};
27+
28+
const ROW_A: EmbeddedUpdateFragment = {
29+
id: 'aaaaaaaa-1111-4000-8000-000000000001',
30+
platform: AppPlatform.Ios,
31+
runtimeVersion: '1.0.0',
32+
channel: 'production',
33+
createdAt: '2026-05-29T00:00:00Z',
34+
};
35+
const ROW_B: EmbeddedUpdateFragment = {
36+
id: 'bbbbbbbb-2222-4000-8000-000000000002',
37+
platform: AppPlatform.Android,
38+
runtimeVersion: '1.0.1',
39+
channel: 'preview',
40+
createdAt: '2026-05-30T00:00:00Z',
41+
};
42+
43+
function emptyConnection(): {
44+
edges: { cursor: string; node: EmbeddedUpdateFragment }[];
45+
pageInfo: {
46+
hasNextPage: boolean;
47+
hasPreviousPage: boolean;
48+
startCursor: string | null;
49+
endCursor: string | null;
50+
};
51+
} {
52+
return {
53+
edges: [],
54+
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: null, endCursor: null },
55+
};
56+
}
57+
58+
describe(UpdateEmbeddedList, () => {
59+
const mockConfig = getMockOclifConfig();
60+
61+
beforeEach(() => {
62+
jest.clearAllMocks();
63+
});
64+
65+
function createCommand(argv: string[]): UpdateEmbeddedList {
66+
const command = new UpdateEmbeddedList(argv, mockConfig);
67+
// @ts-expect-error getContextAsync is protected
68+
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
69+
return command;
70+
}
71+
72+
it('prints each row when results exist', async () => {
73+
mockPaginated.mockResolvedValue({
74+
edges: [
75+
{ cursor: 'c1', node: ROW_A },
76+
{ cursor: 'c2', node: ROW_B },
77+
],
78+
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c2' },
79+
});
80+
await createCommand([]).run();
81+
82+
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
83+
appId: MOCK_CONTEXT.projectId,
84+
filter: undefined,
85+
first: 25,
86+
after: undefined,
87+
});
88+
// Two rows printed (formatted multi-line, one Log.log per row)
89+
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes(ROW_A.id))).toBe(true);
90+
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes(ROW_B.id))).toBe(true);
91+
});
92+
93+
it('prints empty message when no results', async () => {
94+
mockPaginated.mockResolvedValue(emptyConnection());
95+
await createCommand([]).run();
96+
expect(mockLogLog).toHaveBeenCalledWith('No embedded updates found.');
97+
});
98+
99+
it('--json prints connection payload and skips formatted output', async () => {
100+
mockPaginated.mockResolvedValue({
101+
edges: [{ cursor: 'c1', node: ROW_A }],
102+
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
103+
});
104+
await createCommand(['--json', '--non-interactive']).run();
105+
106+
expect(mockEnableJsonOutput).toHaveBeenCalled();
107+
expect(mockPrintJson).toHaveBeenCalledWith({
108+
embeddedUpdates: [ROW_A],
109+
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
110+
});
111+
// No formatted Log.log of the row
112+
expect(mockLogLog.mock.calls.every(c => !String(c[0]).includes(ROW_A.id))).toBe(true);
113+
});
114+
115+
it('passes filter when flags supplied', async () => {
116+
mockPaginated.mockResolvedValue(emptyConnection());
117+
await createCommand([
118+
'--platform',
119+
'ios',
120+
'--runtime-version',
121+
'1.2.0',
122+
'--channel',
123+
'preview',
124+
]).run();
125+
126+
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
127+
appId: MOCK_CONTEXT.projectId,
128+
filter: { platform: AppPlatform.Ios, runtimeVersion: '1.2.0', channel: 'preview' },
129+
first: 25,
130+
after: undefined,
131+
});
132+
});
133+
134+
it('shows next-page hint when hasNextPage', async () => {
135+
mockPaginated.mockResolvedValue({
136+
edges: [{ cursor: 'c1', node: ROW_A }],
137+
pageInfo: { hasNextPage: true, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
138+
});
139+
await createCommand([]).run();
140+
141+
expect(
142+
mockLogLog.mock.calls.some(c => String(c[0]).includes('--after-cursor c1'))
143+
).toBe(true);
144+
});
145+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { Platform } from '@expo/eas-build-job';
2+
import { Flags } from '@oclif/core';
3+
4+
import EasCommand from '../../../commandUtils/EasCommand';
5+
import {
6+
EasNonInteractiveAndJsonFlags,
7+
resolveNonInteractiveAndJsonFlags,
8+
} from '../../../commandUtils/flags';
9+
import { getLimitFlagWithCustomValues } from '../../../commandUtils/pagination';
10+
import { AppPlatform } from '../../../graphql/generated';
11+
import {
12+
EmbeddedUpdateFragment,
13+
EmbeddedUpdateQuery,
14+
} from '../../../graphql/queries/EmbeddedUpdateQuery';
15+
import { toAppPlatform } from '../../../graphql/types/AppPlatform';
16+
import Log from '../../../log';
17+
import formatFields from '../../../utils/formatFields';
18+
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
19+
20+
const DEFAULT_LIMIT = 25;
21+
const MAX_LIMIT = 50;
22+
23+
export default class UpdateEmbeddedList extends EasCommand {
24+
static override description = 'list embedded updates registered with EAS Update for this project';
25+
26+
static override flags = {
27+
platform: Flags.option({
28+
char: 'p',
29+
description: 'Filter by platform',
30+
options: [Platform.IOS, Platform.ANDROID] as const,
31+
})(),
32+
'runtime-version': Flags.string({
33+
description: 'Filter by runtime version',
34+
}),
35+
channel: Flags.string({
36+
description: 'Filter by channel name',
37+
}),
38+
limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_LIMIT, limit: MAX_LIMIT }),
39+
'after-cursor': Flags.string({
40+
description: 'Return items after this cursor (for pagination)',
41+
}),
42+
...EasNonInteractiveAndJsonFlags,
43+
};
44+
45+
static override contextDefinition = {
46+
...this.ContextOptions.ProjectId,
47+
...this.ContextOptions.LoggedIn,
48+
};
49+
50+
async runAsync(): Promise<void> {
51+
const { flags } = await this.parse(UpdateEmbeddedList);
52+
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
53+
54+
const {
55+
projectId,
56+
loggedIn: { graphqlClient },
57+
} = await this.getContextAsync(UpdateEmbeddedList, { nonInteractive });
58+
59+
if (jsonFlag) {
60+
enableJsonOutput();
61+
}
62+
63+
const platform: AppPlatform | undefined = flags.platform
64+
? toAppPlatform(flags.platform as Platform)
65+
: undefined;
66+
const filter =
67+
(platform ?? flags['runtime-version'] ?? flags.channel)
68+
? {
69+
platform,
70+
runtimeVersion: flags['runtime-version'],
71+
channel: flags.channel,
72+
}
73+
: undefined;
74+
75+
const limit = flags.limit ?? DEFAULT_LIMIT;
76+
const connection = await EmbeddedUpdateQuery.viewPaginatedAsync(graphqlClient, {
77+
appId: projectId,
78+
filter,
79+
first: limit,
80+
after: flags['after-cursor'],
81+
});
82+
83+
const embeddedUpdates = connection.edges.map(e => e.node);
84+
85+
if (jsonFlag) {
86+
printJsonOnlyOutput({
87+
embeddedUpdates,
88+
pageInfo: connection.pageInfo,
89+
});
90+
return;
91+
}
92+
93+
if (embeddedUpdates.length === 0) {
94+
Log.log('No embedded updates found.');
95+
return;
96+
}
97+
98+
for (const embeddedUpdate of embeddedUpdates) {
99+
Log.log(formatEmbeddedUpdateRow(embeddedUpdate));
100+
Log.addNewLineIfNone();
101+
}
102+
103+
if (connection.pageInfo.hasNextPage && connection.pageInfo.endCursor) {
104+
Log.log(
105+
`Showing ${embeddedUpdates.length} result${embeddedUpdates.length === 1 ? '' : 's'}. ` +
106+
`For the next page, run with --after-cursor ${connection.pageInfo.endCursor}`
107+
);
108+
}
109+
}
110+
}
111+
112+
function formatEmbeddedUpdateRow(embeddedUpdate: EmbeddedUpdateFragment): string {
113+
return formatFields([
114+
{ label: 'ID', value: embeddedUpdate.id },
115+
{ label: 'Platform', value: embeddedUpdate.platform.toLowerCase() },
116+
{ label: 'Runtime version', value: embeddedUpdate.runtimeVersion },
117+
{ label: 'Channel', value: embeddedUpdate.channel },
118+
{ label: 'Created at', value: new Date(embeddedUpdate.createdAt).toLocaleString() },
119+
]);
120+
}

0 commit comments

Comments
 (0)