diff --git a/CHANGELOG.md b/CHANGELOG.md index 678eaa2f9f..4a287d37b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ 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)) diff --git a/packages/eas-cli/README.md b/packages/eas-cli/README.md index c0c430b191..21ecba4ef1 100644 --- a/packages/eas-cli/README.md +++ b/packages/eas-cli/README.md @@ -56,6 +56,7 @@ eas --help COMMAND * [`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) @@ -208,6 +209,29 @@ DESCRIPTION _See code: [packages/eas-cli/src/commands/account/audit.ts](https://github.com/expo/eas-cli/blob/v21.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 diff --git a/packages/eas-cli/src/commands/account/avatar/__tests__/set.test.ts b/packages/eas-cli/src/commands/account/avatar/__tests__/set.test.ts new file mode 100644 index 0000000000..cb954b9498 --- /dev/null +++ b/packages/eas-cli/src/commands/account/avatar/__tests__/set.test.ts @@ -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(); + }); +}); diff --git a/packages/eas-cli/src/commands/account/avatar/set.ts b/packages/eas-cli/src/commands/account/avatar/set.ts new file mode 100644 index 0000000000..c34811e66a --- /dev/null +++ b/packages/eas-cli/src/commands/account/avatar/set.ts @@ -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 { + 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; + } + } +} diff --git a/packages/eas-cli/src/commands/project/icon/__tests__/set.test.ts b/packages/eas-cli/src/commands/project/icon/__tests__/set.test.ts index b529633790..90c7603f22 100644 --- a/packages/eas-cli/src/commands/project/icon/__tests__/set.test.ts +++ b/packages/eas-cli/src/commands/project/icon/__tests__/set.test.ts @@ -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); }); diff --git a/packages/eas-cli/src/commands/project/icon/set.ts b/packages/eas-cli/src/commands/project/icon/set.ts index 396418c547..95c4ea939a 100644 --- a/packages/eas-cli/src/commands/project/icon/set.ts +++ b/packages/eas-cli/src/commands/project/icon/set.ts @@ -1,23 +1,18 @@ import { Args } from '@oclif/core'; import chalk from 'chalk'; -import fs from 'fs-extra'; -import path from 'path'; import { getProjectDashboardUrl } from '../../../build/utils/url'; import EasCommand from '../../../commandUtils/EasCommand'; -import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; import { EASNonInteractiveFlag } from '../../../commandUtils/flags'; import { AppUploadSessionType } from '../../../graphql/generated'; import { AppQuery } from '../../../graphql/queries/AppQuery'; import Log, { link } from '../../../log'; import { ora } from '../../../ora'; import { uploadAppScopedFileAtPathToGCSAsync } from '../../../uploads'; -import { sleepAsync } from '../../../utils/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; +import { + pollForProfileImageChangeAsync, + validateProfileImageAsync, +} from '../../../utils/profileImages'; export default class ProjectIconSet extends EasCommand { static override description = 'set the project icon displayed on the EAS dashboard'; @@ -51,7 +46,7 @@ export default class ProjectIconSet extends EasCommand { nonInteractive: flags['non-interactive'], }); - await validateImageAsync(imagePath); + await validateProfileImageAsync(imagePath); const app = await AppQuery.byIdAsync(graphqlClient, projectId); const projectDashboardUrl = getProjectDashboardUrl(app.ownerAccount.name, app.slug); @@ -68,13 +63,12 @@ export default class ProjectIconSet extends EasCommand { path: 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)}`); @@ -84,48 +78,3 @@ export default class ProjectIconSet extends EasCommand { } } } - -async function validateImageAsync(imagePath: string): Promise { - 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 icon 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.` - ); - } -} - -async function pollForProfileImageChangeAsync( - graphqlClient: ExpoGraphqlClient, - { - projectId, - previousProfileImageUrl, - projectDashboardUrl, - }: { - projectId: string; - previousProfileImageUrl: string | null; - projectDashboardUrl: string; - } -): Promise { - const startTime = Date.now(); - while (Date.now() - startTime < POLL_TIMEOUT_MS) { - await sleepAsync(POLL_INTERVAL_MS); - const profileImageUrl = await AppQuery.byIdProfileImageUrlAsync(graphqlClient, projectId); - if (profileImageUrl && profileImageUrl !== previousProfileImageUrl) { - return; - } - } - throw new Error( - `Timed out waiting for the icon to be processed. It may still appear on the project page shortly: ${chalk.underline( - projectDashboardUrl - )}` - ); -} diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index 572a5f9749..f3957f3cfd 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -14059,6 +14059,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']; diff --git a/packages/eas-cli/src/graphql/queries/AccountQuery.ts b/packages/eas-cli/src/graphql/queries/AccountQuery.ts index 2bf15e8d14..38542c11ea 100644 --- a/packages/eas-cli/src/graphql/queries/AccountQuery.ts +++ b/packages/eas-cli/src/graphql/queries/AccountQuery.ts @@ -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 { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + 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, diff --git a/packages/eas-cli/src/uploads.ts b/packages/eas-cli/src/uploads.ts index 74044c4b56..8449636b6e 100644 --- a/packages/eas-cli/src/uploads.ts +++ b/packages/eas-cli/src/uploads.ts @@ -40,12 +40,12 @@ export async function uploadAccountScopedFileAtPathToGCSAsync( type, accountId, path, - handleProgressEvent, + handleProgressEvent = () => {}, }: { type: AccountUploadSessionType; accountId: string; path: string; - handleProgressEvent: ProgressHandler; + handleProgressEvent?: ProgressHandler; } ): Promise { const signedUrl = await UploadSessionMutation.createAccountScopedUploadSessionAsync( diff --git a/packages/eas-cli/src/utils/profileImages.ts b/packages/eas-cli/src/utils/profileImages.ts new file mode 100644 index 0000000000..8c19fb4ed7 --- /dev/null +++ b/packages/eas-cli/src/utils/profileImages.ts @@ -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 { + 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; + previousProfileImageUrl: string | null; + /** Shown in the timeout error as the page where the image may still appear. */ + fallbackUrl: string; +}): Promise { + 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 + )}` + ); +}