mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 17:50:32 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7817c58f68 | ||
|
|
3f8ffac11c | ||
|
|
137bee4db0 | ||
|
|
7cb4981d50 | ||
|
|
e16d25f911 | ||
|
|
49d49184f2 |
No files matched your search
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)}`
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user