Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ This is the log of notable changes to EAS CLI and related packages.

### 🐛 Bug fixes

- [eas-cli] Print an actionable error message that says to install the project's dependencies when they aren't installed and EAS CLI needs them to read the app config, instead of a confusing module resolution error. Reading the config with the copy of `@expo/config` bundled with EAS CLI is now reserved for old SDK versions whose `expo` package doesn't include Expo CLI; projects without the `expo` package get an error directing them to install it. ([#4080](https://github.com/expo/eas-cli/pull/4080) by [@ide](https://github.com/ide))

### 🧹 Chores

## [21.2.0](https://github.com/expo/eas-cli/releases/tag/v21.2.0) - 2026-07-24
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getConfig, getConfigFilePaths, modifyConfigAsync } from '@expo/config';
import { vol } from 'memfs';
import resolveFrom from 'resolve-from';
import { anything, instance, mock, when } from 'ts-mockito';

import { Role } from '../../../../graphql/generated';
Expand All @@ -14,6 +15,11 @@ import { getProjectIdAsync } from '../getProjectIdAsync';

jest.mock('@expo/config');
jest.mock('fs');
jest.mock('resolve-from', () => {
const resolveFrom: any = jest.fn();
resolveFrom.silent = jest.fn();
return resolveFrom;
});

jest.mock('../../../../graphql/queries/AppQuery');
jest.mock('../../contextUtils/findProjectDirAndVerifyProjectSetupAsync');
Expand All @@ -36,6 +42,10 @@ describe(getProjectIdAsync, () => {
.mocked(getConfigFilePaths)
.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });

// Simulate an old SDK project: `expo` is installed but doesn't include Expo CLI, so the
// config is read with the copy of `@expo/config` bundled with EAS CLI.
jest.mocked(resolveFrom.silent).mockReturnValue('/app/node_modules/expo/package.json');

const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
actor: {
Expand Down
9 changes: 9 additions & 0 deletions packages/eas-cli/src/commands/project/__tests__/init.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AppJSONConfig, PackageJSONConfig, getConfig } from '@expo/config';
import chalk from 'chalk';
import { vol } from 'memfs';
import resolveFrom from 'resolve-from';
import { instance, mock } from 'ts-mockito';

import { getMockOclifConfig } from '../../../__tests__/commands/utils';
Expand All @@ -21,6 +22,11 @@ import ProjectInit from '../init';

