Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
22 changes: 7 additions & 15 deletions packages/eas-cli/src/commandUtils/new/__tests__/commands.test.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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 }
);
});
});

Expand Down
13 changes: 3 additions & 10 deletions packages/eas-cli/src/commandUtils/new/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const githubUsername = 'expo';
Expand Down Expand Up @@ -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`);
}

Expand Down
5 changes: 4 additions & 1 deletion packages/eas-cli/src/commands/project/__tests__/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 7 additions & 17 deletions packages/eas-cli/src/commands/project/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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');
Expand Down
42 changes: 10 additions & 32 deletions packages/eas-cli/src/project/expoConfig.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
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,
Expand Down Expand Up @@ -41,8 +40,6 @@ export async function createOrModifyExpoConfigAsync(
}
}

let wasExpoConfigWarnPrinted = false;

async function getExpoConfigInternalAsync(
projectDir: string,
opts: ExpoConfigOptionsInternal = {}
Expand All @@ -56,35 +53,16 @@ async function getExpoConfigInternalAsync(

let exp: ExpoConfig;
if (isExpoInstalled(projectDir)) {
try {
const { stdout } = await spawnAsync(
'npx',
['expo', 'config', '--json', ...(opts.isPublicConfig ? ['--type', 'public'] : [])],

{
cwd: projectDir,
env: {
...process.env,
...opts.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,
Expand Down
64 changes: 11 additions & 53 deletions packages/eas-cli/src/project/ios/entitlements.ts
Original file line number Diff line number Diff line change
@@ -1,69 +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 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';

interface Target {
buildConfiguration?: string;
targetName: string;
}

let wasExpoConfigPluginsWarnPrinted = false;

export async function getManagedApplicationTargetEntitlementsAsync(
projectDir: string,
env: Record<string, string>,
vcsClient: Client
env: Record<string, string>
): Promise<JSONObject> {
const originalProcessEnv: NodeJS.ProcessEnv = process.env;

try {
process.env = {
...process.env,
...env,
};

let expWithMods: ExportedConfig;
try {
const { stdout } = await spawnAsync(
'npx',
['expo', 'config', '--json', '--type', 'introspect'],

{
cwd: projectDir,
env: {
...process.env,
...env,
EXPO_NO_DOTENV: '1',
},
}
);
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}.`
);
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(
Expand Down
3 changes: 1 addition & 2 deletions packages/eas-cli/src/project/ios/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,7 @@ export async function resolveManagedProjectTargetsAsync({
);
const applicationTargetEntitlements = await getManagedApplicationTargetEntitlementsAsync(
projectDir,
env ?? {},
vcsClient
env ?? {}
);
const appExtensions: UserDefinedTarget[] =
exp.extra?.eas?.build?.experimental?.ios?.appExtensions ?? [];
Expand Down
40 changes: 26 additions & 14 deletions packages/eas-cli/src/utils/expoCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string, string | undefined> } = {}
): Promise<void> {
opts?: CommonSpawnOptions
): spawnAsync.SpawnPromise<spawnAsync.SpawnResult> {
let expoCliPath;
try {
expoCliPath =
Expand All @@ -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<string, string | undefined> } = {}
): Promise<void> {
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}`);
}
Expand Down