Compare commits

...
10 changed files with 401 additions and 12 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
- [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
+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
)}`
);
}