Skip to content

Commit c8c67e8

Browse files
committed
[eas-cli] Add eas update:embedded:view command
1 parent 2c1837f commit c8c67e8

4 files changed

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

0 commit comments

Comments
 (0)