mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cac6b3812 | ||
|
|
2d694aa02e | ||
|
|
a79750f9a5 | ||
|
|
b9cbf27808 | ||
|
|
3033559fdf | ||
|
|
d001580c02 | ||
|
|
bb51f9b9c1 | ||
|
|
de095bc521 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<
|
||||
@@ -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();
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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({
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Args } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import Log from '../../log';
|
||||
import { selectAsync } from '../../prompts';
|
||||
import { isMultiAccountEnabled } from '../../utils/easCli';
|
||||
|
||||
export default class AccountSwitch extends EasCommand {
|
||||
static override description = 'switch to a different Expo account';
|
||||
|
||||
static override args = {
|
||||
username: Args.string({
|
||||
description: 'Username of the account to switch to',
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
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 switching is not enabled. Set EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1 to enable.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { args } = await this.parse(AccountSwitch);
|
||||
const { sessionManager } = await this.getContextAsync(AccountSwitch, { nonInteractive: false });
|
||||
|
||||
const accounts = sessionManager.getAllAccounts();
|
||||
|
||||
if (accounts.length === 0) {
|
||||
Log.warn('No accounts logged in. Run `eas login` to log in.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (accounts.length === 1) {
|
||||
Log.log(`Only one account is logged in: ${chalk.bold(accounts[0].username)}`);
|
||||
Log.log('Run `eas login` to add another account.');
|
||||
return;
|
||||
}
|
||||
|
||||
let targetUsername = args.username;
|
||||
|
||||
if (!targetUsername) {
|
||||
// Interactive mode: show account picker
|
||||
const choices = accounts.map(account => {
|
||||
const title = account.isActive
|
||||
? `${account.username} ${chalk.dim('(active)')}`
|
||||
: account.username;
|
||||
return { title, value: account.username };
|
||||
});
|
||||
|
||||
choices.push({
|
||||
title: chalk.dim('Add new account...'),
|
||||
value: '__add_new__',
|
||||
});
|
||||
|
||||
targetUsername = await selectAsync('Select an account:', choices);
|
||||
|
||||
if (targetUsername === '__add_new__') {
|
||||
// Match the default `eas login` behavior (browser-based login)
|
||||
await sessionManager.showLoginPromptAsync({ browser: true });
|
||||
Log.log('Logged in');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already active
|
||||
const targetAccount = accounts.find(a => a.username === targetUsername);
|
||||
if (!targetAccount) {
|
||||
Log.error(`Account '${targetUsername}' not found.`);
|
||||
Log.log('Available accounts:');
|
||||
accounts.forEach(a => {
|
||||
Log.log(` • ${a.username}${a.isActive ? ' (active)' : ''}`);
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (targetAccount.isActive) {
|
||||
Log.log(`Already using account ${chalk.bold(targetUsername)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch to the account
|
||||
await sessionManager.switchAccountByUsernameAsync(targetUsername);
|
||||
Log.log(`Switched to ${chalk.bold(targetUsername)}`);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { Role } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { Actor, getActorDisplayName } from '../../user/User';
|
||||
import { isMultiAccountEnabled } from '../../utils/easCli';
|
||||
|
||||
export default class AccountView extends EasCommand {
|
||||
static override description = 'show the username you are logged in as';
|
||||
@@ -11,39 +12,53 @@ export default class AccountView extends EasCommand {
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.MaybeLoggedIn,
|
||||
...this.ContextOptions.SessionManagment,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
maybeLoggedIn: { actor, authenticationInfo },
|
||||
sessionManager,
|
||||
} = await this.getContextAsync(AccountView, { nonInteractive: true });
|
||||
if (actor) {
|
||||
const displayName = getActorDisplayName(actor);
|
||||
const loggedInAs = authenticationInfo.accessToken
|
||||
? `${displayName} (authenticated using EXPO_TOKEN)`
|
||||
: displayName;
|
||||
Log.log(chalk.green(loggedInAs));
|
||||
if ('email' in actor) {
|
||||
Log.log(actor.email);
|
||||
}
|
||||
|
||||
// personal account is included, only show if more accounts that personal account
|
||||
// but do show personal account in list if there are more
|
||||
const accountExcludingPersonalAccount = actor.accounts.filter(
|
||||
account => !('username' in actor) || account.name !== actor.username
|
||||
);
|
||||
if (accountExcludingPersonalAccount.length > 0) {
|
||||
Log.newLine();
|
||||
Log.log('Accounts:');
|
||||
actor.accounts.forEach(account => {
|
||||
const roleOnAccount = AccountView.getRoleOnAccount(actor, account);
|
||||
Log.log(`• ${account.name} (Role: ${AccountView.getLabelForRole(roleOnAccount)})`);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (!actor) {
|
||||
Log.warn('Not logged in');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const displayName = getActorDisplayName(actor);
|
||||
const loggedInAs = authenticationInfo.accessToken
|
||||
? `${displayName} (authenticated using EXPO_TOKEN)`
|
||||
: displayName;
|
||||
Log.log(chalk.green(loggedInAs));
|
||||
if ('email' in actor) {
|
||||
Log.log(actor.email);
|
||||
}
|
||||
|
||||
// Show other logged-in accounts if multi-account is enabled
|
||||
if (isMultiAccountEnabled() && !authenticationInfo.accessToken) {
|
||||
const accounts = sessionManager.getAllAccounts();
|
||||
const otherAccounts = accounts.filter(a => !a.isActive);
|
||||
|
||||
if (otherAccounts.length > 0) {
|
||||
const otherUsernames = otherAccounts.map(a => a.username).join(', ');
|
||||
Log.log(chalk.dim(`Also logged in: ${otherUsernames}`));
|
||||
}
|
||||
}
|
||||
|
||||
// personal account is included, only show if more accounts that personal account
|
||||
// but do show personal account in list if there are more
|
||||
const accountExcludingPersonalAccount = actor.accounts.filter(
|
||||
account => !('username' in actor) || account.name !== actor.username
|
||||
);
|
||||
if (accountExcludingPersonalAccount.length > 0) {
|
||||
Log.newLine();
|
||||
Log.log('Accounts:');
|
||||
actor.accounts.forEach(account => {
|
||||
const roleOnAccount = AccountView.getRoleOnAccount(actor, account);
|
||||
Log.log(`• ${account.name} (Role: ${AccountView.getLabelForRole(roleOnAccount)})`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static getRoleOnAccount(actor: Actor, account: Actor['accounts'][0]): Role {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import JsonFile from '@expo/json-file';
|
||||
import { Errors } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
|
||||
import { fetchSessionSecretAndUserAsync } from './fetchSessionSecretAndUser';
|
||||
import { fetchSessionSecretAndUserFromBrowserAuthFlowAsync } from './fetchSessionSecretAndUserFromBrowserAuthFlow';
|
||||
@@ -13,21 +15,61 @@ import { CurrentUserQuery } from '../graphql/generated';
|
||||
import { UserQuery } from '../graphql/queries/UserQuery';
|
||||
import Log, { learnMore } from '../log';
|
||||
import { promptAsync } from '../prompts';
|
||||
import { isMultiAccountEnabled } from '../utils/easCli';
|
||||
import { getStateJsonPath } from '../utils/paths';
|
||||
|
||||
type UserSettingsData = {
|
||||
auth?: SessionData;
|
||||
};
|
||||
type ConnectionType = 'Username-Password-Authentication' | 'Browser-Flow-Authentication';
|
||||
|
||||
type SessionData = {
|
||||
/**
|
||||
* Legacy session data format (v0 schema).
|
||||
* These fields are also used for Expo CLI backward compatibility.
|
||||
*/
|
||||
type LegacySessionData = {
|
||||
sessionSecret: string;
|
||||
|
||||
// These fields are potentially used by Expo CLI.
|
||||
userId: string;
|
||||
username: string;
|
||||
currentConnection: 'Username-Password-Authentication' | 'Browser-Flow-Authentication';
|
||||
currentConnection: ConnectionType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Account data with timestamps for multi-account support (v1 schema).
|
||||
*/
|
||||
type AccountData = {
|
||||
sessionSecret: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
currentConnection: ConnectionType;
|
||||
addedAt: string;
|
||||
lastUsedAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* State file format for v0 (legacy single-account).
|
||||
*/
|
||||
type StateDataV0 = {
|
||||
auth?: LegacySessionData;
|
||||
};
|
||||
|
||||
/**
|
||||
* State file format for v1 (multi-account).
|
||||
*/
|
||||
type StateDataV1 = {
|
||||
version: 1;
|
||||
auth: {
|
||||
activeAccountId: string | null;
|
||||
accounts: Record<string, AccountData>;
|
||||
// Legacy fields for Expo CLI backward compatibility
|
||||
sessionSecret?: string;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
currentConnection?: ConnectionType;
|
||||
};
|
||||
};
|
||||
|
||||
type StateData = StateDataV0 | StateDataV1;
|
||||
|
||||
type SessionData = LegacySessionData;
|
||||
|
||||
export type LoggedInAuthenticationInfo =
|
||||
| {
|
||||
accessToken: string;
|
||||
@@ -40,9 +82,26 @@ export type LoggedInAuthenticationInfo =
|
||||
|
||||
type Actor = NonNullable<CurrentUserQuery['meActor']>;
|
||||
|
||||
/**
|
||||
* Publicly exposed account info for multi-account features.
|
||||
*/
|
||||
export type StoredAccount = {
|
||||
userId: string;
|
||||
username: string;
|
||||
isActive: boolean;
|
||||
addedAt?: string;
|
||||
lastUsedAt?: string;
|
||||
};
|
||||
|
||||
export default class SessionManager {
|
||||
private currentActor: Actor | undefined;
|
||||
|
||||
/**
|
||||
* When set, this stored account is used for the rest of the process instead of the
|
||||
* active account from the state file. Never persisted (used by the global --account flag).
|
||||
*/
|
||||
private perProcessAccountOverrideUserId: string | undefined;
|
||||
|
||||
constructor(private readonly analytics: AnalyticsWithOrchestration) {}
|
||||
|
||||
public getAccessToken(): string | null {
|
||||
@@ -50,12 +109,16 @@ export default class SessionManager {
|
||||
}
|
||||
|
||||
public getSessionSecret(): string | null {
|
||||
return this.getSession()?.sessionSecret ?? null;
|
||||
return this.getActiveSession()?.sessionSecret ?? null;
|
||||
}
|
||||
|
||||
private getSession(): SessionData | null {
|
||||
// ============================================
|
||||
// State File Reading
|
||||
// ============================================
|
||||
|
||||
private readStateFile(): StateData | null {
|
||||
try {
|
||||
return JsonFile.read<UserSettingsData>(getStateJsonPath())?.auth ?? null;
|
||||
return JsonFile.read<StateData>(getStateJsonPath()) ?? null;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return null;
|
||||
@@ -64,25 +127,550 @@ export default class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
private isV1Schema(state: StateData | null): state is StateDataV1 {
|
||||
return (
|
||||
state !== null &&
|
||||
'version' in state &&
|
||||
state.version === 1 &&
|
||||
state.auth?.accounts !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active session data, reading from appropriate schema.
|
||||
* For v1 schema, reads from the active account in the accounts object.
|
||||
* For v0 schema, reads from legacy fields.
|
||||
*/
|
||||
private getActiveSession(): SessionData | null {
|
||||
const state = this.readStateFile();
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For v1 schema with multi-account enabled, read from the active account
|
||||
if (isMultiAccountEnabled() && this.isV1Schema(state)) {
|
||||
const overrideId = this.perProcessAccountOverrideUserId;
|
||||
const activeId =
|
||||
overrideId && state.auth.accounts[overrideId] ? overrideId : state.auth.activeAccountId;
|
||||
if (activeId && state.auth.accounts[activeId]) {
|
||||
const account = state.auth.accounts[activeId];
|
||||
return {
|
||||
sessionSecret: account.sessionSecret,
|
||||
userId: account.userId,
|
||||
username: account.username,
|
||||
currentConnection: account.currentConnection,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Read from legacy fields (v0 schema or fallback)
|
||||
const auth = state.auth;
|
||||
if (!auth?.sessionSecret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionSecret: auth.sessionSecret,
|
||||
userId: auth.userId!,
|
||||
username: auth.username!,
|
||||
currentConnection: auth.currentConnection!,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// State File Writing
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Write session data, preserving existing schema structure.
|
||||
* - If multi-account disabled: only update legacy fields
|
||||
* - If multi-account enabled: update accounts object + legacy fields
|
||||
*/
|
||||
private async setSessionAsync(sessionData?: SessionData): Promise<void> {
|
||||
await JsonFile.setAsync(getStateJsonPath(), 'auth', sessionData, {
|
||||
default: {},
|
||||
ensureDir: true,
|
||||
});
|
||||
const statePath = getStateJsonPath();
|
||||
|
||||
if (!sessionData) {
|
||||
// Logout: clear session data
|
||||
if (isMultiAccountEnabled()) {
|
||||
await this.clearActiveAccountAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.readStateFile();
|
||||
if (this.isV1Schema(state)) {
|
||||
// Preserve other stored accounts: drop only the logged-out account and legacy fields
|
||||
const loggedOutUserId = state.auth.userId ?? state.auth.activeAccountId;
|
||||
const { [loggedOutUserId ?? '']: _removed, ...remainingAccounts } = state.auth.accounts;
|
||||
await this.writeStateFileAsync({
|
||||
...state,
|
||||
auth: {
|
||||
activeAccountId: null,
|
||||
accounts: remainingAccounts,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await JsonFile.setAsync(statePath, 'auth', undefined, {
|
||||
default: {},
|
||||
ensureDir: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.readStateFile();
|
||||
if (!isMultiAccountEnabled() && !this.isV1Schema(state)) {
|
||||
// Feature disabled and no v1 structure: plain legacy write
|
||||
const existingAuth = state?.auth ?? {};
|
||||
|
||||
const updatedAuth = {
|
||||
...existingAuth,
|
||||
sessionSecret: sessionData.sessionSecret,
|
||||
userId: sessionData.userId,
|
||||
username: sessionData.username,
|
||||
currentConnection: sessionData.currentConnection,
|
||||
};
|
||||
|
||||
await this.writeStateFileAsync({ ...state, auth: updatedAuth });
|
||||
return;
|
||||
}
|
||||
|
||||
// Feature enabled, or a v1 state file already exists: keep the accounts
|
||||
// object and the legacy fields in sync so that toggling the feature flag
|
||||
// never resurrects a stale identity.
|
||||
await this.addOrUpdateAccountAsync(sessionData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write state file atomically with proper permissions.
|
||||
*/
|
||||
private async writeStateFileAsync(state: StateData): Promise<void> {
|
||||
const statePath = getStateJsonPath();
|
||||
const dir = path.dirname(statePath);
|
||||
await fs.ensureDir(dir);
|
||||
|
||||
// Write atomically: write to temp file, then rename
|
||||
const tempPath = `${statePath}.tmp.${process.pid}`;
|
||||
await fs.writeJson(tempPath, state, { spaces: 2 });
|
||||
await fs.rename(tempPath, statePath);
|
||||
|
||||
// Ensure restrictive permissions (owner read/write only)
|
||||
try {
|
||||
await fs.chmod(statePath, 0o600);
|
||||
} catch {
|
||||
// Ignore chmod errors on platforms that don't support it
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Multi-Account Methods (Feature Flag Gated)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get all stored accounts.
|
||||
* Returns single account array when multi-account is disabled.
|
||||
*/
|
||||
public getAllAccounts(): StoredAccount[] {
|
||||
const state = this.readStateFile();
|
||||
|
||||
if (!isMultiAccountEnabled() || !this.isV1Schema(state)) {
|
||||
// Return single account from legacy fields
|
||||
const session = this.getActiveSession();
|
||||
if (!session) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
userId: session.userId,
|
||||
username: session.username,
|
||||
isActive: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Multi-account enabled with v1 schema
|
||||
const accounts = Object.values(state.auth.accounts);
|
||||
const activeId = state.auth.activeAccountId;
|
||||
|
||||
return accounts.map(account => ({
|
||||
userId: account.userId,
|
||||
username: account.username,
|
||||
isActive: account.userId === activeId,
|
||||
addedAt: account.addedAt,
|
||||
lastUsedAt: account.lastUsedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is already logged in (by userId).
|
||||
*/
|
||||
public isAccountLoggedIn(userId: string): boolean {
|
||||
const accounts = this.getAllAccounts();
|
||||
return accounts.some(a => a.userId === userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is already logged in (by username).
|
||||
*/
|
||||
public isAccountLoggedInByUsername(username: string): boolean {
|
||||
const accounts = this.getAllAccounts();
|
||||
return accounts.some(a => a.username === username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active account, or null if not logged in.
|
||||
*/
|
||||
public getActiveAccount(): StoredAccount | null {
|
||||
const accounts = this.getAllAccounts();
|
||||
return accounts.find(a => a.isActive) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update an account in multi-account mode.
|
||||
* If the account already exists (by userId), update its session.
|
||||
* Always sets the new/updated account as active.
|
||||
*/
|
||||
private async addOrUpdateAccountAsync(sessionData: SessionData): Promise<void> {
|
||||
const state = this.readStateFile();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
let newState: StateDataV1;
|
||||
|
||||
if (this.isV1Schema(state)) {
|
||||
// Update existing v1 schema
|
||||
const existingAccount = state.auth.accounts[sessionData.userId];
|
||||
newState = {
|
||||
...state,
|
||||
version: 1,
|
||||
auth: {
|
||||
...state.auth,
|
||||
activeAccountId: sessionData.userId,
|
||||
accounts: {
|
||||
...state.auth.accounts,
|
||||
[sessionData.userId]: {
|
||||
sessionSecret: sessionData.sessionSecret,
|
||||
userId: sessionData.userId,
|
||||
username: sessionData.username,
|
||||
currentConnection: sessionData.currentConnection,
|
||||
addedAt: existingAccount?.addedAt ?? now,
|
||||
lastUsedAt: now,
|
||||
},
|
||||
},
|
||||
// Sync legacy fields
|
||||
sessionSecret: sessionData.sessionSecret,
|
||||
userId: sessionData.userId,
|
||||
username: sessionData.username,
|
||||
currentConnection: sessionData.currentConnection,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// Migrate from v0 to v1
|
||||
newState = {
|
||||
...state,
|
||||
version: 1,
|
||||
auth: {
|
||||
activeAccountId: sessionData.userId,
|
||||
accounts: {
|
||||
[sessionData.userId]: {
|
||||
sessionSecret: sessionData.sessionSecret,
|
||||
userId: sessionData.userId,
|
||||
username: sessionData.username,
|
||||
currentConnection: sessionData.currentConnection,
|
||||
addedAt: now,
|
||||
lastUsedAt: now,
|
||||
},
|
||||
},
|
||||
// Sync legacy fields
|
||||
sessionSecret: sessionData.sessionSecret,
|
||||
userId: sessionData.userId,
|
||||
username: sessionData.username,
|
||||
currentConnection: sessionData.currentConnection,
|
||||
},
|
||||
};
|
||||
|
||||
// If there was an existing session in v0, preserve it as a separate account
|
||||
if (
|
||||
state?.auth?.sessionSecret &&
|
||||
state.auth.userId &&
|
||||
state.auth.userId !== sessionData.userId
|
||||
) {
|
||||
newState.auth.accounts[state.auth.userId] = {
|
||||
sessionSecret: state.auth.sessionSecret,
|
||||
userId: state.auth.userId,
|
||||
username: state.auth.username,
|
||||
currentConnection: state.auth.currentConnection,
|
||||
addedAt: now,
|
||||
lastUsedAt: now,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await this.writeStateFileAsync(newState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different account by userId.
|
||||
* Only available when multi-account is enabled.
|
||||
*/
|
||||
public async switchAccountAsync(userId: string): Promise<void> {
|
||||
if (!isMultiAccountEnabled()) {
|
||||
throw new Error('Multi-account switching is not enabled');
|
||||
}
|
||||
|
||||
const state = this.readStateFile();
|
||||
if (!this.isV1Schema(state)) {
|
||||
throw new Error('No accounts to switch to');
|
||||
}
|
||||
|
||||
const account = state.auth.accounts[userId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${userId}`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const newState: StateDataV1 = {
|
||||
...state,
|
||||
auth: {
|
||||
...state.auth,
|
||||
activeAccountId: userId,
|
||||
accounts: {
|
||||
...state.auth.accounts,
|
||||
[userId]: {
|
||||
...account,
|
||||
lastUsedAt: now,
|
||||
},
|
||||
},
|
||||
// Sync legacy fields
|
||||
sessionSecret: account.sessionSecret,
|
||||
userId: account.userId,
|
||||
username: account.username,
|
||||
currentConnection: account.currentConnection,
|
||||
},
|
||||
};
|
||||
|
||||
await this.writeStateFileAsync(newState);
|
||||
|
||||
// Clear cached actor since we switched
|
||||
this.currentActor = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use a stored account for the rest of this process without persisting a switch
|
||||
* to the state file. Used by the global --account flag for per-command account
|
||||
* selection. Returns false if no such account is stored.
|
||||
*/
|
||||
public useAccountForProcessByUsername(username: string): boolean {
|
||||
if (!isMultiAccountEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const state = this.readStateFile();
|
||||
if (!this.isV1Schema(state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const account = Object.values(state.auth.accounts).find(a => a.username === username);
|
||||
if (!account) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.perProcessAccountOverrideUserId = account.userId;
|
||||
this.currentActor = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different account by username.
|
||||
* Only available when multi-account is enabled.
|
||||
*/
|
||||
public async switchAccountByUsernameAsync(username: string): Promise<void> {
|
||||
if (!isMultiAccountEnabled()) {
|
||||
throw new Error('Multi-account switching is not enabled');
|
||||
}
|
||||
|
||||
const state = this.readStateFile();
|
||||
if (!this.isV1Schema(state)) {
|
||||
throw new Error('No accounts to switch to');
|
||||
}
|
||||
|
||||
const account = Object.values(state.auth.accounts).find(a => a.username === username);
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${username}`);
|
||||
}
|
||||
|
||||
await this.switchAccountAsync(account.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an account by userId. Invalidates its session secret on the server (best-effort).
|
||||
* If removing the active account, switches to the most recently used remaining account.
|
||||
*/
|
||||
public async removeAccountAsync(userId: string): Promise<void> {
|
||||
if (!isMultiAccountEnabled()) {
|
||||
// When disabled, just clear everything
|
||||
await this.invalidateCurrentSessionOnServerAsync();
|
||||
await JsonFile.setAsync(getStateJsonPath(), 'auth', undefined, {
|
||||
default: {},
|
||||
ensureDir: true,
|
||||
});
|
||||
this.currentActor = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.readStateFile();
|
||||
if (!this.isV1Schema(state)) {
|
||||
// V0 schema: just clear
|
||||
await this.invalidateCurrentSessionOnServerAsync();
|
||||
await JsonFile.setAsync(getStateJsonPath(), 'auth', undefined, {
|
||||
default: {},
|
||||
ensureDir: true,
|
||||
});
|
||||
this.currentActor = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const { [userId]: removed, ...remainingAccounts } = state.auth.accounts;
|
||||
if (!removed) {
|
||||
return; // Account not found, nothing to do
|
||||
}
|
||||
|
||||
await this.invalidateSessionSecretOnServerAsync(removed.sessionSecret);
|
||||
|
||||
const wasActive = state.auth.activeAccountId === userId;
|
||||
let newActiveId: string | null = state.auth.activeAccountId;
|
||||
|
||||
if (wasActive) {
|
||||
// Find the most recently used remaining account
|
||||
const remaining = Object.values(remainingAccounts);
|
||||
if (remaining.length > 0) {
|
||||
remaining.sort(
|
||||
(a, b) => new Date(b.lastUsedAt).getTime() - new Date(a.lastUsedAt).getTime()
|
||||
);
|
||||
newActiveId = remaining[0].userId;
|
||||
} else {
|
||||
newActiveId = null;
|
||||
}
|
||||
}
|
||||
|
||||
const newActiveAccount = newActiveId ? remainingAccounts[newActiveId] : null;
|
||||
|
||||
const newState: StateDataV1 = {
|
||||
...state,
|
||||
version: 1,
|
||||
auth: {
|
||||
activeAccountId: newActiveId,
|
||||
accounts: remainingAccounts,
|
||||
// Sync legacy fields to new active account (or clear if none)
|
||||
sessionSecret: newActiveAccount?.sessionSecret,
|
||||
userId: newActiveAccount?.userId,
|
||||
username: newActiveAccount?.username,
|
||||
currentConnection: newActiveAccount?.currentConnection,
|
||||
},
|
||||
};
|
||||
|
||||
await this.writeStateFileAsync(newState);
|
||||
this.currentActor = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all accounts (logout all). Invalidates their session secrets on the
|
||||
* server (best-effort).
|
||||
*/
|
||||
public async removeAllAccountsAsync(): Promise<void> {
|
||||
if (!isMultiAccountEnabled()) {
|
||||
await this.invalidateCurrentSessionOnServerAsync();
|
||||
await JsonFile.setAsync(getStateJsonPath(), 'auth', undefined, {
|
||||
default: {},
|
||||
ensureDir: true,
|
||||
});
|
||||
this.currentActor = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.readStateFile();
|
||||
if (!this.isV1Schema(state)) {
|
||||
await this.invalidateCurrentSessionOnServerAsync();
|
||||
await JsonFile.setAsync(getStateJsonPath(), 'auth', undefined, {
|
||||
default: {},
|
||||
ensureDir: true,
|
||||
});
|
||||
this.currentActor = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const account of Object.values(state.auth.accounts)) {
|
||||
await this.invalidateSessionSecretOnServerAsync(account.sessionSecret);
|
||||
}
|
||||
|
||||
const newState: StateDataV1 = {
|
||||
...state,
|
||||
version: 1,
|
||||
auth: {
|
||||
activeAccountId: null,
|
||||
accounts: {},
|
||||
// Clear legacy fields
|
||||
sessionSecret: undefined,
|
||||
userId: undefined,
|
||||
username: undefined,
|
||||
currentConnection: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
await this.writeStateFileAsync(newState);
|
||||
this.currentActor = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the active account (used by logout when multi-account enabled).
|
||||
*/
|
||||
private async clearActiveAccountAsync(): Promise<void> {
|
||||
const state = this.readStateFile();
|
||||
|
||||
if (!this.isV1Schema(state)) {
|
||||
// V0 schema: just clear
|
||||
await this.invalidateCurrentSessionOnServerAsync();
|
||||
await JsonFile.setAsync(getStateJsonPath(), 'auth', undefined, {
|
||||
default: {},
|
||||
ensureDir: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeId = state.auth.activeAccountId;
|
||||
if (!activeId) {
|
||||
return; // Already no active account
|
||||
}
|
||||
|
||||
await this.removeAccountAsync(activeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort invalidation of a session secret on the server.
|
||||
*/
|
||||
private async invalidateSessionSecretOnServerAsync(sessionSecret: string): Promise<void> {
|
||||
const apiV2Client = new ApiV2Client({ accessToken: null, sessionSecret });
|
||||
try {
|
||||
await apiV2Client.postAsync('auth/logout', { body: {} });
|
||||
} catch (e) {
|
||||
// Best-effort: clear the local session even if the server request fails
|
||||
Log.debug('Failed to invalidate session secret on server:', e);
|
||||
}
|
||||
}
|
||||
|
||||
private async invalidateCurrentSessionOnServerAsync(): Promise<void> {
|
||||
const sessionSecret = this.getSessionSecret();
|
||||
if (sessionSecret) {
|
||||
await this.invalidateSessionSecretOnServerAsync(sessionSecret);
|
||||
}
|
||||
}
|
||||
|
||||
public async logoutAsync(): Promise<void> {
|
||||
const sessionSecret = this.getSessionSecret();
|
||||
if (sessionSecret) {
|
||||
const apiV2Client = new ApiV2Client({ accessToken: null, sessionSecret });
|
||||
try {
|
||||
await apiV2Client.postAsync('auth/logout', { body: {} });
|
||||
} catch (e) {
|
||||
// Best-effort: clear the local session even if the server request fails
|
||||
Log.debug('Failed to invalidate session secret on server:', e);
|
||||
}
|
||||
}
|
||||
this.currentActor = undefined;
|
||||
if (isMultiAccountEnabled()) {
|
||||
// Clearing the active account invalidates its session secret on the server.
|
||||
await this.setSessionAsync(undefined);
|
||||
return;
|
||||
}
|
||||
await this.invalidateCurrentSessionOnServerAsync();
|
||||
await this.setSessionAsync(undefined);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ beforeEach(() => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
process.env = { ...OLD_ENV }; // Make a copy
|
||||
// Tests must not depend on the experimental flag being set in the ambient shell
|
||||
delete process.env.EAS_EXPERIMENTAL_ACCOUNT_SWITCHER;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -55,17 +57,17 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe(SessionManager, () => {
|
||||
describe('getSession', () => {
|
||||
describe('getActiveSession', () => {
|
||||
it('returns null when session is not stored', () => {
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
expect(sessionManager['getSession']()).toBeNull();
|
||||
expect(sessionManager['getActiveSession']()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns stored session data', async () => {
|
||||
await fs.mkdirp(path.dirname(getStateJsonPath()));
|
||||
await fs.writeJSON(getStateJsonPath(), { auth: authStub });
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
expect(sessionManager['getSession']()).toMatchObject(authStub);
|
||||
expect(sessionManager['getActiveSession']()).toMatchObject(authStub);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -392,4 +394,123 @@ describe(SessionManager, () => {
|
||||
).rejects.toThrowError('Cancelled login');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-account (EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1)', () => {
|
||||
const loginAsUserAsync = async (
|
||||
sessionManager: SessionManager,
|
||||
{ id, username }: { id: string; username: string }
|
||||
): Promise<void> => {
|
||||
jest.mocked(fetchSessionSecretAndUserAsync).mockResolvedValueOnce({
|
||||
sessionSecret: `SECRET_${id}`,
|
||||
id,
|
||||
username,
|
||||
});
|
||||
await sessionManager['loginAsync']({ username, password: 'PASSWORD' });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.EAS_EXPERIMENTAL_ACCOUNT_SWITCHER = '1';
|
||||
});
|
||||
|
||||
it('migrates v0 state to v1 on login, preserving the old session and unrelated top-level keys', async () => {
|
||||
await fs.mkdirp(path.dirname(getStateJsonPath()));
|
||||
await fs.writeJSON(getStateJsonPath(), {
|
||||
uuid: 'SOME_UUID',
|
||||
auth: {
|
||||
sessionSecret: 'OLD_SECRET',
|
||||
userId: 'OLD_USER_ID',
|
||||
username: 'OLD_USERNAME',
|
||||
currentConnection: 'Username-Password-Authentication',
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
await loginAsUserAsync(sessionManager, { id: 'USER_ID', username: 'USERNAME' });
|
||||
|
||||
const state = await fs.readJSON(getStateJsonPath());
|
||||
expect(state.uuid).toBe('SOME_UUID');
|
||||
expect(state.version).toBe(1);
|
||||
expect(state.auth.activeAccountId).toBe('USER_ID');
|
||||
expect(Object.keys(state.auth.accounts).sort()).toEqual(['OLD_USER_ID', 'USER_ID']);
|
||||
expect(state.auth.sessionSecret).toBe('SECRET_USER_ID');
|
||||
expect(state.auth.username).toBe('USERNAME');
|
||||
});
|
||||
|
||||
it('uses an account for the process without persisting a switch', async () => {
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_A', username: 'user-a' });
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_B', username: 'user-b' });
|
||||
|
||||
expect(sessionManager['getSessionSecret']()).toBe('SECRET_ID_B');
|
||||
|
||||
expect(sessionManager.useAccountForProcessByUsername('user-a')).toBe(true);
|
||||
expect(sessionManager['getSessionSecret']()).toBe('SECRET_ID_A');
|
||||
|
||||
const state = await fs.readJSON(getStateJsonPath());
|
||||
expect(state.auth.activeAccountId).toBe('ID_B');
|
||||
expect(state.auth.sessionSecret).toBe('SECRET_ID_B');
|
||||
|
||||
expect(sessionManager.useAccountForProcessByUsername('nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
it('invalidates the session secret on the server when removing an account', async () => {
|
||||
const apiV2PostSpy = jest.spyOn(ApiV2Client.prototype, 'postAsync');
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_A', username: 'user-a' });
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_B', username: 'user-b' });
|
||||
|
||||
await sessionManager.removeAccountAsync('ID_A');
|
||||
|
||||
expect(apiV2PostSpy).toHaveBeenCalledWith('auth/logout', { body: {} });
|
||||
const state = await fs.readJSON(getStateJsonPath());
|
||||
expect(Object.keys(state.auth.accounts)).toEqual(['ID_B']);
|
||||
expect(state.auth.activeAccountId).toBe('ID_B');
|
||||
});
|
||||
|
||||
it('invalidates all session secrets on the server when removing all accounts', async () => {
|
||||
const apiV2PostSpy = jest.spyOn(ApiV2Client.prototype, 'postAsync');
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_A', username: 'user-a' });
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_B', username: 'user-b' });
|
||||
|
||||
await sessionManager.removeAllAccountsAsync();
|
||||
|
||||
expect(apiV2PostSpy).toHaveBeenCalledTimes(2);
|
||||
const state = await fs.readJSON(getStateJsonPath());
|
||||
expect(state.auth.accounts).toEqual({});
|
||||
expect(state.auth.activeAccountId).toBeNull();
|
||||
expect(sessionManager['getSessionSecret']()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the v1 accounts map in sync when logging in with the flag off', async () => {
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_A', username: 'user-a' });
|
||||
|
||||
delete process.env.EAS_EXPERIMENTAL_ACCOUNT_SWITCHER;
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_B', username: 'user-b' });
|
||||
|
||||
const state = await fs.readJSON(getStateJsonPath());
|
||||
expect(state.auth.activeAccountId).toBe('ID_B');
|
||||
expect(Object.keys(state.auth.accounts).sort()).toEqual(['ID_A', 'ID_B']);
|
||||
|
||||
// Re-enabling the flag must not resurrect the stale identity
|
||||
process.env.EAS_EXPERIMENTAL_ACCOUNT_SWITCHER = '1';
|
||||
expect(sessionManager['getActiveSession']()).toMatchObject({ userId: 'ID_B' });
|
||||
});
|
||||
|
||||
it('preserves other stored accounts when logging out with the flag off', async () => {
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_A', username: 'user-a' });
|
||||
await loginAsUserAsync(sessionManager, { id: 'ID_B', username: 'user-b' });
|
||||
|
||||
delete process.env.EAS_EXPERIMENTAL_ACCOUNT_SWITCHER;
|
||||
await sessionManager.logoutAsync();
|
||||
|
||||
expect(sessionManager['getSessionSecret']()).toBeNull();
|
||||
const state = await fs.readJSON(getStateJsonPath());
|
||||
expect(state.auth.activeAccountId).toBeNull();
|
||||
expect(Object.keys(state.auth.accounts)).toEqual(['ID_A']);
|
||||
expect(state.auth.sessionSecret).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,13 @@
|
||||
const packageJSON = require('../../package.json');
|
||||
|
||||
export const easCliVersion: string = packageJSON.version;
|
||||
|
||||
/**
|
||||
* Check if the experimental account switcher feature is enabled.
|
||||
* This allows users to log in to multiple accounts and switch between them.
|
||||
*
|
||||
* Enable with: EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1
|
||||
*/
|
||||
export function isMultiAccountEnabled(): boolean {
|
||||
return process.env.EAS_EXPERIMENTAL_ACCOUNT_SWITCHER === '1';
|
||||
}
|
||||
Reference in new issue
Block a user