Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
24 changes: 24 additions & 0 deletions packages/eas-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ eas --help COMMAND

<!-- 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)
Expand Down Expand Up @@ -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
Expand Down
154 changes: 154 additions & 0 deletions packages/eas-cli/src/commands/account/avatar/__tests__/set.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
114 changes: 114 additions & 0 deletions packages/eas-cli/src/commands/account/avatar/set.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
Loading
Loading