mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7817c58f68 | ||
|
|
7cb4981d50 | ||
|
|
e16d25f911 | ||
|
|
49d49184f2 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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