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

### 🎉 New features

- [eas-cli] Add experimental multi-account support behind `EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1`: `account:list`, `account:switch`, a global `--account` flag, and multi-account `account:login`/`account:logout`. ([#3340](https://github.com/expo/eas-cli/pull/3340) by [@brentvatne](https://github.com/brentvatne))

### 🐛 Bug fixes

### 🧹 Chores
Expand Down
65 changes: 65 additions & 0 deletions packages/eas-cli/src/commandUtils/EasCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import Log, { link } from '../log';
import Sentry from '../sentry';
import SessionManager from '../user/SessionManager';
import { getActorDisplayName } from '../user/User';
import { isMultiAccountEnabled } from '../utils/easCli';
import { Client } from '../vcs/vcs';

export type ContextInput<
Expand Down Expand Up @@ -224,11 +225,75 @@ 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().
* Commands that declare their own `account` flag (with different semantics,
* e.g. `project:init`) are skipped so their flag keeps working.
*/
private parseAndRemoveAccountFlag(): string | undefined {
if (this.ctor.flags && 'account' in this.ctor.flags) {
return undefined;
}

const terminatorIndex = this.argv.indexOf('--');
const searchEnd = terminatorIndex === -1 ? this.argv.length : terminatorIndex;

for (let i = 0; i < searchEnd; i++) {
const arg = this.argv[i];
if (arg === '--account') {
const value = i + 1 < searchEnd ? this.argv[i + 1] : undefined;
if (!value || value.startsWith('-')) {
throw new Error('The --account flag requires a username value.');
}
this.argv.splice(i, 2);
return value;
}
if (arg.startsWith('--account=')) {
const value = arg.slice('--account='.length);
if (!value) {
throw new Error('The --account flag requires a username value.');
}
this.argv.splice(i, 1);
return value;
}
}
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 per-command account selection
if (isMultiAccountEnabled()) {
const accountFlag = this.parseAndRemoveAccountFlag();
if (accountFlag) {
if (this.sessionManager.getAccessToken()) {
throw new Error(
'EXPO_TOKEN is set in your environment and is used for all EAS authentication, so --account has no effect. Unset EXPO_TOKEN to select an account with --account.'
);
}
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 &&
!this.sessionManager.useAccountForProcessByUsername(accountFlag)
) {
throw new Error(`Account '${accountFlag}' not found.`);
}
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
const actor = 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.`);
}
}
67 changes: 65 additions & 2 deletions packages/eas-cli/src/commands/account/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import chalk from 'chalk';

import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import { getActorDisplayName } from '../../user/User';
import { confirmAsync, selectAsync } from '../../prompts';
import SessionManager from '../../user/SessionManager';
import { Actor, 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 @@ -47,6 +49,13 @@ export default class AccountLogin extends EasCommand {
}

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

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

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

private async handleMultiAccountLoginAsync(
sessionManager: SessionManager,
actor: Actor,
{ sso, browser }: { sso: boolean; browser: 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 => !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, browser });
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)}`);
}
}
}
107 changes: 105 additions & 2 deletions packages/eas-cli/src/commands/account/logout.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,120 @@
import { Args, 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 = {
username: Args.string({
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()) {
if (args.username || flags.all) {
Log.error(
'Logging out a specific account requires the experimental account switcher. Set EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1 to enable it.'
);
process.exit(1);
}

// 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
Loading