Skip to content

Commit 6a8ec9b

Browse files
committed
[eas-cli] Add eas update:embedded:list command
1 parent 0fc7eca commit 6a8ec9b

4 files changed

Lines changed: 476 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This is the log of notable changes to EAS CLI and related packages.
99
### 🎉 New features
1010

1111
- [eas-cli] Add `eas update:embedded:view` command. ([#3810](https://github.com/expo/eas-cli/pull/3810) by [@gwdp](https://github.com/gwdp))
12+
- [eas-cli] Add `eas update:embedded:list` command. ([#3811](https://github.com/expo/eas-cli/pull/3811) by [@gwdp](https://github.com/gwdp))
1213
- [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))
1314

1415
### 🐛 Bug fixes
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
2+
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
3+
import { AppPlatform } from '../../../../graphql/generated';
4+
import { ChannelQuery } from '../../../../graphql/queries/ChannelQuery';
5+
import {
6+
EmbeddedUpdateFragment,
7+
EmbeddedUpdateQuery,
8+
} from '../../../../graphql/queries/EmbeddedUpdateQuery';
9+
import Log from '../../../../log';
10+
import { selectAsync } from '../../../../prompts';
11+
import * as json from '../../../../utils/json';
12+
import UpdateEmbeddedList from '../list';
13+
14+
jest.mock('../../../../graphql/queries/EmbeddedUpdateQuery', () => ({
15+
EmbeddedUpdateQuery: { viewPaginatedAsync: jest.fn() },
16+
}));
17+
jest.mock('../../../../graphql/queries/ChannelQuery', () => ({
18+
ChannelQuery: { viewUpdateChannelsOnAppAsync: jest.fn() },
19+
}));
20+
jest.mock('../../../../prompts');
21+
jest.mock('../../../../log');
22+
jest.mock('../../../../utils/json');
23+
24+
const mockPaginated = jest.mocked(EmbeddedUpdateQuery.viewPaginatedAsync);
25+
const mockViewChannels = jest.mocked(ChannelQuery.viewUpdateChannelsOnAppAsync);
26+
const mockSelectAsync = jest.mocked(selectAsync);
27+
const mockLogLog = jest.mocked(Log.log);
28+
const mockEnableJsonOutput = jest.mocked(json.enableJsonOutput);
29+
const mockPrintJson = jest.mocked(json.printJsonOnlyOutput);
30+
31+
const MOCK_CONTEXT = {
32+
projectId: 'project-123',
33+
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
34+
};
35+
36+
const ROW_A: EmbeddedUpdateFragment = {
37+
id: 'aaaaaaaa-1111-4000-8000-000000000001',
38+
platform: AppPlatform.Ios,
39+
runtimeVersion: '1.0.0',
40+
channel: 'production',
41+
createdAt: '2026-05-29T00:00:00Z',
42+
launchAsset: { fileSize: 1024, finalFileSize: 768, fileSHA256: 'abc123' },
43+
};
44+
const ROW_B: EmbeddedUpdateFragment = {
45+
id: 'bbbbbbbb-2222-4000-8000-000000000002',
46+
platform: AppPlatform.Android,
47+
runtimeVersion: '1.0.1',
48+
channel: 'preview',
49+
createdAt: '2026-05-30T00:00:00Z',
50+
launchAsset: { fileSize: 2048, finalFileSize: null, fileSHA256: 'def456' },
51+
};
52+
53+
function emptyConnection(): {
54+
edges: { cursor: string; node: EmbeddedUpdateFragment }[];
55+
pageInfo: {
56+
hasNextPage: boolean;
57+
hasPreviousPage: boolean;
58+
startCursor: string | null;
59+
endCursor: string | null;
60+
};
61+
} {
62+
return {
63+
edges: [],
64+
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: null, endCursor: null },
65+
};
66+
}
67+
68+
describe(UpdateEmbeddedList, () => {
69+
const mockConfig = getMockOclifConfig();
70+
71+
beforeEach(() => {
72+
jest.clearAllMocks();
73+
// Default: no channels on the project (skips the prompt).
74+
mockViewChannels.mockResolvedValue([]);
75+
});
76+
77+
function createCommand(argv: string[]): UpdateEmbeddedList {
78+
const command = new UpdateEmbeddedList(argv, mockConfig);
79+
// @ts-expect-error getContextAsync is protected
80+
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
81+
return command;
82+
}
83+
84+
it('prints each row when results exist', async () => {
85+
mockPaginated.mockResolvedValue({
86+
edges: [
87+
{ cursor: 'c1', node: ROW_A },
88+
{ cursor: 'c2', node: ROW_B },
89+
],
90+
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c2' },
91+
});
92+
await createCommand(['--non-interactive']).run();
93+
94+
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
95+
appId: MOCK_CONTEXT.projectId,
96+
filter: undefined,
97+
first: 25,
98+
after: undefined,
99+
});
100+
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes(ROW_A.id))).toBe(true);
101+
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes(ROW_B.id))).toBe(true);
102+
});
103+
104+
it('prints empty message when no results', async () => {
105+
mockPaginated.mockResolvedValue(emptyConnection());
106+
await createCommand(['--non-interactive']).run();
107+
expect(mockLogLog).toHaveBeenCalledWith('No embedded updates found.');
108+
});
109+
110+
it('--json prints connection payload and skips formatted output', async () => {
111+
mockPaginated.mockResolvedValue({
112+
edges: [{ cursor: 'c1', node: ROW_A }],
113+
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
114+
});
115+
await createCommand(['--json', '--non-interactive']).run();
116+
117+
expect(mockEnableJsonOutput).toHaveBeenCalled();
118+
expect(mockPrintJson).toHaveBeenCalledWith({
119+
embeddedUpdates: [ROW_A],
120+
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
121+
});
122+
expect(mockLogLog.mock.calls.every(c => !String(c[0]).includes(ROW_A.id))).toBe(true);
123+
});
124+
125+
it('passes filter when flags supplied', async () => {
126+
mockPaginated.mockResolvedValue(emptyConnection());
127+
await createCommand([
128+
'--non-interactive',
129+
'--platform',
130+
'ios',
131+
'--runtime-version',
132+
'1.2.0',
133+
'--channel',
134+
'preview',
135+
]).run();
136+
137+
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
138+
appId: MOCK_CONTEXT.projectId,
139+
filter: { platform: AppPlatform.Ios, runtimeVersion: '1.2.0', channel: 'preview' },
140+
first: 25,
141+
after: undefined,
142+
});
143+
});
144+
145+
it('shows next-page hint when hasNextPage', async () => {
146+
mockPaginated.mockResolvedValue({
147+
edges: [{ cursor: 'c1', node: ROW_A }],
148+
pageInfo: { hasNextPage: true, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
149+
});
150+
await createCommand(['--non-interactive']).run();
151+
152+
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes('--after-cursor c1'))).toBe(true);
153+
});
154+
155+
it('prompts for channel in interactive mode and applies the selected channel', async () => {
156+
mockViewChannels.mockResolvedValue([
157+
{ id: 'ch1', name: 'production' } as any,
158+
{ id: 'ch2', name: 'preview' } as any,
159+
]);
160+
mockSelectAsync.mockResolvedValue('preview');
161+
mockPaginated.mockResolvedValue(emptyConnection());
162+
163+
await createCommand([]).run();
164+
165+
expect(mockSelectAsync).toHaveBeenCalledTimes(1);
166+
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
167+
appId: MOCK_CONTEXT.projectId,
168+
filter: { platform: undefined, runtimeVersion: undefined, channel: 'preview' },
169+
first: 25,
170+
after: undefined,
171+
});
172+
});
173+
174+
it('skips channel filter when "All channels" is selected', async () => {
175+
mockViewChannels.mockResolvedValue([{ id: 'ch1', name: 'production' } as any]);
176+
mockSelectAsync.mockResolvedValue(undefined);
177+
mockPaginated.mockResolvedValue(emptyConnection());
178+
179+
await createCommand([]).run();
180+
181+
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
182+
appId: MOCK_CONTEXT.projectId,
183+
filter: undefined,
184+
first: 25,
185+
after: undefined,
186+
});
187+
});
188+
189+
it('skips the channel prompt when --channel is supplied', async () => {
190+
mockPaginated.mockResolvedValue(emptyConnection());
191+
await createCommand(['--channel', 'preview']).run();
192+
expect(mockSelectAsync).not.toHaveBeenCalled();
193+
expect(mockViewChannels).not.toHaveBeenCalled();
194+
});
195+
196+
it('treats --channel all as no channel filter', async () => {
197+
mockPaginated.mockResolvedValue(emptyConnection());
198+
await createCommand(['--channel', 'all']).run();
199+
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
200+
appId: MOCK_CONTEXT.projectId,
201+
filter: undefined,
202+
first: 25,
203+
after: undefined,
204+
});
205+
});
206+
207+
it('skips the channel prompt when the project has no channels', async () => {
208+
mockViewChannels.mockResolvedValue([]);
209+
mockPaginated.mockResolvedValue(emptyConnection());
210+
await createCommand([]).run();
211+
expect(mockSelectAsync).not.toHaveBeenCalled();
212+
});
213+
});
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { Platform } from '@expo/eas-build-job';
2+
import { Flags } from '@oclif/core';
3+
import chalk from 'chalk';
4+
5+
import EasCommand from '../../../commandUtils/EasCommand';
6+
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
7+
import {
8+
EasNonInteractiveAndJsonFlags,
9+
resolveNonInteractiveAndJsonFlags,
10+
} from '../../../commandUtils/flags';
11+
import { getLimitFlagWithCustomValues } from '../../../commandUtils/pagination';
12+
import { AppPlatform } from '../../../graphql/generated';
13+
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
14+
import {
15+
EmbeddedUpdateFragment,
16+
EmbeddedUpdateQuery,
17+
} from '../../../graphql/queries/EmbeddedUpdateQuery';
18+
import { toAppPlatform } from '../../../graphql/types/AppPlatform';
19+
import Log from '../../../log';
20+
import { selectAsync } from '../../../prompts';
21+
import { fromNow } from '../../../utils/date';
22+
import formatFields from '../../../utils/formatFields';
23+
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
24+
25+
const DEFAULT_LIMIT = 25;
26+
const MAX_LIMIT = 50;
27+
const CHANNELS_LIMIT = 50;
28+
29+
export default class UpdateEmbeddedList extends EasCommand {
30+
static override description = 'list embedded updates registered with EAS Update for this project';
31+
32+
static override flags = {
33+
platform: Flags.option({
34+
char: 'p',
35+
description: 'Filter by platform',
36+
options: [Platform.IOS, Platform.ANDROID] as const,
37+
})(),
38+
'runtime-version': Flags.string({
39+
description: 'Filter by runtime version',
40+
}),
41+
channel: Flags.string({
42+
description: 'Filter by channel name (pass "all" to skip the channel prompt)',
43+
}),
44+
limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_LIMIT, limit: MAX_LIMIT }),
45+
'after-cursor': Flags.string({
46+
description: 'Return items after this cursor (for pagination)',
47+
}),
48+
...EasNonInteractiveAndJsonFlags,
49+
};
50+
51+
static override contextDefinition = {
52+
...this.ContextOptions.ProjectId,
53+
...this.ContextOptions.LoggedIn,
54+
};
55+
56+
async runAsync(): Promise<void> {
57+
const { flags } = await this.parse(UpdateEmbeddedList);
58+
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
59+
60+
const {
61+
projectId,
62+
loggedIn: { graphqlClient },
63+
} = await this.getContextAsync(UpdateEmbeddedList, { nonInteractive });
64+
65+
if (jsonFlag) {
66+
enableJsonOutput();
67+
}
68+
69+
const platform: AppPlatform | undefined = flags.platform
70+
? toAppPlatform(flags.platform)
71+
: undefined;
72+
73+
// Resolve channel filter:
74+
// - `--channel <name>`: use it
75+
// - `--channel all` (or no flag in non-interactive / json): no channel filter
76+
// - no flag in interactive: prompt with the project's channels + "All channels"
77+
let channel: string | undefined;
78+
if (flags.channel) {
79+
channel = flags.channel.toLowerCase() === 'all' ? undefined : flags.channel;
80+
} else if (!nonInteractive && !jsonFlag) {
81+
channel = await promptForChannelAsync(graphqlClient, projectId);
82+
}
83+
84+
const filter =
85+
platform || flags['runtime-version'] || channel
86+
? {
87+
platform,
88+
runtimeVersion: flags['runtime-version'],
89+
channel,
90+
}
91+
: undefined;
92+
93+
const limit = flags.limit ?? DEFAULT_LIMIT;
94+
const connection = await EmbeddedUpdateQuery.viewPaginatedAsync(graphqlClient, {
95+
appId: projectId,
96+
filter,
97+
first: limit,
98+
after: flags['after-cursor'],
99+
});
100+
101+
const embeddedUpdates = connection.edges.map(e => e.node);
102+
103+
if (jsonFlag) {
104+
printJsonOnlyOutput({
105+
embeddedUpdates,
106+
pageInfo: connection.pageInfo,
107+
});
108+
return;
109+
}
110+
111+
if (embeddedUpdates.length === 0) {
112+
Log.log('No embedded updates found.');
113+
return;
114+
}
115+
116+
Log.addNewLineIfNone();
117+
Log.log(
118+
chalk.bold(
119+
`Embedded updates (${embeddedUpdates.length}${connection.pageInfo.hasNextPage ? '+' : ''}):`
120+
)
121+
);
122+
Log.newLine();
123+
Log.log(embeddedUpdates.map(formatEmbeddedUpdateRow).join(`\n\n${chalk.dim('———')}\n\n`));
124+
125+
if (connection.pageInfo.hasNextPage && connection.pageInfo.endCursor) {
126+
Log.newLine();
127+
Log.log(
128+
chalk.dim(
129+
`Showing ${embeddedUpdates.length}. For the next page, run with --after-cursor ${connection.pageInfo.endCursor}`
130+
)
131+
);
132+
}
133+
}
134+
}
135+
136+
async function promptForChannelAsync(
137+
graphqlClient: ExpoGraphqlClient,
138+
projectId: string
139+
): Promise<string | undefined> {
140+
const channels = await ChannelQuery.viewUpdateChannelsOnAppAsync(graphqlClient, {
141+
appId: projectId,
142+
offset: 0,
143+
limit: CHANNELS_LIMIT,
144+
});
145+
146+
if (channels.length === 0) {
147+
// Nothing to choose from — fall back to listing everything.
148+
return undefined;
149+
}
150+
151+
return await selectAsync<string | undefined>(
152+
'Filter embedded updates by which channel?',
153+
[
154+
...channels.map(c => ({ title: c.name, value: c.name as string | undefined })),
155+
{ title: 'All channels', value: undefined },
156+
]
157+
);
158+
}
159+
160+
function formatEmbeddedUpdateRow(embeddedUpdate: EmbeddedUpdateFragment): string {
161+
const createdAt = new Date(embeddedUpdate.createdAt);
162+
return formatFields([
163+
{ label: 'ID', value: embeddedUpdate.id },
164+
{ label: 'Platform', value: embeddedUpdate.platform.toLowerCase() },
165+
{ label: 'Runtime version', value: embeddedUpdate.runtimeVersion },
166+
{ label: 'Channel', value: embeddedUpdate.channel },
167+
{ label: 'Created', value: `${fromNow(createdAt)} ago` },
168+
]);
169+
}

0 commit comments

Comments
 (0)