diff --git a/CHANGELOG.md b/CHANGELOG.md index c5f55fddcb..fb3ad0fc44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 7e8c600464..64677ba711 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 @@ -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'; @@ -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'); @@ -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(); when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({ actor: { 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 ee6fbef0b3..d6dc7df2bf 100644 --- a/packages/eas-cli/src/commands/project/__tests__/init.test.ts +++ b/packages/eas-cli/src/commands/project/__tests__/init.test.ts @@ -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'; @@ -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(), @@ -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' }); diff --git a/packages/eas-cli/src/project/__tests__/expoConfig.test.ts b/packages/eas-cli/src/project/__tests__/expoConfig.test.ts index 8482629613..555304d278 100644 --- a/packages/eas-cli/src/project/__tests__/expoConfig.test.ts +++ b/packages/eas-cli/src/project/__tests__/expoConfig.test.ts @@ -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(); @@ -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(); + }); + }); }); diff --git a/packages/eas-cli/src/project/expoConfig.ts b/packages/eas-cli/src/project/expoConfig.ts index e1fec4493c..229392009f 100644 --- a/packages/eas-cli/src/project/expoConfig.ts +++ b/packages/eas-cli/src/project/expoConfig.ts @@ -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< @@ -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, {