jest.mock('fs');
jest.mock('@expo/config');
jest.mock('resolve-from', () => {
const resolveFrom: any = jest.fn();
resolveFrom.silent = jest.fn();
return resolveFrom;
});
jest.mock('../../../project/expoConfig', () => ({
...jest.requireActual('../../../project/expoConfig'),
createOrModifyExpoConfigAsync: jest.fn(),
Expand Down Expand Up @@ -99,6 +105,9 @@ function mockTestProject(options: {
// 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);
// Simulate an old SDK project: `expo` is installed but doesn't include Expo CLI, so the config
// is read with the copy of `@expo/config` bundled with EAS CLI.
jest.mocked(resolveFrom.silent).mockReturnValue(projectRoot + '/node_modules/expo/package.json');
}

const commandOptions = getMockOclifConfig({ root: '/test-project' });
Expand Down
42 changes: 40 additions & 2 deletions packages/eas-cli/src/project/__tests__/expoConfig.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { getConfigFilePaths, modifyConfigAsync } from '@expo/config';
import { getConfig, getConfigFilePaths, getPackageJson, modifyConfigAsync } from '@expo/config';
import JsonFile from '@expo/json-file';
import { writeFileSync } from 'fs-extra';
import resolveFrom from 'resolve-from';

import { createOrModifyExpoConfigAsync } from '../expoConfig';
import { createOrModifyExpoConfigAsync, getPrivateExpoConfigAsync } from '../expoConfig';
import { isExpoInstalled } from '../projectUtils';

jest.mock('fs-extra');
jest.mock('@expo/config');
jest.mock('@expo/json-file');
jest.mock('resolve-from', () => {
const resolveFrom: any = jest.fn();
resolveFrom.silent = jest.fn();
return resolveFrom;
});
jest.mock('../projectUtils');

beforeEach(() => {
jest.resetAllMocks();
Expand Down Expand Up @@ -47,4 +55,34 @@ describe('expoConfig', () => {
expect(modifyConfigAsync).toHaveBeenCalledWith('/app', { owner: 'ccheever' });
});
});

describe('getPrivateExpoConfigAsync when Expo CLI is not resolvable', () => {
beforeEach(() => {
jest.mocked(getConfigFilePaths).mockReturnValue({
staticConfigPath: '/app/app.json',
dynamicConfigPath: null,
});
jest.mocked(isExpoInstalled).mockReturnValue(false);
});

it('throws an actionable error when expo is declared in package.json but not installed', async () => {
jest.mocked(resolveFrom.silent).mockReturnValue(undefined);
jest.mocked(getPackageJson).mockReturnValue({ dependencies: { expo: '~53.0.0' } } as any);

await expect(getPrivateExpoConfigAsync('/app')).rejects.toThrow(
/dependencies to be installed/
);
expect(getConfig).not.toHaveBeenCalled();
});

it('throws an actionable error when expo is not declared in package.json', async () => {
jest.mocked(resolveFrom.silent).mockReturnValue(undefined);
jest.mocked(getPackageJson).mockReturnValue({ dependencies: {} } as any);

await expect(getPrivateExpoConfigAsync('/app')).rejects.toThrow(
/The "expo" package was not found/
);
expect(getConfig).not.toHaveBeenCalled();
});
});
});
30 changes: 28 additions & 2 deletions packages/eas-cli/src/project/expoConfig.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { ExpoConfig, getConfig, getConfigFilePaths, modifyConfigAsync } from '@expo/config';
import {
ExpoConfig,
getConfig,
getConfigFilePaths,
getPackageJson,
modifyConfigAsync,
} from '@expo/config';
import { Env } from '@expo/eas-build-job';
import { resolvePackageManager } from '@expo/package-manager';
import chalk from 'chalk';
import fs from 'fs-extra';
import Joi from 'joi';
import path from 'path';
import resolveFrom from 'resolve-from';

import { isExpoInstalled } from './projectUtils';
import { link } from '../log';
import { spawnExpoCommand } from '../utils/expoCli';

export type PublicExpoConfig = Omit<
Expand Down Expand Up @@ -63,12 +73,28 @@ async function getExpoConfigInternalAsync(
}
);
exp = JSON.parse(stdout);
} else {
} else if (resolveFrom.silent(projectDir, 'expo/package.json')) {
// The `expo` package is installed but Expo CLI is not part of it. This happens with old
// SDK versions, which predate the local CLI. Since we can't run `expo config`, read the app
// config with the copy of `@expo/config` that ships with EAS CLI.
exp = getConfig(projectDir, {
skipSDKVersionRequirement: true,
...(opts.isPublicConfig ? { isPublicConfig: true } : {}),
...(opts.skipPlugins ? { skipPlugins: true } : {}),
}).exp;
} else if (getPackageJson(projectDir)?.dependencies?.expo) {
const installCommand = `${resolvePackageManager(projectDir) ?? 'npm'} install`;
throw new Error(
`EAS CLI needs your project's dependencies to be installed to read your app config. Run ${chalk.bold(
installCommand
)} in your project directory and run this command again.`
);
} else {
throw new Error(
`The "expo" package was not found in your project's dependencies, needed to read your app config. Add "expo" to your dependencies and install it. Refer to the version compatibility table at: ${link(
'https://docs.expo.dev/versions/latest/'
)}`
);
}

const { error } = MinimalAppConfigSchema.validate(exp, {
Expand Down
Loading