From 01549dca2bd6b9ed357065774de59f03e6469963 Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Sun, 25 Jan 2026 16:13:21 -0800 Subject: [PATCH 1/8] [eas-cli] Fix --source-maps flag compatibility with expo CLI SDK 55+ supports --source-maps with a value (e.g., 'inline'), but older SDKs only support it as a boolean flag. Passing a value to older SDKs causes it to be parsed as the project root positional argument, resulting in "Invalid project root" errors. --- .../src/project/__tests__/publish-test.ts | 31 ++++++++++++++++++- packages/eas-cli/src/project/publish.ts | 27 +++++++++++++--- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/packages/eas-cli/src/project/__tests__/publish-test.ts b/packages/eas-cli/src/project/__tests__/publish-test.ts index 4b147fd4ee..3bd62d78d6 100644 --- a/packages/eas-cli/src/project/__tests__/publish-test.ts +++ b/packages/eas-cli/src/project/__tests__/publish-test.ts @@ -728,7 +728,8 @@ describe(buildBundlesAsync, () => { }); it('uses --source-maps instead of --dump-sourcemap when provided', async () => { - // When sourceMaps is provided, use --source-maps instead of --dump-sourcemap + // SDK < 55: --source-maps is a boolean flag, value not supported + // When sourceMaps is 'inline' but SDK < 55, pass --source-maps without the value await buildBundlesAsync({ projectDir, inputDir, @@ -736,6 +737,20 @@ describe(buildBundlesAsync, () => { platformFlag: 'all', sourceMaps: 'inline', }); + let args = jest.mocked(expoCommandAsync).mock.calls[0][1]; + expect(args).toContain('--source-maps'); + expect(args).not.toContain('inline'); + expect(args).not.toContain('--dump-sourcemap'); + + // SDK >= 55: --source-maps supports a value (e.g., 'inline') + jest.mocked(expoCommandAsync).mockClear(); + await buildBundlesAsync({ + projectDir, + inputDir, + exp: { sdkVersion: '55.0.0' }, + platformFlag: 'all', + sourceMaps: 'inline', + }); expect(expoCommandAsync).toHaveBeenCalledWith( projectDir, expect.arrayContaining(['--source-maps', 'inline']), @@ -743,6 +758,20 @@ describe(buildBundlesAsync, () => { ); expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).not.toContain('--dump-sourcemap'); + // When sourceMaps is 'true', pass --source-maps without a value (regardless of SDK version) + jest.mocked(expoCommandAsync).mockClear(); + await buildBundlesAsync({ + projectDir, + inputDir, + exp: { sdkVersion: '55.0.0' }, + platformFlag: 'all', + sourceMaps: 'true', + }); + args = jest.mocked(expoCommandAsync).mock.calls[0][1]; + expect(args).toContain('--source-maps'); + expect(args).not.toContain('true'); + expect(args).not.toContain('--dump-sourcemap'); + // When sourceMaps is "false", fall back to --dump-sourcemap jest.mocked(expoCommandAsync).mockClear(); await buildBundlesAsync({ diff --git a/packages/eas-cli/src/project/publish.ts b/packages/eas-cli/src/project/publish.ts index 0960b7e45e..1c5bb7ba79 100644 --- a/packages/eas-cli/src/project/publish.ts +++ b/packages/eas-cli/src/project/publish.ts @@ -11,6 +11,7 @@ import mime from 'mime'; import nullthrows from 'nullthrows'; import path from 'path'; import promiseLimit from 'promise-limit'; +import semver from 'semver'; import { maybeUploadFingerprintAsync } from './maybeUploadFingerprintAsync'; import { isModernExpoUpdatesCLIWithRuntimeVersionCommandSupportedAsync } from './projectUtils'; @@ -256,9 +257,18 @@ export async function buildBundlesAsync({ ? ['--platform', 'ios', '--platform', 'android'] : ['--platform', platformFlag]; - // Use --source-maps if provided, otherwise fall back to --dump-sourcemap + // Use --source-maps if provided, otherwise fall back to --dump-sourcemap. + // SDK 55+ supports --source-maps with a value (e.g., 'inline'), older SDKs only support it as + // a boolean flag. Passing a value to older SDKs causes it to be parsed as the project root. + const supportsSourceMapModes = + exp.sdkVersion && semver.satisfies(exp.sdkVersion, '>=55.0.0'); const sourceMapArgs = - sourceMaps && sourceMaps !== 'false' ? ['--source-maps', sourceMaps] : ['--dump-sourcemap']; + sourceMaps && sourceMaps !== 'false' + ? [ + '--source-maps', + ...(supportsSourceMapModes && sourceMaps !== 'true' ? [sourceMaps] : []), + ] + : ['--dump-sourcemap']; await expoCommandAsync( projectDir, @@ -289,9 +299,18 @@ export async function buildBundlesAsync({ ); } - // Use --source-maps if provided, otherwise fall back to --dump-sourcemap + // Use --source-maps if provided, otherwise fall back to --dump-sourcemap. + // SDK 55+ supports --source-maps with a value (e.g., 'inline'), older SDKs only support it as + // a boolean flag. Passing a value to older SDKs causes it to be parsed as the project root. + const supportsSourceMapModes = + exp.sdkVersion && semver.satisfies(exp.sdkVersion, '>=55.0.0'); const sourceMapArgs = - sourceMaps && sourceMaps !== 'false' ? ['--source-maps', sourceMaps] : ['--dump-sourcemap']; + sourceMaps && sourceMaps !== 'false' + ? [ + '--source-maps', + ...(supportsSourceMapModes && sourceMaps !== 'true' ? [sourceMaps] : []), + ] + : ['--dump-sourcemap']; await expoCommandAsync( projectDir, From 22df81bf7ac902d80774673764576722d024013b Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Sun, 25 Jan 2026 16:03:41 -0800 Subject: [PATCH 2/8] [eas-cli] Add multi-account session management support Add experimental multi-account support behind EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1 flag. SessionManager changes: - Add v1 schema with accounts object and activeAccountId - Support storing multiple account sessions - Add switchAccountAsync/switchAccountByUsernameAsync methods - Add getAllAccounts, removeAccountAsync, removeAllAccountsAsync - Maintain backward compatibility with Expo CLI via legacy fields - Read credentials from active account when multi-account enabled --- packages/eas-cli/src/user/SessionManager.ts | 528 +++++++++++++++++- .../src/user/__tests__/SessionManager-test.ts | 6 +- packages/eas-cli/src/utils/easCli.ts | 10 + 3 files changed, 528 insertions(+), 16 deletions(-) diff --git a/packages/eas-cli/src/user/SessionManager.ts b/packages/eas-cli/src/user/SessionManager.ts index 9a59126b8a..ae9ed2772d 100644 --- a/packages/eas-cli/src/user/SessionManager.ts +++ b/packages/eas-cli/src/user/SessionManager.ts @@ -2,7 +2,9 @@ import JsonFile from '@expo/json-file'; import { Errors } from '@oclif/core'; import assert from 'assert'; import chalk from 'chalk'; +import fs from 'fs-extra'; import nullthrows from 'nullthrows'; +import path from 'path'; import { fetchSessionSecretAndSsoUserAsync } from './fetchSessionSecretAndSsoUser'; import { fetchSessionSecretAndUserAsync } from './fetchSessionSecretAndUser'; @@ -14,21 +16,61 @@ import { CurrentUserQuery } from '../graphql/generated'; import { UserQuery } from '../graphql/queries/UserQuery'; import Log, { learnMore } from '../log'; import { promptAsync, selectAsync } 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; + userId: string; + username: string; + currentConnection: ConnectionType; +}; - // These fields are potentially used by Expo CLI. +/** + * Account data with timestamps for multi-account support (v1 schema). + */ +type AccountData = { + sessionSecret: string; userId: string; username: string; - currentConnection: 'Username-Password-Authentication' | 'Browser-Flow-Authentication'; + 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; + // Legacy fields for Expo CLI backward compatibility + sessionSecret?: string; + userId?: string; + username?: string; + currentConnection?: ConnectionType; + }; +}; + +type StateData = StateDataV0 | StateDataV1; + +type SessionData = LegacySessionData; + export enum UserSecondFactorDeviceMethod { AUTHENTICATOR = 'authenticator', SMS = 'sms', @@ -53,6 +95,17 @@ export type LoggedInAuthenticationInfo = type Actor = NonNullable; +/** + * 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; @@ -63,12 +116,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(getStateJsonPath())?.auth ?? null; + return JsonFile.read(getStateJsonPath()) ?? null; } catch (error: any) { if (error.code === 'ENOENT') { return null; @@ -77,11 +134,456 @@ 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 activeId = 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 { - await JsonFile.setAsync(getStateJsonPath(), 'auth', sessionData, { - default: {}, - ensureDir: true, - }); + const statePath = getStateJsonPath(); + + if (!sessionData) { + // Logout: clear session data + if (isMultiAccountEnabled()) { + await this.clearActiveAccountAsync(); + } else { + await JsonFile.setAsync(statePath, 'auth', undefined, { + default: {}, + ensureDir: true, + }); + } + return; + } + + if (!isMultiAccountEnabled()) { + // Feature disabled: only update legacy fields, preserve any v1 structure + const state = this.readStateFile() ?? {}; + 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: full v1 behavior + await this.addOrUpdateAccountAsync(sessionData); + } + + /** + * Write state file atomically with proper permissions. + */ + private async writeStateFileAsync(state: StateData): Promise { + 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 { + 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 = { + 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 = { + 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 !== 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 { + 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; + } + + /** + * Switch to a different account by username. + * Only available when multi-account is enabled. + */ + public async switchAccountByUsernameAsync(username: string): Promise { + 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. + * If removing the active account, switches to the most recently used remaining account. + */ + public async removeAccountAsync(userId: string): Promise { + if (!isMultiAccountEnabled()) { + // When disabled, just clear everything + 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 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 + } + + 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 = { + 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). + */ + public async removeAllAccountsAsync(): Promise { + if (!isMultiAccountEnabled()) { + await JsonFile.setAsync(getStateJsonPath(), 'auth', undefined, { + default: {}, + ensureDir: true, + }); + this.currentActor = undefined; + return; + } + + const state = this.readStateFile(); + if (!this.isV1Schema(state)) { + await JsonFile.setAsync(getStateJsonPath(), 'auth', undefined, { + default: {}, + ensureDir: true, + }); + this.currentActor = undefined; + return; + } + + const newState: StateDataV1 = { + 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 { + const state = this.readStateFile(); + + if (!this.isV1Schema(state)) { + // V0 schema: just clear + 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); } public async logoutAsync(): Promise { diff --git a/packages/eas-cli/src/user/__tests__/SessionManager-test.ts b/packages/eas-cli/src/user/__tests__/SessionManager-test.ts index 40168a0e38..952488d1b8 100644 --- a/packages/eas-cli/src/user/__tests__/SessionManager-test.ts +++ b/packages/eas-cli/src/user/__tests__/SessionManager-test.ts @@ -55,17 +55,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); }); }); diff --git a/packages/eas-cli/src/utils/easCli.ts b/packages/eas-cli/src/utils/easCli.ts index 17844a946e..00c99bb487 100644 --- a/packages/eas-cli/src/utils/easCli.ts +++ b/packages/eas-cli/src/utils/easCli.ts @@ -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'; +} From 26b375a9fe1f1e1487415885af46973d2d756e67 Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Sun, 25 Jan 2026 16:03:46 -0800 Subject: [PATCH 3/8] [eas-cli] Add account:list and account:switch commands New commands for multi-account support (hidden when feature flag disabled): - account:list: Show all logged-in accounts with active indicator - account:switch: Switch between accounts interactively or by username --- packages/eas-cli/src/commands/account/list.ts | 63 ++++++++++++ .../eas-cli/src/commands/account/switch.ts | 97 +++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 packages/eas-cli/src/commands/account/list.ts create mode 100644 packages/eas-cli/src/commands/account/switch.ts diff --git a/packages/eas-cli/src/commands/account/list.ts b/packages/eas-cli/src/commands/account/list.ts new file mode 100644 index 0000000000..8ac054aa7a --- /dev/null +++ b/packages/eas-cli/src/commands/account/list.ts @@ -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 { + 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.`); + } +} diff --git a/packages/eas-cli/src/commands/account/switch.ts b/packages/eas-cli/src/commands/account/switch.ts new file mode 100644 index 0000000000..8505357b9b --- /dev/null +++ b/packages/eas-cli/src/commands/account/switch.ts @@ -0,0 +1,97 @@ +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 = [ + { + name: 'username', + 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 { + 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__') { + await sessionManager.showLoginPromptAsync(); + 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)}`); + } +} From ea52d667359780acb695cc8ccd0dc5975571cc16 Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Sun, 25 Jan 2026 16:03:51 -0800 Subject: [PATCH 4/8] [eas-cli] Update account commands for multi-account support - login: Add menu to add account or switch when already logged in - logout: Add username arg and --all flag for selective logout - view: Show other logged-in accounts when multi-account enabled --- .../eas-cli/src/commands/account/login.ts | 64 ++++++++++- .../eas-cli/src/commands/account/logout.ts | 101 +++++++++++++++++- packages/eas-cli/src/commands/account/view.ts | 53 +++++---- 3 files changed, 196 insertions(+), 22 deletions(-) diff --git a/packages/eas-cli/src/commands/account/login.ts b/packages/eas-cli/src/commands/account/login.ts index d3c946c1f6..4a1b0ffece 100644 --- a/packages/eas-cli/src/commands/account/login.ts +++ b/packages/eas-cli/src/commands/account/login.ts @@ -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'; @@ -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({ @@ -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 { + 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)}`); + } + } } diff --git a/packages/eas-cli/src/commands/account/logout.ts b/packages/eas-cli/src/commands/account/logout.ts index 11ae3b8ba9..4af1584afa 100644 --- a/packages/eas-cli/src/commands/account/logout.ts +++ b/packages/eas-cli/src/commands/account/logout.ts @@ -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 { + 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.'); + } } } diff --git a/packages/eas-cli/src/commands/account/view.ts b/packages/eas-cli/src/commands/account/view.ts index 37d699d6ab..5007d537aa 100644 --- a/packages/eas-cli/src/commands/account/view.ts +++ b/packages/eas-cli/src/commands/account/view.ts @@ -5,6 +5,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'; @@ -12,35 +13,49 @@ export default class AccountView extends EasCommand { static override contextDefinition = { ...this.ContextOptions.MaybeLoggedIn, + ...this.ContextOptions.SessionManagment, }; async runAsync(): Promise { const { maybeLoggedIn: { actor, authenticationInfo }, + sessionManager, } = await this.getContextAsync(AccountView, { nonInteractive: true }); - if (actor) { - const loggedInAs = authenticationInfo.accessToken - ? `${getActorDisplayName(actor)} (authenticated using EXPO_TOKEN)` - : getActorDisplayName(actor); - Log.log(chalk.green(loggedInAs)); - // 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 loggedInAs = authenticationInfo.accessToken + ? `${getActorDisplayName(actor)} (authenticated using EXPO_TOKEN)` + : getActorDisplayName(actor); + Log.log(chalk.green(loggedInAs)); + + // 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 { From 43e0dbd1f8df8d277b99f7d73365975fc9da7a37 Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Sun, 25 Jan 2026 16:03:56 -0800 Subject: [PATCH 5/8] [eas-cli] Add global --account flag for per-command account selection Allow specifying which account to use for any command via --account flag when multi-account is enabled. Switches to the specified account before executing the command. --- .../eas-cli/src/commandUtils/EasCommand.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/eas-cli/src/commandUtils/EasCommand.ts b/packages/eas-cli/src/commandUtils/EasCommand.ts index c81937607c..1524798f2b 100644 --- a/packages/eas-cli/src/commandUtils/EasCommand.ts +++ b/packages/eas-cli/src/commandUtils/EasCommand.ts @@ -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< @@ -224,11 +225,43 @@ export default abstract class EasCommand extends Command { protected abstract runAsync(): Promise; + /** + * Parse the --account flag from command line arguments. + * This is a global flag available on all commands when multi-account is enabled. + */ + private parseAccountFlag(): string | undefined { + const args = process.argv; + const accountIndex = args.indexOf('--account'); + if (accountIndex !== -1 && accountIndex + 1 < args.length) { + return args[accountIndex + 1]; + } + return undefined; + } + // eslint-disable-next-line async-protect/async-suffix async run(): Promise { this.analyticsInternal = await createAnalyticsAsync(); this.sessionManagerInternal = new SessionManager(this.analytics); + // Handle --account flag for multi-account switching + if (isMultiAccountEnabled()) { + const accountFlag = this.parseAccountFlag(); + 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(); From bd8aa3a4baa33ff9914f444bbc848dd0487eff9f Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Sun, 25 Jan 2026 17:25:37 -0800 Subject: [PATCH 6/8] Fix --account flag parsing to work with oclif --- packages/eas-cli/src/commandUtils/EasCommand.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/eas-cli/src/commandUtils/EasCommand.ts b/packages/eas-cli/src/commandUtils/EasCommand.ts index 1524798f2b..7db10fa58b 100644 --- a/packages/eas-cli/src/commandUtils/EasCommand.ts +++ b/packages/eas-cli/src/commandUtils/EasCommand.ts @@ -226,14 +226,17 @@ export default abstract class EasCommand extends Command { protected abstract runAsync(): Promise; /** - * Parse the --account flag from command line arguments. + * 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 parseAccountFlag(): string | undefined { - const args = process.argv; - const accountIndex = args.indexOf('--account'); - if (accountIndex !== -1 && accountIndex + 1 < args.length) { - return args[accountIndex + 1]; + 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; } @@ -245,7 +248,7 @@ export default abstract class EasCommand extends Command { // Handle --account flag for multi-account switching if (isMultiAccountEnabled()) { - const accountFlag = this.parseAccountFlag(); + const accountFlag = this.parseAndRemoveAccountFlag(); if (accountFlag) { const accounts = this.sessionManager.getAllAccounts(); const targetAccount = accounts.find(a => a.username === accountFlag); From d2779591cdd2d34a149b8dd6ba6c96b77b31a6bc Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Sun, 25 Jan 2026 17:37:24 -0800 Subject: [PATCH 7/8] Add changelog entry and fix prettier formatting --- CHANGELOG.md | 1 + packages/eas-cli/src/project/publish.ts | 11 +++-------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be3b644377..8a760d1436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) - 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)) - eas init should fix and validate project name and slug. ([#3277](https://github.com/expo/eas-cli/pull/3277) by [@douglowder](https://github.com/douglowder)) diff --git a/packages/eas-cli/src/project/publish.ts b/packages/eas-cli/src/project/publish.ts index 1c5bb7ba79..145baa0b30 100644 --- a/packages/eas-cli/src/project/publish.ts +++ b/packages/eas-cli/src/project/publish.ts @@ -260,8 +260,7 @@ export async function buildBundlesAsync({ // Use --source-maps if provided, otherwise fall back to --dump-sourcemap. // SDK 55+ supports --source-maps with a value (e.g., 'inline'), older SDKs only support it as // a boolean flag. Passing a value to older SDKs causes it to be parsed as the project root. - const supportsSourceMapModes = - exp.sdkVersion && semver.satisfies(exp.sdkVersion, '>=55.0.0'); + const supportsSourceMapModes = exp.sdkVersion && semver.satisfies(exp.sdkVersion, '>=55.0.0'); const sourceMapArgs = sourceMaps && sourceMaps !== 'false' ? [ @@ -302,14 +301,10 @@ export async function buildBundlesAsync({ // Use --source-maps if provided, otherwise fall back to --dump-sourcemap. // SDK 55+ supports --source-maps with a value (e.g., 'inline'), older SDKs only support it as // a boolean flag. Passing a value to older SDKs causes it to be parsed as the project root. - const supportsSourceMapModes = - exp.sdkVersion && semver.satisfies(exp.sdkVersion, '>=55.0.0'); + const supportsSourceMapModes = exp.sdkVersion && semver.satisfies(exp.sdkVersion, '>=55.0.0'); const sourceMapArgs = sourceMaps && sourceMaps !== 'false' - ? [ - '--source-maps', - ...(supportsSourceMapModes && sourceMaps !== 'true' ? [sourceMaps] : []), - ] + ? ['--source-maps', ...(supportsSourceMapModes && sourceMaps !== 'true' ? [sourceMaps] : [])] : ['--dump-sourcemap']; await expoCommandAsync( From a268fae05882deb22bb004720483f5e9a193128d Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Sun, 25 Jan 2026 18:00:43 -0800 Subject: [PATCH 8/8] Fix prettier formatting --- packages/eas-cli/src/project/__tests__/publish-test.ts | 2 +- packages/eas-cli/src/project/publish.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/eas-cli/src/project/__tests__/publish-test.ts b/packages/eas-cli/src/project/__tests__/publish-test.ts index 7b5ef6bd67..fa31b6b94c 100644 --- a/packages/eas-cli/src/project/__tests__/publish-test.ts +++ b/packages/eas-cli/src/project/__tests__/publish-test.ts @@ -786,7 +786,7 @@ describe(buildBundlesAsync, () => { ); expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).not.toContain('--dump-sourcemap'); -// When sourceMaps is 'true', pass --source-maps without a value (regardless of SDK version) + // When sourceMaps is 'true', pass --source-maps without a value (regardless of SDK version) jest.mocked(expoCommandAsync).mockClear(); await buildBundlesAsync({ projectDir, diff --git a/packages/eas-cli/src/project/publish.ts b/packages/eas-cli/src/project/publish.ts index 43da8ddc32..38afe543e1 100644 --- a/packages/eas-cli/src/project/publish.ts +++ b/packages/eas-cli/src/project/publish.ts @@ -257,7 +257,7 @@ export async function buildBundlesAsync({ ? ['--platform', 'ios', '--platform', 'android'] : ['--platform', platformFlag]; -const sourceMapArgs = getSourceMapExportCommandArgs({ sourceMaps, sdkVersion: exp.sdkVersion }); + const sourceMapArgs = getSourceMapExportCommandArgs({ sourceMaps, sdkVersion: exp.sdkVersion }); await expoCommandAsync( projectDir, @@ -288,7 +288,7 @@ const sourceMapArgs = getSourceMapExportCommandArgs({ sourceMaps, sdkVersion: ex ); } -const sourceMapArgs = getSourceMapExportCommandArgs({ sourceMaps, sdkVersion: exp.sdkVersion }); + const sourceMapArgs = getSourceMapExportCommandArgs({ sourceMaps, sdkVersion: exp.sdkVersion }); await expoCommandAsync( projectDir,