Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]andbrentvatne 7817c58f68 [eas-cli] Resolve merge conflicts with main
Co-authored-by: brentvatne <[email protected]>
2026-08-21 02:37:31 +00:00
Douglas LowderandClaude Opus 4.8 3f8ffac11c [build-tools] Submit IPAs to the App Store Connect platform from the bundle (#4234)
eas submit uploaded every IPA to the iOS train because the build upload
hardcoded platform 'IOS', so tvOS binaries were validated against iOS rules
and rejected (ITMS-90508/90545/90713/90039), and their build numbers collided
with the iOS train.

Read DTPlatformName from the IPA in upload_to_asc, map it to the App Store
Connect platform (appletvos -> TV_OS, macosx -> MAC_OS, xros -> VISION_OS,
otherwise IOS), and pass it to createBuildUploadAsync and the TestFlight URL.
This also makes the duplicate-build-number precheck run against the correct
platform train. Falls back to IOS when the platform cannot be read.

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-20 15:04:47 -07:00
Expo CI 137bee4db0 update CHANGELOG.md and bump STAGING in cli-versions.json 2026-08-20 16:22:16 +00:00
Brent Vatne 7cb4981d50 Merge branch 'main' into @brent/account-profile-image 2026-07-24 13:03:48 -07:00
Brent Vatne e16d25f911 [eas-cli] Add CHANGELOG entry for #4073 2026-07-24 09:46:13 -07:00
Brent Vatne 49d49184f2 [eas-cli] Add account:avatar:set command 2026-07-24 09:45:41 -07:00
17 changed files with 529 additions and 15 deletions

No files matched your search

+13
View File
@@ -8,6 +8,18 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add `eas account:avatar:set` to upload an account avatar from the command line. ([#4073](https://github.com/expo/eas-cli/pull/4073) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Handle new server-side errors thrown when observe features are blocked. ([#4042](https://github.com/expo/eas-cli/pull/4042) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add `eas project:icon:set` to upload a project icon from the command line. ([#4068](https://github.com/expo/eas-cli/pull/4068) by [@brentvatne](https://github.com/brentvatne))
### 🐛 Bug fixes
### 🧹 Chores
## [22.2.0](https://github.com/expo/eas-cli/releases/tag/v22.2.0) - 2026-08-20
### 🎉 New features
- [eas-cli] Add `--device` flag to `eas simulator` for selecting the virtual device to start. ([#4172](https://github.com/expo/eas-cli/pull/4172) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Add support for Appium EAS Simulator sessions. ([#4190](https://github.com/expo/eas-cli/pull/4190) by [@sjchmiela](https://github.com/sjchmiela))
@@ -17,6 +29,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Send the expected `Content-MD5` checksum with GCS signed URL uploads so GCS rejects corrupted uploads. ([#4208](https://github.com/expo/eas-cli/pull/4208) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Resolve the `eas go` SDK version to its repack target, so `--sdk-version 57` no longer fails with "No Expo Go repack target is published for SDK 57". ([#4215](https://github.com/expo/eas-cli/pull/4215) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Handle missing fingerprint source in fingerprint:compare command. ([#4210](https://github.com/expo/eas-cli/pull/4210) by [@wschurman](https://github.com/wschurman))
- [build-tools] Upload tvOS (and other non-iOS) IPAs to the App Store Connect platform declared in the bundle's `DTPlatformName`, instead of always uploading to the iOS train. ([#4234](https://github.com/expo/eas-cli/pull/4234) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
+1 -1
View File
@@ -1,4 +1,4 @@
{
"STAGING": "22.0.0",
"STAGING": "22.2.0",
"PRODUCTION": "21.5.0"
}
@@ -26,6 +26,7 @@ describe(readIpaInfoAsync, () => {
bundleIdentifier: 'dev.expo.SmallestAppExample',
bundleShortVersion: '1.0',
bundleVersion: '1',
dtPlatformName: 'iphoneos',
});
});
@@ -16,6 +16,10 @@ export type IpaInfo = {
bundleIdentifier: string;
bundleShortVersion: string;
bundleVersion: string;
// `DTPlatformName` from the bundle's Info.plist (e.g. `iphoneos`, `appletvos`,
// `macosx`, `xros`), or null when absent. Used to pick the App Store Connect
// platform for the upload. See `AscApiUtils.ascPlatformFromDtPlatformName`.
dtPlatformName: string | null;
};
const INFO_PLIST_PATH_REGEXP = /^Payload\/[^/]+\.app\/Info\.plist$/;
@@ -91,10 +95,14 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
);
}
const dtPlatformName =
typeof infoPlist.DTPlatformName === 'string' ? infoPlist.DTPlatformName : null;
return {
bundleIdentifier,
bundleShortVersion,
bundleVersion,
dtPlatformName,
};
} catch (error) {
if (error instanceof UserError) {
@@ -115,16 +115,27 @@ export function createUploadToAscBuildFunction(): BuildFunction {
`Uploading Build to "${appResponse.data.attributes.name}" (${ascAppBundleIdentifier})...`
);
// Derive the App Store Connect platform from the IPA itself so tvOS (and
// other) binaries are uploaded to the correct version train instead of the
// iOS one. Falls back to `IOS` if the platform cannot be read.
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const platform = ipaInfoResult.ok
? AscApiUtils.ascPlatformFromDtPlatformName(ipaInfoResult.value.dtPlatformName)
: 'IOS';
stepsCtx.logger.info(`Detected App Store Connect platform: ${platform}`);
stepsCtx.logger.info('Creating Build Upload...');
const buildUploadResponse = await AscApiUtils.createBuildUploadAsync({
client,
appleAppIdentifier,
bundleShortVersion,
bundleVersion,
platform,
});
const buildUploadId = buildUploadResponse.data.id;
const buildUploadUrl = `https://appstoreconnect.apple.com/apps/${appleAppIdentifier}/testflight/ios/${buildUploadId}`;
const platformPathSegment = AscApiUtils.testFlightPlatformPathSegment(platform);
const buildUploadUrl = `https://appstoreconnect.apple.com/apps/${appleAppIdentifier}/testflight/${platformPathSegment}/${buildUploadId}`;
outputs.build_upload_id.set(buildUploadId);
outputs.build_upload_url.set(buildUploadUrl);
@@ -272,6 +272,10 @@ export type AscApiClientPostApi = {
};
};
// https://developer.apple.com/documentation/appstoreconnectapi/platform
export type AscPlatform =
AscApiClientPostApi['/v1/buildUploads']['request']['data']['attributes']['platform'];
export type AscApiClientPatchApi = {
[Path in keyof typeof PatchApi]: {
request: z.input<(typeof PatchApi)[Path]['request']>;
@@ -5,9 +5,44 @@ import {
AscApiClientGetApi,
AscApiClientPostApi,
AscApiRequestError,
AscPlatform,
} from './AscApiClient';
export namespace AscApiUtils {
/**
* Maps a bundle's `DTPlatformName` (from its Info.plist) to the App Store
* Connect platform used for a build upload. Unknown or missing values fall
* back to `IOS` to preserve the previous default.
*/
export function ascPlatformFromDtPlatformName(dtPlatformName: string | null): AscPlatform {
switch (dtPlatformName) {
case 'appletvos':
return 'TV_OS';
case 'macosx':
return 'MAC_OS';
case 'xros':
return 'VISION_OS';
case 'iphoneos':
default:
return 'IOS';
}
}
/** The App Store Connect TestFlight URL path segment for a platform. */
export function testFlightPlatformPathSegment(platform: AscPlatform): string {
switch (platform) {
case 'TV_OS':
return 'tvos';
case 'MAC_OS':
return 'macos';
case 'VISION_OS':
return 'visionos';
case 'IOS':
default:
return 'ios';
}
}
export async function getAppInfoAsync({
client,
appleAppIdentifier,
@@ -61,18 +96,20 @@ export namespace AscApiUtils {
appleAppIdentifier,
bundleShortVersion,
bundleVersion,
platform,
}: {
client: Pick<AscApiClient, 'postAsync'>;
appleAppIdentifier: string;
bundleShortVersion: string;
bundleVersion: string;
platform: AscPlatform;
}): Promise<AscApiClientPostApi['/v1/buildUploads']['response']> {
try {
return await client.postAsync('/v1/buildUploads', {
data: {
type: 'buildUploads',
attributes: {
platform: 'IOS',
platform,
cfBundleShortVersionString: bundleShortVersion,
cfBundleVersion: bundleVersion,
},
@@ -148,6 +148,7 @@ describe('AscApiUtils', () => {
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
platform: 'IOS',
})
).rejects.toEqual(
expect.objectContaining({
@@ -176,10 +177,40 @@ describe('AscApiUtils', () => {
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
platform: 'IOS',
})
).resolves.toEqual(response);
});
it('sends the requested platform in the build upload attributes', async () => {
const response = {
data: {
type: 'buildUploads',
id: 'fdf9c476-aaa4-4ead-b91c-6e3cc3a47805',
},
} as const;
const client = {
postAsync: jest.fn().mockResolvedValue(response),
};
await AscApiUtils.createBuildUploadAsync({
client,
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
platform: 'TV_OS',
});
expect(client.postAsync).toHaveBeenCalledWith(
'/v1/buildUploads',
expect.objectContaining({
data: expect.objectContaining({
attributes: expect.objectContaining({ platform: 'TV_OS' }),
}),
})
);
});
it('rethrows when error payload includes mixed error codes', async () => {
const payload = {
errors: [
@@ -208,8 +239,32 @@ describe('AscApiUtils', () => {
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
platform: 'IOS',
})
).rejects.toBe(mixedError);
});
});
describe('ascPlatformFromDtPlatformName', () => {
it('maps known DTPlatformName values to App Store Connect platforms', () => {
expect(AscApiUtils.ascPlatformFromDtPlatformName('iphoneos')).toBe('IOS');
expect(AscApiUtils.ascPlatformFromDtPlatformName('appletvos')).toBe('TV_OS');
expect(AscApiUtils.ascPlatformFromDtPlatformName('macosx')).toBe('MAC_OS');
expect(AscApiUtils.ascPlatformFromDtPlatformName('xros')).toBe('VISION_OS');
});
it('falls back to IOS for unknown or missing values', () => {
expect(AscApiUtils.ascPlatformFromDtPlatformName(null)).toBe('IOS');
expect(AscApiUtils.ascPlatformFromDtPlatformName('watchos')).toBe('IOS');
});
});
describe('testFlightPlatformPathSegment', () => {
it('returns the TestFlight URL segment for each platform', () => {
expect(AscApiUtils.testFlightPlatformPathSegment('IOS')).toBe('ios');
expect(AscApiUtils.testFlightPlatformPathSegment('TV_OS')).toBe('tvos');
expect(AscApiUtils.testFlightPlatformPathSegment('MAC_OS')).toBe('macos');
expect(AscApiUtils.testFlightPlatformPathSegment('VISION_OS')).toBe('visionos');
});
});
});
+24
View File
@@ -120,6 +120,7 @@ If you want to enforce the `eas-cli` version for your project, use the `"cli.ver
<!-- commands -->
* [`eas account:audit [ACCOUNT_NAME]`](#eas-accountaudit-account_name)
* [`eas account:avatar:set PATH [ACCOUNT_NAME]`](#eas-accountavatarset-path-account_name)
* [`eas account:login`](#eas-accountlogin)
* [`eas account:logout`](#eas-accountlogout)
* [`eas account:usage [ACCOUNT_NAME]`](#eas-accountusage-account_name)
@@ -290,6 +291,29 @@ DESCRIPTION
_See code: [packages/eas-cli/src/commands/account/audit.ts](https://github.com/expo/eas-cli/blob/v22.2.0/packages/eas-cli/src/commands/account/audit.ts)_
## `eas account:avatar:set PATH [ACCOUNT_NAME]`
set the avatar for an account (for a personal account, this is your user avatar)
```
USAGE
$ eas account:avatar:set PATH [ACCOUNT_NAME] [--non-interactive]
ARGUMENTS
PATH Path to the avatar image (PNG or JPEG, at most 10 MB). Non-square images are center-cropped to a
square.
[ACCOUNT_NAME] Name of the account to set the avatar for. Defaults to a prompt when you have access to multiple
accounts.
FLAGS
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
set the avatar for an account (for a personal account, this is your user avatar)
```
_See code: [packages/eas-cli/src/commands/account/avatar/set.ts](https://github.com/expo/eas-cli/blob/v21.2.0/packages/eas-cli/src/commands/account/avatar/set.ts)_
## `eas account:login`
log in with your Expo account
@@ -0,0 +1,154 @@
import { Config } from '@oclif/core';
import { vol } from 'memfs';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AccountUploadSessionType } from '../../../../graphql/generated';
import { AccountQuery } from '../../../../graphql/queries/AccountQuery';
import { selectAsync } from '../../../../prompts';
import { uploadAccountScopedFileAtPathToGCSAsync } from '../../../../uploads';
import { Actor } from '../../../../user/User';
import { sleepAsync } from '../../../../utils/promise';
import AccountAvatarSet from '../set';
jest.mock('fs');
jest.mock('../../../../graphql/queries/AccountQuery');
jest.mock('../../../../log');
jest.mock('../../../../ora', () => ({
ora: jest.fn(() => {
const spinner = {
fail: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
succeed: jest.fn(),
};
spinner.start.mockReturnValue(spinner);
return spinner;
}),
}));
jest.mock('../../../../prompts');
jest.mock('../../../../uploads');
jest.mock('../../../../utils/promise');
const mockByIdProfileImageUrlAsync = jest.mocked(AccountQuery.byIdProfileImageUrlAsync);
const mockSelectAsync = jest.mocked(selectAsync);
const mockUploadAsync = jest.mocked(uploadAccountScopedFileAtPathToGCSAsync);
const mockSleepAsync = jest.mocked(sleepAsync);
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
describe(AccountAvatarSet, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const personalAccount = { id: 'account-1', name: 'test-user' };
const orgAccount = { id: 'account-2', name: 'test-org' };
let now: number;
beforeEach(() => {
vol.reset();
jest.clearAllMocks();
now = 0;
jest.spyOn(Date, 'now').mockImplementation(() => now);
mockSleepAsync.mockImplementation(async ms => {
now += ms;
});
mockByIdProfileImageUrlAsync
.mockResolvedValueOnce('https://example.com/old.png')
.mockResolvedValueOnce('https://example.com/new.png');
});
afterEach(() => {
jest.restoreAllMocks();
});
function createCommand(
argv: string[],
accounts: { id: string; name: string }[]
): AccountAvatarSet {
const command = new AccountAvatarSet(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient, actor: { accounts } as Actor },
});
return command;
}
it('uploads the avatar for the account passed as an argument', async () => {
vol.fromJSON({ '/app/avatar.png': 'fake-png-bytes' });
const command = createCommand(['/app/avatar.png', 'test-org'], [personalAccount, orgAccount]);
await command.runAsync();
expect(mockUploadAsync).toHaveBeenCalledWith(graphqlClient, {
type: AccountUploadSessionType.ProfileImageUpload,
accountId: orgAccount.id,
path: '/app/avatar.png',
});
expect(mockSelectAsync).not.toHaveBeenCalled();
});
it('uses the only account without prompting', async () => {
vol.fromJSON({ '/app/avatar.png': 'fake-png-bytes' });
const command = createCommand(['/app/avatar.png'], [personalAccount]);
await command.runAsync();
expect(mockUploadAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ accountId: personalAccount.id })
);
expect(mockSelectAsync).not.toHaveBeenCalled();
});
it('prompts for an account when there are multiple', async () => {
vol.fromJSON({ '/app/avatar.png': 'fake-png-bytes' });
mockSelectAsync.mockResolvedValue(orgAccount);
const command = createCommand(['/app/avatar.png'], [personalAccount, orgAccount]);
await command.runAsync();
expect(mockSelectAsync).toHaveBeenCalled();
expect(mockUploadAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ accountId: orgAccount.id })
);
});
it('throws in non-interactive mode with multiple accounts and no account name', async () => {
vol.fromJSON({ '/app/avatar.png': 'fake-png-bytes' });
const command = createCommand(
['/app/avatar.png', '--non-interactive'],
[personalAccount, orgAccount]
);
await expect(command.runAsync()).rejects.toThrow(
'ACCOUNT_NAME argument must be provided when running in `--non-interactive` mode.'
);
expect(mockUploadAsync).not.toHaveBeenCalled();
});
it('throws for an account the user does not have access to', async () => {
vol.fromJSON({ '/app/avatar.png': 'fake-png-bytes' });
const command = createCommand(['/app/avatar.png', 'other-org'], [personalAccount, orgAccount]);
await expect(command.runAsync()).rejects.toThrow(
'Account "other-org" not found or you don\'t have access. Available accounts: test-user, test-org'
);
expect(mockUploadAsync).not.toHaveBeenCalled();
});
it('throws when the file does not exist', async () => {
const command = createCommand(['/app/missing.png', 'test-org'], [personalAccount, orgAccount]);
await expect(command.runAsync()).rejects.toThrow('No file found at /app/missing.png');
expect(mockUploadAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,114 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import { getExpoWebsiteBaseUrl } from '../../../api';
import EasCommand from '../../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../../commandUtils/flags';
import { AccountUploadSessionType } from '../../../graphql/generated';
import { AccountQuery } from '../../../graphql/queries/AccountQuery';
import Log, { link } from '../../../log';
import { ora } from '../../../ora';
import { selectAsync } from '../../../prompts';
import { uploadAccountScopedFileAtPathToGCSAsync } from '../../../uploads';
import {
pollForProfileImageChangeAsync,
validateProfileImageAsync,
} from '../../../utils/profileImages';
export default class AccountAvatarSet extends EasCommand {
static override description =
'set the avatar for an account (for a personal account, this is your user avatar)';
static override args = {
path: Args.string({
required: true,
description:
'Path to the avatar image (PNG or JPEG, at most 10 MB). Non-square images are center-cropped to a square.',
}),
account_name: Args.string({
required: false,
description:
'Name of the account to set the avatar for. Defaults to a prompt when you have access to multiple accounts.',
}),
};
static override flags = {
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const {
args: { path: imagePath, account_name: accountName },
flags,
} = await this.parse(AccountAvatarSet);
const nonInteractive = flags['non-interactive'];
const {
loggedIn: { graphqlClient, actor },
} = await this.getContextAsync(AccountAvatarSet, { nonInteractive });
await validateProfileImageAsync(imagePath);
let targetAccount: { id: string; name: string };
if (accountName) {
const found = actor.accounts.find(account => account.name === accountName);
if (found) {
targetAccount = found;
} else {
const availableAccounts = actor.accounts.map(account => account.name).join(', ');
throw new Error(
`Account "${accountName}" not found or you don't have access. Available accounts: ${availableAccounts}`
);
}
} else if (actor.accounts.length === 1) {
targetAccount = actor.accounts[0];
} else if (nonInteractive) {
throw new Error(
'ACCOUNT_NAME argument must be provided when running in `--non-interactive` mode.'
);
} else {
targetAccount = await selectAsync(
'Select account to set the avatar for:',
actor.accounts.map(account => ({
title: account.name,
value: account,
})),
{ initial: actor.accounts[0] }
);
}
const accountSettingsUrl = new URL(
`/accounts/${targetAccount.name}/settings`,
getExpoWebsiteBaseUrl()
).toString();
const previousProfileImageUrl = await AccountQuery.byIdProfileImageUrlAsync(
graphqlClient,
targetAccount.id
);
const spinner = ora('Uploading avatar').start();
try {
await uploadAccountScopedFileAtPathToGCSAsync(graphqlClient, {
type: AccountUploadSessionType.ProfileImageUpload,
accountId: targetAccount.id,
path: imagePath,
});
spinner.text = 'Processing avatar';
await pollForProfileImageChangeAsync({
fetchProfileImageUrlAsync: async () =>
await AccountQuery.byIdProfileImageUrlAsync(graphqlClient, targetAccount.id),
previousProfileImageUrl,
fallbackUrl: accountSettingsUrl,
});
spinner.succeed(`Set avatar for ${chalk.bold(targetAccount.name)}`);
Log.withTick(`View it in the account settings: ${link(accountSettingsUrl)}`);
} catch (error) {
spinner.fail('Failed to set avatar');
throw error;
}
}
}
@@ -137,7 +137,7 @@ describe(ProjectIconSet, () => {
const command = createCommand(['/app/icon.png']);
await expect(command.runAsync()).rejects.toThrow(
'Timed out waiting for the icon to be processed'
'Timed out waiting for the image to be processed'
);
expect(mockUploadAsync).toHaveBeenCalledTimes(1);
});
@@ -7,11 +7,11 @@ import { EASNonInteractiveFlag } from '../../../commandUtils/flags';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import Log, { link } from '../../../log';
import { ora } from '../../../ora';
import { uploadProjectIconAsync } from '../../../project/projectIcon';
import {
pollForProfileImageChangeAsync,
uploadProjectIconAsync,
validateIconAsync,
} from '../../../project/projectIcon';
validateProfileImageAsync,
} from '../../../utils/profileImages';
export default class ProjectIconSet extends EasCommand {
static override description = 'set the project icon displayed on the EAS dashboard';
@@ -45,7 +45,7 @@ export default class ProjectIconSet extends EasCommand {
nonInteractive: flags['non-interactive'],
});
await validateIconAsync(imagePath);
await validateProfileImageAsync(imagePath);
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
const projectDashboardUrl = getProjectDashboardUrl(app.ownerAccount.name, app.slug);
@@ -58,13 +58,12 @@ export default class ProjectIconSet extends EasCommand {
try {
await uploadProjectIconAsync(graphqlClient, { projectId, imagePath });
// The icon is processed asynchronously (resized and assigned to the
// project by the server), so poll until the icon URL changes.
spinner.text = 'Processing project icon';
await pollForProfileImageChangeAsync(graphqlClient, {
projectId,
await pollForProfileImageChangeAsync({
fetchProfileImageUrlAsync: async () =>
await AppQuery.byIdProfileImageUrlAsync(graphqlClient, projectId),
previousProfileImageUrl,
projectDashboardUrl,
fallbackUrl: projectDashboardUrl,
});
spinner.succeed(`Set icon for ${chalk.bold(app.fullName)}`);
Log.withTick(`View it on the project page: ${link(projectDashboardUrl)}`);
+7
View File
@@ -14649,6 +14649,13 @@ export type AccountByNameQueryVariables = Exact<{
export type AccountByNameQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byName: { __typename?: 'Account', id: string, name: string } } };
export type AccountByIdProfileImageUrlQueryVariables = Exact<{
accountId: Scalars['String']['input'];
}>;
export type AccountByIdProfileImageUrlQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, profileImageUrl: string } } };
export type AccountFullUsageQueryVariables = Exact<{
accountId: Scalars['String']['input'];
currentDate: Scalars['DateTime']['input'];
@@ -4,6 +4,7 @@ import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
AccountByIdProfileImageUrlQuery,
AccountFullUsageQuery as AccountFullUsageQueryType,
AccountFullUsageQueryVariables,
AccountUsageForOverageWarningQuery,
@@ -45,6 +46,35 @@ export const AccountQuery = {
return data.account.byName;
},
async byIdProfileImageUrlAsync(
graphqlClient: ExpoGraphqlClient,
accountId: string
): Promise<string | null> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AccountByIdProfileImageUrlQuery>(
gql`
query AccountByIdProfileImageUrlQuery($accountId: String!) {
account {
byId(accountId: $accountId) {
id
profileImageUrl
}
}
}
`,
{ accountId },
{
requestPolicy: 'network-only',
additionalTypenames: ['Account'],
}
)
.toPromise()
);
return data.account.byId.profileImageUrl ?? null;
},
async getFullUsageAsync(
graphqlClient: ExpoGraphqlClient,
accountId: string,
+2 -2
View File
@@ -42,12 +42,12 @@ export async function uploadAccountScopedFileAtPathToGCSAsync(
type,
accountId,
path,
handleProgressEvent,
handleProgressEvent = () => {},
}: {
type: AccountUploadSessionType;
accountId: string;
path: string;
handleProgressEvent: ProgressHandler;
handleProgressEvent?: ProgressHandler;
}
): Promise<string> {
const signedUrl = await UploadSessionMutation.createAccountScopedUploadSessionAsync(
@@ -0,0 +1,57 @@
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { sleepAsync } from './promise';
const ALLOWED_EXTENSIONS = ['.png', '.jpg', '.jpeg'];
const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024; // matches the upload session's GCS limit
const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 90_000;
export async function validateProfileImageAsync(imagePath: string): Promise<void> {
if (!(await fs.pathExists(imagePath))) {
throw new Error(`No file found at ${imagePath}`);
}
const extension = path.extname(imagePath).toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(extension)) {
throw new Error(
`Unsupported image format "${extension}". The image must be a PNG or JPEG file.`
);
}
const { size } = await fs.stat(imagePath);
if (size > MAX_IMAGE_SIZE_BYTES) {
throw new Error(
`The image is ${(size / 1024 / 1024).toFixed(1)} MB, but the maximum allowed size is 10 MB.`
);
}
}
/**
* Profile images are processed asynchronously (resized and assigned to their
* entity by the server), so poll until the image URL changes.
*/
export async function pollForProfileImageChangeAsync({
fetchProfileImageUrlAsync,
previousProfileImageUrl,
fallbackUrl,
}: {
fetchProfileImageUrlAsync: () => Promise<string | null>;
previousProfileImageUrl: string | null;
/** Shown in the timeout error as the page where the image may still appear. */
fallbackUrl: string;
}): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < POLL_TIMEOUT_MS) {
await sleepAsync(POLL_INTERVAL_MS);
const profileImageUrl = await fetchProfileImageUrlAsync();
if (profileImageUrl && profileImageUrl !== previousProfileImageUrl) {
return;
}
}
throw new Error(
`Timed out waiting for the image to be processed. It may still appear shortly: ${chalk.underline(
fallbackUrl
)}`
);
}