Skip to content

Commit 0fc7eca

Browse files
committed
[eas-cli] Add eas update:embedded:view command
1 parent 543261b commit 0fc7eca

4 files changed

Lines changed: 274 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:view` command. ([#3810](https://github.com/expo/eas-cli/pull/3810) by [@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: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { CombinedError } from '@urql/core';
2+
3+
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
4+
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
5+
import { AppPlatform } from '../../../../graphql/generated';
6+
import {
7+
EmbeddedUpdateFragment,
8+
EmbeddedUpdateQuery,
9+
isEmbeddedUpdateNotFoundError,
10+
} from '../../../../graphql/queries/EmbeddedUpdateQuery';
11+
import Log from '../../../../log';
12+
import * as json from '../../../../utils/json';
13+
import UpdateEmbeddedView from '../view';
14+
15+
jest.mock('../../../../graphql/queries/EmbeddedUpdateQuery', () => ({
16+
EmbeddedUpdateQuery: { viewByIdAsync: jest.fn() },
17+
isEmbeddedUpdateNotFoundError: jest.fn(),
18+
}));
19+
jest.mock('../../../../log');
20+
jest.mock('../../../../utils/json');
21+
22+
const mockView = jest.mocked(EmbeddedUpdateQuery.viewByIdAsync);
23+
const mockIsNotFound = jest.mocked(isEmbeddedUpdateNotFoundError);
24+
const mockLogLog = jest.mocked(Log.log);
25+
const mockEnableJsonOutput = jest.mocked(json.enableJsonOutput);
26+
const mockPrintJson = jest.mocked(json.printJsonOnlyOutput);
27+
28+
const VALID_UUID = 'a1b2c3d4-1234-4000-8000-000000000000';
29+
30+
const MOCK_CONTEXT = {
31+
projectId: 'project-123',
32+
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
33+
};
34+
35+
const MOCK_EMBEDDED_UPDATE: EmbeddedUpdateFragment = {
36+
id: VALID_UUID,
37+
platform: AppPlatform.Ios,
38+
runtimeVersion: '1.0.0',
39+
channel: 'production',
40+
createdAt: '2026-05-29T00:00:00Z',
41+
launchAsset: {
42+
fileSize: 1024,
43+
finalFileSize: 768,
44+
fileSHA256: 'abc123',
45+
},
46+
};
47+
48+
describe(UpdateEmbeddedView, () => {
49+
const mockConfig = getMockOclifConfig();
50+
51+
beforeEach(() => {
52+
jest.clearAllMocks();
53+
mockView.mockResolvedValue(MOCK_EMBEDDED_UPDATE);
54+
mockIsNotFound.mockReturnValue(false);
55+
});
56+
57+
function createCommand(argv: string[]): UpdateEmbeddedView {
58+
const command = new UpdateEmbeddedView(argv, mockConfig);
59+
// @ts-expect-error getContextAsync is protected
60+
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
61+
return command;
62+
}
63+
64+
it('prints formatted details on success', async () => {
65+
await createCommand([VALID_UUID]).run();
66+
67+
expect(mockView).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
68+
embeddedUpdateId: VALID_UUID,
69+
appId: MOCK_CONTEXT.projectId,
70+
});
71+
// Header line + body line
72+
expect(mockLogLog).toHaveBeenCalledTimes(2);
73+
expect(mockLogLog.mock.calls[0][0]).toContain('Embedded update');
74+
expect(mockLogLog.mock.calls[1][0]).toContain(VALID_UUID);
75+
expect(mockLogLog.mock.calls[1][0]).toContain('Bundle size');
76+
expect(mockLogLog.mock.calls[1][0]).toContain('Bundle SHA-256');
77+
});
78+
79+
it('--json prints the raw embedded update and skips formatted output', async () => {
80+
await createCommand([VALID_UUID, '--json']).run();
81+
82+
expect(mockEnableJsonOutput).toHaveBeenCalled();
83+
expect(mockPrintJson).toHaveBeenCalledWith(MOCK_EMBEDDED_UPDATE);
84+
expect(mockLogLog).not.toHaveBeenCalled();
85+
});
86+
87+
it('exits with a friendly message when the server returns NOT_FOUND', async () => {
88+
const notFound = new CombinedError({ graphQLErrors: [] });
89+
mockView.mockRejectedValue(notFound);
90+
mockIsNotFound.mockReturnValue(true);
91+
92+
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow();
93+
expect(mockIsNotFound).toHaveBeenCalledWith(notFound);
94+
});
95+
96+
it('rethrows unexpected errors', async () => {
97+
const boom = new Error('boom');
98+
mockView.mockRejectedValue(boom);
99+
mockIsNotFound.mockReturnValue(false);
100+
101+
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow('boom');
102+
});
103+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { Args, Errors } from '@oclif/core';
2+
import chalk from 'chalk';
3+
4+
import EasCommand from '../../../commandUtils/EasCommand';
5+
import { EasJsonOnlyFlag } from '../../../commandUtils/flags';
6+
import {
7+
EmbeddedUpdateFragment,
8+
EmbeddedUpdateQuery,
9+
isEmbeddedUpdateNotFoundError,
10+
} from '../../../graphql/queries/EmbeddedUpdateQuery';
11+
import Log from '../../../log';
12+
import { fromNow } from '../../../utils/date';
13+
import { formatBytes } from '../../../utils/files';
14+
import formatFields from '../../../utils/formatFields';
15+
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
16+
17+
export default class UpdateEmbeddedView extends EasCommand {
18+
static override description = 'view details of an embedded update registered with EAS Update';
19+
20+
static override args = {
21+
id: Args.string({
22+
required: true,
23+
description: 'The ID of the embedded update (manifest UUID from app.manifest).',
24+
}),
25+
};
26+
27+
static override flags = {
28+
...EasJsonOnlyFlag,
29+
};
30+
31+
static override contextDefinition = {
32+
...this.ContextOptions.ProjectId,
33+
...this.ContextOptions.LoggedIn,
34+
};
35+
36+
async runAsync(): Promise<void> {
37+
const {
38+
args: { id: embeddedUpdateId },
39+
flags: { json: jsonFlag },
40+
} = await this.parse(UpdateEmbeddedView);
41+
42+
const {
43+
projectId,
44+
loggedIn: { graphqlClient },
45+
} = await this.getContextAsync(UpdateEmbeddedView, { nonInteractive: true });
46+
47+
if (jsonFlag) {
48+
enableJsonOutput();
49+
}
50+
51+
let embeddedUpdate;
52+
try {
53+
embeddedUpdate = await EmbeddedUpdateQuery.viewByIdAsync(graphqlClient, {
54+
embeddedUpdateId,
55+
appId: projectId,
56+
});
57+
} catch (e: unknown) {
58+
if (isEmbeddedUpdateNotFoundError(e)) {
59+
Errors.error(
60+
`No embedded update found with id "${embeddedUpdateId}" for this project. ` +
61+
`Run "eas update:embedded:list" to see the embedded updates registered for this app.`,
62+
{ exit: 1 }
63+
);
64+
}
65+
throw e;
66+
}
67+
68+
if (jsonFlag) {
69+
printJsonOnlyOutput(embeddedUpdate);
70+
return;
71+
}
72+
73+
Log.addNewLineIfNone();
74+
Log.log(chalk.bold('Embedded update:'));
75+
Log.log(formatEmbeddedUpdate(embeddedUpdate));
76+
}
77+
}
78+
79+
export function formatEmbeddedUpdate(embeddedUpdate: EmbeddedUpdateFragment): string {
80+
const createdAt = new Date(embeddedUpdate.createdAt);
81+
const bundleSize = embeddedUpdate.launchAsset.finalFileSize ?? embeddedUpdate.launchAsset.fileSize;
82+
return formatFields([
83+
{ label: 'ID', value: embeddedUpdate.id },
84+
{ label: 'Platform', value: embeddedUpdate.platform.toLowerCase() },
85+
{ label: 'Runtime version', value: embeddedUpdate.runtimeVersion },
86+
{ label: 'Channel', value: embeddedUpdate.channel },
87+
{ label: 'Bundle size', value: formatBytes(bundleSize) },
88+
{ label: 'Bundle SHA-256', value: embeddedUpdate.launchAsset.fileSHA256 },
89+
{
90+
label: 'Created at',
91+
value: `${createdAt.toLocaleString()} (${fromNow(createdAt)} ago)`,
92+
},
93+
]);
94+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { CombinedError } from '@urql/core';
2+
import gql from 'graphql-tag';
3+
4+
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
5+
import { AppPlatform } from '../generated';
6+
import { withErrorHandlingAsync } from '../client';
7+
8+
export function isEmbeddedUpdateNotFoundError(error: unknown): boolean {
9+
if (!(error instanceof CombinedError)) {
10+
return false;
11+
}
12+
return error.graphQLErrors.some(e => {
13+
const code = e.extensions?.['errorCode'];
14+
return code === 'EMBEDDED_UPDATE_NOT_FOUND' || code === 'NOT_FOUND_ERROR';
15+
});
16+
}
17+
18+
// TODO: replace with generated types once expo/universe#27769 lands and codegen runs.
19+
export type EmbeddedUpdateFragment = {
20+
id: string;
21+
platform: AppPlatform;
22+
runtimeVersion: string;
23+
channel: string;
24+
createdAt: string;
25+
launchAsset: {
26+
fileSize: number;
27+
finalFileSize: number | null;
28+
fileSHA256: string;
29+
};
30+
};
31+
32+
type ViewEmbeddedUpdateByIdQueryResult = {
33+
embeddedUpdates: { byId: EmbeddedUpdateFragment };
34+
};
35+
36+
type ViewEmbeddedUpdateByIdQueryVariables = {
37+
embeddedUpdateId: string;
38+
appId: string;
39+
};
40+
41+
export const EmbeddedUpdateQuery = {
42+
async viewByIdAsync(
43+
graphqlClient: ExpoGraphqlClient,
44+
{ embeddedUpdateId, appId }: { embeddedUpdateId: string; appId: string }
45+
): Promise<EmbeddedUpdateFragment> {
46+
const data = await withErrorHandlingAsync(
47+
graphqlClient
48+
.query<ViewEmbeddedUpdateByIdQueryResult, ViewEmbeddedUpdateByIdQueryVariables>(
49+
/* eslint-disable graphql/template-strings */
50+
gql`
51+
query ViewEmbeddedUpdateById($embeddedUpdateId: ID!, $appId: ID!) {
52+
embeddedUpdates {
53+
byId(embeddedUpdateId: $embeddedUpdateId, appId: $appId) {
54+
id
55+
platform
56+
runtimeVersion
57+
channel
58+
createdAt
59+
launchAsset {
60+
fileSize
61+
finalFileSize
62+
fileSHA256
63+
}
64+
}
65+
}
66+
}
67+
`,
68+
/* eslint-enable graphql/template-strings */
69+
{ embeddedUpdateId, appId },
70+
{ additionalTypenames: ['EmbeddedUpdate'] }
71+
)
72+
.toPromise()
73+
);
74+
return data.embeddedUpdates.byId;
75+
},
76+
};

0 commit comments

Comments
 (0)