Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ This is the log of notable changes to EAS CLI and related packages.

### 🐛 Bug fixes

- Fix `--account` flag for experimental multi-account switcher to work with oclif argument parsing. ([#3340](https://github.com/expo/eas-cli/pull/3340) by [@brentvatne](https://github.com/brentvatne))
- Fix `--source-maps` flag compatibility with older SDKs that only support it as a boolean flag. ([#3341](https://github.com/expo/eas-cli/pull/3341) by [@brentvatne](https://github.com/brentvatne))
- Use `--dump-sourcemaps` as fallback when `--source-maps` is not provided to `eas update`, for backwards compatibility. ([8cc324e1](https://github.com/expo/eas-cli/commit/8cc324e1) by [@brentvatne](https://github.com/brentvatne))
- Fix `metadata:pull` failing for apps with only a live version by falling back to live app version and info. ([#3299](https://github.com/expo/eas-cli/pull/3299) by [@EvanBacon](https://github.com/EvanBacon))
Expand Down
36 changes: 36 additions & 0 deletions packages/eas-cli/src/commandUtils/EasCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from '../analytics/AnalyticsManager';
import Log, { link } from '../log';
import SessionManager from '../user/SessionManager';
import { isMultiAccountEnabled } from '../utils/easCli';
import { Client } from '../vcs/vcs';

export type ContextInput<
Expand Down Expand Up @@ -224,11 +225,46 @@ export default abstract class EasCommand extends Command {

protected abstract runAsync(): Promise<any>;

/**
* Parse and remove the --account flag from command line arguments.
* This is a global flag available on all commands when multi-account is enabled.
* We remove it from this.argv so child commands don't see it during their parse().
*/
private parseAndRemoveAccountFlag(): string | undefined {
const accountIndex = this.argv.indexOf('--account');
if (accountIndex !== -1 && accountIndex + 1 < this.argv.length) {
const accountValue = this.argv[accountIndex + 1];
// Remove --account and its value from argv so child commands don't fail on unknown flag
this.argv.splice(accountIndex, 2);
return accountValue;
}
return undefined;
}

// eslint-disable-next-line async-protect/async-suffix
async run(): Promise<any> {
this.analyticsInternal = await createAnalyticsAsync();
this.sessionManagerInternal = new SessionManager(this.analytics);

// Handle --account flag for multi-account switching
if (isMultiAccountEnabled()) {
const accountFlag = this.parseAndRemoveAccountFlag();
if (accountFlag) {
const accounts = this.sessionManager.getAllAccounts();
const targetAccount = accounts.find(a => a.username === accountFlag);
if (!targetAccount) {
const availableAccounts = accounts.map(a => a.username).join(', ') || 'none';
throw new Error(
`Account '${accountFlag}' not found. Available accounts: ${availableAccounts}`
);
}
if (!targetAccount.isActive) {
await this.sessionManager.switchAccountByUsernameAsync(accountFlag);
Log.log(chalk.dim(`Using account: ${accountFlag}`));
}
}
}

// this is needed for logEvent call below as it identifies the user in the analytics system
// if possible
await this.sessionManager.getUserAsync();
Expand Down
63 changes: 63 additions & 0 deletions packages/eas-cli/src/commands/account/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import chalk from 'chalk';

import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';
import { fromNow } from '../../utils/date';
import { isMultiAccountEnabled } from '../../utils/easCli';

export default class AccountList extends EasCommand {
static override description = 'list all logged-in Expo accounts';

static override contextDefinition = {
...this.ContextOptions.SessionManagment,
};

// Hide this command when the feature flag is disabled
static override get hidden(): boolean {
return !isMultiAccountEnabled();
}

async runAsync(): Promise<void> {
if (!isMultiAccountEnabled()) {
Log.error(
'Multi-account listing is not enabled. Set EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1 to enable.'
);
process.exit(1);
}

const { sessionManager } = await this.getContextAsync(AccountList, { nonInteractive: true });

if (sessionManager.getAccessToken()) {
Log.log(chalk.yellow('Using EXPO_TOKEN from environment for authentication.'));
Log.log('Account switching is not available when using EXPO_TOKEN.');
return;
}

const accounts = sessionManager.getAllAccounts();

if (accounts.length === 0) {
Log.log('No accounts logged in.');
Log.log(`Run ${chalk.bold('eas login')} to log in.`);
return;
}

Log.log('Logged-in accounts:');
Log.newLine();

for (const account of accounts) {
const marker = account.isActive ? chalk.green('●') : chalk.dim('○');
const activeLabel = account.isActive ? chalk.dim(' (active)') : '';
const username = account.isActive ? chalk.bold(account.username) : account.username;

Log.log(`${marker} ${username}${activeLabel}`);

if (account.lastUsedAt) {
const lastUsed = fromNow(new Date(account.lastUsedAt));
Log.log(chalk.dim(` └─ Last used: ${lastUsed} ago`));
}
}

Log.newLine();
Log.log(`Use ${chalk.bold('eas account:switch')} to change accounts.`);
}
}
64 changes: 63 additions & 1 deletion packages/eas-cli/src/commands/account/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import chalk from 'chalk';

import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import { confirmAsync, selectAsync } from '../../prompts';
import { getActorDisplayName } from '../../user/User';
import { isMultiAccountEnabled } from '../../utils/easCli';

export default class AccountLogin extends EasCommand {
static override description = 'log in with your Expo account';
Expand Down Expand Up @@ -41,6 +42,13 @@ export default class AccountLogin extends EasCommand {
}

if (actor) {
if (isMultiAccountEnabled()) {
// Multi-account mode: offer options
await this.handleMultiAccountLoginAsync(sessionManager, actor, sso);
return;
}

// Legacy mode: simple confirmation
Log.warn(`You are already logged in as ${chalk.bold(getActorDisplayName(actor))}.`);

const shouldContinue = await confirmAsync({
Expand All @@ -54,4 +62,58 @@ export default class AccountLogin extends EasCommand {
await sessionManager.showLoginPromptAsync({ sso });
Log.log('Logged in');
}

private async handleMultiAccountLoginAsync(
sessionManager: any,
actor: any,
sso: boolean
): Promise<void> {
const accounts = sessionManager.getAllAccounts();
const currentUsername = getActorDisplayName(actor);

Log.log(`You're logged in as ${chalk.bold(currentUsername)}.`);

const choices: { title: string; value: string }[] = [
{
title: 'Add another account',
value: 'add',
},
];

// Add switch options for other accounts
const otherAccounts = accounts.filter((a: any) => !a.isActive);
if (otherAccounts.length > 0) {
for (const account of otherAccounts) {
choices.push({
title: `Switch to ${account.username}`,
value: `switch:${account.username}`,
});
}
}

choices.push({
title: 'Cancel',
value: 'cancel',
});

const action = await selectAsync('What would you like to do?', choices);

if (action === 'cancel') {
Errors.error('Aborted', { exit: 1 });
}

if (action === 'add') {
await sessionManager.showLoginPromptAsync({ sso });
const newAccounts = sessionManager.getAllAccounts();
Log.log('Logged in');
Log.log(`You now have ${newAccounts.length} account${newAccounts.length > 1 ? 's' : ''}.`);
return;
}

if (action.startsWith('switch:')) {
const username = action.slice('switch:'.length);
await sessionManager.switchAccountByUsernameAsync(username);
Log.log(`Switched to ${chalk.bold(username)}`);
}
}
}
101 changes: 99 additions & 2 deletions packages/eas-cli/src/commands/account/logout.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,114 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';

import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import { isMultiAccountEnabled } from '../../utils/easCli';

export default class AccountLogout extends EasCommand {
static override description = 'log out';
static override aliases = ['logout'];

static override args = [
{
name: 'username',
description: 'Username of the account to log out (multi-account mode only)',
required: false,
},
];

static override flags = {
all: Flags.boolean({
description: 'Log out of all accounts (multi-account mode only)',
default: false,
}),
};

static override contextDefinition = {
...this.ContextOptions.SessionManagment,
};

async runAsync(): Promise<void> {
const { args, flags } = await this.parse(AccountLogout);
const { sessionManager } = await this.getContextAsync(AccountLogout, { nonInteractive: false });
await sessionManager.logoutAsync();
Log.log('Logged out');

if (!isMultiAccountEnabled()) {
// Legacy behavior
await sessionManager.logoutAsync();
Log.log('Logged out');
return;
}

// Multi-account mode
const accounts = sessionManager.getAllAccounts();

if (accounts.length === 0) {
Log.log('Not logged in to any accounts.');
return;
}

// Handle --all flag
if (flags.all) {
const confirmed = await confirmAsync({
message: `Log out of all ${accounts.length} account${accounts.length > 1 ? 's' : ''}?`,
});

if (!confirmed) {
Log.log('Aborted');
return;
}

await sessionManager.removeAllAccountsAsync();
Log.log('Logged out of all accounts');
return;
}

// Handle specific username argument
if (args.username) {
const account = accounts.find(a => a.username === args.username);
if (!account) {
Log.error(`Account '${args.username}' not found.`);
Log.log('Logged-in accounts:');
accounts.forEach(a => {
Log.log(` • ${a.username}${a.isActive ? ' (active)' : ''}`);
});
process.exit(1);
}

await sessionManager.removeAccountAsync(account.userId);
Log.log(`Logged out of ${chalk.bold(args.username)}`);

// Show remaining accounts info
const remainingAccounts = sessionManager.getAllAccounts();
if (remainingAccounts.length > 0) {
const activeAccount = remainingAccounts.find(a => a.isActive);
if (activeAccount) {
Log.log(`Active account is now: ${chalk.bold(activeAccount.username)}`);
}
}
return;
}

// Default: log out of active account
const activeAccount = accounts.find(a => a.isActive);
if (!activeAccount) {
Log.log('No active account to log out of.');
return;
}

await sessionManager.removeAccountAsync(activeAccount.userId);
Log.log(`Logged out of ${chalk.bold(activeAccount.username)}`);

// Show remaining accounts info
const remainingAccounts = sessionManager.getAllAccounts();
if (remainingAccounts.length > 0) {
const newActiveAccount = remainingAccounts.find(a => a.isActive);
if (newActiveAccount) {
Log.log(`Active account is now: ${chalk.bold(newActiveAccount.username)}`);
}
} else {
Log.log('No accounts remaining. Run `eas login` to log in.');
}
}
}
Loading