Compare commits

...
10 changed files with 799 additions and 0 deletions

No files matched your search

+4
View File
@@ -8,6 +8,10 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [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))
- [eas-cli] Add `eas update:embedded:view` command. ([#3721](https://github.com/expo/eas-cli/pull/3721) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:list` command. ([#3722](https://github.com/expo/eas-cli/pull/3722) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
### 🧹 Chores
@@ -6,6 +6,7 @@ import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { Datadog } from '../../datadog';
import { restoreCredentials } from '../../android/credentials';
import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded';
import androidBuilder from '../android';
import { runBuilderWithHooksAsync } from '../common';
import {
@@ -57,6 +58,9 @@ jest.mock('../../utils/expoUpdates', () => ({
configureExpoUpdatesIfInstalledAsync: jest.fn(),
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
}));
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
uploadEmbeddedBundleAsync: jest.fn(),
}));
jest.mock('../../utils/hooks', () => ({
Hook: {
POST_INSTALL: 'POST_INSTALL',
@@ -269,4 +273,37 @@ describe(androidBuilder, () => {
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
});
it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
});
it('skips the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is not set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
});
@@ -32,6 +32,7 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
@@ -208,6 +209,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
});
}
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support saving cache.');
+7
View File
@@ -25,6 +25,7 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
@@ -209,6 +210,12 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
});
}
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support saving cache.');
@@ -0,0 +1,273 @@
import { Platform } from '@expo/eas-build-job';
import { BuildContext } from '../../context';
import * as expoUpdates from '../expoUpdates';
import { uploadEmbeddedBundleAsync } from '../expoUpdatesEmbedded';
import * as easCli from '../easCli';
import * as artifacts from '../artifacts';
jest.mock('../expoUpdates');
jest.mock('../easCli');
jest.mock('../artifacts');
const mockZipEntries = jest.fn();
const mockZipExtract = jest.fn();
const mockZipClose = jest.fn();
jest.mock('node-stream-zip', () => ({
__esModule: true,
default: {
async: jest.fn(() => ({
entries: mockZipEntries,
extract: mockZipExtract,
close: mockZipClose,
})),
},
}));
function zipEntryMap(entries: Record<string, true>): Record<string, { name: string }> {
return Object.fromEntries(Object.keys(entries).map(name => [name, { name }]));
}
function makeCtx(overrides: {
platform: Platform;
simulator?: boolean;
channel?: string;
env?: Record<string, string>;
}): BuildContext<any> {
const job =
overrides.platform === Platform.IOS
? {
platform: Platform.IOS,
simulator: overrides.simulator ?? false,
updates: overrides.channel ? { channel: overrides.channel } : undefined,
}
: {
platform: Platform.ANDROID,
updates: overrides.channel ? { channel: overrides.channel } : undefined,
};
return {
job,
env: overrides.env ?? {},
appConfig: Promise.resolve({
updates: { url: 'https://u.expo.dev/project-id' },
}),
logger: {
info: jest.fn(),
warn: jest.fn(),
},
markBuildPhaseSkipped: jest.fn(),
markBuildPhaseHasWarnings: jest.fn(),
getReactNativeProjectDirectory: () => '/project',
} as any;
}
describe('uploadEmbeddedBundleAsync', () => {
beforeEach(() => {
jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(true);
jest.mocked(easCli.runEasCliCommand).mockResolvedValue({} as any);
jest.mocked(artifacts.findArtifacts).mockResolvedValue([]);
mockZipEntries.mockResolvedValue({});
mockZipExtract.mockResolvedValue(undefined);
mockZipClose.mockResolvedValue(undefined);
});
afterEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
it('skips when EAS Update is not configured', async () => {
jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(false);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('warns when no channel is configured and does not look for the archive', async () => {
const ctx = makeCtx({ platform: Platform.ANDROID });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: no channel configured for this build profile.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('throws for an unsupported platform', async () => {
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
(ctx.job as { platform: string }).platform = 'web';
await expect(uploadEmbeddedBundleAsync(ctx)).rejects.toThrow(
'Uploading embedded updates is not supported for the web platform.'
);
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('uploads from Android APK archives', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/index.android.bundle': true,
'assets/app.manifest': true,
})
);
const ctx = makeCtx({
platform: Platform.ANDROID,
channel: 'production',
env: { EAS_BUILD_ID: 'build-123' },
});
await uploadEmbeddedBundleAsync(ctx);
expect(mockZipExtract).toHaveBeenCalledWith(
'assets/index.android.bundle',
expect.stringContaining('index.android.bundle')
);
expect(easCli.runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining([
'update:embedded:upload',
'--platform',
Platform.ANDROID,
'--channel',
'production',
'--build-id',
'build-123',
]),
})
);
});
it('uploads from Android AAB archives', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.aab']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'base/assets/index.android.bundle': true,
'base/assets/app.manifest': true,
})
);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(mockZipExtract).toHaveBeenCalledWith(
'base/assets/index.android.bundle',
expect.stringContaining('index.android.bundle')
);
expect(easCli.runEasCliCommand).toHaveBeenCalled();
});
it('uploads from iOS IPA archives', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/App.ipa']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'Payload/App.app/main.jsbundle': true,
'Payload/App.app/EXUpdates.bundle/app.manifest': true,
})
);
const ctx = makeCtx({ platform: Platform.IOS, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(easCli.runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(['--platform', Platform.IOS]),
})
);
});
it('skips simulator builds', async () => {
const ctx = makeCtx({ platform: Platform.IOS, simulator: true, channel: 'preview' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('warns when bundle or manifest is missing from the archive', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/app.manifest': true,
})
);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: bundle or manifest not found in archive.'
);
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
});
it('warns when build archive is not found', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue([]);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: build archive not found.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
});
it('treats findArtifacts errors as no archive found', async () => {
jest.mocked(artifacts.findArtifacts).mockRejectedValue(new Error('glob failed'));
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: build archive not found.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
});
it('warns and continues when CLI upload throws', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/index.android.bundle': true,
'assets/app.manifest': true,
})
);
jest.mocked(easCli.runEasCliCommand).mockRejectedValue(new Error('upload failed'));
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to upload embedded bundle.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
});
it('swallows zip.close() failures so they do not mask the upload result', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/index.android.bundle': true,
'assets/app.manifest': true,
})
);
mockZipClose.mockRejectedValue(new Error('close failed'));
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await expect(uploadEmbeddedBundleAsync(ctx)).resolves.toBeUndefined();
expect(easCli.runEasCliCommand).toHaveBeenCalled();
expect(mockZipClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,118 @@
import { Android, BuildJob, Ios, Platform } from '@expo/eas-build-job';
import { PipeMode } from '@expo/logger';
import { asyncResult } from '@expo/results';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import StreamZip from 'node-stream-zip';
import { findArtifacts } from './artifacts';
import { runEasCliCommand } from './easCli';
import { resolveArtifactPath } from '../ios/resolve';
import { BuildContext } from '../context';
import { isEASUpdateConfigured } from './expoUpdates';
export async function uploadEmbeddedBundleAsync(ctx: BuildContext<BuildJob>): Promise<void> {
if (!(await isEASUpdateConfigured(ctx))) {
ctx.markBuildPhaseSkipped();
return;
}
const { platform } = ctx.job;
if (platform === Platform.IOS && (ctx.job as Ios.Job).simulator) {
ctx.markBuildPhaseSkipped();
return;
}
const channel = ctx.job.updates?.channel;
if (!channel) {
ctx.logger.warn(
'Skipping embedded bundle upload: no channel configured for this build profile.'
);
ctx.markBuildPhaseHasWarnings();
return;
}
const projectDir = ctx.getReactNativeProjectDirectory();
let archivePattern: string;
if (platform === Platform.IOS) {
archivePattern = resolveArtifactPath(ctx as BuildContext<Ios.Job>);
} else if (platform === Platform.ANDROID) {
archivePattern =
(ctx as BuildContext<Android.Job>).job.applicationArchivePath ??
'android/app/build/outputs/**/*.{apk,aab}';
} else {
throw new Error(`Uploading embedded updates is not supported for the ${platform} platform.`);
}
const [archivePath] = await findArtifacts({
rootDir: projectDir,
patternOrPath: archivePattern,
logger: null,
}).catch(() => [] as string[]);
if (!archivePath) {
ctx.logger.warn('Skipping embedded bundle upload: build archive not found.');
ctx.markBuildPhaseHasWarnings();
return;
}
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-embedded-bundle-'));
const bundleName = platform === Platform.IOS ? 'main.jsbundle' : 'index.android.bundle';
const bundlePath = path.join(tmpDir, bundleName);
const manifestPath = path.join(tmpDir, 'app.manifest');
const zip = new StreamZip.async({ file: archivePath });
try {
const entries = Object.values(await zip.entries());
const bundleEntry = entries.find(e =>
platform === Platform.IOS
? e.name.endsWith('/main.jsbundle')
: e.name.endsWith('assets/index.android.bundle')
);
const manifestEntry = entries.find(e =>
platform === Platform.IOS
? e.name.includes('EXUpdates.bundle/app.manifest')
: e.name.endsWith('assets/app.manifest')
);
if (!bundleEntry || !manifestEntry) {
ctx.logger.warn('Skipping embedded bundle upload: bundle or manifest not found in archive.');
ctx.markBuildPhaseHasWarnings();
return;
}
await zip.extract(bundleEntry.name, bundlePath);
await zip.extract(manifestEntry.name, manifestPath);
const args = [
'update:embedded:upload',
'--platform',
platform,
'--bundle',
bundlePath,
'--manifest',
manifestPath,
'--channel',
channel,
'--non-interactive',
];
if (ctx.env.EAS_BUILD_ID) {
args.push('--build-id', ctx.env.EAS_BUILD_ID);
}
await runEasCliCommand({
args,
options: {
cwd: projectDir,
env: ctx.env,
logger: ctx.logger,
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
},
});
} catch (err: any) {
ctx.logger.warn({ err }, 'Failed to upload embedded bundle.');
ctx.markBuildPhaseHasWarnings();
} finally {
await asyncResult(zip.close());
}
}
+3
View File
@@ -27,6 +27,7 @@ export enum BuildPhase {
*/
UPLOAD_ARTIFACTS = 'UPLOAD_ARTIFACTS',
UPLOAD_APPLICATION_ARCHIVE = 'UPLOAD_APPLICATION_ARCHIVE',
UPLOAD_EMBEDDED_BUNDLE = 'UPLOAD_EMBEDDED_BUNDLE',
UPLOAD_BUILD_ARTIFACTS = 'UPLOAD_BUILD_ARTIFACTS',
PREPARE_ARTIFACTS = 'PREPARE_ARTIFACTS',
CLEAN_UP_CREDENTIALS = 'CLEAN_UP_CREDENTIALS',
@@ -91,6 +92,7 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
[BuildPhase.CACHE_STATS]: 'Cache stats',
[BuildPhase.UPLOAD_ARTIFACTS]: 'Upload artifacts',
[BuildPhase.UPLOAD_APPLICATION_ARCHIVE]: 'Upload application archive',
[BuildPhase.UPLOAD_EMBEDDED_BUNDLE]: 'Upload embedded bundle',
[BuildPhase.UPLOAD_BUILD_ARTIFACTS]: 'Upload build artifacts',
[BuildPhase.PREPARE_ARTIFACTS]: 'Prepare artifacts',
[BuildPhase.CLEAN_UP_CREDENTIALS]: 'Clean up credentials',
@@ -157,6 +159,7 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
[BuildPhase.CACHE_STATS]: 'cache-stats',
[BuildPhase.UPLOAD_ARTIFACTS]: 'upload-artifacts',
[BuildPhase.UPLOAD_APPLICATION_ARCHIVE]: 'upload-application-archive',
[BuildPhase.UPLOAD_EMBEDDED_BUNDLE]: 'upload-embedded-bundle',
[BuildPhase.UPLOAD_BUILD_ARTIFACTS]: 'upload-build-artifacts',
[BuildPhase.PREPARE_ARTIFACTS]: 'prepare-artifacts',
[BuildPhase.CLEAN_UP_CREDENTIALS]: 'clean-up-credentials',
@@ -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<void> {
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() },
]);
}
@@ -0,0 +1,82 @@
import { Args, Errors } from '@oclif/core';
import EasCommand from '../../../commandUtils/EasCommand';
import { EasJsonOnlyFlag } from '../../../commandUtils/flags';
import {
EmbeddedUpdateFragment,
EmbeddedUpdateQuery,
isEmbeddedUpdateNotFoundError,
} from '../../../graphql/queries/EmbeddedUpdateQuery';
import Log from '../../../log';
import formatFields from '../../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
export default class UpdateEmbeddedView extends EasCommand {
static override description = 'view details of an embedded update registered with EAS Update';
static override args = {
id: Args.string({
required: true,
description: 'The ID of the embedded update (manifest UUID from app.manifest).',
}),
};
static override flags = {
...EasJsonOnlyFlag,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const {
args: { id: embeddedUpdateId },
flags: { json: jsonFlag },
} = await this.parse(UpdateEmbeddedView);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(UpdateEmbeddedView, { nonInteractive: true });
if (jsonFlag) {
enableJsonOutput();
}
let embeddedUpdate;
try {
embeddedUpdate = await EmbeddedUpdateQuery.viewByIdAsync(graphqlClient, {
embeddedUpdateId,
appId: projectId,
});
} catch (e: unknown) {
if (isEmbeddedUpdateNotFoundError(e)) {
Errors.error(
`No embedded update found with id "${embeddedUpdateId}" for this project. ` +
`Verify the id is correct and belongs to this app.`,
{ exit: 1 }
);
}
throw e;
}
if (jsonFlag) {
printJsonOnlyOutput(embeddedUpdate);
return;
}
Log.log(formatEmbeddedUpdate(embeddedUpdate));
}
}
export function formatEmbeddedUpdate(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() },
]);
}
@@ -0,0 +1,148 @@
import { CombinedError } from '@urql/core';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform, EmbeddedUpdate } from '../generated';
import { Connection } from '../../utils/relay';
import { withErrorHandlingAsync } from '../client';
export function isEmbeddedUpdateNotFoundError(error: unknown): boolean {
return (
error instanceof CombinedError &&
error.graphQLErrors.some(e => e.extensions?.['errorCode'] === 'EMBEDDED_UPDATE_NOT_FOUND')
);
}
// Query result types are defined manually because the embeddedUpdates query fields
// are not yet included in the GraphQL codegen schema.
export type EmbeddedUpdateFragment = Pick<
EmbeddedUpdate,
'id' | 'platform' | 'runtimeVersion' | 'channel' | 'createdAt'
>;
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 type EmbeddedUpdateFilter = {
platform?: AppPlatform;
runtimeVersion?: string;
channel?: string;
};
export const EmbeddedUpdateQuery = {
async viewByIdAsync(
graphqlClient: ExpoGraphqlClient,
{ embeddedUpdateId, appId }: { embeddedUpdateId: string; appId: string }
): Promise<EmbeddedUpdateFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<ViewEmbeddedUpdateByIdQueryResult, ViewEmbeddedUpdateByIdQueryVariables>(
gql`
query ViewEmbeddedUpdateById($embeddedUpdateId: ID!, $appId: ID!) {
embeddedUpdates {
byId(embeddedUpdateId: $embeddedUpdateId, appId: $appId) {
id
platform
runtimeVersion
channel
createdAt
}
}
}
`,
{ 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<Connection<EmbeddedUpdateFragment>> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<ViewEmbeddedUpdatesPaginatedQueryResult, ViewEmbeddedUpdatesPaginatedQueryVariables>(
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
}
}
}
}
}
`,
{ appId, first, after, filter },
{ additionalTypenames: ['EmbeddedUpdate'] }
)
.toPromise()
);
return data.app.byId.embeddedUpdatesPaginated;
},
};