From 18b87a5709151b97c557a76e91572e52dc7ce405 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Tue, 6 Jan 2026 13:15:17 +0000 Subject: [PATCH 01/10] Expose raw `spawnExpoCommand` utility --- packages/eas-cli/src/utils/expoCli.ts | 40 +++++++++++++++++---------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/packages/eas-cli/src/utils/expoCli.ts b/packages/eas-cli/src/utils/expoCli.ts index 6c1e6fda00..9d006dcb22 100644 --- a/packages/eas-cli/src/utils/expoCli.ts +++ b/packages/eas-cli/src/utils/expoCli.ts @@ -2,6 +2,7 @@ import { ExpoConfig } from '@expo/config'; import spawnAsync from '@expo/spawn-async'; import chalk from 'chalk'; import { boolish } from 'getenv'; +import type { CommonSpawnOptions } from 'node:child_process'; import resolveFrom, { silent as silentResolveFrom } from 'resolve-from'; import semver from 'semver'; @@ -77,14 +78,11 @@ export const shouldUseVersionedExpoCLIWithExplicitPlatforms = memoize( shouldUseVersionedExpoCLIWithExplicitPlatformsExpensive ); -export async function expoCommandAsync( +export function spawnExpoCommand( projectDir: string, args: string[], - { - silent = false, - extraEnv = {}, - }: { silent?: boolean; extraEnv?: Record } = {} -): Promise { + opts?: CommonSpawnOptions +): spawnAsync.SpawnPromise { let expoCliPath; try { expoCliPath = @@ -101,25 +99,39 @@ export async function expoCommandAsync( } const spawnPromise = spawnAsync(expoCliPath, args, { - stdio: ['inherit', 'pipe', 'pipe'], // inherit stdin so user can install a missing expo-cli from inside this command + cwd: projectDir, + ...opts, env: { ...process.env, - ...extraEnv, + ...opts?.env, }, }); - const { - child: { stdout, stderr }, - } = spawnPromise; - if (!(stdout && stderr)) { + if (!spawnPromise.child.stdout && !spawnPromise.child.stderr) { throw new Error('Failed to spawn expo-cli'); } + + return spawnPromise; +} + +export async function expoCommandAsync( + projectDir: string, + args: string[], + { + silent = false, + extraEnv = {}, + }: { silent?: boolean; extraEnv?: Record } = {} +): Promise { + const spawnPromise = spawnExpoCommand(projectDir, args, { + stdio: ['inherit', 'pipe', 'pipe'], // inherit stdin so user can install a missing expo-cli from inside this command + env: extraEnv, + }); if (!silent) { - stdout.on('data', data => { + spawnPromise.child.stdout?.on('data', data => { for (const line of data.toString().trim().split('\n')) { Log.log(`${chalk.gray('[expo-cli]')} ${line}`); } }); - stderr.on('data', data => { + spawnPromise.child.stderr?.on('data', data => { for (const line of data.toString().trim().split('\n')) { Log.warn(`${chalk.gray('[expo-cli]')} ${line}`); } From 7f7dcc50bebbfd75e10aafc9bf3a66c19009d4da Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Tue, 6 Jan 2026 13:18:57 +0000 Subject: [PATCH 02/10] Remove use of `npx expo` invocation for `spawnExpoCommand` --- packages/eas-cli/src/project/expoConfig.ts | 12 ++++------ .../eas-cli/src/project/ios/entitlements.ts | 23 +++++++------------ 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/packages/eas-cli/src/project/expoConfig.ts b/packages/eas-cli/src/project/expoConfig.ts index d44f00cfcc..38fc8c4ed1 100644 --- a/packages/eas-cli/src/project/expoConfig.ts +++ b/packages/eas-cli/src/project/expoConfig.ts @@ -1,12 +1,12 @@ import { ExpoConfig, getConfig, getConfigFilePaths, modifyConfigAsync } from '@expo/config'; import { Env } from '@expo/eas-build-job'; -import spawnAsync from '@expo/spawn-async'; import fs from 'fs-extra'; import Joi from 'joi'; import path from 'path'; import { isExpoInstalled } from './projectUtils'; import Log from '../log'; +import { spawnExpoCommand } from '../utils/expoCli'; export type PublicExpoConfig = Omit< ExpoConfig, @@ -57,15 +57,11 @@ async function getExpoConfigInternalAsync( let exp: ExpoConfig; if (isExpoInstalled(projectDir)) { try { - const { stdout } = await spawnAsync( - 'npx', - ['expo', 'config', '--json', ...(opts.isPublicConfig ? ['--type', 'public'] : [])], - + const { stdout } = await spawnExpoCommand( + projectDir, + ['config', '--json', ...(opts.isPublicConfig ? ['--type', 'public'] : [])], { - cwd: projectDir, env: { - ...process.env, - ...opts.env, EXPO_NO_DOTENV: '1', }, } diff --git a/packages/eas-cli/src/project/ios/entitlements.ts b/packages/eas-cli/src/project/ios/entitlements.ts index 918990347b..d668721cd2 100644 --- a/packages/eas-cli/src/project/ios/entitlements.ts +++ b/packages/eas-cli/src/project/ios/entitlements.ts @@ -1,9 +1,9 @@ import { ExportedConfig, IOSConfig, compileModsAsync } from '@expo/config-plugins'; import { JSONObject } from '@expo/json-file'; import { getPrebuildConfigAsync } from '@expo/prebuild-config'; -import spawnAsync from '@expo/spawn-async'; import Log from '../../log'; +import { spawnExpoCommand } from '../../utils/expoCli'; import { readPlistAsync } from '../../utils/plist'; import { Client } from '../../vcs/vcs'; import { hasIgnoredIosProjectAsync } from '../workflow'; @@ -30,24 +30,17 @@ export async function getManagedApplicationTargetEntitlementsAsync( let expWithMods: ExportedConfig; try { - const { stdout } = await spawnAsync( - 'npx', - ['expo', 'config', '--json', '--type', 'introspect'], - - { - cwd: projectDir, - env: { - ...process.env, - ...env, - EXPO_NO_DOTENV: '1', - }, - } - ); + const { stdout } = await spawnExpoCommand(projectDir, [ + 'config', + '--json', + '--type', + 'introspect', + ]); expWithMods = JSON.parse(stdout); } catch (err: any) { if (!wasExpoConfigPluginsWarnPrinted) { Log.warn( - `Failed to read the app config from the project using "npx expo config" command: ${err.message}.` + `Failed to read the app config from the project using "expo config" command: ${err.message}.` ); Log.warn('Falling back to the version of "@expo/config" shipped with the EAS CLI.'); wasExpoConfigPluginsWarnPrinted = true; From 0f3ad3c6f703f066df8b485149c9ebd0555c9444 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Tue, 6 Jan 2026 13:20:40 +0000 Subject: [PATCH 03/10] Replace manual `npx expo` invocation in onboarding with `expoCommandAsync` --- .../src/commands/project/onboarding.ts | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/packages/eas-cli/src/commands/project/onboarding.ts b/packages/eas-cli/src/commands/project/onboarding.ts index 828480c67d..251a00cb5d 100644 --- a/packages/eas-cli/src/commands/project/onboarding.ts +++ b/packages/eas-cli/src/commands/project/onboarding.ts @@ -35,6 +35,7 @@ import { ExpoConfigOptions, getPrivateExpoConfigAsync } from '../../project/expo import { promptAsync } from '../../prompts'; import { Actor } from '../../user/User'; import { easCliVersion } from '../../utils/easCli'; +import { expoCommandAsync } from '../../utils/expoCli'; import GitClient from '../../vcs/clients/git'; export default class Onboarding extends EasCommand { @@ -196,23 +197,12 @@ export default class Onboarding extends EasCommand { }); if (!app.githubRepository) { - await runCommandAsync({ - cwd: finalTargetProjectDirectory, - command: 'npx', - args: ['expo', 'install', 'expo-updates'], - }); - Log.log(); - await runCommandAsync({ - cwd: finalTargetProjectDirectory, - command: 'npx', - args: ['expo', 'install', 'expo-insights'], - }); - Log.log(); - await runCommandAsync({ - cwd: finalTargetProjectDirectory, - command: 'npx', - args: ['expo', 'install', 'expo-dev-client'], - }); + await expoCommandAsync(finalTargetProjectDirectory, [ + 'install', + 'expo-updates', + 'expo-insights', + 'expo-dev-client', + ]); Log.log(); } await vcsClient.trackFileAsync('package-lock.json'); From 5621c23916ab0bc76b6a63a8d1ea0fa73a1aa245 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Tue, 6 Jan 2026 13:24:06 +0000 Subject: [PATCH 04/10] Remove npx invocation in "new" command --- packages/eas-cli/src/commandUtils/new/commands.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/eas-cli/src/commandUtils/new/commands.ts b/packages/eas-cli/src/commandUtils/new/commands.ts index e90762150a..cdda28f0c6 100644 --- a/packages/eas-cli/src/commandUtils/new/commands.ts +++ b/packages/eas-cli/src/commandUtils/new/commands.ts @@ -7,6 +7,7 @@ import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../onboar import { PackageManager, installDependenciesAsync } from '../../onboarding/installDependencies'; import { runCommandAsync } from '../../onboarding/runCommand'; import { ora } from '../../ora'; +import { expoCommandAsync } from '../../utils/expoCli'; export async function cloneTemplateAsync(targetProjectDir: string): Promise { const githubUsername = 'expo'; @@ -48,16 +49,8 @@ export async function installProjectDependenciesAsync( }); const dependencies = ['expo-updates', '@expo/metro-runtime']; - for (const dependency of dependencies) { - spinner.text = `Installing ${chalk.bold(dependency)}`; - await runCommandAsync({ - cwd: projectDir, - command: 'npx', - args: ['expo', 'install', dependency], - showOutput: false, - showSpinner: false, - }); - } + spinner.text = `Installing ${dependencies.map(dep => chalk.bold(dep)).join(', ')}`; + await expoCommandAsync(projectDir, ['install', ...dependencies], { silent: true }); spinner.succeed(`Installed project dependencies`); } From 13ef7ebdf50808983259cb288b36c4fbf30757a4 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Tue, 6 Jan 2026 13:27:40 +0000 Subject: [PATCH 05/10] Remove catch-case from `getExpoConfigInternalAsync` --- packages/eas-cli/src/project/expoConfig.ts | 36 ++++++---------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/packages/eas-cli/src/project/expoConfig.ts b/packages/eas-cli/src/project/expoConfig.ts index 38fc8c4ed1..e1fec4493c 100644 --- a/packages/eas-cli/src/project/expoConfig.ts +++ b/packages/eas-cli/src/project/expoConfig.ts @@ -5,7 +5,6 @@ import Joi from 'joi'; import path from 'path'; import { isExpoInstalled } from './projectUtils'; -import Log from '../log'; import { spawnExpoCommand } from '../utils/expoCli'; export type PublicExpoConfig = Omit< @@ -41,8 +40,6 @@ export async function createOrModifyExpoConfigAsync( } } -let wasExpoConfigWarnPrinted = false; - async function getExpoConfigInternalAsync( projectDir: string, opts: ExpoConfigOptionsInternal = {} @@ -56,31 +53,16 @@ async function getExpoConfigInternalAsync( let exp: ExpoConfig; if (isExpoInstalled(projectDir)) { - try { - const { stdout } = await spawnExpoCommand( - projectDir, - ['config', '--json', ...(opts.isPublicConfig ? ['--type', 'public'] : [])], - { - env: { - EXPO_NO_DOTENV: '1', - }, - } - ); - exp = JSON.parse(stdout); - } catch (err: any) { - if (!wasExpoConfigWarnPrinted) { - Log.warn( - `Failed to read the app config from the project using "npx expo config" command: ${err.message}.` - ); - Log.warn('Falling back to the version of "@expo/config" shipped with the EAS CLI.'); - wasExpoConfigWarnPrinted = true; + const { stdout } = await spawnExpoCommand( + projectDir, + ['config', '--json', ...(opts.isPublicConfig ? ['--type', 'public'] : [])], + { + env: { + EXPO_NO_DOTENV: '1', + }, } - exp = getConfig(projectDir, { - skipSDKVersionRequirement: true, - ...(opts.isPublicConfig ? { isPublicConfig: true } : {}), - ...(opts.skipPlugins ? { skipPlugins: true } : {}), - }).exp; - } + ); + exp = JSON.parse(stdout); } else { exp = getConfig(projectDir, { skipSDKVersionRequirement: true, From 149f9276c364e81725c5f9525af48ce029f20b9f Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Tue, 6 Jan 2026 13:30:20 +0000 Subject: [PATCH 06/10] Remove catch-case from `getManagedApplicationTargetEntitlementsAsync` --- .../eas-cli/src/project/ios/entitlements.ts | 55 ++++--------------- packages/eas-cli/src/project/ios/target.ts | 1 - 2 files changed, 10 insertions(+), 46 deletions(-) diff --git a/packages/eas-cli/src/project/ios/entitlements.ts b/packages/eas-cli/src/project/ios/entitlements.ts index d668721cd2..b4dcaa85f5 100644 --- a/packages/eas-cli/src/project/ios/entitlements.ts +++ b/packages/eas-cli/src/project/ios/entitlements.ts @@ -1,62 +1,27 @@ -import { ExportedConfig, IOSConfig, compileModsAsync } from '@expo/config-plugins'; +import { ExportedConfig, IOSConfig } from '@expo/config-plugins'; import { JSONObject } from '@expo/json-file'; -import { getPrebuildConfigAsync } from '@expo/prebuild-config'; -import Log from '../../log'; import { spawnExpoCommand } from '../../utils/expoCli'; import { readPlistAsync } from '../../utils/plist'; -import { Client } from '../../vcs/vcs'; -import { hasIgnoredIosProjectAsync } from '../workflow'; interface Target { buildConfiguration?: string; targetName: string; } -let wasExpoConfigPluginsWarnPrinted = false; - export async function getManagedApplicationTargetEntitlementsAsync( projectDir: string, - env: Record, - vcsClient: Client + env: Record ): Promise { - const originalProcessEnv: NodeJS.ProcessEnv = process.env; - - try { - process.env = { - ...process.env, - ...env, - }; - - let expWithMods: ExportedConfig; - try { - const { stdout } = await spawnExpoCommand(projectDir, [ - 'config', - '--json', - '--type', - 'introspect', - ]); - expWithMods = JSON.parse(stdout); - } catch (err: any) { - if (!wasExpoConfigPluginsWarnPrinted) { - Log.warn( - `Failed to read the app config from the project using "expo config" command: ${err.message}.` - ); - Log.warn('Falling back to the version of "@expo/config" shipped with the EAS CLI.'); - wasExpoConfigPluginsWarnPrinted = true; - } - const { exp } = await getPrebuildConfigAsync(projectDir, { platforms: ['ios'] }); - expWithMods = await compileModsAsync(exp, { - projectRoot: projectDir, - platforms: ['ios'], - introspect: true, - ignoreExistingNativeFiles: await hasIgnoredIosProjectAsync(projectDir, vcsClient), - }); + const { stdout } = await spawnExpoCommand( + projectDir, + ['config', '--json', '--type', 'introspect'], + { + env, } - return expWithMods.ios?.entitlements ?? {}; - } finally { - process.env = originalProcessEnv; - } + ); + const expWithMods: ExportedConfig = JSON.parse(stdout); + return expWithMods.ios?.entitlements ?? {}; } export async function getNativeTargetEntitlementsAsync( diff --git a/packages/eas-cli/src/project/ios/target.ts b/packages/eas-cli/src/project/ios/target.ts index f8ff5446a7..f95ecc1f76 100644 --- a/packages/eas-cli/src/project/ios/target.ts +++ b/packages/eas-cli/src/project/ios/target.ts @@ -61,7 +61,6 @@ export async function resolveManagedProjectTargetsAsync({ const applicationTargetEntitlements = await getManagedApplicationTargetEntitlementsAsync( projectDir, env ?? {}, - vcsClient ); const appExtensions: UserDefinedTarget[] = exp.extra?.eas?.build?.experimental?.ios?.appExtensions ?? []; From 6e9af63476d3cff12a43d6c0c6a4e70cb86a0bf2 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Tue, 6 Jan 2026 13:36:38 +0000 Subject: [PATCH 07/10] Force fallback to `@expo/config` in tests This isn't great and shouldn't be done, but otherwise we're mocking the expo command utility here which also isn't great --- .../context/contextUtils/__tests__/getProjectIdAsync-test.ts | 5 ++++- packages/eas-cli/src/commands/project/__tests__/init.test.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/eas-cli/src/commandUtils/context/contextUtils/__tests__/getProjectIdAsync-test.ts b/packages/eas-cli/src/commandUtils/context/contextUtils/__tests__/getProjectIdAsync-test.ts index d8380c3fef..4588021d65 100644 --- a/packages/eas-cli/src/commandUtils/context/contextUtils/__tests__/getProjectIdAsync-test.ts +++ b/packages/eas-cli/src/commandUtils/context/contextUtils/__tests__/getProjectIdAsync-test.ts @@ -73,7 +73,10 @@ describe(getProjectIdAsync, () => { ); jest.mocked(findProjectRootAsync).mockResolvedValue('/app'); - jest.mocked(isExpoInstalled).mockReturnValue(true); + + // NOTE(@kitten): Updating this test is easiest by letting it fallback to `@expo/config` + // This isn't a great solution, but the test is pretty involved + jest.mocked(isExpoInstalled).mockReturnValue(false); }); it('gets the project ID from app config if exists', async () => { diff --git a/packages/eas-cli/src/commands/project/__tests__/init.test.ts b/packages/eas-cli/src/commands/project/__tests__/init.test.ts index ebf81566eb..b6db0777d7 100644 --- a/packages/eas-cli/src/commands/project/__tests__/init.test.ts +++ b/packages/eas-cli/src/commands/project/__tests__/init.test.ts @@ -94,7 +94,10 @@ function mockTestProject(options: { graphqlClient, authenticationInfo: { accessToken: null, sessionSecret: '1234' }, }); - jest.mocked(isExpoInstalled).mockReturnValue(true); + + // NOTE(@kitten): Updating this test is easiest by letting it fallback to `@expo/config` + // This isn't a great solution, but the test is pretty involved + jest.mocked(isExpoInstalled).mockReturnValue(false); } const commandOptions = { root: '/test-project' } as any; From b91a655c8207f57059dcccb2c376d87d70a92cdb Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Tue, 6 Jan 2026 13:39:08 +0000 Subject: [PATCH 08/10] Update commands.test.ts --- .../new/__tests__/commands.test.ts | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/packages/eas-cli/src/commandUtils/new/__tests__/commands.test.ts b/packages/eas-cli/src/commandUtils/new/__tests__/commands.test.ts index 89d1fe7ea9..e11612ef08 100644 --- a/packages/eas-cli/src/commandUtils/new/__tests__/commands.test.ts +++ b/packages/eas-cli/src/commandUtils/new/__tests__/commands.test.ts @@ -1,12 +1,14 @@ import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../../onboarding/git'; import { installDependenciesAsync } from '../../../onboarding/installDependencies'; import { runCommandAsync } from '../../../onboarding/runCommand'; +import { expoCommandAsync } from '../../../utils/expoCli'; import { cloneTemplateAsync, initializeGitRepositoryAsync, installProjectDependenciesAsync, } from '../commands'; +jest.mock('../../../utils/expoCli'); jest.mock('../../../onboarding/git'); jest.mock('../../../onboarding/runCommand'); jest.mock('../../../onboarding/installDependencies'); @@ -79,21 +81,11 @@ describe('commands', () => { packageManager: 'npm', }); - expect(runCommandAsync).toHaveBeenCalledWith({ - cwd: projectDir, - command: 'npx', - args: ['expo', 'install', 'expo-updates'], - showOutput: false, - showSpinner: false, - }); - - expect(runCommandAsync).toHaveBeenCalledWith({ - cwd: projectDir, - command: 'npx', - args: ['expo', 'install', '@expo/metro-runtime'], - showOutput: false, - showSpinner: false, - }); + expect(expoCommandAsync).toHaveBeenCalledWith( + projectDir, + ['install', 'expo-updates', '@expo/metro-runtime'], + { silent: true } + ); }); }); From bbb8c67cdf87cd4a7a75e3637bf9ddf827dacbfa Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Tue, 6 Jan 2026 13:39:45 +0000 Subject: [PATCH 09/10] Add CHANGELOG entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55ade56f86..61bd6f00b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐Ÿ› Bug fixes - 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)) +- Prevent `npx` invocations that can be unreliable and fail when retrieving entitlements or project configs ([#3282](https://github.com/expo/eas-cli/pull/3282) by [@kitten](https://github.com/kitten)) ### ๐Ÿงน Chores From d9aaedec6b4b4db44a669b01d8edacf663a2594a Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Tue, 6 Jan 2026 14:24:45 +0000 Subject: [PATCH 10/10] Apply lints --- packages/eas-cli/src/project/ios/target.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eas-cli/src/project/ios/target.ts b/packages/eas-cli/src/project/ios/target.ts index f95ecc1f76..5008efaa7c 100644 --- a/packages/eas-cli/src/project/ios/target.ts +++ b/packages/eas-cli/src/project/ios/target.ts @@ -60,7 +60,7 @@ export async function resolveManagedProjectTargetsAsync({ ); const applicationTargetEntitlements = await getManagedApplicationTargetEntitlementsAsync( projectDir, - env ?? {}, + env ?? {} ); const appExtensions: UserDefinedTarget[] = exp.extra?.eas?.build?.experimental?.ios?.appExtensions ?? [];